feat(skills): implement Skill #056 Meteor with 1.13c ground truth parity (Ref #401)

This commit is contained in:
troytt 2026-09-23 12:36:17 +00:00
parent 39de19364f
commit 96eee679b4
23 changed files with 2780 additions and 11 deletions

View File

@ -0,0 +1,309 @@
{
"name": "firemedium",
"celFile": "groundFireMedium",
"width": 1024,
"height": 410,
"directions": 1,
"framesPerDirection": 37,
"animSpeed": 16,
"groups": [
[
[
0,
0,
66,
136,
-33,
-125
],
[
67,
0,
66,
136,
-33,
-125
],
[
134,
0,
66,
136,
-33,
-125
],
[
201,
0,
66,
136,
-33,
-125
],
[
268,
0,
66,
136,
-33,
-125
],
[
335,
0,
66,
136,
-33,
-125
],
[
402,
0,
66,
136,
-33,
-125
],
[
469,
0,
66,
136,
-33,
-125
],
[
536,
0,
66,
136,
-33,
-125
],
[
603,
0,
66,
136,
-33,
-125
],
[
670,
0,
66,
136,
-33,
-125
],
[
737,
0,
66,
136,
-33,
-125
],
[
804,
0,
66,
136,
-33,
-125
],
[
871,
0,
66,
136,
-33,
-125
],
[
938,
0,
66,
136,
-33,
-125
],
[
0,
137,
66,
136,
-33,
-125
],
[
67,
137,
66,
136,
-33,
-125
],
[
134,
137,
66,
136,
-33,
-125
],
[
201,
137,
66,
136,
-33,
-125
],
[
268,
137,
66,
136,
-33,
-125
],
[
335,
137,
66,
136,
-33,
-125
],
[
402,
137,
66,
136,
-33,
-125
],
[
469,
137,
66,
136,
-33,
-125
],
[
536,
137,
66,
136,
-33,
-125
],
[
603,
137,
66,
136,
-33,
-125
],
[
670,
137,
66,
136,
-33,
-125
],
[
737,
137,
66,
136,
-33,
-125
],
[
804,
137,
66,
136,
-33,
-125
],
[
871,
137,
66,
136,
-33,
-125
],
[
938,
137,
66,
136,
-33,
-125
],
[
0,
274,
66,
136,
-33,
-125
],
[
67,
274,
66,
136,
-33,
-125
],
[
134,
274,
66,
136,
-33,
-125
],
[
201,
274,
66,
136,
-33,
-125
],
[
268,
274,
66,
136,
-33,
-125
],
[
335,
274,
66,
136,
-33,
-125
],
[
402,
274,
66,
136,
-33,
-125
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@ -0,0 +1,309 @@
{
"name": "firesmall",
"celFile": "groundFireSmall",
"width": 512,
"height": 539,
"directions": 1,
"framesPerDirection": 37,
"animSpeed": 16,
"groups": [
[
[
0,
0,
66,
89,
-33,
-77
],
[
67,
0,
66,
89,
-33,
-77
],
[
134,
0,
66,
89,
-33,
-77
],
[
201,
0,
66,
89,
-33,
-77
],
[
268,
0,
66,
89,
-33,
-77
],
[
335,
0,
66,
89,
-33,
-77
],
[
402,
0,
66,
89,
-33,
-77
],
[
0,
90,
66,
89,
-33,
-77
],
[
67,
90,
66,
89,
-33,
-77
],
[
134,
90,
66,
89,
-33,
-77
],
[
201,
90,
66,
89,
-33,
-77
],
[
268,
90,
66,
89,
-33,
-77
],
[
335,
90,
66,
89,
-33,
-77
],
[
402,
90,
66,
89,
-33,
-77
],
[
0,
180,
66,
89,
-33,
-77
],
[
67,
180,
66,
89,
-33,
-77
],
[
134,
180,
66,
89,
-33,
-77
],
[
201,
180,
66,
89,
-33,
-77
],
[
268,
180,
66,
89,
-33,
-77
],
[
335,
180,
66,
89,
-33,
-77
],
[
402,
180,
66,
89,
-33,
-77
],
[
0,
270,
66,
89,
-33,
-77
],
[
67,
270,
66,
89,
-33,
-77
],
[
134,
270,
66,
89,
-33,
-77
],
[
201,
270,
66,
89,
-33,
-77
],
[
268,
270,
66,
89,
-33,
-77
],
[
335,
270,
66,
89,
-33,
-77
],
[
402,
270,
66,
89,
-33,
-77
],
[
0,
360,
66,
89,
-33,
-77
],
[
67,
360,
66,
89,
-33,
-77
],
[
134,
360,
66,
89,
-33,
-77
],
[
201,
360,
66,
89,
-33,
-77
],
[
268,
360,
66,
89,
-33,
-77
],
[
335,
360,
66,
89,
-33,
-77
],
[
402,
360,
66,
89,
-33,
-77
],
[
0,
450,
66,
89,
-33,
-77
],
[
67,
450,
66,
89,
-33,
-77
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

109
public/missiles/meteor.json Normal file
View File

@ -0,0 +1,109 @@
{
"name": "meteor",
"celFile": "Meteor",
"width": 512,
"height": 22,
"directions": 1,
"framesPerDirection": 12,
"animSpeed": 16,
"groups": [
[
[
0,
0,
22,
22,
-7,
-55
],
[
23,
0,
22,
22,
-7,
-55
],
[
46,
0,
22,
22,
-7,
-55
],
[
69,
0,
22,
22,
-7,
-55
],
[
92,
0,
22,
22,
-7,
-55
],
[
115,
0,
22,
22,
-7,
-55
],
[
138,
0,
22,
22,
-7,
-55
],
[
161,
0,
22,
22,
-7,
-55
],
[
184,
0,
22,
22,
-7,
-55
],
[
207,
0,
22,
22,
-7,
-55
],
[
230,
0,
22,
22,
-7,
-55
],
[
253,
0,
22,
22,
-7,
-55
]
]
]
}

BIN
public/missiles/meteor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,149 @@
{
"name": "meteorcenter",
"celFile": "MeteorTargetIndicator",
"width": 512,
"height": 374,
"directions": 1,
"framesPerDirection": 17,
"animSpeed": 16,
"groups": [
[
[
0,
0,
112,
74,
-56,
-43
],
[
113,
0,
112,
74,
-56,
-43
],
[
226,
0,
112,
74,
-56,
-43
],
[
339,
0,
112,
74,
-56,
-43
],
[
0,
75,
112,
74,
-56,
-43
],
[
113,
75,
112,
74,
-56,
-43
],
[
226,
75,
112,
74,
-56,
-43
],
[
339,
75,
112,
74,
-56,
-43
],
[
0,
150,
112,
74,
-56,
-43
],
[
113,
150,
112,
74,
-56,
-43
],
[
226,
150,
112,
74,
-56,
-43
],
[
339,
150,
112,
74,
-56,
-43
],
[
0,
225,
112,
74,
-56,
-43
],
[
113,
225,
112,
74,
-56,
-43
],
[
226,
225,
112,
74,
-56,
-43
],
[
339,
225,
112,
74,
-56,
-43
],
[
0,
300,
112,
74,
-56,
-43
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,141 @@
{
"name": "meteorexplode",
"celFile": "ExpArrowExplode",
"width": 1024,
"height": 699,
"directions": 1,
"framesPerDirection": 16,
"animSpeed": 16,
"groups": [
[
[
0,
0,
208,
174,
-108,
-123
],
[
209,
0,
208,
174,
-108,
-123
],
[
418,
0,
208,
174,
-108,
-123
],
[
627,
0,
208,
174,
-108,
-123
],
[
0,
175,
208,
174,
-108,
-123
],
[
209,
175,
208,
174,
-108,
-123
],
[
418,
175,
208,
174,
-108,
-123
],
[
627,
175,
208,
174,
-108,
-123
],
[
0,
350,
208,
174,
-108,
-123
],
[
209,
350,
208,
174,
-108,
-123
],
[
418,
350,
208,
174,
-108,
-123
],
[
627,
350,
208,
174,
-108,
-123
],
[
0,
525,
208,
174,
-108,
-123
],
[
209,
525,
208,
174,
-108,
-123
],
[
418,
525,
208,
174,
-108,
-123
],
[
627,
525,
208,
174,
-108,
-123
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -0,0 +1,309 @@
{
"name": "meteorfire",
"celFile": "groundFireMedium",
"width": 1024,
"height": 410,
"directions": 1,
"framesPerDirection": 37,
"animSpeed": 16,
"groups": [
[
[
0,
0,
66,
136,
-33,
-125
],
[
67,
0,
66,
136,
-33,
-125
],
[
134,
0,
66,
136,
-33,
-125
],
[
201,
0,
66,
136,
-33,
-125
],
[
268,
0,
66,
136,
-33,
-125
],
[
335,
0,
66,
136,
-33,
-125
],
[
402,
0,
66,
136,
-33,
-125
],
[
469,
0,
66,
136,
-33,
-125
],
[
536,
0,
66,
136,
-33,
-125
],
[
603,
0,
66,
136,
-33,
-125
],
[
670,
0,
66,
136,
-33,
-125
],
[
737,
0,
66,
136,
-33,
-125
],
[
804,
0,
66,
136,
-33,
-125
],
[
871,
0,
66,
136,
-33,
-125
],
[
938,
0,
66,
136,
-33,
-125
],
[
0,
137,
66,
136,
-33,
-125
],
[
67,
137,
66,
136,
-33,
-125
],
[
134,
137,
66,
136,
-33,
-125
],
[
201,
137,
66,
136,
-33,
-125
],
[
268,
137,
66,
136,
-33,
-125
],
[
335,
137,
66,
136,
-33,
-125
],
[
402,
137,
66,
136,
-33,
-125
],
[
469,
137,
66,
136,
-33,
-125
],
[
536,
137,
66,
136,
-33,
-125
],
[
603,
137,
66,
136,
-33,
-125
],
[
670,
137,
66,
136,
-33,
-125
],
[
737,
137,
66,
136,
-33,
-125
],
[
804,
137,
66,
136,
-33,
-125
],
[
871,
137,
66,
136,
-33,
-125
],
[
938,
137,
66,
136,
-33,
-125
],
[
0,
274,
66,
136,
-33,
-125
],
[
67,
274,
66,
136,
-33,
-125
],
[
134,
274,
66,
136,
-33,
-125
],
[
201,
274,
66,
136,
-33,
-125
],
[
268,
274,
66,
136,
-33,
-125
],
[
335,
274,
66,
136,
-33,
-125
],
[
402,
274,
66,
136,
-33,
-125
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View File

@ -0,0 +1,109 @@
{
"name": "meteortail",
"celFile": "MeteorTail",
"width": 512,
"height": 251,
"directions": 1,
"framesPerDirection": 12,
"animSpeed": 16,
"groups": [
[
[
0,
0,
77,
125,
-49,
-143
],
[
78,
0,
77,
125,
-49,
-143
],
[
156,
0,
77,
125,
-49,
-143
],
[
234,
0,
77,
125,
-49,
-143
],
[
312,
0,
77,
125,
-49,
-143
],
[
390,
0,
77,
125,
-49,
-143
],
[
0,
126,
77,
125,
-49,
-143
],
[
78,
126,
77,
125,
-49,
-143
],
[
156,
126,
77,
125,
-49,
-143
],
[
234,
126,
77,
125,
-49,
-143
],
[
312,
126,
77,
125,
-49,
-143
],
[
390,
126,
77,
125,
-49,
-143
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -0,0 +1,149 @@
{
"name": "meteortargetindicator",
"celFile": "MeteorTargetIndicator",
"width": 512,
"height": 374,
"directions": 1,
"framesPerDirection": 17,
"animSpeed": 16,
"groups": [
[
[
0,
0,
112,
74,
-56,
-43
],
[
113,
0,
112,
74,
-56,
-43
],
[
226,
0,
112,
74,
-56,
-43
],
[
339,
0,
112,
74,
-56,
-43
],
[
0,
75,
112,
74,
-56,
-43
],
[
113,
75,
112,
74,
-56,
-43
],
[
226,
75,
112,
74,
-56,
-43
],
[
339,
75,
112,
74,
-56,
-43
],
[
0,
150,
112,
74,
-56,
-43
],
[
113,
150,
112,
74,
-56,
-43
],
[
226,
150,
112,
74,
-56,
-43
],
[
339,
150,
112,
74,
-56,
-43
],
[
0,
225,
112,
74,
-56,
-43
],
[
113,
225,
112,
74,
-56,
-43
],
[
226,
225,
112,
74,
-56,
-43
],
[
339,
225,
112,
74,
-56,
-43
],
[
0,
300,
112,
74,
-56,
-43
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -96,6 +96,14 @@ const MISSILE_TARGETS = [
{ name: 'icearrow', celFile: 'IceArrow', animSpeed: 16 },
{ name: 'arrow', celFile: 'Arrow', animSpeed: 16, trans: 0 },
{ name: 'lightningjavelin', celFile: 'LightningJavelin', animSpeed: 16 },
// Meteor (Skill 56)
{ name: 'meteor', celFile: 'Meteor', animSpeed: 16 },
{ name: 'meteortail', celFile: 'MeteorTail', animSpeed: 16 },
{ name: 'meteorcenter', celFile: 'MeteorTargetIndicator', animSpeed: 16 },
{ name: 'meteorexplode', celFile: 'ExpArrowExplode', animSpeed: 16 },
{ name: 'meteorfire', celFile: 'groundFireMedium', animSpeed: 16 },
{ name: 'firesmall', celFile: 'groundFireSmall', animSpeed: 16 },
] as const
const PAGE_SIZE = 4096
@ -324,6 +332,8 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise
}
copyAlias('blizzard4', 'blizzard3')
copyAlias('blizzardexplode3', 'blizzardexplode2')
copyAlias('meteortargetindicator', 'meteorcenter')
copyAlias('firemedium', 'meteorfire')
// Generate src/render/missiles-meta.ts
const metaExports = MISSILE_TARGETS.map(t => {
@ -445,12 +455,24 @@ export const HYDRAFIRE_META: MissileMeta = {
name: 'hydrafire',
}
export const METEORTARGETINDICATOR_META: MissileMeta = {
...METEORCENTER_META,
name: 'meteortargetindicator',
}
export const FIREMEDIUM_META: MissileMeta = {
...METEORFIRE_META,
name: 'firemedium',
}
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
${mapEntries}
'blizzard4': BLIZZARD4_META,
'blizzardexplode3': BLIZZARDEXPLODE3_META,
'hydra': HYDRA_META,
'hydrafire': HYDRAFIRE_META,
'meteortargetindicator': METEORTARGETINDICATOR_META,
'firemedium': FIREMEDIUM_META,
})
`

View File

@ -0,0 +1,38 @@
/**
* Diablo II: Lord of Destruction v1.13c — Meteor Ground Fire Subtile Geometry.
*
* Ground Truth Reference:
* - D2Game.dll VA 0x6FC5CEC0 (MISSMODE_CreateMeteor_MoltenBoulderSubmissiles)
* - D2Game.dll VA 0x6FD1C918 (int16_t pXOffsets[18])
* - D2Game.dll VA 0x6FD1C8D0 (int16_t pYOffsets[18])
*
* Each subtile offset is scaled to world pixels:
* targetX = gx + offset.x * 20
* targetY = gy + offset.y * 10 (20 * ISO_GROUND_ASPECT_RATIO, 2:1 isometric aspect ratio)
*/
export interface MeteorFireSubtileOffset {
readonly x: number
readonly y: number
}
export const METEOR_FIRE_SUBTILE_OFFSETS: readonly MeteorFireSubtileOffset[] = Object.freeze([
{ x: 2, y: -2 }, // [0] Inner Ring
{ x: -2, y: -2 }, // [1] Inner Ring
{ x: 0, y: 2 }, // [2] Inner Ring
{ x: 0, y: 5 }, // [3] North Tip
{ x: -3, y: 3 }, // [4] North-West Outer
{ x: 0, y: 3 }, // [5] Center-North
{ x: 3, y: 3 }, // [6] North-East Outer
{ x: -1, y: 2 }, // [7] Center Cluster
{ x: 1, y: 1 }, // [8] Center Cluster
{ x: -1, y: -1 }, // [9] Center Cluster
{ x: 2, y: -1 }, // [10] Center Cluster
{ x: -4, y: -2 }, // [11] West Tip
{ x: -3, y: -2 }, // [12] West Outer
{ x: -1, y: -3 }, // [13] South-West Outer
{ x: 0, y: -4 }, // [14] South Tip
{ x: 1, y: -3 }, // [15] South-East Outer
{ x: 3, y: -3 }, // [16] East Outer
{ x: 4, y: -2 }, // [17] East Tip
])

View File

@ -17,6 +17,8 @@
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts'
import { getBlizzardDropOffset } from './blizzard-table.ts'
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
import { calculateMeteorResidualFireDamage } from '../skills.ts'
export const ISO_GROUND_ASPECT_RATIO = 0.5
@ -251,7 +253,8 @@ export class MissileEngine {
const isStationary =
isTurret ||
effectiveRec.vel === 0 ||
effectiveRec.name.toLowerCase().startsWith('blizzard')
effectiveRec.name.toLowerCase().startsWith('blizzard') ||
effectiveRec.name.toLowerCase().startsWith('meteor')
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
@ -454,6 +457,9 @@ export class MissileEngine {
}
// Check collision against alive enemy targets
if (m.name === 'meteorfire' && m.ageTicks % 5 === 0) {
m.hitTargetIds.clear()
}
const hitRadius = Math.max(24, (m.record.size || 2) * 14)
for (const target of targets) {
if (target.statList.getHp256() <= 0) continue
@ -467,19 +473,24 @@ export class MissileEngine {
continue
}
// Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), and Hydra turret emitters
// do not deal direct contact damage; all damage is delivered through emitted child bolts / shards.
// Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), Meteor Center (pSrvHitFunc === 14 / meteorcenter),
// and Hydra turret emitters do not deal direct contact damage; all damage is delivered through emitted child bolts / shards.
if (
m.record.pSrvDoFunc === 15 ||
m.record.pSrvDoFunc === 10 ||
m.name === 'blizzardcenter' ||
m.record.pSrvHitFunc === 14 ||
m.name === 'meteorcenter' ||
isTurretEmitter
) {
continue
}
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
const dist = Math.hypot(pos.x - m.x, pos.y - m.y)
const dist =
m.name === 'meteorfire'
? Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO)
: Math.hypot(pos.x - m.x, pos.y - m.y)
if (dist <= hitRadius) {
// Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0`
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
@ -586,7 +597,9 @@ export class MissileEngine {
}
// Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit)
if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
if (m.name === 'meteorfire') {
// Ground fire persists for its full duration and does not die on hit
} else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
m.pierceCount += 1
} else if (m.record.collideKill || !m.canPierce) {
if (
@ -615,6 +628,75 @@ export class MissileEngine {
}
if (m.ageTicks >= m.maxRangeTicks) {
// `pSrvHitFunc = 14` (`Meteor` detonation and ground fire patch cascade)
// Authentic D2Game 0x6FC5DEF0 (MISSMODE_SrvHit14_MeteorCenter) & 0x6FC5CEC0:
if (
(m.record.pSrvHitFunc === 14 || m.name === 'meteorcenter') &&
(!m.currentHitFrame || m.currentHitFrame === 0)
) {
m.currentHitFrame = 1
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
const blastRadius = 120
for (const target of targets) {
if (target.statList.getHp256() <= 0) continue
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
const inRange =
Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
if (inRange) {
const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket)
hits.push({
missileName: 'meteorexplode',
targetId: target.id,
damage: dmgOut.totalDamage,
immune: dmgOut.immuneToElem || dmgOut.immuneToPhys,
})
}
}
// 2. Spawn explosion missile: meteorexplode (ID 103)
const expName = m.record.explosionMissile || 'meteorexplode'
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)
// 3. Spawn 18 meteorfire submissiles using 18-offset table
const subFireName = m.record.hitSubMissile1 || 'meteorfire'
const fireDuration = 30 + (Math.max(1, m.slvl) - 1) * 15
const fireDmg = calculateMeteorResidualFireDamage(m.slvl)
const fireDmgPacket: SUnitDmgPacket = {
skillId: m.sourceSkillId,
attackKind: 'spell',
elemType: 'fire',
elemMin256: fireDmg.min * 8, // HitShift 3: << 3 = * 8
elemMax256: fireDmg.max * 8,
}
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
const fx = m.x + off.x * 20
const fy = m.y + off.y * 10
this.spawnMissile({
missileNameOrId: subFireName,
sourceSkillId: m.sourceSkillId,
slvl: m.slvl,
owner: m.owner,
startX: fx,
startY: fy,
targetX: fx,
targetY: fy,
dmgPacket: fireDmgPacket,
maxRangeTicks: fireDuration,
})
subMissilesSpawned.push(subFireName)
}
}
// Blizzard falling shard ground impact explosion (Missiles 159..162)
if (
m.name.startsWith('blizzard') &&

View File

@ -32,6 +32,14 @@ import {
getBlizzardDropOffset,
} from './engine/blizzard-table.ts'
export { BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y, getBlizzardDropOffset } from './engine/blizzard-table.ts'
import {
METEOR_FIRE_SUBTILE_OFFSETS,
type MeteorFireSubtileOffset,
} from './engine/meteor-table.ts'
export {
METEOR_FIRE_SUBTILE_OFFSETS,
type MeteorFireSubtileOffset,
} from './engine/meteor-table.ts'
/** One castable skill, read from a table row. */
export interface SkillDef {
@ -123,6 +131,12 @@ export interface Projectile {
readonly ageTicks?: number | undefined
/** Altitude above ground in pixels for falling missiles (e.g. Blizzard shards). */
readonly altitude?: number | undefined
/** Skill level of the source skill (for dynamic duration/damage scaling). */
readonly slvl?: number | undefined
/** Residual ground fire duration in ticks. */
readonly fireDuration?: number | undefined
/** Residual ground fire damage per tick. */
readonly residualDamage?: number | undefined
}
/**
@ -667,6 +681,106 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
speedPxPerSec: 400,
distancePx: 480,
},
meteor: {
name: 'meteor',
id: 100,
vel: 0,
maxVel: 0,
range: 60,
levRange: 0,
celFile: 'Meteor',
animLen: 12,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: 'meteorexplode',
light: 0,
red: 0,
green: 0,
blue: 0,
speedPxPerSec: 0,
distancePx: 0,
},
meteorcenter: {
name: 'meteorcenter',
id: 101,
vel: 0,
maxVel: 0,
range: 60,
levRange: 0,
celFile: 'MeteorTargetIndicator',
animLen: 17,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: 'meteorexplode',
light: 4,
red: 255,
green: 64,
blue: 64,
speedPxPerSec: 0,
distancePx: 0,
},
meteortail: {
name: 'meteortail',
id: 102,
vel: 0,
maxVel: 0,
range: 60,
levRange: 0,
celFile: 'MeteorTail',
animLen: 12,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 0,
green: 0,
blue: 0,
speedPxPerSec: 0,
distancePx: 0,
},
meteorexplode: {
name: 'meteorexplode',
id: 103,
vel: 0,
maxVel: 0,
range: 16,
levRange: 0,
celFile: 'ExpArrowExplode',
animLen: 16,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 13,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 0,
distancePx: 0,
},
meteorfire: {
name: 'meteorfire',
id: 240,
vel: 0,
maxVel: 0,
range: 30,
levRange: 15,
celFile: 'groundFireMedium',
animLen: 37,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 3,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 0,
distancePx: 0,
},
chargedbolt: {
name: 'chargedbolt',
id: 56,
@ -3542,6 +3656,77 @@ export function tickProjectiles(
aoeRadius: 32,
pierce: false,
})
} else if (projectile.missileType === 'meteorcenter') {
// Authentic D2 1.13c MISSMODE_SrvHit14_MeteorCenter (0x6FC5DEF0) & 0x6FC5CEC0:
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
const blastRadius = projectile.aoeRadius ?? 120
for (const target of targets) {
if (!target.alive) continue
const inRange =
Math.hypot(target.x - projectile.x, (target.y - projectile.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
if (inRange) {
hits.push({
targetIndex: target.index,
damage: projectile.damage,
x: target.x,
y: target.y,
skillId: projectile.skillId,
missileType: 'meteorexplode',
aoeRadius: blastRadius,
})
}
}
// 2. Spawn explosion missile: meteorexplode (ID 103, Range 16)
spawned.push({
skillId: projectile.skillId,
x: projectile.x,
y: projectile.y,
vx: 0,
vy: 0,
damage: 0,
ttl: 16,
fromPlayer: projectile.fromPlayer,
missileType: 'meteorexplode',
aoeRadius: blastRadius,
pierce: true,
})
wallHitEvents.push({
x: projectile.x,
y: projectile.y,
skillId: projectile.skillId,
missileType: 'meteorexplode',
aoeRadius: blastRadius,
})
// 3. Spawn 18 meteorfire submissiles using 18-offset table
const slvl = Math.max(1, projectile.slvl ?? 1)
const fireDuration = projectile.fireDuration ?? (30 + (slvl - 1) * 15)
const residualDmg = projectile.residualDamage ?? calculateMeteorResidualFireDamage(slvl).min
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
const fx = projectile.x + off.x * 20
const fy = projectile.y + off.y * 10
const blocked =
options.isMissileBlocked !== undefined
? options.isMissileBlocked(fx, fy, projectile.x, projectile.y)
: options.overlap(fx, fy) > 0
if (!blocked) {
spawned.push({
skillId: projectile.skillId,
x: fx,
y: fy,
vx: 0,
vy: 0,
damage: residualDmg,
ttl: fireDuration,
fromPlayer: projectile.fromPlayer,
missileType: 'meteorfire',
aoeRadius: 16,
pierce: true,
slvl,
})
}
}
}
continue
}
@ -3738,6 +3923,10 @@ export function tickProjectiles(
}
const hitTargets = projectile.hitTargets ?? new Set<number>()
const currentAge = (projectile.ageTicks ?? 0) + 1
if (projectile.missileType === 'meteorfire' && currentAge % 5 === 0) {
hitTargets.clear()
}
const moved: Projectile = {
...projectile,
x,
@ -3748,7 +3937,7 @@ export function tickProjectiles(
subMissileIdx,
altitude,
hitTargets,
ageTicks: (projectile.ageTicks ?? 0) + 1,
ageTicks: currentAge,
ttl: projectile.ttl - 1,
}
@ -3813,12 +4002,17 @@ export function tickProjectiles(
const isHarmlessEmitter =
projectile.missileType === 'frozenorb' ||
projectile.missileType === 'blizzardcenter' ||
projectile.missileType === 'meteorcenter' ||
isTurretEmitter
if (!isHarmlessEmitter) {
for (const target of targets) {
if (!target.alive) continue
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
const dist =
projectile.missileType === 'meteorfire'
? Math.hypot(target.x - x, (target.y - y) / ISO_GROUND_ASPECT_RATIO)
: Math.hypot(target.x - x, target.y - y)
if (dist > target.radius + radius) continue
const statusEffect =
projectile.statusEffect ??
@ -3930,6 +4124,8 @@ export interface Batch1SkillDef {
readonly range?: number
readonly speed?: number
readonly castOverlay?: string
readonly srvdofunc?: number | undefined
readonly castDelay?: number | undefined
}
export const BATCH1_SKILLS: Readonly<Record<number, Batch1SkillDef>> = Object.freeze({
@ -4010,6 +4206,11 @@ export function isBatch1Skill(skillId: number): boolean {
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
*/
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
if (skillId === 56) {
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
return (34 + (lvl - 1)) * 0.5
}
const def = getBatch1SkillDef(skillId)
if (!def) return 0
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
@ -4024,9 +4225,13 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
* - Werewolf: 25 frames (1.0s)
* - Firestorm: 15 frames (0.6s)
* - Molten Boulder: 50 frames (2.0s)
* - Meteor: 30 frames (1.2s)
* - All other Batch 1 skills: 0 frames
*/
export function getSkillCooldownTicks(skillId: number): number {
if (skillId === 56) {
return 30
}
const def = getBatch1SkillDef(skillId)
return def?.delay ?? 0
}
@ -4047,12 +4252,19 @@ export function calculateSkillDamage(
readonly iceBolt?: number | undefined
readonly iceBlast?: number | undefined
readonly glacialSpike?: number | undefined
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
readonly inferno?: number | undefined
},
weaponDamage?: { min: number; max: number }
): { min: number; max: number } {
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
return calculateBlizzardDamage(slvl, synergies)
}
if (skillId === 56 || skillId === '56' || skillId === 'meteor') {
return calculateMeteorDamage(slvl, synergies as any)
}
const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
@ -4191,6 +4403,199 @@ export function calculateBlizzardDamage(
}
}
/**
* Calculates Meteor (Skill 56) synergy multiplier:
* +5% fire damage per hard point in Fire Bolt (36) and Fire Ball (47).
* Fire Mastery (61): +30% base, +7%/lvl if present.
*/
export function calculateMeteorSynergyMultiplier(synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
}): number {
const points = (synergies.fireBolt ?? 0) + (synergies.fireBall ?? 0)
const synPct = points * 5 // +5% per hard point
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
? 30 + (synergies.fireMastery - 1) * 7 // Fire Mastery: +30% base, +7%/lvl
: 0
return (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
}
/**
* Calculates Meteor (Skill 56) primary detonation fire damage with 1.13c 5-band level scaling and synergies.
* Base: 80 - 100
* 5 bands: [23, 39, 79, 81, 83] min / [25, 41, 81, 83, 85] max.
*/
export function calculateMeteorDamage(
effectiveLevel: number,
synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
} | number = {},
): { min: number; max: number } {
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
syn = calculateMeteorSynergyMultiplier(synergies)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 80
let max = 100
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 23
max += b1 * 25
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 39
max += b2 * 41
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 79
max += b3 * 81
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 81
max += b4 * 83
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 83
max += b5 * 85
}
}
return {
min: Math.floor(min * syn),
max: Math.floor(max * syn),
}
}
/**
* Calculates Meteor residual ground fire (meteorfire, ID 240) periodic damage.
* 1.13c Ground Truth:
* - Base: 15 - 25
* - 5 bands: [4, 5, 6, 6, 6] min/max
* - Synergies: +3% per hard point of Inferno (41); Fire Mastery (61) applies.
* - HitShift: 3 (8/256 internal precision per tick).
* - Per-second multiplier: 25 * 8 / 256 = 200 / 256 = 0.78125.
*/
export function calculateMeteorResidualFireDamage(
effectiveLevel: number,
synergies: {
readonly inferno?: number | undefined
readonly fireMastery?: number | undefined
} | number = {},
): {
min: number
max: number
perSecMin: number
perSecMax: number
} {
let syn = 1.0
if (typeof synergies === 'number') {
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
} else if (synergies && typeof synergies === 'object') {
const infernoPts = synergies.inferno ?? 0
const infernoPct = infernoPts * 3 // +3% per point
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
? 30 + (synergies.fireMastery - 1) * 7
: 0
syn = (1.0 + infernoPct / 100) * (1.0 + masteryPct / 100)
}
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
let min = 15
let max = 25
if (lvl > 1) {
const b1 = Math.min(lvl - 1, 7)
min += b1 * 4
max += b1 * 4
if (lvl > 8) {
const b2 = Math.min(lvl - 8, 8)
min += b2 * 5
max += b2 * 5
}
if (lvl > 16) {
const b3 = Math.min(lvl - 16, 6)
min += b3 * 6
max += b3 * 6
}
if (lvl > 22) {
const b4 = Math.min(lvl - 22, 6)
min += b4 * 6
max += b4 * 6
}
if (lvl > 28) {
const b5 = lvl - 28
min += b5 * 6
max += b5 * 6
}
}
const rawMin = Math.floor(min * syn)
const rawMax = Math.floor(max * syn)
// In 1.13c HitShift is 3 (8/256) at 25 fps: per-second multiplier is 200/256 = 0.78125
const perSecMin = Math.floor((rawMin * 200) / 256)
const perSecMax = Math.floor((rawMax * 200) / 256)
return {
min: rawMin,
max: rawMax,
perSecMin,
perSecMax,
}
}
/**
* Calculates Meteor detailed damage profile including impact fire, optional physical split,
* and residual ground fire damage per second.
*/
export function calculateMeteorDetailedDamage(
effectiveLevel: number,
synergies: {
readonly fireBolt?: number | undefined
readonly fireBall?: number | undefined
readonly fireMastery?: number | undefined
readonly inferno?: number | undefined
} = {},
options?: { readonly physRatio?: number | undefined }
): {
impactFire: { min: number; max: number }
impactPhys: { min: number; max: number }
residualFire: { min: number; max: number; perSecMin: number; perSecMax: number }
} {
const det = calculateMeteorDamage(effectiveLevel, synergies)
const physRatio = Math.max(0, Math.min(1, options?.physRatio ?? 0)) // 0 in vanilla 1.13c
const fireRatio = 1.0 - physRatio
const impactFire = {
min: Math.floor(det.min * fireRatio),
max: Math.floor(det.max * fireRatio),
}
const impactPhys = {
min: Math.floor(det.min * physRatio),
max: Math.floor(det.max * physRatio),
}
const residualFire = calculateMeteorResidualFireDamage(effectiveLevel, synergies)
return {
impactFire,
impactPhys,
residualFire,
}
}
/** Result of executing a Batch 1 skill. */
export type SkillExecutionResult =
| { readonly kind: 'cooldown'; readonly ticksLeft: number }

View File

@ -4947,6 +4947,221 @@ export const LIGHTNINGJAVELIN_META: MissileMeta = {
],
}
export const METEOR_META: MissileMeta = {
name: "meteor",
celFile: "Meteor",
width: 512,
height: 22,
directions: 1,
framesPerDirection: 12,
animSpeed: 16,
groups: [
[
[0, 0, 22, 22, -7, -55],
[23, 0, 22, 22, -7, -55],
[46, 0, 22, 22, -7, -55],
[69, 0, 22, 22, -7, -55],
[92, 0, 22, 22, -7, -55],
[115, 0, 22, 22, -7, -55],
[138, 0, 22, 22, -7, -55],
[161, 0, 22, 22, -7, -55],
[184, 0, 22, 22, -7, -55],
[207, 0, 22, 22, -7, -55],
[230, 0, 22, 22, -7, -55],
[253, 0, 22, 22, -7, -55]
]
],
}
export const METEORTAIL_META: MissileMeta = {
name: "meteortail",
celFile: "MeteorTail",
width: 512,
height: 251,
directions: 1,
framesPerDirection: 12,
animSpeed: 16,
groups: [
[
[0, 0, 77, 125, -49, -143],
[78, 0, 77, 125, -49, -143],
[156, 0, 77, 125, -49, -143],
[234, 0, 77, 125, -49, -143],
[312, 0, 77, 125, -49, -143],
[390, 0, 77, 125, -49, -143],
[0, 126, 77, 125, -49, -143],
[78, 126, 77, 125, -49, -143],
[156, 126, 77, 125, -49, -143],
[234, 126, 77, 125, -49, -143],
[312, 126, 77, 125, -49, -143],
[390, 126, 77, 125, -49, -143]
]
],
}
export const METEORCENTER_META: MissileMeta = {
name: "meteorcenter",
celFile: "MeteorTargetIndicator",
width: 512,
height: 374,
directions: 1,
framesPerDirection: 17,
animSpeed: 16,
groups: [
[
[0, 0, 112, 74, -56, -43],
[113, 0, 112, 74, -56, -43],
[226, 0, 112, 74, -56, -43],
[339, 0, 112, 74, -56, -43],
[0, 75, 112, 74, -56, -43],
[113, 75, 112, 74, -56, -43],
[226, 75, 112, 74, -56, -43],
[339, 75, 112, 74, -56, -43],
[0, 150, 112, 74, -56, -43],
[113, 150, 112, 74, -56, -43],
[226, 150, 112, 74, -56, -43],
[339, 150, 112, 74, -56, -43],
[0, 225, 112, 74, -56, -43],
[113, 225, 112, 74, -56, -43],
[226, 225, 112, 74, -56, -43],
[339, 225, 112, 74, -56, -43],
[0, 300, 112, 74, -56, -43]
]
],
}
export const METEOREXPLODE_META: MissileMeta = {
name: "meteorexplode",
celFile: "ExpArrowExplode",
width: 1024,
height: 699,
directions: 1,
framesPerDirection: 16,
animSpeed: 16,
groups: [
[
[0, 0, 208, 174, -108, -123],
[209, 0, 208, 174, -108, -123],
[418, 0, 208, 174, -108, -123],
[627, 0, 208, 174, -108, -123],
[0, 175, 208, 174, -108, -123],
[209, 175, 208, 174, -108, -123],
[418, 175, 208, 174, -108, -123],
[627, 175, 208, 174, -108, -123],
[0, 350, 208, 174, -108, -123],
[209, 350, 208, 174, -108, -123],
[418, 350, 208, 174, -108, -123],
[627, 350, 208, 174, -108, -123],
[0, 525, 208, 174, -108, -123],
[209, 525, 208, 174, -108, -123],
[418, 525, 208, 174, -108, -123],
[627, 525, 208, 174, -108, -123]
]
],
}
export const METEORFIRE_META: MissileMeta = {
name: "meteorfire",
celFile: "groundFireMedium",
width: 1024,
height: 410,
directions: 1,
framesPerDirection: 37,
animSpeed: 16,
groups: [
[
[0, 0, 66, 136, -33, -125],
[67, 0, 66, 136, -33, -125],
[134, 0, 66, 136, -33, -125],
[201, 0, 66, 136, -33, -125],
[268, 0, 66, 136, -33, -125],
[335, 0, 66, 136, -33, -125],
[402, 0, 66, 136, -33, -125],
[469, 0, 66, 136, -33, -125],
[536, 0, 66, 136, -33, -125],
[603, 0, 66, 136, -33, -125],
[670, 0, 66, 136, -33, -125],
[737, 0, 66, 136, -33, -125],
[804, 0, 66, 136, -33, -125],
[871, 0, 66, 136, -33, -125],
[938, 0, 66, 136, -33, -125],
[0, 137, 66, 136, -33, -125],
[67, 137, 66, 136, -33, -125],
[134, 137, 66, 136, -33, -125],
[201, 137, 66, 136, -33, -125],
[268, 137, 66, 136, -33, -125],
[335, 137, 66, 136, -33, -125],
[402, 137, 66, 136, -33, -125],
[469, 137, 66, 136, -33, -125],
[536, 137, 66, 136, -33, -125],
[603, 137, 66, 136, -33, -125],
[670, 137, 66, 136, -33, -125],
[737, 137, 66, 136, -33, -125],
[804, 137, 66, 136, -33, -125],
[871, 137, 66, 136, -33, -125],
[938, 137, 66, 136, -33, -125],
[0, 274, 66, 136, -33, -125],
[67, 274, 66, 136, -33, -125],
[134, 274, 66, 136, -33, -125],
[201, 274, 66, 136, -33, -125],
[268, 274, 66, 136, -33, -125],
[335, 274, 66, 136, -33, -125],
[402, 274, 66, 136, -33, -125]
]
],
}
export const FIRESMALL_META: MissileMeta = {
name: "firesmall",
celFile: "groundFireSmall",
width: 512,
height: 539,
directions: 1,
framesPerDirection: 37,
animSpeed: 16,
groups: [
[
[0, 0, 66, 89, -33, -77],
[67, 0, 66, 89, -33, -77],
[134, 0, 66, 89, -33, -77],
[201, 0, 66, 89, -33, -77],
[268, 0, 66, 89, -33, -77],
[335, 0, 66, 89, -33, -77],
[402, 0, 66, 89, -33, -77],
[0, 90, 66, 89, -33, -77],
[67, 90, 66, 89, -33, -77],
[134, 90, 66, 89, -33, -77],
[201, 90, 66, 89, -33, -77],
[268, 90, 66, 89, -33, -77],
[335, 90, 66, 89, -33, -77],
[402, 90, 66, 89, -33, -77],
[0, 180, 66, 89, -33, -77],
[67, 180, 66, 89, -33, -77],
[134, 180, 66, 89, -33, -77],
[201, 180, 66, 89, -33, -77],
[268, 180, 66, 89, -33, -77],
[335, 180, 66, 89, -33, -77],
[402, 180, 66, 89, -33, -77],
[0, 270, 66, 89, -33, -77],
[67, 270, 66, 89, -33, -77],
[134, 270, 66, 89, -33, -77],
[201, 270, 66, 89, -33, -77],
[268, 270, 66, 89, -33, -77],
[335, 270, 66, 89, -33, -77],
[402, 270, 66, 89, -33, -77],
[0, 360, 66, 89, -33, -77],
[67, 360, 66, 89, -33, -77],
[134, 360, 66, 89, -33, -77],
[201, 360, 66, 89, -33, -77],
[268, 360, 66, 89, -33, -77],
[335, 360, 66, 89, -33, -77],
[402, 360, 66, 89, -33, -77],
[0, 450, 66, 89, -33, -77],
[67, 450, 66, 89, -33, -77]
]
],
}
export const BLIZZARD4_META: MissileMeta = {
...BLIZZARD3_META,
name: 'blizzard4',
@ -4967,6 +5182,16 @@ export const HYDRAFIRE_META: MissileMeta = {
name: 'hydrafire',
}
export const METEORTARGETINDICATOR_META: MissileMeta = {
...METEORCENTER_META,
name: 'meteortargetindicator',
}
export const FIREMEDIUM_META: MissileMeta = {
...METEORFIRE_META,
name: 'firemedium',
}
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
'firebolt': FIREBOLT_META,
'fireball': FIREBALL_META,
@ -4997,8 +5222,16 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
'icearrow': ICEARROW_META,
'arrow': ARROW_META,
'lightningjavelin': LIGHTNINGJAVELIN_META,
'meteor': METEOR_META,
'meteortail': METEORTAIL_META,
'meteorcenter': METEORCENTER_META,
'meteorexplode': METEOREXPLODE_META,
'meteorfire': METEORFIRE_META,
'firesmall': FIRESMALL_META,
'blizzard4': BLIZZARD4_META,
'blizzardexplode3': BLIZZARDEXPLODE3_META,
'hydra': HYDRA_META,
'hydrafire': HYDRAFIRE_META,
'meteortargetindicator': METEORTARGETINDICATOR_META,
'firemedium': FIREMEDIUM_META,
})

View File

@ -45,7 +45,7 @@ import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { npcDialog } from '../game/quests.ts'
import type { NpcDef } from '../game/quests.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts'
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts'
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
import {
@ -1082,7 +1082,7 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
case 60: return 12
case 62: return 20
case 64: return 30
case 56: return 22
case 56: return 17
case 49: return 11
case 38: return 4
default: return 10
@ -1323,6 +1323,39 @@ export function castSkill(
return true
}
if (skillId === 56) {
// Meteor (Skill 56): spawn meteorcenter target emitter missile at target coordinates
// 1.13c Skills.txt: Param1: 6 (radius subtiles), Param3: 30, Param4: 15, delay: 30 (1.2s cooldown)
// 1.13c Missiles.txt: meteorcenter (ID 101), Range: 60 ticks (2.4s duration), Vel: 0
const slvl = 1
const dmgRange = calculateSkillDamage(56, slvl)
const meteorDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
const resFire = calculateMeteorResidualFireDamage(slvl)
const fireDmg = Math.round((resFire.min + resFire.max) / 2)
const fireDuration = 30 + (slvl - 1) * 15
engine.projectiles.push({
skillId: '56',
x: targetX,
y: targetY,
vx: 0,
vy: 0,
damage: meteorDamage,
ttl: 60, // 2.40s at 25 fps per Missiles.txt Range = 60
fromPlayer: true,
missileType: 'meteorcenter',
slvl,
fireDuration,
residualDamage: fireDmg,
subMissileIdx: 0,
})
player.cooldown = 30
engine.metrics.casts += 1
status.textContent = '施展陨石'
return true
}
if (skillId === 40) {
// Frozen Armor
player.cooldown = 10

View File

@ -1,14 +1,49 @@
/**
* 1.13c Numerical & Runtime Verification Suite — Skill #056: Meteor (SOR)
* Gitea Tracking Issue: #210
* Gitea Tracking Issue: #210 & #401
*
* 7-Dimensional Verification Scope:
* Dimension 1: Static data table parsing (Skills.txt row 56, Missiles.txt IDs 100, 101, 102, 103, 240).
* Dimension 2: Visual assets & atlas metadata on disk (PNG/JSON exist with non-zero size).
* Dimension 3: Harmless emitter countdown (60 ticks / 2.40s) without premature contact damage.
* Dimension 4: Primary detonation damage scaling & synergies (5-band formula: 80-100 base).
* Dimension 5: Detonation AOE 120px with 2:1 isometric ground ratio (ISO_GROUND_ASPECT_RATIO = 0.5).
* Dimension 6: 18-offset ground fire geometry and duration scaling (30 + (slvl - 1) * 15 ticks).
* Dimension 7: Residual ground fire periodic burn damage with NextDelay = 0.
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
import { skillModule } from '../../../src/game/skills/impl/sor/skill-056-meteor.ts'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
tickProjectiles,
ISO_GROUND_ASPECT_RATIO,
calculateMeteorDamage,
calculateMeteorSynergyMultiplier,
calculateMeteorResidualFireDamage,
calculateMeteorDetailedDamage,
calculateSkillDamage,
getSkillManaCost,
getSkillCooldownTicks,
METEOR_FIRE_SUBTILE_OFFSETS,
type Projectile,
} from '../../../src/game/skills.ts'
import {
METEOR_META,
METEORTAIL_META,
METEORCENTER_META,
METEOREXPLODE_META,
METEORFIRE_META,
MISSILE_METAS,
} from '../../../src/render/missiles-meta.ts'
describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210)', () => {
describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () => {
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
const registry = await getSharedDataRegistry()
const skillRec = registry.getSkillById(56)!
@ -106,4 +141,341 @@ describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210)', () => {
expect(dbg.actionFrameTriggered).toBe(true)
expect(outcome?.executed).toBe(true)
})
// ==========================================================================
// 7-Dimensional Verification Suite (Issue #401)
// ==========================================================================
it('Dimension 1: Parses authentic 1.13c Missiles.txt & Skills.txt for Meteor suite', async () => {
const registry = await getSharedDataRegistry()
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
const map = parseMissilesTxt(missilesTxtContent)
// meteor (ID 100)
const meteor = map.get('meteor')
expect(meteor).toBeDefined()
expect(meteor!.id).toBe(100)
expect(meteor!.vel).toBe(0)
expect(meteor!.range).toBe(60)
expect(meteor!.celFile.toLowerCase()).toBe('meteor')
expect(meteor!.animLen).toBe(12)
// meteorcenter (ID 101)
const center = map.get('meteorcenter')
expect(center).toBeDefined()
expect(center!.id).toBe(101)
expect(center!.vel).toBe(0)
expect(center!.range).toBe(60)
expect(center!.celFile.toLowerCase()).toBe('meteortargetindicator')
expect(center!.animLen).toBe(17)
expect(center!.light).toBe(4)
expect(center!.red).toBe(255)
expect(center!.green).toBe(64)
expect(center!.blue).toBe(64)
// meteortail (ID 102)
const tail = map.get('meteortail')
expect(tail).toBeDefined()
expect(tail!.id).toBe(102)
expect(tail!.range).toBe(60)
expect(tail!.celFile.toLowerCase()).toBe('meteortail')
expect(tail!.animLen).toBe(12)
// meteorexplode (ID 103)
const explode = map.get('meteorexplode')
expect(explode).toBeDefined()
expect(explode!.id).toBe(103)
expect(explode!.range).toBe(16)
expect(explode!.celFile.toLowerCase()).toBe('exparrowexplode')
expect(explode!.animLen).toBe(16)
expect(explode!.light).toBe(13)
// meteorfire (ID 240)
const fire = map.get('meteorfire')
expect(fire).toBeDefined()
expect(fire!.id).toBe(240)
expect(fire!.celFile.toLowerCase()).toBe('groundfiremedium')
expect(fire!.animLen).toBe(37)
expect(fire!.light).toBe(3)
// Skills.txt metadata specification check
const skillRec = registry.getSkillById(56)!
expect(skillRec).toBeDefined()
expect(skillRec.name).toBe('Meteor')
expect(skillRec.delay).toBe('30')
expect(skillRec.mana).toBe(34)
expect(skillRec.lvlMana).toBe(1)
expect(skillRec.manaShift).toBe(7)
expect(skillRec.eMin).toBe(80)
expect(skillRec.eMax).toBe(100)
expect(skillRec.srvDoFunc).toBe(28)
expect(skillRec.castOverlay).toBe('fire_cast_2')
// Mana cost formula: (34 + (lvl - 1)) * 0.5
expect(getSkillManaCost(56, 1)).toBe(17)
expect(getSkillManaCost(56, 10)).toBe(21.5)
expect(getSkillManaCost(56, 20)).toBe(26.5)
expect(getSkillCooldownTicks(56)).toBe(30)
})
it('Dimension 2: Baked DCC missile atlases and metadata exist on disk with non-zero size', () => {
expect(METEOR_META.name).toBe('meteor')
expect(METEOR_META.celFile).toBe('Meteor')
expect(METEOR_META.framesPerDirection).toBe(12)
expect(METEORTAIL_META.name).toBe('meteortail')
expect(METEORTAIL_META.celFile).toBe('MeteorTail')
expect(METEORTAIL_META.framesPerDirection).toBe(12)
expect(METEORCENTER_META.name).toBe('meteorcenter')
expect(METEORCENTER_META.celFile).toBe('MeteorTargetIndicator')
expect(METEORCENTER_META.framesPerDirection).toBe(17)
expect(METEOREXPLODE_META.name).toBe('meteorexplode')
expect(METEOREXPLODE_META.celFile).toBe('ExpArrowExplode')
expect(METEOREXPLODE_META.framesPerDirection).toBe(16)
expect(METEORFIRE_META.name).toBe('meteorfire')
expect(METEORFIRE_META.celFile).toBe('groundFireMedium')
expect(METEORFIRE_META.framesPerDirection).toBe(37)
expect(MISSILE_METAS['meteor']).toBe(METEOR_META)
expect(MISSILE_METAS['meteortail']).toBe(METEORTAIL_META)
expect(MISSILE_METAS['meteorcenter']).toBe(METEORCENTER_META)
expect(MISSILE_METAS['meteorexplode']).toBe(METEOREXPLODE_META)
expect(MISSILE_METAS['meteorfire']).toBe(METEORFIRE_META)
const missileFiles = ['meteor', 'meteortail', 'meteorcenter', 'meteorexplode', 'meteorfire']
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('Dimension 3: Harmless emitter countdown (60 ticks / 2.40s) without premature contact damage', () => {
let center: Projectile = {
skillId: '56',
x: 300,
y: 300,
vx: 0,
vy: 0,
damage: 500,
ttl: 60, // 2.40s at 25 fps
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 1,
}
const dummyMonster = [{ index: 0, x: 300, y: 300, radius: 30, alive: true }]
const openTerrain = { overlap: () => 0 }
// Over ticks 1 to 60, meteorcenter must not damage monster despite standing at exact same coordinates
for (let tick = 1; tick <= 60; tick += 1) {
const outcome = tickProjectiles([center], dummyMonster, openTerrain)
expect(outcome.hits.length).toBe(0)
if (tick < 60) {
expect(outcome.alive.length).toBe(1)
center = outcome.alive[0]!
expect(center.missileType).toBe('meteorcenter')
expect(center.ttl).toBe(60 - tick)
} else {
// At tick 60, center.ttl reaches 0
center = outcome.alive[0]!
expect(center.ttl).toBe(0)
}
}
// On tick 61, center with ttl 0 expires and detonates
const finalOutcome = tickProjectiles([center], dummyMonster, openTerrain)
expect(finalOutcome.expired).toBe(1)
expect(finalOutcome.hits.length).toBe(1) // monster takes detonation damage on expiry!
expect(finalOutcome.hits[0]!.damage).toBe(500)
expect(finalOutcome.hits[0]!.missileType).toBe('meteorexplode')
})
it('Dimension 4: Primary detonation damage scaling with 5-band level progression & synergies', () => {
// slvl 1: 80 - 100
const d1 = calculateMeteorDamage(1)
expect(d1.min).toBe(80)
expect(d1.max).toBe(100)
// slvl 5: 80 + 4*23 = 172, 100 + 4*25 = 200
const d5 = calculateMeteorDamage(5)
expect(d5.min).toBe(172)
expect(d5.max).toBe(200)
// slvl 10: 80 + 7*23 + 2*39 = 319, 100 + 7*25 + 2*41 = 357
const d10 = calculateMeteorDamage(10)
expect(d10.min).toBe(319)
expect(d10.max).toBe(357)
// slvl 20: 80 + 7*23 + 8*39 + 4*79 = 869, 100 + 7*25 + 8*41 + 4*81 = 927
const d20 = calculateMeteorDamage(20)
expect(d20.min).toBe(869)
expect(d20.max).toBe(927)
// slvl 30: 869 + 2*79 + 6*81 + 2*83 = 1679, 927 + 2*81 + 6*83 + 2*85 = 1757
const d30 = calculateMeteorDamage(30)
expect(d30.min).toBe(1679)
expect(d30.max).toBe(1757)
// Synergies:
// Fire Bolt: 20 pts (+100% = 2.0x)
const syn20 = calculateMeteorSynergyMultiplier({ fireBolt: 20 })
expect(syn20).toBe(2.0)
const d20Syn = calculateMeteorDamage(20, { fireBolt: 20 })
expect(d20Syn.min).toBe(1738) // 869 * 2.0
expect(d20Syn.max).toBe(1854) // 927 * 2.0
// Full synergies: Fire Bolt 20, Fire Ball 20 (+200% = 3.0x)
const syn40 = calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20 })
expect(syn40).toBe(3.0)
const d20Full = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20 })
expect(d20Full.min).toBe(2607) // 869 * 3.0
expect(d20Full.max).toBe(2781) // 927 * 3.0
// Generic calculateSkillDamage router checks
expect(calculateSkillDamage(56, 1)).toEqual(d1)
expect(calculateSkillDamage('56', 10)).toEqual(d10)
expect(calculateSkillDamage('meteor', 20)).toEqual(d20)
})
it('Dimension 5: Detonation AOE 120px with 2:1 isometric ground foreshortening', () => {
// 6 subtiles = 120px ground radius.
// In 2:1 isometric projection: ellipse is dx^2 + (dy / 0.5)^2 <= 120^2.
const center: Projectile = {
skillId: '56',
x: 200,
y: 200,
vx: 0,
vy: 0,
damage: 400,
ttl: 0, // detonates immediately on tick
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 1,
}
const monsters = [
{ index: 1, x: 200, y: 250, radius: 10, alive: true }, // dx=0, dy=50: dy/0.5 = 100 <= 120 (INSIDE)
{ index: 2, x: 200, y: 310, radius: 10, alive: true }, // dx=0, dy=110: dy/0.5 = 220 > 120 (OUTSIDE)
{ index: 3, x: 300, y: 200, radius: 10, alive: true }, // dx=100, dy=0: dx = 100 <= 120 (INSIDE)
{ index: 4, x: 330, y: 200, radius: 10, alive: true }, // dx=130, dy=0: dx = 130 > 120 (OUTSIDE)
]
const outcome = tickProjectiles([center], monsters, { overlap: () => 0 })
expect(outcome.hits.length).toBe(2)
const hitIndices = outcome.hits.map(h => h.targetIndex)
expect(hitIndices).toContain(1)
expect(hitIndices).toContain(3)
expect(hitIndices).not.toContain(2)
expect(hitIndices).not.toContain(4)
// Visual meteorexplode missile spawned
expect(outcome.spawnedProjectiles?.some(p => p.missileType === 'meteorexplode')).toBe(true)
})
it('Dimension 6: 18-offset ground fire geometry and duration scaling (30 + (slvl-1)*15)', () => {
expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18)
// Check specific cardinal offsets from D2Game.dll 0x6FD1C918 and 0x6FD1C8D0
expect(METEOR_FIRE_SUBTILE_OFFSETS[3]).toEqual({ x: 0, y: 5 }) // North Tip
expect(METEOR_FIRE_SUBTILE_OFFSETS[11]).toEqual({ x: -4, y: -2 }) // West Tip
expect(METEOR_FIRE_SUBTILE_OFFSETS[14]).toEqual({ x: 0, y: -4 }) // South Tip
expect(METEOR_FIRE_SUBTILE_OFFSETS[17]).toEqual({ x: 4, y: -2 }) // East Tip
// Verify duration scaling: slvl 1: 30 ticks; slvl 10: 165 ticks; slvl 20: 315 ticks
const centerSlvl10: Projectile = {
skillId: '56',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 300,
ttl: 0,
fromPlayer: true,
missileType: 'meteorcenter',
slvl: 10,
}
const outcome10 = tickProjectiles([centerSlvl10], [], { overlap: () => 0 })
const flames10 = outcome10.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
expect(flames10.length).toBe(18)
for (const f of flames10) {
expect(f.ttl).toBe(30 + 9 * 15) // 165 ticks = 6.60 seconds
expect(f.pierce).toBe(true)
}
// Verify wall collision filtering: flames inside walls are suppressed
const outcomeWall = tickProjectiles([centerSlvl10], [], {
overlap: (x, y) => (x > 140 ? 1 : 0), // wall on eastern side
})
const flamesWall = outcomeWall.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
expect(flamesWall.length).toBeLessThan(18)
expect(flamesWall.every(f => f.x <= 140)).toBe(true)
})
it('Dimension 7: Residual ground fire periodic burn damage with NextDelay = 0', () => {
// Residual fire formulas
const res1 = calculateMeteorResidualFireDamage(1)
expect(res1.min).toBe(15)
expect(res1.max).toBe(25)
expect(res1.perSecMin).toBe(11) // floor(15 * 200 / 256)
expect(res1.perSecMax).toBe(19) // floor(25 * 200 / 256)
const res10 = calculateMeteorResidualFireDamage(10)
expect(res10.min).toBe(53)
expect(res10.max).toBe(63)
expect(res10.perSecMin).toBe(41)
expect(res10.perSecMax).toBe(49)
const res20 = calculateMeteorResidualFireDamage(20)
expect(res20.min).toBe(107)
expect(res20.max).toBe(117)
expect(res20.perSecMin).toBe(83)
expect(res20.perSecMax).toBe(91)
// Detailed damage breakdown
const detailed = calculateMeteorDetailedDamage(20, { fireBolt: 20 })
expect(detailed.impactFire.min).toBe(1738)
expect(detailed.impactPhys.min).toBe(0) // 1.13c vanilla has 0 physical damage
expect(detailed.residualFire.min).toBe(107)
// Optional physical split support (e.g. 50/50 mod support)
const splitDetailed = calculateMeteorDetailedDamage(20, {}, { physRatio: 0.5 })
expect(splitDetailed.impactFire.min).toBe(434)
expect(splitDetailed.impactPhys.min).toBe(434)
// Simulation of residual flame patch over 10 ticks
let flame: Projectile = {
skillId: '56',
x: 100,
y: 100,
vx: 0,
vy: 0,
damage: 20,
ttl: 30,
fromPlayer: true,
missileType: 'meteorfire',
pierce: true,
}
const dummyMonster = [{ index: 5, x: 100, y: 100, radius: 15, alive: true }]
let hitCount = 0
for (let t = 1; t <= 10; t += 1) {
const outcome = tickProjectiles([flame], dummyMonster, { overlap: () => 0 })
flame = outcome.alive[0]!
hitCount += outcome.hits.length
}
// Over 10 ticks with 5-tick periodic burn cadence, monster is damaged 3 times (tick 1, 5, 10)
expect(hitCount).toBe(3)
expect(flame.ttl).toBe(20) // remains alive, doesn't vanish on hit
})
})