feat(skills): implement Frozen Orb with 1.13c parity and visual assets

This commit is contained in:
troytt 2026-09-23 08:07:47 +00:00
parent e5c40fa244
commit c3bb79e403
32 changed files with 1086 additions and 53 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -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
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -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
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -67,6 +67,8 @@ const MISSILE_TARGETS = [
{ name: 'glacialspike', celFile: 'GlacialSpike', animSpeed: 16 },
{ name: 'iceexplode', celFile: 'IceArrowExplode', animSpeed: 16 },
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
{ name: 'frozenorb', celFile: 'IceOrb', animSpeed: 16 },
{ name: 'frozenorbexplode', celFile: 'IceOrbExplode', animSpeed: 16 },
// Lightning
{ name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 },

View File

@ -17,6 +17,27 @@
import type { D2DataRegistry, MissileRecord } from './data-registry.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 {
readonly uid: string
readonly missileId: number
@ -42,6 +63,8 @@ export interface ActiveMissile {
readonly dmgPacket: SUnitDmgPacket
chainRemaining: number
expired: boolean
subMissileIdx?: number
currentHitFrame?: number
}
export interface MissileStepResult {
@ -273,22 +296,49 @@ export class MissileEngine {
if (m.expired) continue
m.ageTicks += 1
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `icebolt` sub-missiles while flying
if (m.record.pSrvDoFunc === 15 && m.ageTicks % 3 === 0) {
const subName = m.record.subMissile1 || 'icebolt'
const subAngle = m.angleRad + m.ageTicks * 0.8
this.spawnMissile({
missileNameOrId: subName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x + Math.cos(subAngle) * 100,
targetY: m.y + Math.sin(subAngle) * (100 * 0.5),
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(subName)
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `frozenorbbolt` sub-missiles while flying
// Authentic D2Game.0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb):
// - 64-entry discrete circle lookup table (radius 30)
// - Trigger frequency: dwParam[0] (1 frame)
// - Angular step: dwParam[1] (19 directions per step, coprime with 64)
// - Foreshortened by ISO_GROUND_ASPECT_RATIO = 0.5 in 2:1 isometric ground perspective
if (m.record.pSrvDoFunc === 15) {
const nFrames = Math.max(1, m.record.param1 || 1)
if (m.ageTicks % nFrames === 0 || (m.maxRangeTicks - m.ageTicks) % nFrames <= 0) {
const subName = m.record.subMissile1 || 'frozenorbbolt'
const idx = Math.abs((m.subMissileIdx ?? 0) % 64)
const offX = FROZEN_ORB_X_POSITIONS[idx]!
const offY = FROZEN_ORB_Y_POSITIONS[idx]! * ISO_GROUND_ASPECT_RATIO
this.spawnMissile({
missileNameOrId: 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`)
@ -317,6 +367,12 @@ export class MissileEngine {
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 dist = Math.hypot(pos.x - m.x, pos.y - m.y)
if (dist <= hitRadius) {
@ -436,10 +492,17 @@ export class MissileEngine {
if (m.ageTicks >= m.maxRangeTicks) {
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
if (m.record.pSrvHitFunc === 29) {
const subName = m.record.hitSubMissile1 || 'icebolt'
for (let i = 0; i < 8; i += 1) {
const ang = (i * Math.PI) / 4
// Authentic D2Game.0x6FC5CE10 (MISSMODE_SrvHit29_FrozenOrb):
// - Triggers only on first frame of hit (currentHitFrame === 0)
// - Loop with step sHitPar1 = 4 over 64 directions -> exactly 16 frozenorbnova missiles!
// - 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({
missileNameOrId: subName,
sourceSkillId: m.sourceSkillId,
@ -447,12 +510,25 @@ export class MissileEngine {
owner: m.owner,
startX: m.x,
startY: m.y,
targetX: m.x + Math.cos(ang) * 100,
targetY: m.y + Math.sin(ang) * 100,
targetX: m.x + offX * 10,
targetY: m.y + offY * 10,
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
}

View File

@ -25,6 +25,7 @@ import type { D2Table } from './acts.ts'
import type { Rng } from './rng.ts'
import type { CharacterClassCode } from './classes.ts'
import type { CombatPlayer } from './combat.ts'
import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.ts'
/** One castable skill, read from a table row. */
export interface SkillDef {
@ -97,6 +98,10 @@ export interface Projectile {
readonly jitter?: boolean | undefined
/** Target restriction: only hits undead monsters. */
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,
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: {
name: 'chargedbolt',
id: 56,
@ -1666,6 +1751,8 @@ export interface ProjectileOutcome {
readonly missileType?: string | undefined
readonly aoeRadius?: number | 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 {
const radius = options.radius ?? 8
const alive: Projectile[] = []
const spawned: Projectile[] = []
const hits: {
targetIndex: number
damage: number
@ -1710,18 +1798,68 @@ export function tickProjectiles(
let wallHits = 0
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 vy = projectile.vy
let x = projectile.x + vx
let y = projectile.y + vy
let spiral = projectile.spiral
let subMissileIdx = projectile.subMissileIdx
// 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.
if (spiral !== undefined) {
// 0. Frozen Orb Nova affine rotation & damping curl in final frames
// Authentic D2 1.13c MISSMODE_SrvDo16_FrozenOrbNova (0x6FC58340):
// In final 6 frames before expiry, every 2 frames apply: x' = (x - y) / 2, y' = (x + y) / 2
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 nextRadius = spiral.radius + 3.2
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 moved: Projectile = {
...projectile,
@ -1780,6 +1944,7 @@ export function tickProjectiles(
vx,
vy,
spiral,
subMissileIdx,
hitTargets,
ttl: projectile.ttl - 1,
}
@ -1798,32 +1963,58 @@ export function tickProjectiles(
missileType: projectile.missileType,
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
}
let struck = false
for (const target of targets) {
if (!target.alive) continue
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
const isHarmlessEmitter = projectile.missileType === 'frozenorb'
if (!isHarmlessEmitter) {
for (const target of targets) {
if (!target.alive) continue
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
hits.push({
targetIndex: target.index,
damage: projectile.damage,
x,
y,
skillId: projectile.skillId,
missileType: projectile.missileType,
aoeRadius: projectile.aoeRadius,
statusEffect: projectile.statusEffect,
statusDuration: projectile.statusDuration,
isUndeadOnly: projectile.isUndeadOnly,
})
hits.push({
targetIndex: target.index,
damage: projectile.damage,
x,
y,
skillId: projectile.skillId,
missileType: projectile.missileType,
aoeRadius: projectile.aoeRadius,
statusEffect: projectile.statusEffect,
statusDuration: projectile.statusDuration,
isUndeadOnly: projectile.isUndeadOnly,
})
hitTargets.add(target.index)
if (!projectile.pierce) {
struck = true
break
hitTargets.add(target.index)
if (!projectile.pierce) {
struck = true
break
}
}
}
@ -1831,7 +2022,9 @@ export function tickProjectiles(
alive.push(moved)
}
}
return { alive, hits, expired, wallHits, wallHitEvents }
alive.push(...spawned)
return { alive, hits, expired, wallHits, wallHitEvents, spawnedProjectiles: spawned }
}
// ============================================================================

View File

@ -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 = {
name: "chargedbolt",
celFile: "ChargedBolt",
@ -4792,6 +4854,8 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
'glacialspike': GLACIALSPIKE_META,
'iceexplode': ICEEXPLODE_META,
'freezeexplode': FREEZEEXPLODE_META,
'frozenorb': FROZENORB_META,
'frozenorbexplode': FROZENORBEXPLODE_META,
'chargedbolt': CHARGEDBOLT_META,
'teeth': TEETH_META,
'teethexplode': TEETHEXPLODE_META,

View File

@ -1505,10 +1505,15 @@ export function castSkill(
missileType = 'lightningjavelin'
skillName = '闪电之枪'
} else if (skillId === 64) {
speed = 400
range = 480
damage = 35
const missile = getMissileTxtData('frozenorb')
speed = missile.speedPxPerSec || 250
range = missile.distancePx || 300
damage = 40
delay = 25
pierce = true
statusEffect = 'chill'
statusDuration = 100
missileType = 'frozenorb'
skillName = '冰封球'
} else if (skillId === 49) {
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
}
@ -5152,7 +5162,7 @@ if (typeof window !== 'undefined') {
for (const shot of engine.projectiles) {
pushEntity(shot.x, shot.y, () => {
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
const art = mType !== undefined ? missileArtMap.get(mType) : undefined
const art = mType !== undefined ? (missileArtMap.get(mType) ?? (mType === 'frozenorbbolt' || mType === 'frozenorbnova' ? missileArtMap.get('icebolt') : undefined)) : undefined
if (art !== undefined) {
drawMissileProjectile(renderer, shot, art)
} else {

View File

@ -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
})
})