fix(skill): fix Fire Ball explosion missile asset and 80px AoE radius (Fixes #440)

This commit is contained in:
troytt 2026-09-24 16:54:28 +00:00
parent f107d05dd2
commit cc994fe3c8
6 changed files with 450 additions and 27 deletions

View File

@ -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,

View File

@ -696,7 +696,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
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<Record<string, MissileTxtData>> =
},
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 }

View File

@ -8075,6 +8075,7 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
'meteortail': METEORTAIL_META,
'meteorcenter': METEORCENTER_META,
'meteorexplode': METEOREXPLODE_META,
'explodingarrowexp': METEOREXPLODE_META,
'meteorfire': METEORFIRE_META,
'firesmall': FIRESMALL_META,
'nova': NOVA_META,

View File

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

View File

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

View File

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