feat: 将所有动态实体加入地形深度排序渲染列队 (fixes #10)

将 monsters、npcs、projectiles、ground items 等 Engine 内的物理对象一并收集,
利用相同的 `cell.x + cell.y` 画家排序与 `act-scene.ts` 里由预处理好的 `walls` / `objectDrawables` 进行混合深度渲染。
此改变为后续加载真实的实体资源图集打下渲染基础。
This commit is contained in:
troytt 2026-09-14 12:22:51 +00:00
parent 1e7645d388
commit 6e6d8ed022
2 changed files with 157 additions and 40 deletions

View File

@ -830,26 +830,70 @@ function runScene(runtime: MapRuntime, renderer: SpriteRenderer, started: number
}
}
drawUpToDepth(playerDepth)
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 {
const direction = facingToDirection(engine.world.player.facing, character.directions)
const groupIndex = (playerMoving ? character.walk : character.stand) + direction
const group = character.groups[groupIndex] ?? character.groups[character.walk + direction]
const frameIndex = playerMoving
? Math.floor(playerWalkFrame / 3) % Math.max(1, group?.length ?? 1)
: 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
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) {
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 {
const direction = facingToDirection(engine.world.player.facing, character.directions)
const groupIndex = (playerMoving ? character.walk : character.stand) + direction
const group = character.groups[groupIndex] ?? character.groups[character.walk + direction]
const frameIndex = playerMoving
? Math.floor(playerWalkFrame / 3) % Math.max(1, group?.length ?? 1)
: 0
const frame = group?.[frameIndex]
if (frame !== undefined) {
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
}
}
})
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

View File

@ -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')
})
})