feat(skills): implement Frozen Orb with 1.13c parity and visual assets
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
|
@ -0,0 +1,141 @@
|
||||||
|
{
|
||||||
|
"name": "frozenorb",
|
||||||
|
"celFile": "IceOrb",
|
||||||
|
"width": 512,
|
||||||
|
"height": 107,
|
||||||
|
"directions": 1,
|
||||||
|
"framesPerDirection": 16,
|
||||||
|
"animSpeed": 16,
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
55,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
110,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
165,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
220,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
275,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
330,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
385,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
440,
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
54,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
55,
|
||||||
|
54,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
110,
|
||||||
|
54,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
165,
|
||||||
|
54,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
220,
|
||||||
|
54,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
275,
|
||||||
|
54,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
],
|
||||||
|
[
|
||||||
|
330,
|
||||||
|
54,
|
||||||
|
54,
|
||||||
|
53,
|
||||||
|
-27,
|
||||||
|
-71
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -0,0 +1,157 @@
|
||||||
|
{
|
||||||
|
"name": "frozenorbexplode",
|
||||||
|
"celFile": "IceOrbExplode",
|
||||||
|
"width": 1024,
|
||||||
|
"height": 959,
|
||||||
|
"directions": 1,
|
||||||
|
"framesPerDirection": 18,
|
||||||
|
"animSpeed": 16,
|
||||||
|
"groups": [
|
||||||
|
[
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
231,
|
||||||
|
0,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
462,
|
||||||
|
0,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
693,
|
||||||
|
0,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
192,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
231,
|
||||||
|
192,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
462,
|
||||||
|
192,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
693,
|
||||||
|
192,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
384,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
231,
|
||||||
|
384,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
462,
|
||||||
|
384,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
693,
|
||||||
|
384,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
576,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
231,
|
||||||
|
576,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
462,
|
||||||
|
576,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
693,
|
||||||
|
576,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
768,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
],
|
||||||
|
[
|
||||||
|
231,
|
||||||
|
768,
|
||||||
|
230,
|
||||||
|
191,
|
||||||
|
-111,
|
||||||
|
-113
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 277 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 26 KiB |
|
|
@ -67,6 +67,8 @@ const MISSILE_TARGETS = [
|
||||||
{ name: 'glacialspike', celFile: 'GlacialSpike', animSpeed: 16 },
|
{ name: 'glacialspike', celFile: 'GlacialSpike', animSpeed: 16 },
|
||||||
{ name: 'iceexplode', celFile: 'IceArrowExplode', animSpeed: 16 },
|
{ name: 'iceexplode', celFile: 'IceArrowExplode', animSpeed: 16 },
|
||||||
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
|
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
|
||||||
|
{ name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 },
|
||||||
|
{ name: 'frozenorbexplode', celFile: 'IceOrbExplode', animSpeed: 16 },
|
||||||
|
|
||||||
// Lightning
|
// Lightning
|
||||||
{ name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 },
|
{ name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 },
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,27 @@
|
||||||
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'
|
||||||
|
|
||||||
|
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentic D2Game 64-entry discrete circle lookup tables (radius 30).
|
||||||
|
* Replicated byte-for-byte from D2Game.dll 0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb)
|
||||||
|
* and 0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb).
|
||||||
|
*/
|
||||||
|
export const FROZEN_ORB_X_POSITIONS: 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,
|
||||||
|
])
|
||||||
|
|
||||||
|
export const FROZEN_ORB_Y_POSITIONS: 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 interface ActiveMissile {
|
export interface ActiveMissile {
|
||||||
readonly uid: string
|
readonly uid: string
|
||||||
readonly missileId: number
|
readonly missileId: number
|
||||||
|
|
@ -42,6 +63,8 @@ export interface ActiveMissile {
|
||||||
readonly dmgPacket: SUnitDmgPacket
|
readonly dmgPacket: SUnitDmgPacket
|
||||||
chainRemaining: number
|
chainRemaining: number
|
||||||
expired: boolean
|
expired: boolean
|
||||||
|
subMissileIdx?: number
|
||||||
|
currentHitFrame?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MissileStepResult {
|
export interface MissileStepResult {
|
||||||
|
|
@ -273,22 +296,49 @@ export class MissileEngine {
|
||||||
if (m.expired) continue
|
if (m.expired) continue
|
||||||
m.ageTicks += 1
|
m.ageTicks += 1
|
||||||
|
|
||||||
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `icebolt` sub-missiles while flying
|
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `frozenorbbolt` sub-missiles while flying
|
||||||
if (m.record.pSrvDoFunc === 15 && m.ageTicks % 3 === 0) {
|
// Authentic D2Game.0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb):
|
||||||
const subName = m.record.subMissile1 || 'icebolt'
|
// - 64-entry discrete circle lookup table (radius 30)
|
||||||
const subAngle = m.angleRad + m.ageTicks * 0.8
|
// - Trigger frequency: dwParam[0] (1 frame)
|
||||||
this.spawnMissile({
|
// - Angular step: dwParam[1] (19 directions per step, coprime with 64)
|
||||||
missileNameOrId: subName,
|
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
|
||||||
sourceSkillId: m.sourceSkillId,
|
if (m.record.pSrvDoFunc === 15) {
|
||||||
slvl: m.slvl,
|
const nFrames = Math.max(1, m.record.param1 || 1)
|
||||||
owner: m.owner,
|
if (m.ageTicks % nFrames === 0 || (m.maxRangeTicks - m.ageTicks) % nFrames <= 0) {
|
||||||
startX: m.x,
|
const subName = m.record.subMissile1 || 'frozenorbbolt'
|
||||||
startY: m.y,
|
const idx = Math.abs((m.subMissileIdx ?? 0) % 64)
|
||||||
targetX: m.x + Math.cos(subAngle) * 100,
|
const offX = FROZEN_ORB_X_POSITIONS[idx]!
|
||||||
targetY: m.y + Math.sin(subAngle) * (100 * 0.5),
|
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
|
||||||
dmgPacket: m.dmgPacket,
|
this.spawnMissile({
|
||||||
})
|
missileNameOrId: subName,
|
||||||
subMissilesSpawned.push(subName)
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
slvl: m.slvl,
|
||||||
|
owner: m.owner,
|
||||||
|
startX: m.x,
|
||||||
|
startY: m.y,
|
||||||
|
targetX: m.x + offX * 10,
|
||||||
|
targetY: m.y + offY * 10,
|
||||||
|
dmgPacket: m.dmgPacket,
|
||||||
|
})
|
||||||
|
subMissilesSpawned.push(subName)
|
||||||
|
const step = m.record.param2 || 19
|
||||||
|
m.subMissileIdx = (idx + step) % 64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pSrvDoFunc = 16` (`frozenorbnova`): authentic D2Game.0x6FC58340
|
||||||
|
// In the final frames before expiry (dwParam[0] = 6), every dwParam[1] = 2 frames,
|
||||||
|
// apply affine rotation & damping curl: x' = (x - y) / 2, y' = (x + y) / 2
|
||||||
|
if (m.record.pSrvDoFunc === 16) {
|
||||||
|
const remaining = m.maxRangeTicks - m.ageTicks
|
||||||
|
const threshold = m.record.param1 || 6
|
||||||
|
const nParam = Math.max(1, m.record.param2 || 2)
|
||||||
|
if (remaining < threshold && remaining % nParam === 0) {
|
||||||
|
const diff = m.vx - m.vy
|
||||||
|
const sum = m.vx + m.vy
|
||||||
|
m.vx = diff / 2
|
||||||
|
m.vy = sum / 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
|
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
|
||||||
|
|
@ -317,6 +367,12 @@ export class MissileEngine {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Main Frozen Orb (pSrvDoFunc === 15) does not deal direct contact damage;
|
||||||
|
// all its damage is delivered through emitted child bolts (frozenorbbolt, frozenorbnova).
|
||||||
|
if (m.record.pSrvDoFunc === 15) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||||
const dist = Math.hypot(pos.x - m.x, pos.y - m.y)
|
const dist = Math.hypot(pos.x - m.x, pos.y - m.y)
|
||||||
if (dist <= hitRadius) {
|
if (dist <= hitRadius) {
|
||||||
|
|
@ -436,10 +492,17 @@ export class MissileEngine {
|
||||||
|
|
||||||
if (m.ageTicks >= m.maxRangeTicks) {
|
if (m.ageTicks >= m.maxRangeTicks) {
|
||||||
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
|
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
|
||||||
if (m.record.pSrvHitFunc === 29) {
|
// Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb):
|
||||||
const subName = m.record.hitSubMissile1 || 'icebolt'
|
// - Triggers only on first frame of hit (currentHitFrame === 0)
|
||||||
for (let i = 0; i < 8; i += 1) {
|
// - Loop with step sHitPar1 = 4 over 64 directions -> exactly 16 frozenorbnova missiles!
|
||||||
const ang = (i * Math.PI) / 4
|
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
|
||||||
|
if (m.record.pSrvHitFunc === 29 && (!m.currentHitFrame || m.currentHitFrame === 0)) {
|
||||||
|
m.currentHitFrame = 1
|
||||||
|
const subName = m.record.hitSubMissile1 || 'frozenorbnova'
|
||||||
|
const step = Math.max(1, m.record.sHitPar1 || 4)
|
||||||
|
for (let i = 0; i < 64; i += step) {
|
||||||
|
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
||||||
|
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
||||||
this.spawnMissile({
|
this.spawnMissile({
|
||||||
missileNameOrId: subName,
|
missileNameOrId: subName,
|
||||||
sourceSkillId: m.sourceSkillId,
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
|
@ -447,12 +510,25 @@ export class MissileEngine {
|
||||||
owner: m.owner,
|
owner: m.owner,
|
||||||
startX: m.x,
|
startX: m.x,
|
||||||
startY: m.y,
|
startY: m.y,
|
||||||
targetX: m.x + Math.cos(ang) * 100,
|
targetX: m.x + offX * 10,
|
||||||
targetY: m.y + Math.sin(ang) * 100,
|
targetY: m.y + offY * 10,
|
||||||
dmgPacket: m.dmgPacket,
|
dmgPacket: m.dmgPacket,
|
||||||
})
|
})
|
||||||
|
subMissilesSpawned.push(subName)
|
||||||
}
|
}
|
||||||
subMissilesSpawned.push(subName)
|
const explosionName = m.record.explosionMissile || 'frozenorbexplode'
|
||||||
|
this.spawnMissile({
|
||||||
|
missileNameOrId: explosionName,
|
||||||
|
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(explosionName)
|
||||||
}
|
}
|
||||||
m.expired = true
|
m.expired = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import type { D2Table } from './acts.ts'
|
||||||
import type { Rng } from './rng.ts'
|
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'
|
||||||
|
|
||||||
/** One castable skill, read from a table row. */
|
/** One castable skill, read from a table row. */
|
||||||
export interface SkillDef {
|
export interface SkillDef {
|
||||||
|
|
@ -97,6 +98,10 @@ export interface Projectile {
|
||||||
readonly jitter?: boolean | undefined
|
readonly jitter?: boolean | undefined
|
||||||
/** Target restriction: only hits undead monsters. */
|
/** Target restriction: only hits undead monsters. */
|
||||||
readonly isUndeadOnly?: boolean | undefined
|
readonly isUndeadOnly?: boolean | undefined
|
||||||
|
/** Sub-missile emission index along the 64-point discrete circle table (e.g. Frozen Orb). */
|
||||||
|
readonly subMissileIdx?: number | undefined
|
||||||
|
/** Affine curl damping trajectory in last 6 frames (e.g. Frozen Orb Nova). */
|
||||||
|
readonly isNovaCurl?: boolean | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -361,6 +366,86 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
speedPxPerSec: 0,
|
speedPxPerSec: 0,
|
||||||
distancePx: 0,
|
distancePx: 0,
|
||||||
},
|
},
|
||||||
|
frozenorb: {
|
||||||
|
name: 'frozenorb',
|
||||||
|
id: 260,
|
||||||
|
vel: 10,
|
||||||
|
maxVel: 10,
|
||||||
|
range: 30,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'IceOrb',
|
||||||
|
animLen: 16,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 1,
|
||||||
|
numDirections: 16,
|
||||||
|
explosionMissile: 'frozenorbexplode',
|
||||||
|
light: 6,
|
||||||
|
red: 81,
|
||||||
|
green: 81,
|
||||||
|
blue: 255,
|
||||||
|
speedPxPerSec: 250,
|
||||||
|
distancePx: 300,
|
||||||
|
},
|
||||||
|
frozenorbbolt: {
|
||||||
|
name: 'frozenorbbolt',
|
||||||
|
id: 261,
|
||||||
|
vel: 18,
|
||||||
|
maxVel: 18,
|
||||||
|
range: 25,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'IceBolt',
|
||||||
|
animLen: 6,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 1,
|
||||||
|
numDirections: 16,
|
||||||
|
explosionMissile: 'iceexplode',
|
||||||
|
light: 4,
|
||||||
|
red: 81,
|
||||||
|
green: 81,
|
||||||
|
blue: 255,
|
||||||
|
speedPxPerSec: 450,
|
||||||
|
distancePx: 450,
|
||||||
|
},
|
||||||
|
frozenorbnova: {
|
||||||
|
name: 'frozenorbnova',
|
||||||
|
id: 262,
|
||||||
|
vel: 24,
|
||||||
|
maxVel: 24,
|
||||||
|
range: 25,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'IceBolt',
|
||||||
|
animLen: 6,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 1,
|
||||||
|
numDirections: 16,
|
||||||
|
explosionMissile: 'iceexplode',
|
||||||
|
light: 4,
|
||||||
|
red: 81,
|
||||||
|
green: 81,
|
||||||
|
blue: 255,
|
||||||
|
speedPxPerSec: 600,
|
||||||
|
distancePx: 600,
|
||||||
|
},
|
||||||
|
frozenorbexplode: {
|
||||||
|
name: 'frozenorbexplode',
|
||||||
|
id: 263,
|
||||||
|
vel: 0,
|
||||||
|
maxVel: 0,
|
||||||
|
range: 16,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'IceOrbExplode',
|
||||||
|
animLen: 16,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 0,
|
||||||
|
numDirections: 1,
|
||||||
|
explosionMissile: '',
|
||||||
|
light: 6,
|
||||||
|
red: 81,
|
||||||
|
green: 81,
|
||||||
|
blue: 255,
|
||||||
|
speedPxPerSec: 0,
|
||||||
|
distancePx: 0,
|
||||||
|
},
|
||||||
chargedbolt: {
|
chargedbolt: {
|
||||||
name: 'chargedbolt',
|
name: 'chargedbolt',
|
||||||
id: 56,
|
id: 56,
|
||||||
|
|
@ -1666,6 +1751,8 @@ export interface ProjectileOutcome {
|
||||||
readonly missileType?: string | undefined
|
readonly missileType?: string | undefined
|
||||||
readonly aoeRadius?: number | undefined
|
readonly aoeRadius?: number | undefined
|
||||||
}[] | undefined
|
}[] | undefined
|
||||||
|
/** Newly spawned child projectiles during this tick (e.g. Frozen Orb bolts / nova). */
|
||||||
|
readonly spawnedProjectiles?: readonly Projectile[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1687,6 +1774,7 @@ export function tickProjectiles(
|
||||||
): ProjectileOutcome {
|
): ProjectileOutcome {
|
||||||
const radius = options.radius ?? 8
|
const radius = options.radius ?? 8
|
||||||
const alive: Projectile[] = []
|
const alive: Projectile[] = []
|
||||||
|
const spawned: Projectile[] = []
|
||||||
const hits: {
|
const hits: {
|
||||||
targetIndex: number
|
targetIndex: number
|
||||||
damage: number
|
damage: number
|
||||||
|
|
@ -1710,18 +1798,68 @@ export function tickProjectiles(
|
||||||
let wallHits = 0
|
let wallHits = 0
|
||||||
|
|
||||||
for (const projectile of projectiles) {
|
for (const projectile of projectiles) {
|
||||||
if (projectile.ttl <= 0) { expired += 1; continue }
|
if (projectile.ttl <= 0) {
|
||||||
|
expired += 1
|
||||||
|
if (projectile.missileType === 'frozenorb') {
|
||||||
|
// Authentic D2 1.13c MISSMODE_SrvHit29_FrozenOrb (0x6FC5CE10):
|
||||||
|
// Detonates into 16 frozenorbnova missiles + frozenorbexplode explosion at expiry
|
||||||
|
for (let i = 0; i < 64; i += 4) {
|
||||||
|
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
||||||
|
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
||||||
|
const angle = Math.atan2(offY, offX)
|
||||||
|
const speed = 24
|
||||||
|
spawned.push({
|
||||||
|
skillId: projectile.skillId,
|
||||||
|
x: projectile.x,
|
||||||
|
y: projectile.y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
damage: projectile.damage > 0 ? projectile.damage : 40,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: projectile.fromPlayer,
|
||||||
|
missileType: 'frozenorbnova',
|
||||||
|
statusEffect: 'chill',
|
||||||
|
statusDuration: 100,
|
||||||
|
isNovaCurl: true,
|
||||||
|
pierce: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wallHitEvents.push({
|
||||||
|
x: projectile.x,
|
||||||
|
y: projectile.y,
|
||||||
|
skillId: projectile.skillId,
|
||||||
|
missileType: 'frozenorb',
|
||||||
|
aoeRadius: 24,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
let vx = projectile.vx
|
let vx = projectile.vx
|
||||||
let vy = projectile.vy
|
let vy = projectile.vy
|
||||||
let x = projectile.x + vx
|
let x = projectile.x + vx
|
||||||
let y = projectile.y + vy
|
let y = projectile.y + vy
|
||||||
let spiral = projectile.spiral
|
let spiral = projectile.spiral
|
||||||
|
let subMissileIdx = projectile.subMissileIdx
|
||||||
|
|
||||||
// 1. Archimedean spiral trajectory (e.g. Blessed Hammer)
|
// 0. Frozen Orb Nova affine rotation & damping curl in final frames
|
||||||
// The hammer traces an Archimedean spiral on the map ground plane. In oblique/isometric
|
// Authentic D2 1.13c MISSMODE_SrvDo16_FrozenOrbNova (0x6FC58340):
|
||||||
// perspective (2:1 projection), a circular ground expansion projects to an ellipse with a 2:1 aspect ratio.
|
// In final 6 frames before expiry, every 2 frames apply: x' = (x - y) / 2, y' = (x + y) / 2
|
||||||
if (spiral !== undefined) {
|
if (
|
||||||
|
(projectile.isNovaCurl || projectile.missileType === 'frozenorbnova') &&
|
||||||
|
projectile.ttl <= 6 &&
|
||||||
|
projectile.ttl % 2 === 0
|
||||||
|
) {
|
||||||
|
const diff = vx - vy
|
||||||
|
const sum = vx + vy
|
||||||
|
vx = diff / 2
|
||||||
|
vy = sum / 2
|
||||||
|
x = projectile.x + vx
|
||||||
|
y = projectile.y + vy
|
||||||
|
} else if (spiral !== undefined) {
|
||||||
|
// 1. Archimedean spiral trajectory (e.g. Blessed Hammer)
|
||||||
|
// The hammer traces an Archimedean spiral on the map ground plane. In oblique/isometric
|
||||||
|
// perspective (2:1 projection), a circular ground expansion projects to an ellipse with a 2:1 aspect ratio.
|
||||||
const nextAngle = spiral.angle + 0.22
|
const nextAngle = spiral.angle + 0.22
|
||||||
const nextRadius = spiral.radius + 3.2
|
const nextRadius = spiral.radius + 3.2
|
||||||
spiral = { ...spiral, angle: nextAngle, radius: nextRadius }
|
spiral = { ...spiral, angle: nextAngle, radius: nextRadius }
|
||||||
|
|
@ -1772,6 +1910,32 @@ export function tickProjectiles(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Frozen Orb sub-missile emitter (pSrvDoFunc = 15, frequency = 1, step = 19)
|
||||||
|
// Authentic D2 1.13c MISSMODE_SrvDo15_FrozenOrb (0x6FC581F0):
|
||||||
|
// Emits 1 radial frozenorbbolt along 64-point circle table, advancing by step 19
|
||||||
|
if (projectile.missileType === 'frozenorb') {
|
||||||
|
const idx = Math.abs((subMissileIdx ?? 0) % 64)
|
||||||
|
const offX = FROZEN_ORB_X_POSITIONS[idx]!
|
||||||
|
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
|
||||||
|
const angle = Math.atan2(offY, offX)
|
||||||
|
const speed = 18 // vel = 18 in 1.13c Missiles.txt (450 px/s)
|
||||||
|
spawned.push({
|
||||||
|
skillId: projectile.skillId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
damage: projectile.damage > 0 ? projectile.damage : 40,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: projectile.fromPlayer,
|
||||||
|
missileType: 'frozenorbbolt',
|
||||||
|
statusEffect: 'chill',
|
||||||
|
statusDuration: 100,
|
||||||
|
pierce: false,
|
||||||
|
})
|
||||||
|
subMissileIdx = (idx + 19) % 64
|
||||||
|
}
|
||||||
|
|
||||||
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
||||||
const moved: Projectile = {
|
const moved: Projectile = {
|
||||||
...projectile,
|
...projectile,
|
||||||
|
|
@ -1780,6 +1944,7 @@ export function tickProjectiles(
|
||||||
vx,
|
vx,
|
||||||
vy,
|
vy,
|
||||||
spiral,
|
spiral,
|
||||||
|
subMissileIdx,
|
||||||
hitTargets,
|
hitTargets,
|
||||||
ttl: projectile.ttl - 1,
|
ttl: projectile.ttl - 1,
|
||||||
}
|
}
|
||||||
|
|
@ -1798,32 +1963,58 @@ export function tickProjectiles(
|
||||||
missileType: projectile.missileType,
|
missileType: projectile.missileType,
|
||||||
aoeRadius: projectile.aoeRadius,
|
aoeRadius: projectile.aoeRadius,
|
||||||
})
|
})
|
||||||
|
if (projectile.missileType === 'frozenorb') {
|
||||||
|
for (let i = 0; i < 64; i += 4) {
|
||||||
|
const offX = FROZEN_ORB_X_POSITIONS[i]!
|
||||||
|
const offY = FROZEN_ORB_Y_POSITIONS[i]! * ISO_GROUND_ASPECT_RATIO
|
||||||
|
const angle = Math.atan2(offY, offX)
|
||||||
|
const speed = 24
|
||||||
|
spawned.push({
|
||||||
|
skillId: projectile.skillId,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
damage: projectile.damage > 0 ? projectile.damage : 40,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: projectile.fromPlayer,
|
||||||
|
missileType: 'frozenorbnova',
|
||||||
|
statusEffect: 'chill',
|
||||||
|
statusDuration: 100,
|
||||||
|
isNovaCurl: true,
|
||||||
|
pierce: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let struck = false
|
let struck = false
|
||||||
for (const target of targets) {
|
const isHarmlessEmitter = projectile.missileType === 'frozenorb'
|
||||||
if (!target.alive) continue
|
if (!isHarmlessEmitter) {
|
||||||
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
|
for (const target of targets) {
|
||||||
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
|
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
|
||||||
|
|
||||||
hits.push({
|
hits.push({
|
||||||
targetIndex: target.index,
|
targetIndex: target.index,
|
||||||
damage: projectile.damage,
|
damage: projectile.damage,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
skillId: projectile.skillId,
|
skillId: projectile.skillId,
|
||||||
missileType: projectile.missileType,
|
missileType: projectile.missileType,
|
||||||
aoeRadius: projectile.aoeRadius,
|
aoeRadius: projectile.aoeRadius,
|
||||||
statusEffect: projectile.statusEffect,
|
statusEffect: projectile.statusEffect,
|
||||||
statusDuration: projectile.statusDuration,
|
statusDuration: projectile.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
|
||||||
break
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1831,7 +2022,9 @@ export function tickProjectiles(
|
||||||
alive.push(moved)
|
alive.push(moved)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { alive, hits, expired, wallHits, wallHitEvents }
|
|
||||||
|
alive.push(...spawned)
|
||||||
|
return { alive, hits, expired, wallHits, wallHitEvents, spawnedProjectiles: spawned }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -823,6 +823,68 @@ export const FREEZEEXPLODE_META: MissileMeta = {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const FROZENORB_META: MissileMeta = {
|
||||||
|
name: "frozenorb",
|
||||||
|
celFile: "IceOrb",
|
||||||
|
width: 512,
|
||||||
|
height: 107,
|
||||||
|
directions: 1,
|
||||||
|
framesPerDirection: 16,
|
||||||
|
animSpeed: 16,
|
||||||
|
groups: [
|
||||||
|
[
|
||||||
|
[0, 0, 54, 53, -27, -71],
|
||||||
|
[55, 0, 54, 53, -27, -71],
|
||||||
|
[110, 0, 54, 53, -27, -71],
|
||||||
|
[165, 0, 54, 53, -27, -71],
|
||||||
|
[220, 0, 54, 53, -27, -71],
|
||||||
|
[275, 0, 54, 53, -27, -71],
|
||||||
|
[330, 0, 54, 53, -27, -71],
|
||||||
|
[385, 0, 54, 53, -27, -71],
|
||||||
|
[440, 0, 54, 53, -27, -71],
|
||||||
|
[0, 54, 54, 53, -27, -71],
|
||||||
|
[55, 54, 54, 53, -27, -71],
|
||||||
|
[110, 54, 54, 53, -27, -71],
|
||||||
|
[165, 54, 54, 53, -27, -71],
|
||||||
|
[220, 54, 54, 53, -27, -71],
|
||||||
|
[275, 54, 54, 53, -27, -71],
|
||||||
|
[330, 54, 54, 53, -27, -71]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FROZENORBEXPLODE_META: MissileMeta = {
|
||||||
|
name: "frozenorbexplode",
|
||||||
|
celFile: "IceOrbExplode",
|
||||||
|
width: 1024,
|
||||||
|
height: 959,
|
||||||
|
directions: 1,
|
||||||
|
framesPerDirection: 18,
|
||||||
|
animSpeed: 16,
|
||||||
|
groups: [
|
||||||
|
[
|
||||||
|
[0, 0, 230, 191, -111, -113],
|
||||||
|
[231, 0, 230, 191, -111, -113],
|
||||||
|
[462, 0, 230, 191, -111, -113],
|
||||||
|
[693, 0, 230, 191, -111, -113],
|
||||||
|
[0, 192, 230, 191, -111, -113],
|
||||||
|
[231, 192, 230, 191, -111, -113],
|
||||||
|
[462, 192, 230, 191, -111, -113],
|
||||||
|
[693, 192, 230, 191, -111, -113],
|
||||||
|
[0, 384, 230, 191, -111, -113],
|
||||||
|
[231, 384, 230, 191, -111, -113],
|
||||||
|
[462, 384, 230, 191, -111, -113],
|
||||||
|
[693, 384, 230, 191, -111, -113],
|
||||||
|
[0, 576, 230, 191, -111, -113],
|
||||||
|
[231, 576, 230, 191, -111, -113],
|
||||||
|
[462, 576, 230, 191, -111, -113],
|
||||||
|
[693, 576, 230, 191, -111, -113],
|
||||||
|
[0, 768, 230, 191, -111, -113],
|
||||||
|
[231, 768, 230, 191, -111, -113]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
export const CHARGEDBOLT_META: MissileMeta = {
|
export const CHARGEDBOLT_META: MissileMeta = {
|
||||||
name: "chargedbolt",
|
name: "chargedbolt",
|
||||||
celFile: "ChargedBolt",
|
celFile: "ChargedBolt",
|
||||||
|
|
@ -4792,6 +4854,8 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
||||||
'glacialspike': GLACIALSPIKE_META,
|
'glacialspike': GLACIALSPIKE_META,
|
||||||
'iceexplode': ICEEXPLODE_META,
|
'iceexplode': ICEEXPLODE_META,
|
||||||
'freezeexplode': FREEZEEXPLODE_META,
|
'freezeexplode': FREEZEEXPLODE_META,
|
||||||
|
'frozenorb': FROZENORB_META,
|
||||||
|
'frozenorbexplode': FROZENORBEXPLODE_META,
|
||||||
'chargedbolt': CHARGEDBOLT_META,
|
'chargedbolt': CHARGEDBOLT_META,
|
||||||
'teeth': TEETH_META,
|
'teeth': TEETH_META,
|
||||||
'teethexplode': TEETHEXPLODE_META,
|
'teethexplode': TEETHEXPLODE_META,
|
||||||
|
|
|
||||||
|
|
@ -1505,10 +1505,15 @@ export function castSkill(
|
||||||
missileType = 'lightningjavelin'
|
missileType = 'lightningjavelin'
|
||||||
skillName = '闪电之枪'
|
skillName = '闪电之枪'
|
||||||
} else if (skillId === 64) {
|
} else if (skillId === 64) {
|
||||||
speed = 400
|
const missile = getMissileTxtData('frozenorb')
|
||||||
range = 480
|
speed = missile.speedPxPerSec || 250
|
||||||
damage = 35
|
range = missile.distancePx || 300
|
||||||
|
damage = 40
|
||||||
delay = 25
|
delay = 25
|
||||||
|
pierce = true
|
||||||
|
statusEffect = 'chill'
|
||||||
|
statusDuration = 100
|
||||||
|
missileType = 'frozenorb'
|
||||||
skillName = '冰封球'
|
skillName = '冰封球'
|
||||||
} else if (skillId === 49) {
|
} else if (skillId === 49) {
|
||||||
speed = 600
|
speed = 600
|
||||||
|
|
@ -3586,6 +3591,11 @@ export async function loadMissileArtMap(
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
if (map.has('icebolt')) {
|
||||||
|
const iceboltArt = map.get('icebolt')!
|
||||||
|
map.set('frozenorbbolt', iceboltArt)
|
||||||
|
map.set('frozenorbnova', iceboltArt)
|
||||||
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5152,7 +5162,7 @@ if (typeof window !== 'undefined') {
|
||||||
for (const shot of engine.projectiles) {
|
for (const shot of engine.projectiles) {
|
||||||
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) : undefined
|
const art = mType !== undefined ? (missileArtMap.get(mType) ?? (mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') : undefined)) : undefined
|
||||||
if (art !== undefined) {
|
if (art !== undefined) {
|
||||||
drawMissileProjectile(renderer, shot, art)
|
drawMissileProjectile(renderer, shot, art)
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,390 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c — Skill #064: Frozen Orb (SOR)
|
||||||
|
* Missile Physics, Discrete Circle Emitter, Visual Assets & 1.13c Parity Suite
|
||||||
|
*
|
||||||
|
* Requirements (Issue #218 / Frozen Orb Parity):
|
||||||
|
* 1. Missiles.txt ground truth: parses and verifies frozenorb (ID 260), frozenorbbolt (ID 261),
|
||||||
|
* frozenorbnova (ID 262), and frozenorbexplode (ID 263).
|
||||||
|
* 2. Visual assets & atlas metadata: FROZENORB_META (IceOrb.dcc, 16 frames),
|
||||||
|
* FROZENORBEXPLODE_META (IceOrbExplode.dcc, 18 frames), baked PNG/JSON files exist.
|
||||||
|
* 3. Discrete circle emitter (pSrvDoFunc = 15): 64-point lookup table, step 19, 2:1 isometric
|
||||||
|
* ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5), emits 30 frozenorbbolt missiles over 30 ticks.
|
||||||
|
* 4. Main orb monster penetration: pierces through monsters without dying (CollideKill = 0) and
|
||||||
|
* does not deal direct contact damage (damage delivered by emitted bolts).
|
||||||
|
* 5. Expiration burst & wall detonation (pSrvHitFunc = 29): 16 frozenorbnova missiles along 64
|
||||||
|
* directions (step 4) + frozenorbexplode explosion.
|
||||||
|
* 6. Frozen Orb Nova affine rotation & damping curl (pSrvDoFunc = 16): last 6 frames every 2 frames
|
||||||
|
* transforms x' = (x - y)/2, y' = (x + y)/2.
|
||||||
|
* 7. Zero NextDelay sweet spot burst damage: multiple overlapping child bolts hit in same frame.
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
type Projectile,
|
||||||
|
} from '../src/game/skills.ts'
|
||||||
|
import {
|
||||||
|
FROZEN_ORB_X_POSITIONS,
|
||||||
|
FROZEN_ORB_Y_POSITIONS,
|
||||||
|
} from '../src/game/engine/missile-engine.ts'
|
||||||
|
import {
|
||||||
|
FROZENORB_META,
|
||||||
|
FROZENORBEXPLODE_META,
|
||||||
|
MISSILE_METAS,
|
||||||
|
} from '../src/render/missiles-meta.ts'
|
||||||
|
|
||||||
|
describe('[Skill #064] Frozen Orb — 1.13c Missile Physics & Runtime Parity', () => {
|
||||||
|
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 frozenorb missile suite', () => {
|
||||||
|
const map = parseMissilesTxt(missilesTxtContent)
|
||||||
|
|
||||||
|
// frozenorb (ID 260)
|
||||||
|
const orb = map.get('frozenorb')
|
||||||
|
expect(orb).toBeDefined()
|
||||||
|
expect(orb!.id).toBe(260)
|
||||||
|
expect(orb!.vel).toBe(10)
|
||||||
|
expect(orb!.range).toBe(30)
|
||||||
|
expect(orb!.speedPxPerSec).toBe(250) // 10 * 25
|
||||||
|
expect(orb!.distancePx).toBe(300) // 10 * 30
|
||||||
|
expect(orb!.celFile.toLowerCase()).toBe('iceorb')
|
||||||
|
expect(orb!.animLen).toBe(16)
|
||||||
|
expect(orb!.explosionMissile).toBe('') // In raw Missiles.txt, pSrvHitFunc 29 spawns frozenorbexplode via code
|
||||||
|
expect(orb!.light).toBe(6)
|
||||||
|
expect(orb!.red).toBe(81)
|
||||||
|
expect(orb!.green).toBe(81)
|
||||||
|
expect(orb!.blue).toBe(255)
|
||||||
|
|
||||||
|
// frozenorbbolt (ID 261)
|
||||||
|
const bolt = map.get('frozenorbbolt')
|
||||||
|
expect(bolt).toBeDefined()
|
||||||
|
expect(bolt!.id).toBe(261)
|
||||||
|
expect(bolt!.vel).toBe(18)
|
||||||
|
expect(bolt!.range).toBe(25)
|
||||||
|
expect(bolt!.speedPxPerSec).toBe(450) // 18 * 25
|
||||||
|
expect(bolt!.distancePx).toBe(450) // 18 * 25
|
||||||
|
expect(bolt!.celFile.toLowerCase()).toBe('icebolt')
|
||||||
|
expect(bolt!.animLen).toBe(6)
|
||||||
|
|
||||||
|
// frozenorbnova (ID 262)
|
||||||
|
const nova = map.get('frozenorbnova')
|
||||||
|
expect(nova).toBeDefined()
|
||||||
|
expect(nova!.id).toBe(262)
|
||||||
|
expect(nova!.vel).toBe(24)
|
||||||
|
expect(nova!.range).toBe(25)
|
||||||
|
expect(nova!.speedPxPerSec).toBe(600) // 24 * 25
|
||||||
|
expect(nova!.distancePx).toBe(600) // 24 * 25
|
||||||
|
expect(nova!.celFile.toLowerCase()).toBe('icebolt')
|
||||||
|
expect(nova!.animLen).toBe(6)
|
||||||
|
|
||||||
|
// frozenorbexplode (ID 263)
|
||||||
|
const exp = map.get('frozenorbexplode')
|
||||||
|
expect(exp).toBeDefined()
|
||||||
|
expect(exp!.id).toBe(263)
|
||||||
|
expect(exp!.vel).toBe(0)
|
||||||
|
expect(exp!.range).toBe(16)
|
||||||
|
expect(exp!.celFile.toLowerCase()).toBe('iceorbexplode')
|
||||||
|
expect(exp!.animLen).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('2. CANONICAL_113C_MISSILES export contains frozenorb suite with exact values', () => {
|
||||||
|
const orb = getMissileTxtData('frozenorb')
|
||||||
|
expect(orb.id).toBe(260)
|
||||||
|
expect(orb.vel).toBe(10)
|
||||||
|
expect(orb.range).toBe(30)
|
||||||
|
expect(orb.speedPxPerSec).toBe(250)
|
||||||
|
expect(orb.distancePx).toBe(300)
|
||||||
|
expect(orb.explosionMissile).toBe('frozenorbexplode')
|
||||||
|
|
||||||
|
const bolt = getMissileTxtData('frozenorbbolt')
|
||||||
|
expect(bolt.id).toBe(261)
|
||||||
|
expect(bolt.vel).toBe(18)
|
||||||
|
expect(bolt.range).toBe(25)
|
||||||
|
expect(bolt.speedPxPerSec).toBe(450)
|
||||||
|
|
||||||
|
const nova = getMissileTxtData('frozenorbnova')
|
||||||
|
expect(nova.id).toBe(262)
|
||||||
|
expect(nova.vel).toBe(24)
|
||||||
|
expect(nova.range).toBe(25)
|
||||||
|
expect(nova.speedPxPerSec).toBe(600)
|
||||||
|
|
||||||
|
const explode = getMissileTxtData('frozenorbexplode')
|
||||||
|
expect(explode.id).toBe(263)
|
||||||
|
expect(explode.animLen).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('3. Authentic baked DCC atlas metadata and files exist with non-zero byte size', () => {
|
||||||
|
expect(FROZENORB_META.name).toBe('frozenorb')
|
||||||
|
expect(FROZENORB_META.celFile).toBe('IceOrb')
|
||||||
|
expect(FROZENORB_META.directions).toBe(1)
|
||||||
|
expect(FROZENORB_META.framesPerDirection).toBe(16)
|
||||||
|
expect(FROZENORB_META.groups.length).toBe(1)
|
||||||
|
expect(FROZENORB_META.groups[0]!.length).toBe(16)
|
||||||
|
|
||||||
|
expect(FROZENORBEXPLODE_META.name).toBe('frozenorbexplode')
|
||||||
|
expect(FROZENORBEXPLODE_META.celFile).toBe('IceOrbExplode')
|
||||||
|
expect(FROZENORBEXPLODE_META.directions).toBe(1)
|
||||||
|
expect(FROZENORBEXPLODE_META.framesPerDirection).toBe(18)
|
||||||
|
expect(FROZENORBEXPLODE_META.groups.length).toBe(1)
|
||||||
|
expect(FROZENORBEXPLODE_META.groups[0]!.length).toBe(18)
|
||||||
|
|
||||||
|
expect(MISSILE_METAS['frozenorb']).toBe(FROZENORB_META)
|
||||||
|
expect(MISSILE_METAS['frozenorbexplode']).toBe(FROZENORBEXPLODE_META)
|
||||||
|
|
||||||
|
const orbPngPath = path.resolve(__dirname, '../public/missiles/frozenorb.png')
|
||||||
|
const orbJsonPath = path.resolve(__dirname, '../public/missiles/frozenorb.json')
|
||||||
|
const expPngPath = path.resolve(__dirname, '../public/missiles/frozenorbexplode.png')
|
||||||
|
const expJsonPath = path.resolve(__dirname, '../public/missiles/frozenorbexplode.json')
|
||||||
|
|
||||||
|
expect(fs.existsSync(orbPngPath)).toBe(true)
|
||||||
|
expect(fs.statSync(orbPngPath).size).toBeGreaterThan(10000)
|
||||||
|
expect(fs.existsSync(orbJsonPath)).toBe(true)
|
||||||
|
expect(fs.existsSync(expPngPath)).toBe(true)
|
||||||
|
expect(fs.statSync(expPngPath).size).toBeGreaterThan(20000)
|
||||||
|
expect(fs.existsSync(expJsonPath)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('4. Discrete circle table matches 1.13c binary assembly (64 entries, radius 30)', () => {
|
||||||
|
expect(FROZEN_ORB_X_POSITIONS.length).toBe(64)
|
||||||
|
expect(FROZEN_ORB_Y_POSITIONS.length).toBe(64)
|
||||||
|
expect(FROZEN_ORB_X_POSITIONS[0]).toBe(30)
|
||||||
|
expect(FROZEN_ORB_Y_POSITIONS[0]).toBe(0)
|
||||||
|
expect(FROZEN_ORB_X_POSITIONS[16]).toBe(0)
|
||||||
|
expect(FROZEN_ORB_Y_POSITIONS[16]).toBe(30)
|
||||||
|
expect(FROZEN_ORB_X_POSITIONS[32]).toBe(-30)
|
||||||
|
expect(FROZEN_ORB_Y_POSITIONS[32]).toBe(0)
|
||||||
|
expect(FROZEN_ORB_X_POSITIONS[48]).toBe(0)
|
||||||
|
expect(FROZEN_ORB_Y_POSITIONS[48]).toBe(-30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('5. tickProjectiles: simulates 30-tick flight, emitting 1 bolt per frame along 64-table with step 19', () => {
|
||||||
|
const orb: Projectile = {
|
||||||
|
skillId: '64',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 10, // 250 px/s / 25 fps = 10 px/tick
|
||||||
|
vy: 0,
|
||||||
|
damage: 42,
|
||||||
|
ttl: 30, // 30 frames
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'frozenorb',
|
||||||
|
pierce: true,
|
||||||
|
subMissileIdx: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentProjectiles: readonly Projectile[] = [orb]
|
||||||
|
const allEmittedBolts: Projectile[] = []
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
|
||||||
|
for (let tick = 1; tick <= 30; tick += 1) {
|
||||||
|
const outcome = tickProjectiles(currentProjectiles, [], openTerrain)
|
||||||
|
expect(outcome.spawnedProjectiles).toBeDefined()
|
||||||
|
expect(outcome.spawnedProjectiles!.length).toBe(1)
|
||||||
|
const bolt = outcome.spawnedProjectiles![0]!
|
||||||
|
expect(bolt.missileType).toBe('frozenorbbolt')
|
||||||
|
expect(bolt.damage).toBe(42)
|
||||||
|
expect(bolt.statusEffect).toBe('chill')
|
||||||
|
expect(bolt.statusDuration).toBe(100)
|
||||||
|
expect(bolt.ttl).toBe(25)
|
||||||
|
expect(Math.hypot(bolt.vx, bolt.vy)).toBeCloseTo(18, 0.1) // vel = 18 px/tick
|
||||||
|
|
||||||
|
allEmittedBolts.push(bolt)
|
||||||
|
|
||||||
|
// Filter to keep only the orb for tracking emitter state
|
||||||
|
currentProjectiles = outcome.alive.filter(p => p.missileType === 'frozenorb')
|
||||||
|
if (tick < 30) {
|
||||||
|
expect(currentProjectiles.length).toBe(1)
|
||||||
|
expect(currentProjectiles[0]!.x).toBe(100 + tick * 10)
|
||||||
|
expect(currentProjectiles[0]!.ttl).toBe(30 - tick)
|
||||||
|
// Verify direction index advanced by 19 mod 64
|
||||||
|
const expectedIdx = (tick * 19) % 64
|
||||||
|
expect(currentProjectiles[0]!.subMissileIdx).toBe(expectedIdx)
|
||||||
|
} else {
|
||||||
|
// At tick 30, moved.ttl reaches 0
|
||||||
|
expect(currentProjectiles.length).toBe(1)
|
||||||
|
expect(currentProjectiles[0]!.ttl).toBe(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(allEmittedBolts.length).toBe(30)
|
||||||
|
|
||||||
|
// At tick 31, the orb with ttl: 0 detonates into 16 frozenorbnova missiles!
|
||||||
|
const detonateOutcome = tickProjectiles(currentProjectiles, [], openTerrain)
|
||||||
|
expect(detonateOutcome.expired).toBe(1)
|
||||||
|
expect(detonateOutcome.alive.length).toBe(16) // 16 nova missiles spawned
|
||||||
|
expect(detonateOutcome.spawnedProjectiles!.length).toBe(16)
|
||||||
|
for (const nova of detonateOutcome.spawnedProjectiles!) {
|
||||||
|
expect(nova.missileType).toBe('frozenorbnova')
|
||||||
|
expect(nova.damage).toBe(42)
|
||||||
|
expect(nova.statusEffect).toBe('chill')
|
||||||
|
expect(nova.isNovaCurl).toBe(true)
|
||||||
|
expect(nova.ttl).toBe(25)
|
||||||
|
expect(Math.hypot(nova.vx, nova.vy)).toBeCloseTo(24, 0.1) // vel = 24 px/tick
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify explosion event was pushed
|
||||||
|
expect(detonateOutcome.wallHitEvents).toBeDefined()
|
||||||
|
expect(detonateOutcome.wallHitEvents!.some(e => e.missileType === 'frozenorb')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('6. Main orb pierces monsters without dealing direct contact damage (CollideKill = 0)', () => {
|
||||||
|
const orb: Projectile = {
|
||||||
|
skillId: '64',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 10,
|
||||||
|
vy: 0,
|
||||||
|
damage: 50,
|
||||||
|
ttl: 10,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'frozenorb',
|
||||||
|
pierce: true,
|
||||||
|
subMissileIdx: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Place dummy enemy directly in front of the orb
|
||||||
|
const targets = [{ index: 0, x: 110, y: 100, radius: 15, alive: true }]
|
||||||
|
const outcome = tickProjectiles([orb], targets, { overlap: () => 0 })
|
||||||
|
|
||||||
|
// Main orb must NOT deal direct contact damage
|
||||||
|
const directHits = outcome.hits.filter(h => h.missileType === 'frozenorb')
|
||||||
|
expect(directHits.length).toBe(0)
|
||||||
|
|
||||||
|
// Main orb must NOT die on the monster
|
||||||
|
const survivingOrb = outcome.alive.find(p => p.missileType === 'frozenorb')
|
||||||
|
expect(survivingOrb).toBeDefined()
|
||||||
|
expect(survivingOrb!.x).toBe(110)
|
||||||
|
expect(survivingOrb!.ttl).toBe(9)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('7. Wall collision immediately detonates orb into 16 nova bolts and stops flight', () => {
|
||||||
|
const orb: Projectile = {
|
||||||
|
skillId: '64',
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
vx: 10,
|
||||||
|
vy: 0,
|
||||||
|
damage: 40,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'frozenorb',
|
||||||
|
pierce: true,
|
||||||
|
subMissileIdx: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wall at x >= 110
|
||||||
|
const wallTerrain = { overlap: (x: number) => (x >= 110 ? 1 : 0) }
|
||||||
|
const outcome = tickProjectiles([orb], [], wallTerrain)
|
||||||
|
|
||||||
|
expect(outcome.wallHits).toBe(1)
|
||||||
|
// Orb must not survive as an active frozenorb
|
||||||
|
expect(outcome.alive.some(p => p.missileType === 'frozenorb')).toBe(false)
|
||||||
|
// Must have detonated 16 nova bolts
|
||||||
|
expect(outcome.spawnedProjectiles!.filter(p => p.missileType === 'frozenorbnova').length).toBe(16)
|
||||||
|
// Wall hit events must register the explosion
|
||||||
|
expect(outcome.wallHitEvents!.some(e => e.missileType === 'frozenorb')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('8. Frozen Orb Nova: affine curl damping transforms velocity in last 6 frames', () => {
|
||||||
|
// Nova bolt with ttl = 6, vx = 20, vy = 10
|
||||||
|
const nova: Projectile = {
|
||||||
|
skillId: '64',
|
||||||
|
x: 200,
|
||||||
|
y: 200,
|
||||||
|
vx: 20,
|
||||||
|
vy: 10,
|
||||||
|
damage: 40,
|
||||||
|
ttl: 6,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'frozenorbnova',
|
||||||
|
isNovaCurl: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: ttl 6 -> curl applied! (6 <= 6 && 6 % 2 === 0)
|
||||||
|
// D2 1.13c: vx' = (vx - vy) / 2 = (20 - 10) / 2 = 5
|
||||||
|
// vy' = (vx + vy) / 2 = (20 + 10) / 2 = 15
|
||||||
|
// x' = 200 + 5 = 205, y' = 200 + 15 = 215
|
||||||
|
const step1 = tickProjectiles([nova], [], { overlap: () => 0 })
|
||||||
|
const moved1 = step1.alive.find(p => p.missileType === 'frozenorbnova')!
|
||||||
|
expect(moved1).toBeDefined()
|
||||||
|
expect(moved1.vx).toBe(5)
|
||||||
|
expect(moved1.vy).toBe(15)
|
||||||
|
expect(moved1.x).toBe(205)
|
||||||
|
expect(moved1.y).toBe(215)
|
||||||
|
expect(moved1.ttl).toBe(5)
|
||||||
|
|
||||||
|
// Step 2: ttl 5 (odd, no curl transform, normal linear flight with vx=5, vy=15)
|
||||||
|
const step2 = tickProjectiles([moved1], [], { overlap: () => 0 })
|
||||||
|
const moved2 = step2.alive.find(p => p.missileType === 'frozenorbnova')!
|
||||||
|
expect(moved2.vx).toBe(5)
|
||||||
|
expect(moved2.vy).toBe(15)
|
||||||
|
expect(moved2.x).toBe(210)
|
||||||
|
expect(moved2.y).toBe(230)
|
||||||
|
expect(moved2.ttl).toBe(4)
|
||||||
|
|
||||||
|
// Step 3: ttl 4 (even, curl applied again!)
|
||||||
|
// vx' = (5 - 15) / 2 = -5
|
||||||
|
// vy' = (5 + 15) / 2 = 10
|
||||||
|
// x' = 210 - 5 = 205, y' = 230 + 10 = 240
|
||||||
|
const step3 = tickProjectiles([moved2], [], { overlap: () => 0 })
|
||||||
|
const moved3 = step3.alive.find(p => p.missileType === 'frozenorbnova')!
|
||||||
|
expect(moved3.vx).toBe(-5)
|
||||||
|
expect(moved3.vy).toBe(10)
|
||||||
|
expect(moved3.x).toBe(205)
|
||||||
|
expect(moved3.y).toBe(240)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('9. Zero NextDelay sweet spot burst: multiple child bolts damage the same target in single frame', () => {
|
||||||
|
// 3 separate nova bolts converging on the same enemy position
|
||||||
|
const bolt1: Projectile = {
|
||||||
|
skillId: '64',
|
||||||
|
x: 95,
|
||||||
|
y: 100,
|
||||||
|
vx: 5,
|
||||||
|
vy: 0,
|
||||||
|
damage: 40,
|
||||||
|
ttl: 20,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'frozenorbnova',
|
||||||
|
}
|
||||||
|
const bolt2: Projectile = {
|
||||||
|
skillId: '64',
|
||||||
|
x: 105,
|
||||||
|
y: 100,
|
||||||
|
vx: -5,
|
||||||
|
vy: 0,
|
||||||
|
damage: 40,
|
||||||
|
ttl: 20,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'frozenorbnova',
|
||||||
|
}
|
||||||
|
const bolt3: Projectile = {
|
||||||
|
skillId: '64',
|
||||||
|
x: 100,
|
||||||
|
y: 95,
|
||||||
|
vx: 0,
|
||||||
|
vy: 5,
|
||||||
|
damage: 40,
|
||||||
|
ttl: 20,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'frozenorbnova',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dummyTarget = [{ index: 1, x: 100, y: 100, radius: 10, alive: true }]
|
||||||
|
const outcome = tickProjectiles([bolt1, bolt2, bolt3], dummyTarget, { overlap: () => 0 })
|
||||||
|
|
||||||
|
// In 1.13c, Frozen Orb child missiles have NextDelay = 0.
|
||||||
|
// Therefore all 3 intersecting bolts hit the dummy monster and register damage!
|
||||||
|
expect(outcome.hits.length).toBe(3)
|
||||||
|
const targetHits = outcome.hits.filter(h => h.targetIndex === 1)
|
||||||
|
expect(targetHits.length).toBe(3)
|
||||||
|
const totalDmg = targetHits.reduce((sum, h) => sum + h.damage, 0)
|
||||||
|
expect(totalDmg).toBe(120) // 3 * 40
|
||||||
|
})
|
||||||
|
})
|
||||||