feat(skills): implement Blizzard (Skill 59) with 1.13c parity and baked missile assets (Ref #400)
- Add authoritative 64-entry circle lookup table (BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y) matching D2Game.dll 0x6FD1C960 with 2:1 isometric projection (ISO_GROUND_ASPECT_RATIO = 0.5) - Pack authentic DCC missile sprites (blizzard1..3, blizzardexplode1..2, blizzard4 alias, blizzardexplode3 alias) from d2data.mpq into public/missiles/ and export metadata in missiles-meta.ts - Implement 1.13c simulation in skills.ts and missile-engine.ts: * Controller missile 158 blizzardcenter: 100 ticks lifetime, emits 1 falling shard every 4 ticks (Param3=4), coprime step 13 mod 64, radius 7 subtiles * Falling shards (blizzard1..4): 9 ticks range, vertical screen altitude descent from 120 px to 0 px * Impact explosions (blizzardexplode1..3): 6 ticks duration, cold damage, 100 ticks chill, NextDelay = 0 * Authentic 5-band level scaling [15, 30, 45, 55, 65] and +5%/pt synergies - Integrate Blizzard casting in act-scene.ts: spawn blizzardcenter, handle altitude offset, aliased art fallback, and ice_cast_3 overlay - Add comprehensive 10-case verification test suite (tests/blizzard-missile-113c.test.ts)
|
|
@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 39 KiB |
|
|
@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 39 KiB |
|
|
@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -17,7 +17,7 @@
|
||||||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
* 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 { join } from 'node:path'
|
||||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
import { fileSource } from '../src/mpq/file-source.ts'
|
import { fileSource } from '../src/mpq/file-source.ts'
|
||||||
|
|
@ -69,6 +69,11 @@ const MISSILE_TARGETS = [
|
||||||
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
|
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
|
||||||
{ name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 },
|
{ name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 },
|
||||||
{ name: 'frozenorbexplode', celFile: 'IceOrbExplode', 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
|
// Lightning
|
||||||
{ name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 },
|
{ 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', 'Patch_D2.mpq'),
|
||||||
join(projectRoot, 'samples', 'd2', 'd2exp.mpq'),
|
join(projectRoot, 'samples', 'd2', 'd2exp.mpq'),
|
||||||
join(projectRoot, 'samples', 'd2', 'd2data.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[] = []
|
const archives: MpqArchive[] = []
|
||||||
for (const p of archivePaths) {
|
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`)
|
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
|
// Generate src/render/missiles-meta.ts
|
||||||
const metaExports = MISSILE_TARGETS.map(t => {
|
const metaExports = MISSILE_TARGETS.map(t => {
|
||||||
const upper = t.name.toUpperCase()
|
const upper = t.name.toUpperCase()
|
||||||
|
|
@ -401,8 +425,32 @@ export function velocityToDccDirection(vx: number, vy: number, directions = 16):
|
||||||
|
|
||||||
${metaExports}
|
${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<Record<string, MissileMeta>> = Object.freeze({
|
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
||||||
${mapEntries}
|
${mapEntries}
|
||||||
|
'blizzard4': BLIZZARD4_META,
|
||||||
|
'blizzardexplode3': BLIZZARDEXPLODE3_META,
|
||||||
|
'hydra': HYDRA_META,
|
||||||
|
'hydrafire': HYDRAFIRE_META,
|
||||||
})
|
})
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
*/
|
*/
|
||||||
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
|
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
|
||||||
import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.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
|
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||||
|
|
||||||
|
|
@ -246,17 +247,20 @@ export class MissileEngine {
|
||||||
const dx = params.targetX - params.startX
|
const dx = params.targetX - params.startX
|
||||||
const dy = params.targetY - params.startY
|
const dy = params.targetY - params.startY
|
||||||
const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
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 isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret')
|
||||||
const vx = isTurret ? 0 : Math.cos(angleRad) * speedPxPerTick
|
const isStationary =
|
||||||
const vy = isTurret ? 0 : Math.sin(angleRad) * speedPxPerTick
|
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 ?? (
|
const maxRangeTicks = params.maxRangeTicks ?? (
|
||||||
isTurret
|
isTurret
|
||||||
? 250 // 1.13c Hydra duration: 250 frames (10 seconds)
|
? 250 // 1.13c Hydra duration: 250 frames (10 seconds)
|
||||||
: Math.max(
|
: calculatedRange
|
||||||
10,
|
|
||||||
(effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0),
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const missile: ActiveMissile = {
|
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
|
// Turret emitter (Hydra heads): stationary emitter periodically firing bolts at nearest target
|
||||||
const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret')
|
const isTurretEmitter = Boolean(m.isTurret || m.name.toLowerCase() === 'hydra_turret')
|
||||||
if (isTurretEmitter) {
|
if (isTurretEmitter) {
|
||||||
|
|
@ -434,9 +467,14 @@ export class MissileEngine {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main Frozen Orb (pSrvDoFunc === 15) and Hydra turret emitters do not deal direct contact damage;
|
// Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), and Hydra turret emitters
|
||||||
// all damage is delivered through emitted child bolts.
|
// do not deal direct contact damage; all damage is delivered through emitted child bolts / shards.
|
||||||
if (m.record.pSrvDoFunc === 15 || isTurretEmitter) {
|
if (
|
||||||
|
m.record.pSrvDoFunc === 15 ||
|
||||||
|
m.record.pSrvDoFunc === 10 ||
|
||||||
|
m.name === 'blizzardcenter' ||
|
||||||
|
isTurretEmitter
|
||||||
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -551,6 +589,25 @@ export class MissileEngine {
|
||||||
if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
||||||
m.pierceCount += 1
|
m.pierceCount += 1
|
||||||
} else if (m.record.collideKill || !m.canPierce) {
|
} 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
|
m.expired = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -558,6 +615,29 @@ export class MissileEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (m.ageTicks >= m.maxRangeTicks) {
|
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)
|
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
|
||||||
// Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb):
|
// Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb):
|
||||||
// - Triggers only on first frame of hit (currentHitFrame === 0)
|
// - Triggers only on first frame of hit (currentHitFrame === 0)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,12 @@ import type { Rng } from './rng.ts'
|
||||||
import type { CharacterClassCode } from './classes.ts'
|
import type { CharacterClassCode } from './classes.ts'
|
||||||
import type { CombatPlayer } from './combat.ts'
|
import type { CombatPlayer } from './combat.ts'
|
||||||
import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.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. */
|
/** One castable skill, read from a table row. */
|
||||||
export interface SkillDef {
|
export interface SkillDef {
|
||||||
|
|
@ -115,6 +121,8 @@ export interface Projectile {
|
||||||
readonly spawnMissileType?: string | undefined
|
readonly spawnMissileType?: string | undefined
|
||||||
/** Number of elapsed ticks this projectile has lived. */
|
/** Number of elapsed ticks this projectile has lived. */
|
||||||
readonly ageTicks?: number | undefined
|
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<Record<string, MissileTxtData>> =
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
distancePx: 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: {
|
hydra: {
|
||||||
name: 'hydra',
|
name: 'hydra',
|
||||||
id: 247,
|
id: 247,
|
||||||
|
|
@ -3257,6 +3425,30 @@ export interface ProjectileOutcome {
|
||||||
readonly spawnedProjectiles?: readonly Projectile[] | undefined
|
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.
|
* Advance every projectile one tick.
|
||||||
*
|
*
|
||||||
|
|
@ -3333,6 +3525,23 @@ export function tickProjectiles(
|
||||||
missileType: 'frozenorb',
|
missileType: 'frozenorb',
|
||||||
aoeRadius: 24,
|
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
|
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<number>()
|
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
||||||
const moved: Projectile = {
|
const moved: Projectile = {
|
||||||
...projectile,
|
...projectile,
|
||||||
|
|
@ -3501,6 +3746,7 @@ export function tickProjectiles(
|
||||||
vy,
|
vy,
|
||||||
spiral,
|
spiral,
|
||||||
subMissileIdx,
|
subMissileIdx,
|
||||||
|
altitude,
|
||||||
hitTargets,
|
hitTargets,
|
||||||
ageTicks: (projectile.ageTicks ?? 0) + 1,
|
ageTicks: (projectile.ageTicks ?? 0) + 1,
|
||||||
ttl: projectile.ttl - 1,
|
ttl: projectile.ttl - 1,
|
||||||
|
|
@ -3542,18 +3788,49 @@ export function tickProjectiles(
|
||||||
pierce: false,
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let struck = false
|
let struck = false
|
||||||
const isHarmlessEmitter = projectile.missileType === 'frozenorb' || isTurretEmitter
|
const isHarmlessEmitter =
|
||||||
|
projectile.missileType === 'frozenorb' ||
|
||||||
|
projectile.missileType === 'blizzardcenter' ||
|
||||||
|
isTurretEmitter
|
||||||
if (!isHarmlessEmitter) {
|
if (!isHarmlessEmitter) {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
if (!target.alive) continue
|
if (!target.alive) continue
|
||||||
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
||||||
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
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({
|
hits.push({
|
||||||
targetIndex: target.index,
|
targetIndex: target.index,
|
||||||
damage: projectile.damage,
|
damage: projectile.damage,
|
||||||
|
|
@ -3562,14 +3839,32 @@ export function tickProjectiles(
|
||||||
skillId: projectile.skillId,
|
skillId: projectile.skillId,
|
||||||
missileType: projectile.missileType,
|
missileType: projectile.missileType,
|
||||||
aoeRadius: projectile.aoeRadius,
|
aoeRadius: projectile.aoeRadius,
|
||||||
statusEffect: projectile.statusEffect,
|
statusEffect,
|
||||||
statusDuration: projectile.statusDuration,
|
statusDuration,
|
||||||
isUndeadOnly: projectile.isUndeadOnly,
|
isUndeadOnly: projectile.isUndeadOnly,
|
||||||
})
|
})
|
||||||
|
|
||||||
hitTargets.add(target.index)
|
hitTargets.add(target.index)
|
||||||
if (!projectile.pierce) {
|
if (!projectile.pierce) {
|
||||||
struck = true
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3822,6 +4117,66 @@ export function calculateSkillDamage(
|
||||||
return { min, max }
|
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. */
|
/** Result of executing a Batch 1 skill. */
|
||||||
export type SkillExecutionResult =
|
export type SkillExecutionResult =
|
||||||
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
export const CHARGEDBOLT_META: MissileMeta = {
|
||||||
name: "chargedbolt",
|
name: "chargedbolt",
|
||||||
celFile: "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 = {
|
export const HYDRA_META: MissileMeta = {
|
||||||
...FIREBOLT_META,
|
...FIREBOLT_META,
|
||||||
name: 'hydra',
|
name: 'hydra',
|
||||||
|
|
@ -4857,8 +4969,6 @@ export const HYDRAFIRE_META: MissileMeta = {
|
||||||
|
|
||||||
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
||||||
'firebolt': FIREBOLT_META,
|
'firebolt': FIREBOLT_META,
|
||||||
'hydra': HYDRA_META,
|
|
||||||
'hydrafire': HYDRAFIRE_META,
|
|
||||||
'fireball': FIREBALL_META,
|
'fireball': FIREBALL_META,
|
||||||
'fireexplode': FIREEXPLODE_META,
|
'fireexplode': FIREEXPLODE_META,
|
||||||
'icebolt': ICEBOLT_META,
|
'icebolt': ICEBOLT_META,
|
||||||
|
|
@ -4868,6 +4978,11 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
||||||
'freezeexplode': FREEZEEXPLODE_META,
|
'freezeexplode': FREEZEEXPLODE_META,
|
||||||
'frozenorb': FROZENORB_META,
|
'frozenorb': FROZENORB_META,
|
||||||
'frozenorbexplode': FROZENORBEXPLODE_META,
|
'frozenorbexplode': FROZENORBEXPLODE_META,
|
||||||
|
'blizzard1': BLIZZARD1_META,
|
||||||
|
'blizzard2': BLIZZARD2_META,
|
||||||
|
'blizzard3': BLIZZARD3_META,
|
||||||
|
'blizzardexplode1': BLIZZARDEXPLODE1_META,
|
||||||
|
'blizzardexplode2': BLIZZARDEXPLODE2_META,
|
||||||
'chargedbolt': CHARGEDBOLT_META,
|
'chargedbolt': CHARGEDBOLT_META,
|
||||||
'teeth': TEETH_META,
|
'teeth': TEETH_META,
|
||||||
'teethexplode': TEETHEXPLODE_META,
|
'teethexplode': TEETHEXPLODE_META,
|
||||||
|
|
@ -4882,4 +4997,8 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
||||||
'icearrow': ICEARROW_META,
|
'icearrow': ICEARROW_META,
|
||||||
'arrow': ARROW_META,
|
'arrow': ARROW_META,
|
||||||
'lightningjavelin': LIGHTNINGJAVELIN_META,
|
'lightningjavelin': LIGHTNINGJAVELIN_META,
|
||||||
|
'blizzard4': BLIZZARD4_META,
|
||||||
|
'blizzardexplode3': BLIZZARDEXPLODE3_META,
|
||||||
|
'hydra': HYDRA_META,
|
||||||
|
'hydrafire': HYDRAFIRE_META,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1078,7 +1078,7 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
||||||
case 21: return 4
|
case 21: return 4
|
||||||
case 40: return 7
|
case 40: return 7
|
||||||
case 42: return 9
|
case 42: return 9
|
||||||
case 59: return 27
|
case 59: return 23
|
||||||
case 60: return 12
|
case 60: return 12
|
||||||
case 62: return 20
|
case 62: return 20
|
||||||
case 64: return 30
|
case 64: return 30
|
||||||
|
|
@ -1297,19 +1297,29 @@ export function castSkill(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skillId === 59) {
|
if (skillId === 59) {
|
||||||
// Blizzard: AoE radius 120 around target, delay 45 ticks
|
// Blizzard (Skill 59): spawn invisible blizzardcenter emitter missile at target coordinates
|
||||||
let hitCount = 0
|
// 1.13c Skills.txt: Param1: 7 (radius subtiles), Param3: 4 (interval ticks), delay: 45 (1.8s cooldown)
|
||||||
for (const monster of engine.world.monsters) {
|
// 1.13c Missiles.txt: blizzardcenter (ID 158), Range: 100 ticks (4.0s duration), Vel: 0
|
||||||
if (monster.state === 'dead' || monster.hp <= 0) continue
|
const slvl = 1
|
||||||
if (Math.hypot(monster.x - targetX, monster.y - targetY) <= 120) {
|
const dmgRange = calculateSkillDamage(59, slvl)
|
||||||
damageMonster(engine.world, monster.index, 35)
|
const blizzardDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||||
engine.metrics.castHits += 1
|
|
||||||
hitCount++
|
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
|
player.cooldown = 45
|
||||||
engine.metrics.casts += 1
|
engine.metrics.casts += 1
|
||||||
status.textContent = `施展暴风雪,命中 ${hitCount} 个目标`
|
status.textContent = '施展暴风雪'
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3664,6 +3674,12 @@ export async function loadMissileArtMap(
|
||||||
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
||||||
if (!map.has('hydrafire')) map.set('hydrafire', 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
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3677,6 +3693,12 @@ export function drawMissileProjectile(
|
||||||
shot: Projectile,
|
shot: Projectile,
|
||||||
missileArt?: LoadedMissileArt,
|
missileArt?: LoadedMissileArt,
|
||||||
): void {
|
): void {
|
||||||
|
if (shot.missileType === 'blizzardcenter') {
|
||||||
|
return // Invisible controller missile
|
||||||
|
}
|
||||||
|
|
||||||
|
const altitude = shot.altitude ?? 0
|
||||||
|
|
||||||
if (missileArt !== undefined) {
|
if (missileArt !== undefined) {
|
||||||
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
|
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
|
||||||
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
|
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
|
||||||
|
|
@ -3685,7 +3707,7 @@ export function drawMissileProjectile(
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
const frame = group?.[frameIndex] ?? group?.[0]
|
||||||
if (frame !== undefined) {
|
if (frame !== undefined) {
|
||||||
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
|
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'
|
const isAdditive = missileArt.meta.name !== 'arrow'
|
||||||
renderer.draw(frame, drawX, drawY, {
|
renderer.draw(frame, drawX, drawY, {
|
||||||
atlas: missileArt.handle,
|
atlas: missileArt.handle,
|
||||||
|
|
@ -3696,7 +3718,7 @@ export function drawMissileProjectile(
|
||||||
}
|
}
|
||||||
|
|
||||||
const x = shot.x
|
const x = shot.x
|
||||||
const y = shot.y
|
const y = shot.y - altitude
|
||||||
|
|
||||||
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
||||||
// Authentic stationary hydra head turret plume
|
// Authentic stationary hydra head turret plume
|
||||||
|
|
@ -5256,9 +5278,15 @@ if (typeof window !== 'undefined') {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const shot of engine.projectiles) {
|
for (const shot of engine.projectiles) {
|
||||||
|
if (shot.missileType === 'blizzardcenter') continue
|
||||||
pushEntity(shot.x, shot.y, () => {
|
pushEntity(shot.x, shot.y, () => {
|
||||||
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
|
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) {
|
if (art !== undefined) {
|
||||||
drawMissileProjectile(renderer, shot, art)
|
drawMissileProjectile(renderer, shot, art)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -5269,7 +5297,9 @@ if (typeof window !== 'undefined') {
|
||||||
|
|
||||||
for (const exp of engine.explosions) {
|
for (const exp of engine.explosions) {
|
||||||
pushEntity(exp.x, exp.y, () => {
|
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)
|
drawExplosion(renderer, exp, art)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
})
|
||||||
|
})
|
||||||