fix(skill): implement Glacial Spike explosion and 64px AoE freeze (Fixes #430)

This commit is contained in:
troytt 2026-09-24 12:23:39 +00:00
parent 0aa6c3680d
commit 71466df5e7
7 changed files with 219 additions and 9 deletions

View File

@ -70,6 +70,7 @@ export interface MonsterSimInput {
y: number
stats: { id: string }
chillTicks?: number
frozenTicks?: number
}
export interface AnimatorSimInput {
@ -125,13 +126,18 @@ export function executeMonsterRenderLoop(
const drawY = frame.anchorY !== undefined
? monster.y + frame.anchorY
: monster.y - frame.height + FEET_HEIGHT / 2
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal')
renderer.draw(frame, drawX, drawY, {
atlas: art.handle,
blendMode: baseBlendMode,
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
...(isFrozen
? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 }
: isChilled
? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 }
: {}),
})
if (art.glowGroups !== undefined) {
const glowGroup = art.glowGroups[groupIndex]
@ -151,8 +157,13 @@ export function executeMonsterRenderLoop(
if (!missingArtList.includes(missingId)) {
missingArtList.push(missingId)
}
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
const tint = isChilled ? ([0.4, 0.65, 1.0, 1.0] as const) : ([0.9, 0.2, 0.2, 1.0] as const)
const tint = isFrozen
? ([0.25, 0.6, 1.0, 1.0] as const)
: isChilled
? ([0.4, 0.65, 1.0, 1.0] as const)
: ([0.9, 0.2, 0.2, 1.0] as const)
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, tint)
}
}

View File

@ -550,6 +550,17 @@ export class GameEngine {
maxFrames,
radius: hit.aoeRadius ?? 24,
})
if (missileType === 'glacialspike' || hit.skillId === '55' || hit.skillId === 'glacialspike') {
const ejectaData = CANONICAL_113C_MISSILES['glacialspikeejecta']
this.explosions.push({
missileType: 'glacialspikeejecta',
x: hit.x,
y: hit.y,
frame: 0,
maxFrames: ejectaData?.animLen || 15,
radius: hit.aoeRadius ?? 64,
})
}
this.world.events.push({
kind: 'missileExplode',
missileType: explType,
@ -567,7 +578,15 @@ export class GameEngine {
for (const m of this.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
if (Math.hypot(m.x - wallHit.x, m.y - wallHit.y) <= wallHit.aoeRadius) {
damageMonster(this.world, m.index, 10)
const dmg = wallHit.damage ?? 10
damageMonster(this.world, m.index, dmg)
if (wallHit.statusEffect === 'freeze' || wallHit.missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') {
m.frozenTicks = wallHit.statusDuration ?? 75
} else if (wallHit.statusEffect === 'chill') {
m.chillTicks = wallHit.statusDuration ?? 100
} else if (wallHit.statusEffect === 'poison') {
m.poisonTicks = wallHit.statusDuration ?? 100
}
}
}
}
@ -585,6 +604,17 @@ export class GameEngine {
maxFrames,
radius: wallHit.aoeRadius ?? 24,
})
if (missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') {
const ejectaData = CANONICAL_113C_MISSILES['glacialspikeejecta']
this.explosions.push({
missileType: 'glacialspikeejecta',
x: wallHit.x,
y: wallHit.y,
frame: 0,
maxFrames: ejectaData?.animLen || 15,
radius: wallHit.aoeRadius ?? 64,
})
}
this.world.events.push({
kind: 'missileExplode',
missileType: explType,

View File

@ -1145,6 +1145,17 @@ export class MissileEngine {
targetY: hitWallY,
dmgPacket: m.dmgPacket,
})
this.spawnMissile({
missileNameOrId: 'glacialspikeejecta',
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: hitWallX,
startY: hitWallY,
targetX: hitWallX,
targetY: hitWallY,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
subMissilesSpawned.push('glacialspikeejecta')
} else if (m.name === 'iceblast' || m.sourceSkillId === 45) {
@ -1463,7 +1474,19 @@ export class MissileEngine {
targetY: m.y,
dmgPacket: m.dmgPacket,
})
this.spawnMissile({
missileNameOrId: 'glacialspikeejecta',
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x,
targetY: m.y,
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(expName)
subMissilesSpawned.push('glacialspikeejecta')
} else if (m.name === 'teeth') {
const expName = m.record.explosionMissile || 'teethexplode'
this.spawnMissile({

View File

@ -815,6 +815,27 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
trans: 1,
distancePx: 0,
},
glacialspikeejecta: {
name: 'glacialspikeejecta',
id: 89,
vel: 0,
maxVel: 0,
range: 15,
levRange: 0,
celFile: 'FreezeExplodeEjecta',
animLen: 15,
animSpeed: 16,
loopAnim: 0,
numDirections: 8,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
trans: 1,
distancePx: 0,
},
frozenorb: {
name: 'frozenorb',
id: 260,
@ -4384,6 +4405,9 @@ export interface ProjectileOutcome {
readonly skillId?: string | undefined
readonly missileType?: string | undefined
readonly aoeRadius?: number | undefined
readonly damage?: number | undefined
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
readonly statusDuration?: number | undefined
}[] | undefined
/** Newly spawned child projectiles during this tick (e.g. Frozen Orb bolts / nova). */
readonly spawnedProjectiles?: readonly Projectile[] | undefined
@ -4454,6 +4478,9 @@ export function tickProjectiles(
skillId?: string | undefined
missileType?: string | undefined
aoeRadius?: number | undefined
damage?: number | undefined
statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
statusDuration?: number | undefined
}[] = []
let expired = 0
let wallHits = 0
@ -4848,6 +4875,9 @@ export function tickProjectiles(
skillId: projectile.skillId,
missileType: projectile.missileType,
aoeRadius: projectile.aoeRadius,
damage: projectile.damage,
statusEffect: projectile.statusEffect,
statusDuration: projectile.statusDuration,
})
if (projectile.missileType === 'frozenorb') {
for (let i = 0; i < 64; i += 4) {
@ -4871,6 +4901,33 @@ export function tickProjectiles(
pierce: false,
})
}
} else if (projectile.missileType === 'glacialspike') {
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'freezeexplode',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'glacialspikeejecta',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
} else if (isBlizzardShard(projectile.missileType)) {
const expType = getBlizzardExplosionMissile(projectile.missileType!)
spawned.push({
@ -4900,6 +4957,8 @@ export function tickProjectiles(
projectile.missileType === 'meteor' ||
projectile.missileType === 'meteortail' ||
projectile.missileType === 'firewallmaker' ||
projectile.missileType === 'freezeexplode' ||
projectile.missileType === 'glacialspikeejecta' ||
isTurretEmitter
if (!isHarmlessEmitter) {
for (const target of targets) {
@ -5015,7 +5074,34 @@ export function tickProjectiles(
if (!projectile.pierce) {
struck = true
if (isBlizzardShard(projectile.missileType)) {
if (projectile.missileType === 'glacialspike') {
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'freezeexplode',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
spawned.push({
skillId: projectile.skillId,
x,
y,
vx: 0,
vy: 0,
damage: 0,
ttl: 15,
fromPlayer: projectile.fromPlayer,
missileType: 'glacialspikeejecta',
aoeRadius: projectile.aoeRadius ?? 64,
pierce: true,
})
} else if (isBlizzardShard(projectile.missileType)) {
const expType = getBlizzardExplosionMissile(projectile.missileType!)
spawned.push({
skillId: projectile.skillId,

View File

@ -5090,6 +5090,21 @@ export function drawExplosion(
if (explosionArt !== undefined) {
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
if (explosionArt.meta.directions > 1) {
for (let dir = 0; dir < explosionArt.meta.directions; dir++) {
const group = explosionArt.frames[dir]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
renderer.draw(frame, drawX, drawY, {
atlas: explosionArt.handle,
blendMode: 'additive',
})
}
}
return
}
const group = explosionArt.frames[0]
const frame = group?.[frameIndex] ?? group?.[0]
if (frame !== undefined) {
@ -6382,7 +6397,9 @@ if (typeof window !== 'undefined') {
const clip = art.clips['nu'] ?? art.clips['wl']
if (clip !== undefined) animator.play('nu', clip)
}
animator.tick()
if (!monster.frozenTicks || monster.frozenTicks <= 0) {
animator.tick()
}
}
}
for (const idx of monsterAnimators.keys()) {
@ -6770,6 +6787,7 @@ if (typeof window !== 'undefined') {
const drawY = frame.anchorY !== undefined
? monster.y + frame.anchorY
: monster.y - frame.height + FEET_HEIGHT / 2
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
const isAdditiveFallback = ADDITIVE_MONSTER_TOKENS.has(art.token.toUpperCase())
const baseBlendMode = art.blendMode ?? (isAdditiveFallback ? 'additive' : 'normal')
@ -6777,7 +6795,11 @@ if (typeof window !== 'undefined') {
atlas: art.handle,
blendMode: baseBlendMode,
elevationY: Math.max(0, frame.height - 16),
...(isChilled ? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 } : {}),
...(isFrozen
? { tint: [0.25, 0.6, 1.0, 1.0] as const, paletteRow: 1 }
: isChilled
? { tint: [0.4, 0.65, 1.0, 1.0] as const, paletteRow: 1 }
: {}),
})
if (art.glowGroups !== undefined) {
const glowGroup = art.glowGroups[groupIndex]
@ -6798,8 +6820,13 @@ if (typeof window !== 'undefined') {
state.missingMonsterArt.push(missingId)
console.error(`monster art missing at draw time: ${missingId} (drawn as a red box)`)
}
const isFrozen = monster.frozenTicks !== undefined && monster.frozenTicks > 0
const isChilled = monster.chillTicks !== undefined && monster.chillTicks > 0
const tint = isChilled ? ([0.4, 0.65, 1.0, 1.0] as const) : ([0.9, 0.2, 0.2, 1.0] as const)
const tint = isFrozen
? ([0.25, 0.6, 1.0, 1.0] as const)
: isChilled
? ([0.4, 0.65, 1.0, 1.0] as const)
: ([0.9, 0.2, 0.2, 1.0] as const)
renderer.drawSolid(monster.x - MARKER_WIDTH / 2, monster.y - MARKER_HEIGHT, MARKER_WIDTH, MARKER_HEIGHT, tint)
}
})

View File

@ -272,4 +272,33 @@ describe('Monster Rendering Dispatch in act-scene.ts (Milestone 2 / R2)', () =>
expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal')
expect(renderer.drawCalls[1]!.options.blendMode).toBe('additive')
})
it('Case 8: Frozen monster applies ice blue tint [0.25, 0.6, 1.0, 1.0] and paletteRow: 1 to base body without polluting additive glow pass', () => {
const renderer = new MockRenderer()
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [64, 64] })
const artMap = new Map<string, LoadedMonsterArt>([['frozen_fr', art]])
const monster: MonsterSimInput = {
index: 9,
state: 'chase',
corpseTicks: 0,
facing: 0,
x: 200,
y: 300,
stats: { id: 'frozen_fr' },
frozenTicks: 75,
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(2)
const [baseCall, glowCall] = renderer.drawCalls
expect(baseCall!.options.paletteRow).toBe(1)
expect(baseCall!.options.tint).toEqual([0.25, 0.6, 1.0, 1.0])
expect(baseCall!.options.blendMode).toBe('normal')
expect(glowCall!.options.paletteRow).toBeUndefined()
expect(glowCall!.options.tint).toBeUndefined()
expect(glowCall!.options.blendMode).toBe('additive')
})
})

View File

@ -1170,8 +1170,12 @@ describe('Diablo II v1.13c Sorceress Cold & Fire Cross-Tree E2E Integration Stre
// Fire Wall struck 5 times (once per tick): 5 * 50 = 250 fire damage!
expect(totalFireDamage).toBe(250)
// Fire Wall patch is still burning (30 - 5 = 25 ttl)
expect(projs).toHaveLength(1)
expect(projs[0]!.ttl).toBe(25)
const fwPatches = projs.filter(p => p.missileType === 'firewall')
expect(fwPatches).toHaveLength(1)
expect(fwPatches[0]!.ttl).toBe(25)
// Glacial Spike explosion & ejecta submissiles are active
expect(projs.some(p => p.missileType === 'freezeexplode')).toBe(true)
expect(projs.some(p => p.missileType === 'glacialspikeejecta')).toBe(true)
})
it('verifies player running with Blaze active generates footstep patches while casting Glacial Spike with zero self-damage', () => {