fix(skill): fix Fire Wall collision indexing and implement SubLoop flame animation (Fixes #439)

This commit is contained in:
troytt 2026-09-24 16:28:29 +00:00
parent ebea702572
commit f107d05dd2
3 changed files with 224 additions and 13 deletions

View File

@ -1211,18 +1211,18 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
},
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,

View File

@ -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

View File

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