diff --git a/public/missiles/blizzard1.json b/public/missiles/blizzard1.json new file mode 100644 index 0000000..557f486 --- /dev/null +++ b/public/missiles/blizzard1.json @@ -0,0 +1,61 @@ +{ + "name": "blizzard1", + "celFile": "icestormfallvar01", + "width": 512, + "height": 198, + "directions": 1, + "framesPerDirection": 6, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 56, + 198, + -50, + -208 + ], + [ + 57, + 0, + 56, + 198, + -50, + -208 + ], + [ + 114, + 0, + 56, + 198, + -50, + -208 + ], + [ + 171, + 0, + 56, + 198, + -50, + -208 + ], + [ + 228, + 0, + 56, + 198, + -50, + -208 + ], + [ + 285, + 0, + 56, + 198, + -50, + -208 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/blizzard1.png b/public/missiles/blizzard1.png new file mode 100644 index 0000000..47b7590 Binary files /dev/null and b/public/missiles/blizzard1.png differ diff --git a/public/missiles/blizzard2.json b/public/missiles/blizzard2.json new file mode 100644 index 0000000..c8aeef1 --- /dev/null +++ b/public/missiles/blizzard2.json @@ -0,0 +1,61 @@ +{ + "name": "blizzard2", + "celFile": "icestormfallvar02", + "width": 512, + "height": 195, + "directions": 1, + "framesPerDirection": 6, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 48, + 195, + -46, + -201 + ], + [ + 49, + 0, + 48, + 195, + -46, + -201 + ], + [ + 98, + 0, + 48, + 195, + -46, + -201 + ], + [ + 147, + 0, + 48, + 195, + -46, + -201 + ], + [ + 196, + 0, + 48, + 195, + -46, + -201 + ], + [ + 245, + 0, + 48, + 195, + -46, + -201 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/blizzard2.png b/public/missiles/blizzard2.png new file mode 100644 index 0000000..3a6792c Binary files /dev/null and b/public/missiles/blizzard2.png differ diff --git a/public/missiles/blizzard3.json b/public/missiles/blizzard3.json new file mode 100644 index 0000000..e9c6f85 --- /dev/null +++ b/public/missiles/blizzard3.json @@ -0,0 +1,77 @@ +{ + "name": "blizzard3", + "celFile": "Blizzard", + "width": 512, + "height": 307, + "directions": 1, + "framesPerDirection": 8, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 97, + 153, + -71, + -153 + ], + [ + 98, + 0, + 97, + 153, + -71, + -153 + ], + [ + 196, + 0, + 97, + 153, + -71, + -153 + ], + [ + 294, + 0, + 97, + 153, + -71, + -153 + ], + [ + 392, + 0, + 97, + 153, + -71, + -153 + ], + [ + 0, + 154, + 97, + 153, + -71, + -153 + ], + [ + 98, + 154, + 97, + 153, + -71, + -153 + ], + [ + 196, + 154, + 97, + 153, + -71, + -153 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/blizzard3.png b/public/missiles/blizzard3.png new file mode 100644 index 0000000..3b93d40 Binary files /dev/null and b/public/missiles/blizzard3.png differ diff --git a/public/missiles/blizzard4.json b/public/missiles/blizzard4.json new file mode 100644 index 0000000..38fd57a --- /dev/null +++ b/public/missiles/blizzard4.json @@ -0,0 +1,77 @@ +{ + "name": "blizzard4", + "celFile": "Blizzard", + "width": 512, + "height": 307, + "directions": 1, + "framesPerDirection": 8, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 97, + 153, + -71, + -153 + ], + [ + 98, + 0, + 97, + 153, + -71, + -153 + ], + [ + 196, + 0, + 97, + 153, + -71, + -153 + ], + [ + 294, + 0, + 97, + 153, + -71, + -153 + ], + [ + 392, + 0, + 97, + 153, + -71, + -153 + ], + [ + 0, + 154, + 97, + 153, + -71, + -153 + ], + [ + 98, + 154, + 97, + 153, + -71, + -153 + ], + [ + 196, + 154, + 97, + 153, + -71, + -153 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/blizzard4.png b/public/missiles/blizzard4.png new file mode 100644 index 0000000..3b93d40 Binary files /dev/null and b/public/missiles/blizzard4.png differ diff --git a/public/missiles/blizzardexplode1.json b/public/missiles/blizzardexplode1.json new file mode 100644 index 0000000..58378d4 --- /dev/null +++ b/public/missiles/blizzardexplode1.json @@ -0,0 +1,61 @@ +{ + "name": "blizzardexplode1", + "celFile": "icestormimpactvar01", + "width": 512, + "height": 145, + "directions": 1, + "framesPerDirection": 6, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 113, + 72, + -54, + -31 + ], + [ + 114, + 0, + 113, + 72, + -54, + -31 + ], + [ + 228, + 0, + 113, + 72, + -54, + -31 + ], + [ + 342, + 0, + 113, + 72, + -54, + -31 + ], + [ + 0, + 73, + 113, + 72, + -54, + -31 + ], + [ + 114, + 73, + 113, + 72, + -54, + -31 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/blizzardexplode1.png b/public/missiles/blizzardexplode1.png new file mode 100644 index 0000000..6c68449 Binary files /dev/null and b/public/missiles/blizzardexplode1.png differ diff --git a/public/missiles/blizzardexplode2.json b/public/missiles/blizzardexplode2.json new file mode 100644 index 0000000..809d8bd --- /dev/null +++ b/public/missiles/blizzardexplode2.json @@ -0,0 +1,61 @@ +{ + "name": "blizzardexplode2", + "celFile": "icestormimpactvar02", + "width": 512, + "height": 141, + "directions": 1, + "framesPerDirection": 6, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 117, + 70, + -55, + -30 + ], + [ + 118, + 0, + 117, + 70, + -55, + -30 + ], + [ + 236, + 0, + 117, + 70, + -55, + -30 + ], + [ + 354, + 0, + 117, + 70, + -55, + -30 + ], + [ + 0, + 71, + 117, + 70, + -55, + -30 + ], + [ + 118, + 71, + 117, + 70, + -55, + -30 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/blizzardexplode2.png b/public/missiles/blizzardexplode2.png new file mode 100644 index 0000000..a5ade64 Binary files /dev/null and b/public/missiles/blizzardexplode2.png differ diff --git a/public/missiles/blizzardexplode3.json b/public/missiles/blizzardexplode3.json new file mode 100644 index 0000000..6fb4262 --- /dev/null +++ b/public/missiles/blizzardexplode3.json @@ -0,0 +1,61 @@ +{ + "name": "blizzardexplode3", + "celFile": "icestormimpactvar02", + "width": 512, + "height": 141, + "directions": 1, + "framesPerDirection": 6, + "animSpeed": 16, + "groups": [ + [ + [ + 0, + 0, + 117, + 70, + -55, + -30 + ], + [ + 118, + 0, + 117, + 70, + -55, + -30 + ], + [ + 236, + 0, + 117, + 70, + -55, + -30 + ], + [ + 354, + 0, + 117, + 70, + -55, + -30 + ], + [ + 0, + 71, + 117, + 70, + -55, + -30 + ], + [ + 118, + 71, + 117, + 70, + -55, + -30 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/blizzardexplode3.png b/public/missiles/blizzardexplode3.png new file mode 100644 index 0000000..a5ade64 Binary files /dev/null and b/public/missiles/blizzardexplode3.png differ diff --git a/scripts/pack-missiles.ts b/scripts/pack-missiles.ts index d1758d6..f49d35d 100644 --- a/scripts/pack-missiles.ts +++ b/scripts/pack-missiles.ts @@ -17,7 +17,7 @@ * Enforces Zero Runtime MPQ/DLL Architectural Invariant. */ -import { mkdirSync, writeFileSync } from 'node:fs' +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' @@ -69,6 +69,11 @@ const MISSILE_TARGETS = [ { name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 }, { name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 }, { name: 'frozenorbexplode', celFile: 'IceOrbExplode', animSpeed: 16 }, + { name: 'blizzard1', celFile: 'icestormfallvar01', animSpeed: 16 }, + { name: 'blizzard2', celFile: 'icestormfallvar02', animSpeed: 16 }, + { name: 'blizzard3', celFile: 'Blizzard', animSpeed: 16 }, + { name: 'blizzardexplode1', celFile: 'icestormimpactvar01', animSpeed: 16 }, + { name: 'blizzardexplode2', celFile: 'icestormimpactvar02', animSpeed: 16 }, // Lightning { name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 }, @@ -227,6 +232,9 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise join(projectRoot, 'samples', 'd2', 'Patch_D2.mpq'), join(projectRoot, 'samples', 'd2', 'd2exp.mpq'), join(projectRoot, 'samples', 'd2', 'd2data.mpq'), + '/usr/local/google/home/taodao/d2-data/Patch_D2.mpq', + '/usr/local/google/home/taodao/d2-data/d2exp.mpq', + '/usr/local/google/home/taodao/d2-data/d2data.mpq', ] const archives: MpqArchive[] = [] for (const p of archivePaths) { @@ -301,6 +309,22 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise console.log(`[Missile Packer] Baked ${target.name} (${target.celFile}.dcc): ${packed.width}x${packed.height}, ${packed.png.byteLength} bytes PNG`) } + // Aliases for blizzard4 (from blizzard3) and blizzardexplode3 (from blizzardexplode2) + const copyAlias = (aliasName: string, sourceName: string) => { + const srcMeta = metaRecord[sourceName] + if (!srcMeta) return + const aliasMeta: MissileMeta = { ...srcMeta, name: aliasName } + const jsonStr = JSON.stringify(aliasMeta, null, 2) + writeFileSync(join(publicOutDir, `${aliasName}.json`), jsonStr, 'utf-8') + writeFileSync(join(packOutDir, `${aliasName}.json`), jsonStr, 'utf-8') + const srcPngBytes = readFileSync(join(publicOutDir, `${sourceName}.png`)) + writeFileSync(join(publicOutDir, `${aliasName}.png`), srcPngBytes) + writeFileSync(join(packOutDir, `${aliasName}.png`), srcPngBytes) + console.log(`[Missile Packer] Aliased ${aliasName} -> ${sourceName}`) + } + copyAlias('blizzard4', 'blizzard3') + copyAlias('blizzardexplode3', 'blizzardexplode2') + // Generate src/render/missiles-meta.ts const metaExports = MISSILE_TARGETS.map(t => { const upper = t.name.toUpperCase() @@ -401,8 +425,32 @@ export function velocityToDccDirection(vx: number, vy: number, directions = 16): ${metaExports} +export const BLIZZARD4_META: MissileMeta = { + ...BLIZZARD3_META, + name: 'blizzard4', +} + +export const BLIZZARDEXPLODE3_META: MissileMeta = { + ...BLIZZARDEXPLODE2_META, + name: 'blizzardexplode3', +} + +export const HYDRA_META: MissileMeta = { + ...FIREBOLT_META, + name: 'hydra', +} + +export const HYDRAFIRE_META: MissileMeta = { + ...FIREBOLT_META, + name: 'hydrafire', +} + export const MISSILE_METAS: Readonly> = Object.freeze({ ${mapEntries} + 'blizzard4': BLIZZARD4_META, + 'blizzardexplode3': BLIZZARDEXPLODE3_META, + 'hydra': HYDRA_META, + 'hydrafire': HYDRAFIRE_META, }) ` diff --git a/src/game/engine/blizzard-table.ts b/src/game/engine/blizzard-table.ts new file mode 100644 index 0000000..052bcac --- /dev/null +++ b/src/game/engine/blizzard-table.ts @@ -0,0 +1,65 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Blizzard (Skill 59) Circle Table. + * + * Authoritative 64-entry circle lookup table from D2Game.dll (VA 0x6FD1C960, 0x6FD1CA60). + * Table radius = 30 subtiles. + * + * Used by MISSMODE_SrvDo10_blizzardcenter (0x6FC60CA0) to disperse falling shards + * across a radius of 7 subtiles with 2:1 isometric ground projection (Y * 0.5). + */ + +export const BLIZZARD_CIRCLE_X: readonly number[] = Object.freeze([ + 0, 2, 5, 8, 11, 14, 16, 19, + 21, 23, 24, 26, 27, 28, 29, 29, + 30, 29, 29, 28, 27, 26, 24, 23, + 21, 19, 16, 14, 11, 8, 5, 2, + 0, -2, -5, -8, -11, -14, -16, -19, + -21, -23, -24, -26, -27, -28, -29, -29, + -30, -29, -29, -28, -27, -26, -24, -23, + -21, -19, -16, -14, -11, -8, -5, -2, +]) + +export const BLIZZARD_CIRCLE_Y: readonly number[] = Object.freeze([ + 30, 29, 29, 28, 27, 26, 24, 23, + 21, 19, 16, 14, 11, 8, 5, 2, + 0, -2, -5, -8, -11, -14, -16, -19, + -21, -23, -24, -26, -27, -28, -29, -29, + -30, -29, -29, -28, -27, -26, -24, -23, + -21, -19, -16, -14, -11, -8, -5, -2, + 0, 2, 5, 8, 11, 14, 16, 19, + 21, 23, 24, 26, 27, 28, 29, 29, +]) + +/** + * 2:1 Isometric ground projection ratio. + */ +export const ISO_GROUND_ASPECT_RATIO = 0.5 + +/** + * Default subtile to world pixel conversion ratio. + * 1 full tile = 160x80 px = 5x5 subtiles -> 1 subtile = 16 px half-width (32 px full-width). + */ +export const SUBTILE_TO_PX = 16 + +/** + * Calculates ground target offset for a Blizzard falling shard using the 64-entry circle table + * with 2:1 isometric ground foreshortening (Y * 0.5). + * + * @param angleIdx - Angle index in the 64-entry table (0..63) + * @param radiusSubtiles - Radius in subtiles (default: 7 from Skills.txt Param1) + * @param subtileToPx - Subtile to world pixel ratio (default: 16) + * @returns { dx, dy } in world pixel coordinates + */ +export function getBlizzardDropOffset( + angleIdx: number, + radiusSubtiles = 7, + subtileToPx = SUBTILE_TO_PX, +): { dx: number; dy: number } { + const idx = Math.abs(angleIdx % 64) + const tblX = BLIZZARD_CIRCLE_X[idx]! + const tblY = BLIZZARD_CIRCLE_Y[idx]! + const scale = (radiusSubtiles * subtileToPx) / 30 + const dx = tblX * scale + const dy = tblY * scale * ISO_GROUND_ASPECT_RATIO + return { dx, dy } +} diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 482c629..5ff61f5 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -16,6 +16,7 @@ */ import type { D2DataRegistry, MissileRecord } from './data-registry.ts' import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts' +import { getBlizzardDropOffset } from './blizzard-table.ts' export const ISO_GROUND_ASPECT_RATIO = 0.5 @@ -246,17 +247,20 @@ export class MissileEngine { const dx = params.targetX - params.startX const dy = params.targetY - params.startY const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx) - const speedPxPerTick = Math.max(8, effectiveRec.vel || 18) const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret') - const vx = isTurret ? 0 : Math.cos(angleRad) * speedPxPerTick - const vy = isTurret ? 0 : Math.sin(angleRad) * speedPxPerTick + const isStationary = + isTurret || + effectiveRec.vel === 0 || + effectiveRec.name.toLowerCase().startsWith('blizzard') + const speedPxPerTick = isStationary ? 0 : Math.max(8, effectiveRec.vel || 18) + const vx = isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick + const vy = isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick + const baseRange = effectiveRec.range !== undefined && effectiveRec.range > 0 ? effectiveRec.range : 35 + const calculatedRange = baseRange + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0) const maxRangeTicks = params.maxRangeTicks ?? ( isTurret ? 250 // 1.13c Hydra duration: 250 frames (10 seconds) - : Math.max( - 10, - (effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0), - ) + : calculatedRange ) const missile: ActiveMissile = { @@ -366,6 +370,35 @@ export class MissileEngine { } } + // `pSrvDoFunc = 10` (`blizzardcenter`): emits falling shards across 64-entry circle table + // Authentic D2Game.0x6FC60CA0 (MISSMODE_SrvDo10_blizzardcenter): + // - Trigger frequency: dwParam[2] (4 frames, Param3 in Skills.txt) + // - Angular step: 13 (coprime with 64) + // - Radius: 7 subtiles + // - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective + if (m.record.pSrvDoFunc === 10 || m.name === 'blizzardcenter') { + const cadence = Math.max(1, m.record.param3 || 4) + if (m.ageTicks % cadence === 0) { + const idx = Math.abs((m.subMissileIdx ?? 0) % 64) + const offset = getBlizzardDropOffset(idx, 7) + const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const + const shardName = shardVariants[Math.floor((m.ageTicks / cadence) - 1) % shardVariants.length]! + this.spawnMissile({ + missileNameOrId: shardName, + sourceSkillId: m.sourceSkillId, + slvl: m.slvl, + owner: m.owner, + startX: m.x + offset.dx, + startY: m.y + offset.dy, + targetX: m.x + offset.dx, + targetY: m.y + offset.dy, + dmgPacket: m.dmgPacket, + }) + subMissilesSpawned.push(shardName) + m.subMissileIdx = (idx + 13) % 64 + } + } + // Turret emitter (Hydra heads): stationary emitter periodically firing bolts at nearest target const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret') if (isTurretEmitter) { @@ -434,9 +467,14 @@ export class MissileEngine { continue } - // Main Frozen Orb (pSrvDoFunc === 15) and Hydra turret emitters do not deal direct contact damage; - // all damage is delivered through emitted child bolts. - if (m.record.pSrvDoFunc === 15 || isTurretEmitter) { + // Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), and Hydra turret emitters + // do not deal direct contact damage; all damage is delivered through emitted child bolts / shards. + if ( + m.record.pSrvDoFunc === 15 || + m.record.pSrvDoFunc === 10 || + m.name === 'blizzardcenter' || + isTurretEmitter + ) { continue } @@ -551,6 +589,25 @@ export class MissileEngine { if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) { m.pierceCount += 1 } else if (m.record.collideKill || !m.canPierce) { + if ( + m.name.startsWith('blizzard') && + !m.name.includes('explode') && + m.name !== 'blizzardcenter' + ) { + const expName = m.record.explosionMissile || 'blizzardexplode1' + this.spawnMissile({ + missileNameOrId: expName, + 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) + } m.expired = true break } @@ -558,6 +615,29 @@ export class MissileEngine { } if (m.ageTicks >= m.maxRangeTicks) { + // Blizzard falling shard ground impact explosion (Missiles 159..162) + if ( + m.name.startsWith('blizzard') && + !m.name.includes('explode') && + m.name !== 'blizzardcenter' && + (!m.currentHitFrame || m.currentHitFrame === 0) + ) { + m.currentHitFrame = 1 + const expName = m.record.explosionMissile || 'blizzardexplode1' + this.spawnMissile({ + missileNameOrId: expName, + 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) + } + // `pSrvHitFunc = 29` (`Frozen Orb` expiration burst) // Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb): // - Triggers only on first frame of hit (currentHitFrame === 0) diff --git a/src/game/skills.ts b/src/game/skills.ts index 8d5bf7e..fd8343a 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -26,6 +26,12 @@ import type { Rng } from './rng.ts' import type { CharacterClassCode } from './classes.ts' import type { CombatPlayer } from './combat.ts' import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.ts' +import { + BLIZZARD_CIRCLE_X, + BLIZZARD_CIRCLE_Y, + getBlizzardDropOffset, +} from './engine/blizzard-table.ts' +export { BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y, getBlizzardDropOffset } from './engine/blizzard-table.ts' /** One castable skill, read from a table row. */ export interface SkillDef { @@ -115,6 +121,8 @@ export interface Projectile { readonly spawnMissileType?: string | undefined /** Number of elapsed ticks this projectile has lived. */ readonly ageTicks?: number | undefined + /** Altitude above ground in pixels for falling missiles (e.g. Blizzard shards). */ + readonly altitude?: number | undefined } /** @@ -459,6 +467,166 @@ export const CANONICAL_113C_MISSILES: Readonly> = speedPxPerSec: 0, distancePx: 0, }, + blizzardcenter: { + name: 'blizzardcenter', + id: 158, + vel: 0, + maxVel: 0, + range: 100, + levRange: 0, + celFile: '', + animLen: 2, + animSpeed: 16, + loopAnim: 1, + numDirections: 1, + explosionMissile: '', + light: 0, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, + blizzard1: { + name: 'blizzard1', + id: 159, + vel: 0, + maxVel: 0, + range: 9, + levRange: 0, + celFile: 'icestormfallvar01', + animLen: 6, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: 'blizzardexplode1', + light: 4, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, + blizzard2: { + name: 'blizzard2', + id: 160, + vel: 0, + maxVel: 0, + range: 9, + levRange: 0, + celFile: 'icestormfallvar02', + animLen: 6, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: 'blizzardexplode2', + light: 4, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, + blizzard3: { + name: 'blizzard3', + id: 161, + vel: 0, + maxVel: 0, + range: 9, + levRange: 0, + celFile: 'Blizzard', + animLen: 8, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: 'blizzardexplode3', + light: 4, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, + blizzard4: { + name: 'blizzard4', + id: 162, + vel: 0, + maxVel: 0, + range: 9, + levRange: 0, + celFile: 'Blizzard', + animLen: 8, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: 'blizzardexplode1', + light: 4, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, + blizzardexplode1: { + name: 'blizzardexplode1', + id: 163, + vel: 0, + maxVel: 0, + range: 6, + levRange: 0, + celFile: 'icestormimpactvar01', + animLen: 6, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: '', + light: 6, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, + blizzardexplode2: { + name: 'blizzardexplode2', + id: 164, + vel: 0, + maxVel: 0, + range: 6, + levRange: 0, + celFile: 'icestormimpactvar02', + animLen: 6, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: '', + light: 6, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, + blizzardexplode3: { + name: 'blizzardexplode3', + id: 165, + vel: 0, + maxVel: 0, + range: 6, + levRange: 0, + celFile: 'icestormimpactvar01', + animLen: 6, + animSpeed: 16, + loopAnim: 0, + numDirections: 1, + explosionMissile: '', + light: 6, + red: 81, + green: 81, + blue: 255, + speedPxPerSec: 0, + distancePx: 0, + }, hydra: { name: 'hydra', id: 247, @@ -3257,6 +3425,30 @@ export interface ProjectileOutcome { readonly spawnedProjectiles?: readonly Projectile[] | undefined } +function getBlizzardExplosionMissile(missileType: string): string { + switch (missileType) { + case 'blizzard1': + return 'blizzardexplode1' + case 'blizzard2': + return 'blizzardexplode2' + case 'blizzard3': + return 'blizzardexplode3' + case 'blizzard4': + return 'blizzardexplode1' + default: + return 'blizzardexplode1' + } +} + +function isBlizzardShard(missileType?: string): boolean { + return ( + missileType === 'blizzard1' || + missileType === 'blizzard2' || + missileType === 'blizzard3' || + missileType === 'blizzard4' + ) +} + /** * Advance every projectile one tick. * @@ -3333,6 +3525,23 @@ export function tickProjectiles( missileType: 'frozenorb', aoeRadius: 24, }) + } else if (isBlizzardShard(projectile.missileType)) { + const expType = getBlizzardExplosionMissile(projectile.missileType!) + spawned.push({ + skillId: projectile.skillId, + x: projectile.x, + y: projectile.y, + vx: 0, + vy: 0, + damage: projectile.damage, + ttl: 6, + fromPlayer: projectile.fromPlayer, + missileType: expType, + statusEffect: 'chill', + statusDuration: 100, + aoeRadius: 32, + pierce: false, + }) } continue } @@ -3492,6 +3701,42 @@ export function tickProjectiles( } } + // 6. Blizzard Center Emitter (pSrvDoFunc = 10, Skill 59) + // Authentic D2 1.13c MISSMODE_SrvDo10_blizzardcenter (0x6FC60CA0): + // Emits 1 falling shard every 4 ticks (Param3 = 4) across 64-entry circle table (Radius 7) + // with step 13, for a total of exactly 25 shards over 100 ticks. + if (projectile.missileType === 'blizzardcenter') { + const currentAge = (projectile.ageTicks ?? 0) + 1 + if (currentAge % 4 === 0) { + const idx = Math.abs((subMissileIdx ?? 0) % 64) + const offset = getBlizzardDropOffset(idx, 7) + const shardVariants = ['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4'] as const + const shardType = shardVariants[Math.floor((currentAge / 4) - 1) % shardVariants.length]! + spawned.push({ + skillId: projectile.skillId, + x: projectile.x + offset.dx, + y: projectile.y + offset.dy, + vx: 0, + vy: 0, + altitude: 120, + damage: projectile.damage > 0 ? projectile.damage : 45, + ttl: 9, + fromPlayer: projectile.fromPlayer, + missileType: shardType, + statusEffect: 'chill', + statusDuration: 100, + pierce: false, + }) + subMissileIdx = (idx + 13) % 64 + } + } + + let altitude = projectile.altitude + if (isBlizzardShard(projectile.missileType)) { + if (altitude === undefined) altitude = 120 + altitude = Math.max(0, altitude - (120 / 9)) + } + const hitTargets = projectile.hitTargets ?? new Set() const moved: Projectile = { ...projectile, @@ -3501,6 +3746,7 @@ export function tickProjectiles( vy, spiral, subMissileIdx, + altitude, hitTargets, ageTicks: (projectile.ageTicks ?? 0) + 1, ttl: projectile.ttl - 1, @@ -3542,18 +3788,49 @@ export function tickProjectiles( pierce: false, }) } + } else if (isBlizzardShard(projectile.missileType)) { + const expType = getBlizzardExplosionMissile(projectile.missileType!) + spawned.push({ + skillId: projectile.skillId, + x, + y, + vx: 0, + vy: 0, + damage: 0, + ttl: 6, + fromPlayer: projectile.fromPlayer, + missileType: expType, + statusEffect: 'chill', + statusDuration: 100, + aoeRadius: 32, + pierce: false, + }) } continue } let struck = false - const isHarmlessEmitter = projectile.missileType === 'frozenorb' || isTurretEmitter + const isHarmlessEmitter = + projectile.missileType === 'frozenorb' || + projectile.missileType === 'blizzardcenter' || + isTurretEmitter if (!isHarmlessEmitter) { for (const target of targets) { if (!target.alive) continue if (hitTargets.has(target.index)) continue // already struck by piercing projectile if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue + const statusEffect = + projectile.statusEffect ?? + (isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode') + ? 'chill' + : undefined) + const statusDuration = + projectile.statusDuration ?? + (isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode') + ? 100 + : undefined) + hits.push({ targetIndex: target.index, damage: projectile.damage, @@ -3562,14 +3839,32 @@ export function tickProjectiles( skillId: projectile.skillId, missileType: projectile.missileType, aoeRadius: projectile.aoeRadius, - statusEffect: projectile.statusEffect, - statusDuration: projectile.statusDuration, + statusEffect, + statusDuration, isUndeadOnly: projectile.isUndeadOnly, }) hitTargets.add(target.index) if (!projectile.pierce) { struck = true + if (isBlizzardShard(projectile.missileType)) { + const expType = getBlizzardExplosionMissile(projectile.missileType!) + spawned.push({ + skillId: projectile.skillId, + x, + y, + vx: 0, + vy: 0, + damage: 0, + ttl: 6, + fromPlayer: projectile.fromPlayer, + missileType: expType, + statusEffect: 'chill', + statusDuration: 100, + aoeRadius: 32, + pierce: false, + }) + } break } } @@ -3822,6 +4117,66 @@ export function calculateSkillDamage( return { min, max } } +/** + * Calculates Blizzard (Skill 59) synergy multiplier: + * +5% damage per hard point in Ice Bolt (39), Ice Blast (45), and Glacial Spike (55). + */ +export function calculateBlizzardSynergyMultiplier(synergies: { + readonly iceBolt?: number | undefined + readonly iceBlast?: number | undefined + readonly glacialSpike?: number | undefined +}): number { + const points = (synergies.iceBolt ?? 0) + (synergies.iceBlast ?? 0) + (synergies.glacialSpike ?? 0) + return 1.0 + points * 0.05 +} + +/** + * Calculates Blizzard (Skill 59) min and max damage with 1.13c 5-band level scaling and synergies. + */ +export function calculateBlizzardDamage( + effectiveLevel: number, + synergies: { + readonly iceBolt?: number | undefined + readonly iceBlast?: number | undefined + readonly glacialSpike?: number | undefined + } = {}, +): { min: number; max: number } { + const syn = calculateBlizzardSynergyMultiplier(synergies) + const lvl = Math.max(1, Math.floor(effectiveLevel)) + let min = 45 + let max = 75 + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + min += b1 * 15 + max += b1 * 16 + + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + min += b2 * 30 + max += b2 * 31 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + min += b3 * 45 + max += b3 * 46 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + min += b4 * 55 + max += b4 * 56 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 65 + max += b5 * 66 + } + } + return { + min: Math.floor(min * syn), + max: Math.floor(max * syn), + } +} + /** Result of executing a Batch 1 skill. */ export type SkillExecutionResult = | { readonly kind: 'cooldown'; readonly ticksLeft: number } diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index 5cc40cf..eb32812 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -885,6 +885,108 @@ export const FROZENORBEXPLODE_META: MissileMeta = { ], } +export const BLIZZARD1_META: MissileMeta = { + name: "blizzard1", + celFile: "icestormfallvar01", + width: 512, + height: 198, + directions: 1, + framesPerDirection: 6, + animSpeed: 16, + groups: [ + [ + [0, 0, 56, 198, -50, -208], + [57, 0, 56, 198, -50, -208], + [114, 0, 56, 198, -50, -208], + [171, 0, 56, 198, -50, -208], + [228, 0, 56, 198, -50, -208], + [285, 0, 56, 198, -50, -208] + ] + ], +} + +export const BLIZZARD2_META: MissileMeta = { + name: "blizzard2", + celFile: "icestormfallvar02", + width: 512, + height: 195, + directions: 1, + framesPerDirection: 6, + animSpeed: 16, + groups: [ + [ + [0, 0, 48, 195, -46, -201], + [49, 0, 48, 195, -46, -201], + [98, 0, 48, 195, -46, -201], + [147, 0, 48, 195, -46, -201], + [196, 0, 48, 195, -46, -201], + [245, 0, 48, 195, -46, -201] + ] + ], +} + +export const BLIZZARD3_META: MissileMeta = { + name: "blizzard3", + celFile: "Blizzard", + width: 512, + height: 307, + directions: 1, + framesPerDirection: 8, + animSpeed: 16, + groups: [ + [ + [0, 0, 97, 153, -71, -153], + [98, 0, 97, 153, -71, -153], + [196, 0, 97, 153, -71, -153], + [294, 0, 97, 153, -71, -153], + [392, 0, 97, 153, -71, -153], + [0, 154, 97, 153, -71, -153], + [98, 154, 97, 153, -71, -153], + [196, 154, 97, 153, -71, -153] + ] + ], +} + +export const BLIZZARDEXPLODE1_META: MissileMeta = { + name: "blizzardexplode1", + celFile: "icestormimpactvar01", + width: 512, + height: 145, + directions: 1, + framesPerDirection: 6, + animSpeed: 16, + groups: [ + [ + [0, 0, 113, 72, -54, -31], + [114, 0, 113, 72, -54, -31], + [228, 0, 113, 72, -54, -31], + [342, 0, 113, 72, -54, -31], + [0, 73, 113, 72, -54, -31], + [114, 73, 113, 72, -54, -31] + ] + ], +} + +export const BLIZZARDEXPLODE2_META: MissileMeta = { + name: "blizzardexplode2", + celFile: "icestormimpactvar02", + width: 512, + height: 141, + directions: 1, + framesPerDirection: 6, + animSpeed: 16, + groups: [ + [ + [0, 0, 117, 70, -55, -30], + [118, 0, 117, 70, -55, -30], + [236, 0, 117, 70, -55, -30], + [354, 0, 117, 70, -55, -30], + [0, 71, 117, 70, -55, -30], + [118, 71, 117, 70, -55, -30] + ] + ], +} + export const CHARGEDBOLT_META: MissileMeta = { name: "chargedbolt", celFile: "ChargedBolt", @@ -4845,6 +4947,16 @@ export const LIGHTNINGJAVELIN_META: MissileMeta = { ], } +export const BLIZZARD4_META: MissileMeta = { + ...BLIZZARD3_META, + name: 'blizzard4', +} + +export const BLIZZARDEXPLODE3_META: MissileMeta = { + ...BLIZZARDEXPLODE2_META, + name: 'blizzardexplode3', +} + export const HYDRA_META: MissileMeta = { ...FIREBOLT_META, name: 'hydra', @@ -4857,8 +4969,6 @@ export const HYDRAFIRE_META: MissileMeta = { export const MISSILE_METAS: Readonly> = Object.freeze({ 'firebolt': FIREBOLT_META, - 'hydra': HYDRA_META, - 'hydrafire': HYDRAFIRE_META, 'fireball': FIREBALL_META, 'fireexplode': FIREEXPLODE_META, 'icebolt': ICEBOLT_META, @@ -4868,6 +4978,11 @@ export const MISSILE_METAS: Readonly> = Object.freez 'freezeexplode': FREEZEEXPLODE_META, 'frozenorb': FROZENORB_META, 'frozenorbexplode': FROZENORBEXPLODE_META, + 'blizzard1': BLIZZARD1_META, + 'blizzard2': BLIZZARD2_META, + 'blizzard3': BLIZZARD3_META, + 'blizzardexplode1': BLIZZARDEXPLODE1_META, + 'blizzardexplode2': BLIZZARDEXPLODE2_META, 'chargedbolt': CHARGEDBOLT_META, 'teeth': TEETH_META, 'teethexplode': TEETHEXPLODE_META, @@ -4882,4 +4997,8 @@ export const MISSILE_METAS: Readonly> = Object.freez 'icearrow': ICEARROW_META, 'arrow': ARROW_META, 'lightningjavelin': LIGHTNINGJAVELIN_META, + 'blizzard4': BLIZZARD4_META, + 'blizzardexplode3': BLIZZARDEXPLODE3_META, + 'hydra': HYDRA_META, + 'hydrafire': HYDRAFIRE_META, }) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 1a62df6..866b0d6 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -1078,7 +1078,7 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n case 21: return 4 case 40: return 7 case 42: return 9 - case 59: return 27 + case 59: return 23 case 60: return 12 case 62: return 20 case 64: return 30 @@ -1297,19 +1297,29 @@ export function castSkill( } if (skillId === 59) { - // Blizzard: AoE radius 120 around target, delay 45 ticks - let hitCount = 0 - for (const monster of engine.world.monsters) { - if (monster.state === 'dead' || monster.hp <= 0) continue - if (Math.hypot(monster.x - targetX, monster.y - targetY) <= 120) { - damageMonster(engine.world, monster.index, 35) - engine.metrics.castHits += 1 - hitCount++ - } - } + // Blizzard (Skill 59): spawn invisible blizzardcenter emitter missile at target coordinates + // 1.13c Skills.txt: Param1: 7 (radius subtiles), Param3: 4 (interval ticks), delay: 45 (1.8s cooldown) + // 1.13c Missiles.txt: blizzardcenter (ID 158), Range: 100 ticks (4.0s duration), Vel: 0 + const slvl = 1 + const dmgRange = calculateSkillDamage(59, slvl) + const blizzardDamage = Math.round((dmgRange.min + dmgRange.max) / 2) + + engine.projectiles.push({ + skillId: '59', + x: targetX, + y: targetY, + vx: 0, + vy: 0, + damage: blizzardDamage, + ttl: 100, // 4.00s at 25 fps + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: 0, + }) + player.cooldown = 45 engine.metrics.casts += 1 - status.textContent = `施展暴风雪,命中 ${hitCount} 个目标` + status.textContent = '施展暴风雪' return true } @@ -3664,6 +3674,12 @@ export async function loadMissileArtMap( if (!map.has('hydra')) map.set('hydra', fireboltArt) if (!map.has('hydrafire')) map.set('hydrafire', fireboltArt) } + if (map.has('blizzard3') && !map.has('blizzard4')) { + map.set('blizzard4', map.get('blizzard3')!) + } + if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) { + map.set('blizzardexplode3', map.get('blizzardexplode2')!) + } return map } @@ -3677,6 +3693,12 @@ export function drawMissileProjectile( shot: Projectile, missileArt?: LoadedMissileArt, ): void { + if (shot.missileType === 'blizzardcenter') { + return // Invisible controller missile + } + + const altitude = shot.altitude ?? 0 + if (missileArt !== undefined) { const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions) const frameCount = Math.max(1, missileArt.meta.framesPerDirection) @@ -3685,7 +3707,7 @@ export function drawMissileProjectile( const frame = group?.[frameIndex] ?? group?.[0] if (frame !== undefined) { const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2 - const drawY = frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2 + const drawY = (frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2) - altitude const isAdditive = missileArt.meta.name !== 'arrow' renderer.draw(frame, drawX, drawY, { atlas: missileArt.handle, @@ -3696,7 +3718,7 @@ export function drawMissileProjectile( } const x = shot.x - const y = shot.y + const y = shot.y - altitude if (shot.isTurret || shot.missileType === 'hydra_turret') { // Authentic stationary hydra head turret plume @@ -5256,9 +5278,15 @@ if (typeof window !== 'undefined') { } for (const shot of engine.projectiles) { + if (shot.missileType === 'blizzardcenter') continue pushEntity(shot.x, shot.y, () => { const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined) - const art = mType !== undefined ? (missileArtMap.get(mType) ?? (mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') : undefined)) : undefined + const art = mType !== undefined + ? (missileArtMap.get(mType) ?? + (mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') : + mType === 'blizzard4' ? missileArtMap.get('blizzard3') : + mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : undefined)) + : undefined if (art !== undefined) { drawMissileProjectile(renderer, shot, art) } else { @@ -5269,7 +5297,9 @@ if (typeof window !== 'undefined') { for (const exp of engine.explosions) { pushEntity(exp.x, exp.y, () => { - const art = missileArtMap.get(exp.missileType) ?? missileArtMap.get('fireexplode') + const art = + missileArtMap.get(exp.missileType) ?? + (exp.missileType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : missileArtMap.get('fireexplode')) drawExplosion(renderer, exp, art) }) } diff --git a/tests/blizzard-missile-113c.test.ts b/tests/blizzard-missile-113c.test.ts new file mode 100644 index 0000000..379d96b --- /dev/null +++ b/tests/blizzard-missile-113c.test.ts @@ -0,0 +1,481 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Skill #059: Blizzard (SOR) + * Missile Physics, 64-Entry Circle Emitter, Falling Altitude & 1.13c Parity Suite + * + * Requirements (Issue #400 / Blizzard Parity): + * 1. Missiles.txt ground truth: parses and verifies blizzardcenter (ID 158), blizzard1..4 (IDs 159..162), + * and blizzardexplode1..3 (IDs 163..165). + * 2. Visual assets & atlas metadata: BLIZZARD1..4_META (icestormfallvar01/02 and Blizzard.dcc), + * BLIZZARDEXPLODE1..3_META (icestormimpactvar01/02.dcc), baked PNG/JSON files exist. + * 3. Authoritative 64-entry circle table from D2Game.dll (VA 0x6FD1C960, 0x6FD1CA60), + * 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5), step 13 coprime rotation. + * 4. Controller missile blizzardcenter (pSrvDoFunc = 10): lives 100 ticks, emits 25 shards total + * (every 4 ticks), harmless emitter (no direct contact damage). + * 5. Falling shard vertical descent: altitude drops from 120 px to 0 over 9 ticks (120/9 px/tick). + * 6. Ground impact & wall detonation: spawns blizzardexplode1..3 upon expiry or obstacle collision. + * 7. NextDelay = 0 sweet-spot burst: multiple overlapping shards deal full damage without immunity windows. + */ +import { describe, expect, it } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { + parseMissilesTxt, + getMissileTxtData, + CANONICAL_113C_MISSILES, + tickProjectiles, + ISO_GROUND_ASPECT_RATIO, + calculateBlizzardDamage, + calculateBlizzardSynergyMultiplier, + type Projectile, +} from '../src/game/skills.ts' +import { + BLIZZARD_CIRCLE_X, + BLIZZARD_CIRCLE_Y, + getBlizzardDropOffset, +} from '../src/game/engine/blizzard-table.ts' +import { + BLIZZARD1_META, + BLIZZARD2_META, + BLIZZARD3_META, + BLIZZARD4_META, + BLIZZARDEXPLODE1_META, + BLIZZARDEXPLODE2_META, + BLIZZARDEXPLODE3_META, + MISSILE_METAS, +} from '../src/render/missiles-meta.ts' + +describe('[Skill #059] Blizzard — 1.13c Missile Physics & Runtime Parity (Issue #400)', () => { + const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt') + const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1') + + it('1. Parses authentic 1.13c Missiles.txt and verifies Blizzard missile suite (IDs 158..165)', () => { + const map = parseMissilesTxt(missilesTxtContent) + + // blizzardcenter (ID 158) + const center = map.get('blizzardcenter') + expect(center).toBeDefined() + expect(center!.id).toBe(158) + expect(center!.vel).toBe(0) + expect(center!.range).toBe(100) + expect(center!.animLen).toBe(2) + expect(center!.red).toBe(81) + expect(center!.green).toBe(81) + expect(center!.blue).toBe(255) + + // blizzard1 (ID 159) + const b1 = map.get('blizzard1') + expect(b1).toBeDefined() + expect(b1!.id).toBe(159) + expect(b1!.vel).toBe(0) + expect(b1!.range).toBe(9) + expect(b1!.celFile.toLowerCase()).toBe('icestormfallvar01') + expect(b1!.animLen).toBe(6) + + // blizzard2 (ID 160) + const b2 = map.get('blizzard2') + expect(b2).toBeDefined() + expect(b2!.id).toBe(160) + expect(b2!.vel).toBe(0) + expect(b2!.range).toBe(9) + expect(b2!.celFile.toLowerCase()).toBe('icestormfallvar02') + expect(b2!.animLen).toBe(6) + + // blizzard3 (ID 161) + const b3 = map.get('blizzard3') + expect(b3).toBeDefined() + expect(b3!.id).toBe(161) + expect(b3!.vel).toBe(0) + expect(b3!.range).toBe(9) + expect(b3!.celFile.toLowerCase()).toBe('blizzard') + expect(b3!.animLen).toBe(8) + + // blizzard4 (ID 162) + const b4 = map.get('blizzard4') + expect(b4).toBeDefined() + expect(b4!.id).toBe(162) + expect(b4!.vel).toBe(0) + expect(b4!.range).toBe(9) + expect(b4!.celFile.toLowerCase()).toBe('blizzard') + expect(b4!.animLen).toBe(8) + + // blizzardexplode1 (ID 163) + const exp1 = map.get('blizzardexplode1') + expect(exp1).toBeDefined() + expect(exp1!.id).toBe(163) + expect(exp1!.vel).toBe(0) + expect(exp1!.range).toBe(6) + expect(exp1!.celFile.toLowerCase()).toBe('icestormimpactvar01') + expect(exp1!.animLen).toBe(6) + + // blizzardexplode2 (ID 164) + const exp2 = map.get('blizzardexplode2') + expect(exp2).toBeDefined() + expect(exp2!.id).toBe(164) + expect(exp2!.vel).toBe(0) + expect(exp2!.range).toBe(6) + expect(exp2!.celFile.toLowerCase()).toBe('icestormimpactvar02') + expect(exp2!.animLen).toBe(6) + + // blizzardexplode3 (ID 165) + const exp3 = map.get('blizzardexplode3') + expect(exp3).toBeDefined() + expect(exp3!.id).toBe(165) + expect(exp3!.vel).toBe(0) + expect(exp3!.range).toBe(6) + expect(exp3!.celFile.toLowerCase()).toBe('icestormimpactvar02') + expect(exp3!.animLen).toBe(6) + }) + + it('2. CANONICAL_113C_MISSILES export contains Blizzard suite with exact 1.13c values', () => { + const center = getMissileTxtData('blizzardcenter') + expect(center.id).toBe(158) + expect(center.vel).toBe(0) + expect(center.range).toBe(100) + + const b1 = getMissileTxtData('blizzard1') + expect(b1.id).toBe(159) + expect(b1.vel).toBe(0) + expect(b1.range).toBe(9) + expect(b1.animLen).toBe(6) + expect(b1.explosionMissile).toBe('blizzardexplode1') + + const b2 = getMissileTxtData('blizzard2') + expect(b2.id).toBe(160) + expect(b2.range).toBe(9) + expect(b2.explosionMissile).toBe('blizzardexplode2') + + const b3 = getMissileTxtData('blizzard3') + expect(b3.id).toBe(161) + expect(b3.range).toBe(9) + expect(b3.animLen).toBe(8) + expect(b3.explosionMissile).toBe('blizzardexplode3') + + const b4 = getMissileTxtData('blizzard4') + expect(b4.id).toBe(162) + expect(b4.range).toBe(9) + expect(b4.animLen).toBe(8) + expect(b4.explosionMissile).toBe('blizzardexplode1') + + const exp1 = getMissileTxtData('blizzardexplode1') + expect(exp1.id).toBe(163) + expect(exp1.range).toBe(6) + + const exp2 = getMissileTxtData('blizzardexplode2') + expect(exp2.id).toBe(164) + expect(exp2.range).toBe(6) + + const exp3 = getMissileTxtData('blizzardexplode3') + expect(exp3.id).toBe(165) + expect(exp3.range).toBe(6) + }) + + it('3. Authentic baked DCC atlas metadata and files exist on disk with non-zero byte size', () => { + expect(BLIZZARD1_META.name).toBe('blizzard1') + expect(BLIZZARD1_META.celFile).toBe('icestormfallvar01') + expect(BLIZZARD1_META.framesPerDirection).toBe(6) + + expect(BLIZZARD2_META.name).toBe('blizzard2') + expect(BLIZZARD2_META.celFile).toBe('icestormfallvar02') + expect(BLIZZARD2_META.framesPerDirection).toBe(6) + + expect(BLIZZARD3_META.name).toBe('blizzard3') + expect(BLIZZARD3_META.celFile).toBe('Blizzard') + expect(BLIZZARD3_META.framesPerDirection).toBe(8) + + expect(BLIZZARD4_META.name).toBe('blizzard4') + expect(BLIZZARD4_META.celFile).toBe('Blizzard') + expect(BLIZZARD4_META.framesPerDirection).toBe(8) + + expect(BLIZZARDEXPLODE1_META.name).toBe('blizzardexplode1') + expect(BLIZZARDEXPLODE1_META.celFile).toBe('icestormimpactvar01') + expect(BLIZZARDEXPLODE1_META.framesPerDirection).toBe(6) + + expect(BLIZZARDEXPLODE2_META.name).toBe('blizzardexplode2') + expect(BLIZZARDEXPLODE2_META.celFile).toBe('icestormimpactvar02') + expect(BLIZZARDEXPLODE2_META.framesPerDirection).toBe(6) + + expect(BLIZZARDEXPLODE3_META.name).toBe('blizzardexplode3') + expect(BLIZZARDEXPLODE3_META.celFile).toBe('icestormimpactvar02') + expect(BLIZZARDEXPLODE3_META.framesPerDirection).toBe(6) + + expect(MISSILE_METAS['blizzard1']).toBe(BLIZZARD1_META) + expect(MISSILE_METAS['blizzard2']).toBe(BLIZZARD2_META) + expect(MISSILE_METAS['blizzard3']).toBe(BLIZZARD3_META) + expect(MISSILE_METAS['blizzard4']).toBe(BLIZZARD4_META) + expect(MISSILE_METAS['blizzardexplode1']).toBe(BLIZZARDEXPLODE1_META) + expect(MISSILE_METAS['blizzardexplode2']).toBe(BLIZZARDEXPLODE2_META) + expect(MISSILE_METAS['blizzardexplode3']).toBe(BLIZZARDEXPLODE3_META) + + const missileFiles = [ + 'blizzard1', + 'blizzard2', + 'blizzard3', + 'blizzard4', + 'blizzardexplode1', + 'blizzardexplode2', + 'blizzardexplode3', + ] + + for (const name of missileFiles) { + const pngPath = path.resolve(__dirname, `../public/missiles/${name}.png`) + const jsonPath = path.resolve(__dirname, `../public/missiles/${name}.json`) + expect(fs.existsSync(pngPath), `Missing ${pngPath}`).toBe(true) + expect(fs.statSync(pngPath).size).toBeGreaterThan(1000) + expect(fs.existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true) + expect(fs.statSync(jsonPath).size).toBeGreaterThan(100) + } + }) + + it('4. Authoritative 64-entry circle lookup table matches 1.13c binary assembly (radius 30, Y*0.5)', () => { + expect(BLIZZARD_CIRCLE_X.length).toBe(64) + expect(BLIZZARD_CIRCLE_Y.length).toBe(64) + + // Primary cardinal points + expect(BLIZZARD_CIRCLE_X[0]).toBe(0) + expect(BLIZZARD_CIRCLE_Y[0]).toBe(30) + expect(BLIZZARD_CIRCLE_X[16]).toBe(30) + expect(BLIZZARD_CIRCLE_Y[16]).toBe(0) + expect(BLIZZARD_CIRCLE_X[32]).toBe(0) + expect(BLIZZARD_CIRCLE_Y[32]).toBe(-30) + expect(BLIZZARD_CIRCLE_X[48]).toBe(-30) + expect(BLIZZARD_CIRCLE_Y[48]).toBe(0) + + // getBlizzardDropOffset checks + // At index 16 (X=30, Y=0), radius 7 subtiles: dx = 30 * (112/30) = 112, dy = 0 + const offEast = getBlizzardDropOffset(16, 7, 16) + expect(offEast.dx).toBe(112) + expect(offEast.dy).toBe(0) + + // At index 0 (X=0, Y=30), radius 7 subtiles: dx = 0, dy = 30 * (112/30) * 0.5 = 56 + const offSouth = getBlizzardDropOffset(0, 7, 16) + expect(offSouth.dx).toBe(0) + expect(offSouth.dy).toBe(56) + + // 2:1 isometric aspect ratio verification + expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5) + }) + + it('5. tickProjectiles: blizzardcenter lives 100 ticks and emits exactly 25 shards (cadence = 4 ticks, step = 13)', () => { + let center: Projectile = { + skillId: '59', + x: 300, + y: 300, + vx: 0, + vy: 0, + damage: 150, + ttl: 100, // 100 frames = 4.0s + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: 0, + } + + const allEmittedShards: Projectile[] = [] + const openTerrain = { overlap: () => 0 } + + for (let tick = 1; tick <= 100; tick += 1) { + const outcome = tickProjectiles([center], [], openTerrain) + if (tick % 4 === 0) { + expect(outcome.spawnedProjectiles).toBeDefined() + expect(outcome.spawnedProjectiles!.length).toBe(1) + const shard = outcome.spawnedProjectiles![0]! + expect(['blizzard1', 'blizzard2', 'blizzard3', 'blizzard4']).toContain(shard.missileType) + expect(shard.damage).toBe(150) + expect(shard.statusEffect).toBe('chill') + expect(shard.statusDuration).toBe(100) + expect(shard.ttl).toBe(9) + expect(shard.altitude).toBe(120) + allEmittedShards.push(shard) + } else { + expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0) + } + + // Center must remain alive until tick 100 + center = outcome.alive.find(p => p.missileType === 'blizzardcenter')! + if (tick < 100) { + expect(center).toBeDefined() + expect(center.ttl).toBe(100 - tick) + } + } + + // Exactly 25 shards produced over 100 ticks (100 / 4) + expect(allEmittedShards.length).toBe(25) + + // At tick 101, center expires + const expiredOutcome = tickProjectiles([center], [], openTerrain) + expect(expiredOutcome.expired).toBe(1) + expect(expiredOutcome.alive.length).toBe(0) + }) + + it('6. Main blizzardcenter is a harmless emitter and pierces monsters without dealing direct contact damage', () => { + const center: Projectile = { + skillId: '59', + x: 200, + y: 200, + vx: 0, + vy: 0, + damage: 100, + ttl: 20, + fromPlayer: true, + missileType: 'blizzardcenter', + subMissileIdx: 0, + } + + const monster = [{ index: 0, x: 200, y: 200, radius: 25, alive: true }] + const outcome = tickProjectiles([center], monster, { overlap: () => 0 }) + + // Center must not trigger hits or take damage on the monster + expect(outcome.hits.length).toBe(0) + expect(outcome.alive.some(p => p.missileType === 'blizzardcenter')).toBe(true) + }) + + it('7. Falling shard altitude descends vertically from 120 px to 0 over 9 ticks and detonates on ground impact', () => { + let shard: Projectile = { + skillId: '59', + x: 150, + y: 150, + vx: 0, + vy: 0, + altitude: 120, + damage: 80, + ttl: 9, + fromPlayer: true, + missileType: 'blizzard1', + } + + const openTerrain = { overlap: () => 0 } + + for (let tick = 1; tick <= 9; tick += 1) { + const outcome = tickProjectiles([shard], [], openTerrain) + shard = outcome.alive.find(p => p.missileType === 'blizzard1')! + if (tick < 9) { + expect(shard).toBeDefined() + expect(shard.ttl).toBe(9 - tick) + // Decrements by 120 / 9 px per tick + const expectedAltitude = 120 - tick * (120 / 9) + expect(shard.altitude).toBeCloseTo(expectedAltitude, 0.1) + } else { + // At tick 9, ttl reached 0 + expect(shard.ttl).toBe(0) + } + } + + // Next tick: shard with ttl 0 expires and detonates ground impact explosion + const impactOutcome = tickProjectiles([shard], [], openTerrain) + expect(impactOutcome.expired).toBe(1) + expect(impactOutcome.spawnedProjectiles).toBeDefined() + expect(impactOutcome.spawnedProjectiles!.length).toBe(1) + + const exp = impactOutcome.spawnedProjectiles![0]! + expect(exp.missileType).toBe('blizzardexplode1') + expect(exp.damage).toBe(80) + expect(exp.ttl).toBe(6) // 1.13c Range = 6 + expect(exp.statusEffect).toBe('chill') + expect(exp.statusDuration).toBe(100) // 100 ticks chill length + }) + + it('8. Wall collision halts descending shard and spawns impact explosion', () => { + const shard: Projectile = { + skillId: '59', + x: 100, + y: 100, + vx: 0, + vy: 0, + altitude: 80, + damage: 90, + ttl: 7, + fromPlayer: true, + missileType: 'blizzard2', + } + + // Solid wall at (100, 100) + const wallTerrain = { overlap: () => 1 } + const outcome = tickProjectiles([shard], [], wallTerrain) + + expect(outcome.wallHits).toBe(1) + expect(outcome.alive.some(p => p.missileType === 'blizzard2')).toBe(false) + expect(outcome.spawnedProjectiles).toBeDefined() + expect(outcome.spawnedProjectiles!.length).toBe(1) + + const exp = outcome.spawnedProjectiles![0]! + expect(exp.missileType).toBe('blizzardexplode2') + expect(exp.ttl).toBe(6) + }) + + it('9. Direct monster hit while falling deals cold damage and chill without immunity window (NextDelay = 0)', () => { + const shard1: Projectile = { + skillId: '59', + x: 100, + y: 100, + vx: 0, + vy: 0, + damage: 100, + ttl: 5, + fromPlayer: true, + missileType: 'blizzard1', + } + const shard2: Projectile = { + skillId: '59', + x: 102, + y: 100, + vx: 0, + vy: 0, + damage: 100, + ttl: 5, + fromPlayer: true, + missileType: 'blizzard2', + } + + const dummyMonster = [{ index: 1, x: 101, y: 100, radius: 12, alive: true }] + const outcome = tickProjectiles([shard1, shard2], dummyMonster, { overlap: () => 0 }) + + // NextDelay = 0 allows both shards to hit the monster in the same frame + expect(outcome.hits.length).toBe(2) + for (const hit of outcome.hits) { + expect(hit.targetIndex).toBe(1) + expect(hit.damage).toBe(100) + expect(hit.statusEffect).toBe('chill') + expect(hit.statusDuration).toBe(100) + } + + // Both shards must trigger explosion + expect(outcome.spawnedProjectiles!.length).toBe(2) + expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode1')).toBe(true) + expect(outcome.spawnedProjectiles!.some(p => p.missileType === 'blizzardexplode2')).toBe(true) + }) + + it('10. calculateBlizzardDamage and calculateBlizzardSynergyMultiplier evaluate 5-band formulas & synergies', () => { + // slvl 1: 45 - 75 + const d1 = calculateBlizzardDamage(1) + expect(d1.min).toBe(45) + expect(d1.max).toBe(75) + + // slvl 10: 210 - 249 + const d10 = calculateBlizzardDamage(10) + expect(d10.min).toBe(210) + expect(d10.max).toBe(249) + + // slvl 20: 570 - 619 + const d20 = calculateBlizzardDamage(20) + expect(d20.min).toBe(570) + expect(d20.max).toBe(619) + + // slvl 30: 1120 - 1179 + const d30 = calculateBlizzardDamage(30) + expect(d30.min).toBe(1120) + expect(d30.max).toBe(1179) + + // Synergy: 20 points in Ice Bolt (+100% = 2.0x) + const syn20 = calculateBlizzardSynergyMultiplier({ iceBolt: 20 }) + expect(syn20).toBe(2.0) + const d20Syn = calculateBlizzardDamage(20, { iceBolt: 20 }) + expect(d20Syn.min).toBe(1140) // 570 * 2.0 + expect(d20Syn.max).toBe(1238) // 619 * 2.0 + + // Full synergies: 20 in Ice Bolt, 20 in Ice Blast, 20 in Glacial Spike (+300% = 4.0x) + const syn60 = calculateBlizzardSynergyMultiplier({ iceBolt: 20, iceBlast: 20, glacialSpike: 20 }) + expect(syn60).toBe(4.0) + const d20Full = calculateBlizzardDamage(20, { iceBolt: 20, iceBlast: 20, glacialSpike: 20 }) + expect(d20Full.min).toBe(2280) // 570 * 4.0 + expect(d20Full.max).toBe(2476) // 619 * 4.0 + }) +})