diff --git a/src/game/skills.ts b/src/game/skills.ts index b7d9c05..073e5bc 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -1211,18 +1211,18 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, firewall: { name: 'firewall', - id: 83, + id: 69, vel: 0, maxVel: 0, range: 90, levRange: 0, - celFile: 'FireWall', - animLen: 15, + celFile: 'groundFireBig', + animLen: 37, animSpeed: 16, loopAnim: 1, numDirections: 1, explosionMissile: '', - light: 10, + light: 5, red: 255, green: 178, blue: 64, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index cddcf68..d28f692 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -19,7 +19,7 @@ import { ViewportCamera } from "../sim/camera.ts" import type { Palette } from '../formats/pal.ts' import { ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH, - cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed, + cellAt, cellCentre, findIsoSpawn, isBlockedAt, isMissileBlockedAt, levelSeed, } from '../game/d2map.ts' import type { CharacterClassCode } from '../game/classes.ts' import type { CollisionGrid, IsoDraw } from '../game/d2map.ts' @@ -2003,9 +2003,13 @@ export function castSkill( spacing: 24, isBlocked: (x, y) => { if (runtime?.grid) { - const c = cellOf(runtime.grid, x, y) - if (c.x < 0 || c.x >= runtime.grid.cellsX || c.y < 0 || c.y >= runtime.grid.cellsY) return true - return runtime.grid.blocked[c.y * runtime.grid.gridWidth + c.x] !== 0 + return isMissileBlockedAt(runtime.grid, x, y) + } + if ((runtime as any)?.walkable) { + return !(runtime as any).walkable.isWalkable(x, y) + } + if ((runtime as any)?.collision) { + return !(runtime as any).collision.isWalkable(x, y) } return false }, @@ -2020,6 +2024,7 @@ export function castSkill( vy: 0, damage: fireWallDamage, ttl: 90, // 3.60s per Missiles.txt Range = 90 + fireDuration: 90, fromPlayer: true, missileType: 'firewall', pierce: true, @@ -5284,11 +5289,22 @@ export function drawMissileProjectile( // For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl) frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount - } else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) { - // For meteorfire: use elapsed ticks (shot.ageTicks or duration - shot.ttl). + } else if ( + (missileArt.meta.name === 'meteorfire' || + missileArt.meta.name === 'firewall' || + missileArt.meta.name === 'groundfirebig') && + frameCount === 37 + ) { + // For meteorfire & firewall: use elapsed ticks (shot.ageTicks or duration - shot.ttl). // Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)). // Extinction phase (shot.ttl <= 12): reverse emergence sequence (frames 11 down to 0) + shrinking scale & fade. - const duration = shot.fireDuration ?? (90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15) + const defaultDuration = + missileArt.meta.name === 'meteorfire' + ? 90 + (Math.max(1, shot.slvl ?? 1) - 1) * 15 + : shot.ttl > 0 + ? (shot.ageTicks ?? 0) + shot.ttl + : 90 + const duration = shot.fireDuration ?? defaultDuration const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl) const EXTINCTION_TICKS = 12 if (shot.ttl <= EXTINCTION_TICKS) { @@ -5305,7 +5321,11 @@ export function drawMissileProjectile( const group = missileArt.frames[dccDir] ?? missileArt.frames[0] const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { - const isExtinguishing = missileArt.meta.name === 'meteorfire' && shot.ttl <= 12 + const isGroundFlame = + missileArt.meta.name === 'meteorfire' || + missileArt.meta.name === 'firewall' || + missileArt.meta.name === 'groundfirebig' + const isExtinguishing = isGroundFlame && shot.ttl <= 12 const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0 const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0 const opacity = isExtinguishing ? decayRatio : 1.0 @@ -5454,7 +5474,11 @@ export function drawMissileProjectile( return } - if (shot.missileType === 'meteorfire') { + if ( + shot.missileType === 'meteorfire' || + shot.missileType === 'firewall' || + shot.missileType === 'groundfirebig' + ) { const isExtinguishing = shot.ttl <= 12 const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0 const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0 diff --git a/tests/skills/sor/issue-439-fire-wall-collision-and-subloop.test.ts b/tests/skills/sor/issue-439-fire-wall-collision-and-subloop.test.ts new file mode 100644 index 0000000..ac10c91 --- /dev/null +++ b/tests/skills/sor/issue-439-fire-wall-collision-and-subloop.test.ts @@ -0,0 +1,187 @@ +/** + * Issue #439: Fire Wall (#51) Collision Grid Check & SubLoop Animation + * + * 1.13c Ground Truth Invariants: + * 1. Collision Indexing: `isBlocked` correctly indexes collision cells using `isMissileBlockedAt` + * so walkable ground spawns all 15 fire wall patches instead of dropping them all as false-positive walls. + * 2. SubLoop Animation: `firewall` (`groundFireBig`, 37 frames) uses authentic SubLoop structure: + * - Frames 0..11: emergence flame rise + * - Frames 12..36: 25-frame sustain loop + * - Extinction (ttl <= 12): reverse frame sequence (11 down to 0) + shrinking scale & alpha fade. + * 3. Constant Parity: `skills.ts` canonical missile definition matches Missiles.txt row 69 + * (id: 69, celFile: 'groundFireBig', animLen: 37). + */ +import { describe, expect, it, vi } from 'vitest' +import { GameEngine } from '../../../src/game/engine.ts' +import { castSkill, drawMissileProjectile } from '../../../src/scene/act-scene.ts' +import type { LoadedMissileArt } from '../../../src/scene/act-scene.ts' +import type { SpriteRenderer, AtlasHandle } from '../../../src/render/renderer.ts' +import { CANONICAL_113C_MISSILES, type Projectile } from '../../../src/game/skills.ts' +import { FIREWALL_META } from '../../../src/render/missiles-meta.ts' +import { DEMO_SKILLS } from '../../../src/game/demo-data.ts' + +describe('Issue #439: Fire Wall (#51) Collision Check & SubLoop Animation', () => { + it('correctly spawns all 15 fire wall patches on open walkable ground without false obstacle clipping', () => { + const engine = new GameEngine( + { widthPx: 2000, heightPx: 2000, overlap: () => 0 }, + { spawn: { x: 500, y: 500 }, stats: [], questDefs: [], npcDefs: [], skills: DEMO_SKILLS } as any, + ) + const player = engine.world.player + player.x = 500 + player.y = 500 + player.mana = 500 + player.cooldown = 0 + + // Construct a standard collision grid (all walkable: blocked array all 0s) + const cellsX = 20 + const cellsY = 20 + const subTilesX = cellsX * 5 + const subTilesY = cellsY * 5 + const mockGrid = { + cellsX, + cellsY, + gridWidth: subTilesX, + gridHeight: subTilesY, + blocked: new Uint8Array(subTilesX * subTilesY), + baseX: 0, + baseY: 0, + } + + const mockRuntime = { + grid: mockGrid, + walkable: { isWalkable: () => true }, + } as any + + const statusEl = { textContent: '' } as HTMLElement + + // Cast Fire Wall towards (700, 500) + const castOk = castSkill(51, 700, 500, { + engine, + runtime: mockRuntime, + hudManager: null, + status: statusEl, + }) + expect(castOk).toBe(true) + + // Verify all 15 patches were successfully spawned + const fireWallPatches = engine.projectiles.filter(p => p.missileType === 'firewall') + expect(fireWallPatches).toHaveLength(15) + + // Verify patch attributes + for (const patch of fireWallPatches) { + expect(patch.ttl).toBe(90) // 3.60s per Missiles.txt + expect(patch.fireDuration).toBe(90) + expect(patch.pierce).toBe(true) + expect(patch.damage).toBeGreaterThan(0) + } + }) + + it('animates firewall with emergence (0..11), sustain loop (12..36), and reverse extinction decay', () => { + const drawnCalls: Array<{ frameIdx: number; width?: number; height?: number; tint?: readonly number[] }> = [] + const mockRenderer = { + draw(frame: any, _x: number, _y: number, options: any = {}) { + drawnCalls.push({ + frameIdx: frame.frameIdx, + width: options.width, + height: options.height, + tint: options.tint, + }) + }, + drawSolid: vi.fn(), + } as unknown as SpriteRenderer + + const makeIndexedArt = (meta: any): LoadedMissileArt => ({ + meta, + handle: 1 as unknown as AtlasHandle, + frames: [ + meta.groups[0].map(([x, y, w, h, ax, ay]: number[], idx: number) => ({ + x, + y, + width: w, + height: h, + anchorX: ax, + anchorY: ay, + frameIdx: idx, + })), + ], + }) + + const firewallArt = makeIndexedArt(FIREWALL_META) + + // 1. Emergence: frames 0..11 + for (let elapsed = 0; elapsed < 12; elapsed++) { + drawnCalls.length = 0 + const shot: Projectile = { + skillId: '51', + missileType: 'firewall', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 10, + ttl: 90 - elapsed, + ageTicks: elapsed, + fromPlayer: true, + fireDuration: 90, + } + drawMissileProjectile(mockRenderer, shot, firewallArt) + expect(drawnCalls[0]!.frameIdx).toBe(elapsed) + } + + // 2. Sustain Loop: frames 12..36 + for (let elapsed = 12; elapsed < 50; elapsed++) { + drawnCalls.length = 0 + const shot: Projectile = { + skillId: '51', + missileType: 'firewall', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 10, + ttl: 90 - elapsed, + ageTicks: elapsed, + fromPlayer: true, + fireDuration: 90, + } + drawMissileProjectile(mockRenderer, shot, firewallArt) + const expectedFrame = 12 + ((elapsed - 12) % 25) + expect(drawnCalls[0]!.frameIdx).toBe(expectedFrame) + expect(drawnCalls[0]!.frameIdx).toBeGreaterThanOrEqual(12) + expect(drawnCalls[0]!.frameIdx).toBeLessThanOrEqual(36) + } + + // 3. Extinction Phase: ttl <= 12 + for (let ttl = 12; ttl >= 1; ttl--) { + drawnCalls.length = 0 + const shot: Projectile = { + skillId: '51', + missileType: 'firewall', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 10, + ttl, + ageTicks: 90 - ttl, + fromPlayer: true, + fireDuration: 90, + } + drawMissileProjectile(mockRenderer, shot, firewallArt) + const call = drawnCalls[0]! + const expectedFrame = Math.max(0, Math.min(11, ttl - 1)) + expect(call.frameIdx).toBe(expectedFrame) + expect(call.width).toBeDefined() + expect(call.tint).toBeDefined() + } + }) + + it('aligns canonical CANONICAL_113C_MISSILES.firewall with 1.13c Missiles.txt row 69', () => { + const fw = CANONICAL_113C_MISSILES['firewall'] + expect(fw).toBeDefined() + expect(fw!.id).toBe(69) + expect(fw!.celFile).toBe('groundFireBig') + expect(fw!.animLen).toBe(37) + expect(fw!.range).toBe(90) + }) +})