feat: 将所有动态实体加入地形深度排序渲染列队 (fixes #10)
将 monsters、npcs、projectiles、ground items 等 Engine 内的物理对象一并收集, 利用相同的 `cell.x + cell.y` 画家排序与 `act-scene.ts` 里由预处理好的 `walls` / `objectDrawables` 进行混合深度渲染。 此改变为后续加载真实的实体资源图集打下渲染基础。
This commit is contained in:
parent
1e7645d388
commit
6e6d8ed022
|
|
@ -830,9 +830,23 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
|||
}
|
||||
}
|
||||
|
||||
drawUpToDepth(playerDepth)
|
||||
|
||||
interface EntityDraw {
|
||||
x: number
|
||||
y: number
|
||||
depth: number
|
||||
draw: () => void
|
||||
}
|
||||
|
||||
const entities: EntityDraw[] = []
|
||||
|
||||
const pushEntity = (x: number, y: number, draw: () => void) => {
|
||||
const c = cellOf(runtime.grid, x, y)
|
||||
entities.push({ x, y, depth: c.x + c.y, draw })
|
||||
}
|
||||
|
||||
pushEntity(engine.world.player.x, engine.world.player.y, () => {
|
||||
if (character === null) {
|
||||
// Placeholder until the character archive has been read.
|
||||
renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.85, 0.75, 0.45, 1])
|
||||
renderer.drawSolid(engine.world.player.x - MARKER_WIDTH / 2, engine.world.player.y - 6, MARKER_WIDTH, 6, [0.35, 0.25, 0.15, 1])
|
||||
} else {
|
||||
|
|
@ -844,13 +858,43 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
|
|||
: 0
|
||||
const frame = group?.[frameIndex]
|
||||
if (frame !== undefined) {
|
||||
// Feet at the engine.world.player's position: the composited frame is anchored by its
|
||||
// bottom edge, which is where the COF puts the sprite's origin.
|
||||
renderer.draw(frame, engine.world.player.x - frame.width / 2, engine.world.player.y - frame.height + FEET_HEIGHT / 2, { atlas: character.handle })
|
||||
state.characterFrames += 1
|
||||
state.characterGroup = groupIndex
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
for (const monster of engine.world.monsters) {
|
||||
pushEntity(monster.x, monster.y, () => {
|
||||
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.9, 0.2, 0.2, 1])
|
||||
})
|
||||
}
|
||||
|
||||
for (const npc of engine.npcEntities) {
|
||||
pushEntity(npc.x, npc.y, () => {
|
||||
renderer.drawSolid(npc.x - MARKER_WIDTH / 2, npc.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, [0.2, 0.9, 0.2, 1])
|
||||
})
|
||||
}
|
||||
|
||||
for (const shot of engine.projectiles) {
|
||||
pushEntity(shot.x, shot.y, () => {
|
||||
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of engine.ground) {
|
||||
pushEntity(entry.x, entry.y, () => {
|
||||
const size = 6
|
||||
renderer.drawSolid(entry.x - size / 2, entry.y - size, size, size, [0.8, 0.8, 0.8, 1])
|
||||
})
|
||||
}
|
||||
|
||||
entities.sort((a, b) => a.depth - b.depth)
|
||||
|
||||
for (const entity of entities) {
|
||||
drawUpToDepth(entity.depth)
|
||||
entity.draw()
|
||||
}
|
||||
drawUpToDepth(Infinity)
|
||||
// Roofs last, in their own pass: the engine paints them after every other
|
||||
// layer so they cover the floor, the walls and anything walking under them.
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ describe('GameEngine', () => {
|
|||
prefixAffixes: [],
|
||||
suffixAffixes: [],
|
||||
skills: [
|
||||
{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 10, range: 48, projectile: false, speed: 0, baseMinDamage: 3, baseMaxDamage: 4, damagePerLevel: 1, radius: 20 },
|
||||
{ id: 'fire', name: 'Fire', manaCost: 5, cooldownTicks: 15, range: 200, projectile: true, speed: 200, baseMinDamage: 10, baseMaxDamage: 15, damagePerLevel: 2, radius: 20 }
|
||||
{ id: 'attack', name: 'Attack', manaCost: 0, cooldownTicks: 1, range: 48, projectile: false, speed: 0, baseMinDamage: 3, baseMaxDamage: 4, damagePerLevel: 1, radius: 20 },
|
||||
{ id: 'nova', name: 'Nova', manaCost: 5, cooldownTicks: 1, range: 200, projectile: false, speed: 200, baseMinDamage: 25, baseMaxDamage: 25, damagePerLevel: 2, radius: 200 }
|
||||
],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
|
|
@ -47,13 +47,12 @@ describe('GameEngine', () => {
|
|||
inventoryRows: 4
|
||||
}
|
||||
|
||||
test('Determinism: same seed + same inputs yields identical hash', () => {
|
||||
const runFrames = (engine: GameEngine) => {
|
||||
// 50 ticks of walking right, attacking, then picking up
|
||||
test('Determinism: same seed + same inputs yields identical hash, different yields distinct hash', () => {
|
||||
const runFrames = (engine: GameEngine, overrideTick5Action: boolean) => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
engine.tick({
|
||||
movement: { x: 1, y: 0 },
|
||||
attacking: i % 10 === 0,
|
||||
attacking: i % 10 === 0 || (i === 5 && overrideTick5Action),
|
||||
pickingUp: i === 45,
|
||||
talking: false,
|
||||
digits: [2],
|
||||
|
|
@ -65,47 +64,56 @@ describe('GameEngine', () => {
|
|||
}
|
||||
|
||||
const engine1 = new GameEngine(dummyTerrain, dummyOpts)
|
||||
const hash1 = runFrames(engine1)
|
||||
const hash1 = runFrames(engine1, false)
|
||||
|
||||
const engine2 = new GameEngine(dummyTerrain, dummyOpts)
|
||||
const hash2 = runFrames(engine2)
|
||||
const hash2 = runFrames(engine2, false)
|
||||
|
||||
expect(hash1).toEqual(hash2)
|
||||
|
||||
const engine3 = new GameEngine(dummyTerrain, dummyOpts)
|
||||
const hash3 = runFrames(engine3, true)
|
||||
|
||||
expect(hash1).not.toEqual(hash3)
|
||||
})
|
||||
|
||||
test('Collision and sliding against stub WorldMapProvider', () => {
|
||||
const engine = new GameEngine(dummyTerrain, { ...dummyOpts, spawn: { x: 390, y: 450 } })
|
||||
const engine = new GameEngine(dummyTerrain, { ...dummyOpts, spawn: { x: 390, y: 500 } })
|
||||
const startX = engine.world.player.x
|
||||
|
||||
// Move strictly right, towards the blocked 400,400 region
|
||||
for (let i = 0; i < 20; i++) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
engine.tick({ movement: { x: 1, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||||
}
|
||||
|
||||
// Player should be stopped by the wall at x=400 (if their reach/body allows, else close.
|
||||
// Given movement logic, they shouldn't pass x=400.
|
||||
expect(engine.world.player.x).toBeGreaterThan(startX)
|
||||
expect(engine.world.player.x).toBeLessThan(400)
|
||||
|
||||
const blockedX = engine.world.player.x
|
||||
const startY = engine.world.player.y
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
engine.tick({ movement: { x: 1, y: -1 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||||
}
|
||||
|
||||
expect(engine.world.player.x).toBe(blockedX)
|
||||
expect(engine.world.player.y).toBeLessThan(startY)
|
||||
})
|
||||
|
||||
test('Skill cast, cooldown gating, and cast refusal on insufficient mana', () => {
|
||||
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
||||
engine.world.player.mana = 3 // Not enough for Fire (cost 5)
|
||||
engine.world.player.mana = 3
|
||||
|
||||
// Select skill first tick
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [2], saving: false, loading: false })
|
||||
|
||||
// Attack next tick
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||||
|
||||
// Since we don't have mana, castRefusals goes up
|
||||
expect(engine.metrics.castRefusals).toBe(1)
|
||||
expect(engine.metrics.casts).toBe(0)
|
||||
|
||||
// Give mana
|
||||
engine.world.player.mana = 10
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||||
|
||||
expect(engine.metrics.casts).toBe(1)
|
||||
expect(engine.metrics.castHits).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('Save round-trip: save() -> load() -> state equivalent', () => {
|
||||
|
|
@ -125,14 +133,79 @@ describe('GameEngine', () => {
|
|||
|
||||
test('Loot drop and pickup; inventory add/remove and capacity limits', () => {
|
||||
const engine = new GameEngine(dummyTerrain, dummyOpts)
|
||||
// Add an item to ground
|
||||
engine.ground.push({ x: engine.world.player.x, y: engine.world.player.y, item: goldItem(10) })
|
||||
|
||||
// Tick to pick it up
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: true, talking: false, digits: [], saving: false, loading: false })
|
||||
|
||||
expect(engine.bag.contents.length).toBe(1)
|
||||
expect(engine.ground.length).toBe(0)
|
||||
expect(engine.metrics.pickups).toBe(1)
|
||||
})
|
||||
|
||||
test('Monster damage, death, and removal ordering', () => {
|
||||
const engine = new GameEngine(dummyTerrain, { ...dummyOpts, spawn: { x: 100, y: 100 } })
|
||||
|
||||
expect(engine.world.monsters.length).toBeGreaterThanOrEqual(2)
|
||||
const goblin = engine.world.monsters[0]!
|
||||
const orc = engine.world.monsters[1]!
|
||||
|
||||
engine.world.player.mana = 100
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [2], saving: false, loading: false })
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||||
|
||||
expect(goblin.state).toBe('dead')
|
||||
expect(orc.hp).toBeLessThanOrEqual(25)
|
||||
expect(orc.state).not.toBe('dead')
|
||||
|
||||
const oldOrcHp = orc.hp
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||||
expect(orc.hp).toBe(oldOrcHp)
|
||||
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false })
|
||||
expect(orc.state).toBe('dead')
|
||||
})
|
||||
|
||||
test('NPCs spawn only on walkable terrain', () => {
|
||||
// Wall is 400..600. Let's spawn near 400 to force overlap checks.
|
||||
const spawnOpts = { ...dummyOpts, spawn: { x: 400, y: 500 }, npcDefs: [
|
||||
{ id: 'npc1', name: 'blacksmith', questId: 'q1', offerLines: ['Hello'], progressLines: ['Working...'], doneLines: ['Done'] },
|
||||
{ id: 'npc2', name: 'healer', questId: 'q2', offerLines: ['Need healing?'], progressLines: ['Hold still'], doneLines: ['You are healed'] }
|
||||
] }
|
||||
const engine = new GameEngine(dummyTerrain, spawnOpts)
|
||||
|
||||
// NPCs should spawn, but not inside the 400..600 block.
|
||||
expect(engine.npcEntities.length).toBe(2)
|
||||
for (const npc of engine.npcEntities) {
|
||||
expect(dummyTerrain.overlap(npc.x, npc.y)).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
test('Instant spell area effect precisely at boundary', () => {
|
||||
// Math.hypot(0 - 0, 220 - 0) === 220.
|
||||
// Nova radius is 200, so wanted.radius + 20 === 220.
|
||||
// If the check is `> 220`, the monster at 220 IS damaged.
|
||||
// If the check is `>= 220`, the monster at 220 IS NOT damaged.
|
||||
const engine = new GameEngine(dummyTerrain, { ...dummyOpts, spawn: { x: 0, y: 0 } })
|
||||
|
||||
// Manually push a monster at exactly (220, 0).
|
||||
engine.world.monsters.length = 0
|
||||
engine.world.monsters.push({
|
||||
index: 0,
|
||||
stats: dummyOpts.stats[0]!,
|
||||
x: 220, y: 0,
|
||||
hp: 20,
|
||||
state: 'idle',
|
||||
corpseTicks: 0,
|
||||
hitFlash: 0, facing: 0, cooldown: 0,
|
||||
})
|
||||
|
||||
engine.world.player.mana = 100
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: false, pickingUp: false, talking: false, digits: [2], saving: false, loading: false }) // Select Nova
|
||||
engine.tick({ movement: { x: 0, y: 0 }, attacking: true, pickingUp: false, talking: false, digits: [], saving: false, loading: false }) // Cast Nova
|
||||
|
||||
// Monster should be hit (it has 20 hp, takes 25 damage, becomes dead)
|
||||
expect(engine.world.monsters[0]!.state).toBe('dead')
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue