fix(skill): fix Fire Wall collision indexing and implement SubLoop flame animation (Fixes #439)
This commit is contained in:
parent
ebea702572
commit
f107d05dd2
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue