fix(skill): fix Fire Ball explosion missile asset and 80px AoE radius (Fixes #440)
This commit is contained in:
parent
f107d05dd2
commit
cc994fe3c8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue