diff --git a/scripts/verify-monster-dispatch.ts b/scripts/verify-monster-dispatch.ts index e69290c..c13a9fd 100644 --- a/scripts/verify-monster-dispatch.ts +++ b/scripts/verify-monster-dispatch.ts @@ -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) } } diff --git a/src/game/engine.ts b/src/game/engine.ts index f03bd5e..cb06b3d 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -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, diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 523f8a6..65eea40 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -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({ diff --git a/src/game/skills.ts b/src/game/skills.ts index a0e7f7d..9f93805 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -815,6 +815,27 @@ export const CANONICAL_113C_MISSILES: Readonly> = 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, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 6e59b92..83d5cb7 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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) } }) diff --git a/tests/act-scene-monster-dispatch.test.ts b/tests/act-scene-monster-dispatch.test.ts index a23523b..e5d94b4 100644 --- a/tests/act-scene-monster-dispatch.test.ts +++ b/tests/act-scene-monster-dispatch.test.ts @@ -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([['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') + }) }) diff --git a/tests/skills/sor/adv-cold-fire-e2e-integration.test.ts b/tests/skills/sor/adv-cold-fire-e2e-integration.test.ts index 3e96092..b5429a8 100644 --- a/tests/skills/sor/adv-cold-fire-e2e-integration.test.ts +++ b/tests/skills/sor/adv-cold-fire-e2e-integration.test.ts @@ -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', () => {