From 6e6d8ed02288cc9b33c6350e59d2dccb625c1ca2 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 12:22:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=B0=86=E6=89=80=E6=9C=89=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=AE=9E=E4=BD=93=E5=8A=A0=E5=85=A5=E5=9C=B0=E5=BD=A2?= =?UTF-8?q?=E6=B7=B1=E5=BA=A6=E6=8E=92=E5=BA=8F=E6=B8=B2=E6=9F=93=E5=88=97?= =?UTF-8?q?=E9=98=9F=20(fixes=20#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 monsters、npcs、projectiles、ground items 等 Engine 内的物理对象一并收集, 利用相同的 `cell.x + cell.y` 画家排序与 `act-scene.ts` 里由预处理好的 `walls` / `objectDrawables` 进行混合深度渲染。 此改变为后续加载真实的实体资源图集打下渲染基础。 --- src/scene/act-scene.ts | 82 ++++++++++++++++++++++------- tests/engine.test.ts | 115 +++++++++++++++++++++++++++++++++-------- 2 files changed, 157 insertions(+), 40 deletions(-) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index f7f2c83..0afaafe 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 diff --git a/tests/engine.test.ts b/tests/engine.test.ts index fae6952..eb878ac 100644 --- a/tests/engine.test.ts +++ b/tests/engine.test.ts @@ -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') + }) + })