From cc994fe3c896d03fc54433d744d28688d7075b00 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 24 Sep 2026 16:54:28 +0000 Subject: [PATCH] fix(skill): fix Fire Ball explosion missile asset and 80px AoE radius (Fixes #440) --- src/game/engine.ts | 16 +- src/game/skills.ts | 166 +++++++++++- src/render/missiles-meta.ts | 1 + src/scene/act-scene.ts | 19 +- tests/missile-skills-113c.test.ts | 22 +- .../issue-440-fireball-explosion-aoe.test.ts | 253 ++++++++++++++++++ 6 files changed, 450 insertions(+), 27 deletions(-) create mode 100644 tests/skills/sor/issue-440-fireball-explosion-aoe.test.ts diff --git a/src/game/engine.ts b/src/game/engine.ts index 0810f53..6295b3c 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -556,12 +556,12 @@ export class GameEngine { } } - const missileType = hit.missileType ?? (hit.skillId === '36' || hit.skillId === 'firebolt' ? 'firebolt' : undefined) + const missileType = hit.missileType ?? (hit.skillId === '36' || hit.skillId === 'firebolt' ? 'firebolt' : hit.skillId === '47' || hit.skillId === 'fireball' ? 'fireball' : undefined) const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined - const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : '') + const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : missileType === 'fireball' ? 'explodingarrowexp' : '') if (explType) { const explData = CANONICAL_113C_MISSILES[explType] - const maxFrames = explData?.animLen || 12 + const maxFrames = explData?.animLen || 16 this.explosions.push({ missileType: explType, x: hit.x, @@ -597,7 +597,9 @@ export class GameEngine { if (wallHit.aoeRadius && wallHit.aoeRadius > 0) { for (const m of this.world.monsters) { if (m.state === 'dead' || m.hp <= 0) continue - if (Math.hypot(m.x - wallHit.x, m.y - wallHit.y) <= wallHit.aoeRadius) { + const dx = m.x - wallHit.x + const dy = (m.y - wallHit.y) / ISO_GROUND_ASPECT_RATIO + if (Math.hypot(dx, dy) <= wallHit.aoeRadius) { const dmg = wallHit.damage ?? 10 damageMonster(this.world, m.index, dmg) if (wallHit.statusEffect === 'freeze' || wallHit.missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') { @@ -610,12 +612,12 @@ export class GameEngine { } } } - const missileType = wallHit.missileType ?? (wallHit.skillId === '36' || wallHit.skillId === 'firebolt' ? 'firebolt' : undefined) + const missileType = wallHit.missileType ?? (wallHit.skillId === '36' || wallHit.skillId === 'firebolt' ? 'firebolt' : wallHit.skillId === '47' || wallHit.skillId === 'fireball' ? 'fireball' : undefined) const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined - const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : '') + const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : missileType === 'fireball' ? 'explodingarrowexp' : '') if (explType) { const explData = CANONICAL_113C_MISSILES[explType] - const maxFrames = explData?.animLen || 12 + const maxFrames = explData?.animLen || 16 this.explosions.push({ missileType: explType, x: wallHit.x, diff --git a/src/game/skills.ts b/src/game/skills.ts index 073e5bc..28e3266 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -696,7 +696,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = animSpeed: 16, loopAnim: 1, numDirections: 16, - explosionMissile: 'fireexplode', + explosionMissile: 'explodingarrowexp', light: 7, red: 255, green: 178, @@ -728,18 +728,18 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, explodingarrowexp: { name: 'explodingarrowexp', - id: 47, + id: 42, vel: 0, maxVel: 0, - range: 12, + range: 16, levRange: 0, - celFile: 'FireArrowExplode2', - animLen: 12, + celFile: 'ExpArrowExplode', + animLen: 16, animSpeed: 16, loopAnim: 0, numDirections: 1, explosionMissile: '', - light: 13, + light: 0, red: 255, green: 178, blue: 64, @@ -4733,6 +4733,44 @@ export function tickProjectiles( aoeRadius: 32, pierce: false, }) + } else if (projectile.missileType === 'fireball') { + const blastRadius = projectile.aoeRadius ?? 80 + 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: 'fireball', + aoeRadius: blastRadius, + }) + } + } + spawned.push({ + skillId: projectile.skillId, + x: projectile.x, + y: projectile.y, + vx: 0, + vy: 0, + damage: 0, + ttl: 16, + fromPlayer: projectile.fromPlayer, + missileType: 'explodingarrowexp', + aoeRadius: blastRadius, + pierce: true, + }) + wallHitEvents.push({ + x: projectile.x, + y: projectile.y, + skillId: projectile.skillId, + missileType: 'explodingarrowexp', + aoeRadius: blastRadius, + }) } 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 @@ -5159,6 +5197,20 @@ export function tickProjectiles( aoeRadius: 32, pierce: false, }) + } else if (projectile.missileType === 'fireball') { + spawned.push({ + skillId: projectile.skillId, + x, + y, + vx: 0, + vy: 0, + damage: 0, + ttl: 16, + fromPlayer: projectile.fromPlayer, + missileType: 'explodingarrowexp', + aoeRadius: projectile.aoeRadius ?? 80, + pierce: true, + }) } continue } @@ -5173,6 +5225,7 @@ export function tickProjectiles( projectile.missileType === 'firewallmaker' || projectile.missileType === 'freezeexplode' || projectile.missileType === 'glacialspikeejecta' || + projectile.missileType === 'explodingarrowexp' || projectile.missileType === 'thunderstorm1' || projectile.missileType === 'thunderstorm' || projectile.missileType === 'lightninghit' || @@ -5335,6 +5388,20 @@ export function tickProjectiles( aoeRadius: 32, pierce: false, }) + } else if (projectile.missileType === 'fireball') { + spawned.push({ + skillId: projectile.skillId, + x, + y, + vx: 0, + vy: 0, + damage: 0, + ttl: 16, + fromPlayer: projectile.fromPlayer, + missileType: 'explodingarrowexp', + aoeRadius: projectile.aoeRadius ?? 80, + pierce: true, + }) } break } @@ -5688,6 +5755,9 @@ export function calculateSkillDamage( if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') { return calculateChargedBoltDamage(slvl, synergies as any) } + if (skillId === 47 || skillId === '47' || skillId === 'fireball' || skillId === 'fire ball') { + return calculateFireBallDamage(slvl, synergies as any) + } if (skillId === 41 || skillId === '41' || skillId === 'inferno') { return calculateInfernoDamage(slvl, synergies as any) } @@ -6208,6 +6278,90 @@ export function calculateHydraDamage( } } +/** + * Calculates Fire Ball (Skill 47) synergy multiplier: + * +14% fire damage per hard point in Fire Bolt (36) and Meteor (56). + * Fire Mastery (61): +30% base, +7%/lvl if present. + */ +export function calculateFireBallSynergyMultiplier(synergies: { + readonly fireBolt?: number | undefined + readonly meteor?: number | undefined + readonly fireMastery?: number | undefined +} | number = {}): number { + if (typeof synergies === 'number') { + return Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0 + } + const points = (synergies.fireBolt ?? 0) + (synergies.meteor ?? 0) + const synPct = points * 14 // +14% 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 Fire Ball (Skill 47) min and max damage with 1.13c 5-band level scaling and synergies. + * 1.13c Ground Truth (`Skills.txt` row 47): + * - EMin: 12, EMax: 28 + * - 5 bands: elev = [13, 23, 28, 33, 38], emaxlev = [15, 25, 30, 35, 40] + * - HitShift: 7 (factor = 0.5) + * - Synergies: +14% per hard point in Fire Bolt (36) and Meteor (56). + */ +export function calculateFireBallDamage( + effectiveLevel: number, + synergies: { + readonly fireBolt?: number | undefined + readonly meteor?: 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 = calculateFireBallSynergyMultiplier(synergies) + } + + const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + let min = 12 + let max = 28 + + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + min += b1 * 13 + max += b1 * 15 + + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + min += b2 * 23 + max += b2 * 25 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + min += b3 * 28 + max += b3 * 30 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + min += b4 * 33 + max += b4 * 35 + } + if (lvl > 28) { + const b5 = lvl - 28 + min += b5 * 38 + max += b5 * 40 + } + } + + min *= 0.5 + max *= 0.5 + + return { + min: Math.floor(min * syn), + max: Math.floor(max * syn), + } +} + /** 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 8aa1ca3..5b7c55c 100644 --- a/src/render/missiles-meta.ts +++ b/src/render/missiles-meta.ts @@ -8075,6 +8075,7 @@ export const MISSILE_METAS: Readonly> = Object.freez 'meteortail': METEORTAIL_META, 'meteorcenter': METEORCENTER_META, 'meteorexplode': METEOREXPLODE_META, + 'explodingarrowexp': METEOREXPLODE_META, 'meteorfire': METEORFIRE_META, 'firesmall': FIRESMALL_META, 'nova': NOVA_META, diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index d28f692..837b8db 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -2768,9 +2768,14 @@ export function castSkill( const missile = getMissileTxtData('fireball') speed = missile.speedPxPerSec || 500 range = missile.distancePx || 1000 - damage = 35 + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(47) ?? 1) + const fireBoltBlvl = (hudManager as any)?.getSkillLevel?.(36) ?? 0 + const meteorBlvl = (hudManager as any)?.getSkillLevel?.(56) ?? 0 + const fireMasterySlvl = (hudManager as any)?.getSkillLevel?.(61) ?? 0 + const dmgRange = calculateSkillDamage(47, slvl, { fireBolt: fireBoltBlvl, meteor: meteorBlvl, fireMastery: fireMasterySlvl }) + damage = Math.round((dmgRange.min + dmgRange.max) / 2) delay = 0 - aoeRadius = 48 + aoeRadius = 80 missileType = 'fireball' skillName = '火球' } else if (skillId === 39) { @@ -5244,6 +5249,10 @@ export async function loadMissileArtMap( map.set('frozenorbbolt', iceboltArt) map.set('frozenorbnova', iceboltArt) } + if (map.has('meteorexplode')) { + const meteorArt = map.get('meteorexplode')! + if (!map.has('explodingarrowexp')) map.set('explodingarrowexp', meteorArt) + } if (map.has('firebolt')) { const fireboltArt = map.get('firebolt')! if (!map.has('hydra')) map.set('hydra', fireboltArt) @@ -7520,7 +7529,11 @@ if (typeof window !== 'undefined') { pushEntity(exp.x, exp.y, () => { const art = missileArtMap.get(exp.missileType) ?? - (exp.missileType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : missileArtMap.get('fireexplode')) + (exp.missileType === 'explodingarrowexp' + ? missileArtMap.get('meteorexplode') ?? missileArtMap.get('ExpArrowExplode') + : exp.missileType === 'blizzardexplode3' + ? missileArtMap.get('blizzardexplode2') + : missileArtMap.get('fireexplode')) drawExplosion(renderer, exp, art) }) } diff --git a/tests/missile-skills-113c.test.ts b/tests/missile-skills-113c.test.ts index eda8ef3..838d5a9 100644 --- a/tests/missile-skills-113c.test.ts +++ b/tests/missile-skills-113c.test.ts @@ -115,7 +115,7 @@ describe('Diablo II 1.13c Missile Skills Suite', () => { // TIER 1: Direct 1:1 Projectiles // ------------------------------------------------------------- describe('Tier 1: Direct 1:1 Projectiles', () => { - it('Fire Ball (47): authentic physics, fireball missileType, 48px AoE splash', () => { + it('Fire Ball (47): authentic physics, fireball missileType, 80px AoE splash', () => { const fbData = getMissileTxtData('fireball', parsedMissiles) expect(fbData.vel).toBe(20) expect(fbData.range).toBe(50) @@ -125,7 +125,7 @@ describe('Diablo II 1.13c Missile Skills Suite', () => { const engine = createTestEngine([ { x: 150, y: 100, hp: 100 }, // direct target - { x: 170, y: 100, hp: 100 }, // splash target within 48px + { x: 170, y: 100, hp: 100 }, // splash target within 80px { x: 300, y: 100, hp: 100 }, // out of splash radius ]) @@ -140,8 +140,8 @@ describe('Diablo II 1.13c Missile Skills Suite', () => { expect(engine.projectiles.length).toBe(1) const proj = engine.projectiles[0]! expect(proj.missileType).toBe('fireball') - expect(proj.aoeRadius).toBe(48) - expect(proj.damage).toBe(35) + expect(proj.aoeRadius).toBe(80) + expect(proj.damage).toBe(10) // Advance until impact for (let t = 0; t < 5; t++) { @@ -599,16 +599,16 @@ describe('Diablo II 1.13c Missile Skills Suite', () => { }) it('AoE splash damage evaluates ground distance with 2:1 isometric metric (Math.hypot(dx, dy / 0.5) <= aoeRadius)', () => { - // Fireball has aoeRadius = 48 + // Fireball has aoeRadius = 80 // Origin at (100, 100) - // Monster A at (140, 100): dx=40, dy=0 -> groundDist = 40 <= 48 (HIT) - // Monster B at (100, 120): dx=0, dy=20 -> groundDist = 20 / 0.5 = 40 <= 48 (HIT) - // Monster C at (100, 130): dx=0, dy=30 -> groundDist = 30 / 0.5 = 60 > 48 (MISS, even though Euclidean dy=30 < 48!) + // Monster A at (160, 100): dx=60, dy=0 -> groundDist = 60 <= 80 (HIT) + // Monster B at (100, 135): dx=0, dy=35 -> groundDist = 35 / 0.5 = 70 <= 80 (HIT) + // Monster C at (100, 145): dx=0, dy=45 -> groundDist = 45 / 0.5 = 90 > 80 (MISS, even though Euclidean dy=45 < 80!) const engine = createTestEngine([ { x: 100, y: 100, hp: 100 }, // direct hit target - { x: 140, y: 100, hp: 100 }, // A: horizontal within AoE - { x: 100, y: 120, hp: 100 }, // B: vertical within isometric AoE (dy=20 -> ground 40) - { x: 100, y: 130, hp: 100 }, // C: vertical outside isometric AoE (dy=30 -> ground 60) + { x: 160, y: 100, hp: 100 }, // A: horizontal within AoE + { x: 100, y: 135, hp: 100 }, // B: vertical within isometric AoE (dy=35 -> ground 70) + { x: 100, y: 145, hp: 100 }, // C: vertical outside isometric AoE (dy=45 -> ground 90) ]) const status = { textContent: '' } as HTMLElement castSkill(47, 100, 100, { engine, runtime: {} as any, hudManager: null, status }) diff --git a/tests/skills/sor/issue-440-fireball-explosion-aoe.test.ts b/tests/skills/sor/issue-440-fireball-explosion-aoe.test.ts new file mode 100644 index 0000000..6caee99 --- /dev/null +++ b/tests/skills/sor/issue-440-fireball-explosion-aoe.test.ts @@ -0,0 +1,253 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Issue #440 Verification Suite + * Fire Ball (Skill #047) AoE Explosion & Blast Visual Ground Truth + * + * Invariants: + * 1. Missiles.txt: fireball explosionMissile === 'explodingarrowexp' (ID 42, CelFile ExpArrowExplode, 16 frames). + * 2. 80px (4 subtiles) AoE splash radius in 2:1 isometric ground perspective (sHitPar1: 4). + * 3. Dynamic damage calculation with 1.13c 5-band level scaling and +14% synergy from Fire Bolt / Meteor. + * 4. Wall collision and target impact trigger full 16-frame explodingarrowexp detonation. + */ +import { describe, expect, it } from 'vitest' +import { + getMissileTxtData, + CANONICAL_113C_MISSILES, + calculateFireBallDamage, + calculateFireBallSynergyMultiplier, + calculateSkillDamage, + ISO_GROUND_ASPECT_RATIO, +} from '../../../src/game/skills.ts' +import { MISSILE_METAS, METEOREXPLODE_META } from '../../../src/render/missiles-meta.ts' +import { GameEngine } from '../../../src/game/engine.ts' +import { castSkill } from '../../../src/scene/act-scene.ts' +import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../../../src/game/demo-data.ts' + +function createTestEngine( + monsters?: { x: number; y: number; hp?: number }[], + terrainOverrides?: any, +) { + const terrain = { + widthPx: 1000, + heightPx: 1000, + overlap: () => 0, + ...terrainOverrides, + } + const engine = new GameEngine(terrain, { + spawn: { x: 100, y: 100 }, + stats: [], + xpTable: DEMO_EXPERIENCE, + skills: DEMO_SKILLS, + npcDefs: [], + questDefs: DEMO_QUESTS, + combatOptions: { + playerSpeed: 4, + playerReach: 50, + playerCooldownTicks: 0, + playerDamage: 5, + playerManaPerAttack: 1, + respawnTicks: 100, + }, + talkRadius: 50, + pickupRadius: 30, + inventoryCols: 10, + inventoryRows: 4, + monsterCount: 0, + }) + + if (monsters) { + engine.world.monsters.length = 0 + for (let i = 0; i < monsters.length; i++) { + const m = monsters[i]! + engine.world.monsters.push({ + index: i, + stats: { + id: 'fallen', + name: 'Fallen', + hp: m.hp ?? 100, + damage: 5, + cooldownTicks: 25, + reach: 25, + aggroRadius: 200, + speed: 100, + xp: 10, + }, + x: m.x, + y: m.y, + hp: m.hp ?? 100, + cooldown: 0, + state: 'idle', + facing: 0, + hitFlash: 0, + corpseTicks: 0, + }) + } + } + + return engine +} + +const DUMMY_INPUT = { + movement: { x: 0, y: 0 }, + attacking: false, + pickingUp: false, + talking: false, + digits: [], + saving: false, + loading: false, +} + +describe('[Issue #440] Fire Ball AoE Explosion & Blast Visual (1.13c Ground Truth)', () => { + it('1.13c Missiles.txt: fireball explosionMissile points to explodingarrowexp (ExpArrowExplode, 16 frames)', () => { + const fb = getMissileTxtData('fireball') + expect(fb.explosionMissile).toBe('explodingarrowexp') + + const exp = getMissileTxtData('explodingarrowexp') + expect(exp.id).toBe(42) + expect(exp.celFile).toBe('ExpArrowExplode') + expect(exp.animLen).toBe(16) + expect(exp.animSpeed).toBe(16) + expect(exp.range).toBe(16) + }) + + it('missiles-meta: explodingarrowexp resolves to authentic 16-frame ExpArrowExplode atlas', () => { + expect(MISSILE_METAS.explodingarrowexp).toBeDefined() + expect(MISSILE_METAS.explodingarrowexp).toBe(METEOREXPLODE_META) + expect(MISSILE_METAS.explodingarrowexp.celFile).toBe('ExpArrowExplode') + expect(MISSILE_METAS.explodingarrowexp.framesPerDirection).toBe(16) + expect(MISSILE_METAS.explodingarrowexp.groups[0]!.length).toBe(16) + }) + + it('calculates 1.13c 5-band damage and synergies for Fire Ball', () => { + // slvl 1: min 6, max 14 + const d1 = calculateFireBallDamage(1) + expect(d1.min).toBe(6) + expect(d1.max).toBe(14) + + // slvl 10: raw min = 12 + 7*13 + 2*23 = 149 -> floor(149*0.5) = 74 + // raw max = 28 + 7*15 + 2*25 = 183 -> floor(183*0.5) = 91 + const d10 = calculateFireBallDamage(10) + expect(d10.min).toBe(74) + expect(d10.max).toBe(91) + + // slvl 20: raw min = 12 + 7*13 + 8*23 + 4*28 = 399 -> floor(399*0.5) = 199 + // raw max = 28 + 7*15 + 8*25 + 4*30 = 453 -> floor(453*0.5) = 226 + const d20 = calculateFireBallDamage(20) + expect(d20.min).toBe(199) + expect(d20.max).toBe(226) + + // Synergies: +14% per hard point in Fire Bolt (36) and Meteor (56) + const synMult = calculateFireBallSynergyMultiplier({ fireBolt: 10, meteor: 10 }) + // points = 20 -> synPct = 280% -> multiplier = 3.8 + expect(synMult).toBeCloseTo(3.8, 5) + + const d20Syn = calculateFireBallDamage(20, { fireBolt: 10, meteor: 10 }) + // 199.5 * 3.8 = 758.1 -> floor = 758 + // 226.5 * 3.8 = 860.7 -> floor = 860 + expect(d20Syn.min).toBe(758) + expect(d20Syn.max).toBe(860) + + // calculateSkillDamage routes skill 47 + const viaGeneral = calculateSkillDamage(47, 20, { fireBolt: 10, meteor: 10 }) + expect(viaGeneral).toEqual(d20Syn) + }) + + it('castSkill spawns fireball with 80px AoE and dynamic damage', () => { + const engine = createTestEngine() + + const hudMock = { + getSkillLevel: (id: number) => { + if (id === 47) return 10 + if (id === 36) return 5 + return 0 + }, + } + + castSkill(47, 200, 100, { + engine, + runtime: {} as any, + hudManager: hudMock as any, + status: { textContent: '' } as any, + }) + + expect(engine.projectiles.length).toBe(1) + const p = engine.projectiles[0]! + expect(p.missileType).toBe('fireball') + expect(p.aoeRadius).toBe(80) + + // Expected damage at slvl 10 with 5 pts fireBolt (+70% synergy): + // syn = 1.7. min = floor(74.5 * 1.7) = 126, max = floor(91.5 * 1.7) = 155. avg = 141. + const expectedDmg = calculateFireBallDamage(10, { fireBolt: 5 }) + const avgDmg = Math.round((expectedDmg.min + expectedDmg.max) / 2) + expect(p.damage).toBe(avgDmg) + }) + + it('monster impact triggers explodingarrowexp with 16 frames and damages all enemies in 80px 2:1 radius', () => { + // Setup monsters: + // Direct target at (150, 100) + // Splash target A at (190, 100): dx=40, dy=0 -> groundDist=40 <= 80 (HIT) + // Splash target B at (150, 135): dx=0, dy=35 -> groundDist=35/0.5=70 <= 80 (HIT) + // Distant target C at (150, 150): dx=0, dy=50 -> groundDist=50/0.5=100 > 80 (MISS) + const engine = createTestEngine([ + { x: 150, y: 100, hp: 500 }, + { x: 190, y: 100, hp: 500 }, + { x: 150, y: 135, hp: 500 }, + { x: 150, y: 150, hp: 500 }, + ]) + + castSkill(47, 200, 100, { + engine, + runtime: {} as any, + hudManager: null, + status: { textContent: '' } as any, + }) + + // Step until impact + for (let t = 0; t < 10; t++) { + engine.tick(DUMMY_INPUT) + } + + // Explodingarrowexp spawned with 16 frames + const explosion = engine.explosions.find(e => e.missileType === 'explodingarrowexp') + expect(explosion).toBeDefined() + expect(explosion!.maxFrames).toBe(16) + expect(explosion!.radius).toBe(80) + + // Damage check: + expect(engine.world.monsters[0]!.hp).toBeLessThan(500) // direct target hit + expect(engine.world.monsters[1]!.hp).toBeLessThan(500) // splash target A hit + expect(engine.world.monsters[2]!.hp).toBeLessThan(500) // splash target B hit (2:1 isometric ground) + expect(engine.world.monsters[3]!.hp).toBe(500) // target C out of 80px ground radius, unharmed + }) + + it('wall collision detonates fireball into explodingarrowexp and deals 80px AoE splash', () => { + // Terrain blocks at x >= 120 + const engine = createTestEngine( + [ + { x: 120, y: 120, hp: 200 }, // dy=20 -> groundDist = 40 <= 80 (HIT) + { x: 120, y: 160, hp: 200 }, // dy=60 -> groundDist = 120 > 80 (MISS) + ], + { + overlap: (x: number) => (x >= 120 ? 1 : 0), + isMissileBlocked: (x: number) => x >= 120, + }, + ) + + castSkill(47, 200, 100, { + engine, + runtime: {} as any, + hudManager: null, + status: { textContent: '' } as any, + }) + + for (let t = 0; t < 10; t++) { + engine.tick(DUMMY_INPUT) + } + + const explosion = engine.explosions.find(e => e.missileType === 'explodingarrowexp') + expect(explosion).toBeDefined() + expect(explosion!.maxFrames).toBe(16) + + expect(engine.world.monsters[0]!.hp).toBeLessThan(200) // monster A damaged by wall detonation + expect(engine.world.monsters[1]!.hp).toBe(200) // monster B outside AoE untouched + }) +})