diff --git a/public/missiles/firemedium.json b/public/missiles/firemedium.json new file mode 100644 index 0000000..8f5d047 --- /dev/null +++ b/public/missiles/firemedium.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/firemedium.png b/public/missiles/firemedium.png new file mode 100644 index 0000000..d5b9b55 Binary files /dev/null and b/public/missiles/firemedium.png differ diff --git a/public/missiles/firesmall.json b/public/missiles/firesmall.json new file mode 100644 index 0000000..80aba9e --- /dev/null +++ b/public/missiles/firesmall.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/firesmall.png b/public/missiles/firesmall.png new file mode 100644 index 0000000..88256d6 Binary files /dev/null and b/public/missiles/firesmall.png differ diff --git a/public/missiles/meteor.json b/public/missiles/meteor.json new file mode 100644 index 0000000..df7163c --- /dev/null +++ b/public/missiles/meteor.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/meteor.png b/public/missiles/meteor.png new file mode 100644 index 0000000..9b99110 Binary files /dev/null and b/public/missiles/meteor.png differ diff --git a/public/missiles/meteorcenter.json b/public/missiles/meteorcenter.json new file mode 100644 index 0000000..6ae82a2 --- /dev/null +++ b/public/missiles/meteorcenter.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/meteorcenter.png b/public/missiles/meteorcenter.png new file mode 100644 index 0000000..f81517d Binary files /dev/null and b/public/missiles/meteorcenter.png differ diff --git a/public/missiles/meteorexplode.json b/public/missiles/meteorexplode.json new file mode 100644 index 0000000..7357da8 --- /dev/null +++ b/public/missiles/meteorexplode.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/meteorexplode.png b/public/missiles/meteorexplode.png new file mode 100644 index 0000000..e6dedd3 Binary files /dev/null and b/public/missiles/meteorexplode.png differ diff --git a/public/missiles/meteorfire.json b/public/missiles/meteorfire.json new file mode 100644 index 0000000..3f443aa --- /dev/null +++ b/public/missiles/meteorfire.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/meteorfire.png b/public/missiles/meteorfire.png new file mode 100644 index 0000000..d5b9b55 Binary files /dev/null and b/public/missiles/meteorfire.png differ diff --git a/public/missiles/meteortail.json b/public/missiles/meteortail.json new file mode 100644 index 0000000..e46cd99 --- /dev/null +++ b/public/missiles/meteortail.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/meteortail.png b/public/missiles/meteortail.png new file mode 100644 index 0000000..6f6a4e8 Binary files /dev/null and b/public/missiles/meteortail.png differ diff --git a/public/missiles/meteortargetindicator.json b/public/missiles/meteortargetindicator.json new file mode 100644 index 0000000..74b9c0f --- /dev/null +++ b/public/missiles/meteortargetindicator.json @@ -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 + ] + ] + ] +} \ No newline at end of file diff --git a/public/missiles/meteortargetindicator.png b/public/missiles/meteortargetindicator.png new file mode 100644 index 0000000..f81517d Binary files /dev/null and b/public/missiles/meteortargetindicator.png differ diff --git a/scripts/pack-missiles.ts b/scripts/pack-missiles.ts index f49d35d..56ceec1 100644 --- a/scripts/pack-missiles.ts +++ b/scripts/pack-missiles.ts @@ -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> = Object.freeze({ ${mapEntries} 'blizzard4': BLIZZARD4_META, 'blizzardexplode3': BLIZZARDEXPLODE3_META, 'hydra': HYDRA_META, 'hydrafire': HYDRAFIRE_META, + 'meteortargetindicator': METEORTARGETINDICATOR_META, + 'firemedium': FIREMEDIUM_META, }) ` diff --git a/src/game/engine/meteor-table.ts b/src/game/engine/meteor-table.ts new file mode 100644 index 0000000..08e923a --- /dev/null +++ b/src/game/engine/meteor-table.ts @@ -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 +]) diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index 5ff61f5..bf25288 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -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') && diff --git a/src/game/skills.ts b/src/game/skills.ts index e8ac1da..3c26c04 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -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> = 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() + 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> = 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 } diff --git a/src/render/missiles-meta.ts b/src/render/missiles-meta.ts index eb32812..4494cc5 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -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> = Object.freeze({ 'firebolt': FIREBOLT_META, 'fireball': FIREBALL_META, @@ -4997,8 +5222,16 @@ export const MISSILE_METAS: Readonly> = 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, }) diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 866b0d6..cd8fb22 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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 diff --git a/tests/skills/sor/skill-056-meteor.test.ts b/tests/skills/sor/skill-056-meteor.test.ts index 0ca174b..71ebbf5 100644 --- a/tests/skills/sor/skill-056-meteor.test.ts +++ b/tests/skills/sor/skill-056-meteor.test.ts @@ -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 + }) })