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: {
|
firewall: {
|
||||||
name: 'firewall',
|
name: 'firewall',
|
||||||
id: 83,
|
id: 69,
|
||||||
vel: 0,
|
vel: 0,
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
range: 90,
|
range: 90,
|
||||||
levRange: 0,
|
levRange: 0,
|
||||||
celFile: 'FireWall',
|
celFile: 'groundFireBig',
|
||||||
animLen: 15,
|
animLen: 37,
|
||||||
animSpeed: 16,
|
animSpeed: 16,
|
||||||
loopAnim: 1,
|
loopAnim: 1,
|
||||||
numDirections: 1,
|
numDirections: 1,
|
||||||
explosionMissile: '',
|
explosionMissile: '',
|
||||||
light: 10,
|
light: 5,
|
||||||
red: 255,
|
red: 255,
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import { ViewportCamera } from "../sim/camera.ts"
|
||||||
import type { Palette } from '../formats/pal.ts'
|
import type { Palette } from '../formats/pal.ts'
|
||||||
import {
|
import {
|
||||||
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
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'
|
} from '../game/d2map.ts'
|
||||||
import type { CharacterClassCode } from '../game/classes.ts'
|
import type { CharacterClassCode } from '../game/classes.ts'
|
||||||
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
||||||
|
|
@ -2003,9 +2003,13 @@ export function castSkill(
|
||||||
spacing: 24,
|
spacing: 24,
|
||||||
isBlocked: (x, y) => {
|
isBlocked: (x, y) => {
|
||||||
if (runtime?.grid) {
|
if (runtime?.grid) {
|
||||||
const c = cellOf(runtime.grid, x, y)
|
return isMissileBlockedAt(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
|
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
|
return false
|
||||||
},
|
},
|
||||||
|
|
@ -2020,6 +2024,7 @@ export function castSkill(
|
||||||
vy: 0,
|
vy: 0,
|
||||||
damage: fireWallDamage,
|
damage: fireWallDamage,
|
||||||
ttl: 90, // 3.60s per Missiles.txt Range = 90
|
ttl: 90, // 3.60s per Missiles.txt Range = 90
|
||||||
|
fireDuration: 90,
|
||||||
fromPlayer: true,
|
fromPlayer: true,
|
||||||
missileType: 'firewall',
|
missileType: 'firewall',
|
||||||
pierce: true,
|
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
|
// 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)
|
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
||||||
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
||||||
} else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) {
|
} else if (
|
||||||
// For meteorfire: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
|
(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)).
|
// 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.
|
// 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 elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
|
||||||
const EXTINCTION_TICKS = 12
|
const EXTINCTION_TICKS = 12
|
||||||
if (shot.ttl <= EXTINCTION_TICKS) {
|
if (shot.ttl <= EXTINCTION_TICKS) {
|
||||||
|
|
@ -5305,7 +5321,11 @@ export function drawMissileProjectile(
|
||||||
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
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 decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
||||||
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
const scale = isExtinguishing ? 0.2 + 0.8 * decayRatio : 1.0
|
||||||
const opacity = isExtinguishing ? decayRatio : 1.0
|
const opacity = isExtinguishing ? decayRatio : 1.0
|
||||||
|
|
@ -5454,7 +5474,11 @@ export function drawMissileProjectile(
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shot.missileType === 'meteorfire') {
|
if (
|
||||||
|
shot.missileType === 'meteorfire' ||
|
||||||
|
shot.missileType === 'firewall' ||
|
||||||
|
shot.missileType === 'groundfirebig'
|
||||||
|
) {
|
||||||
const isExtinguishing = shot.ttl <= 12
|
const isExtinguishing = shot.ttl <= 12
|
||||||
const decayRatio = isExtinguishing ? Math.max(0, Math.min(1, shot.ttl / 12)) : 1.0
|
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 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