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 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) {
|
if (explType) {
|
||||||
const explData = CANONICAL_113C_MISSILES[explType]
|
const explData = CANONICAL_113C_MISSILES[explType]
|
||||||
const maxFrames = explData?.animLen || 12
|
const maxFrames = explData?.animLen || 16
|
||||||
this.explosions.push({
|
this.explosions.push({
|
||||||
missileType: explType,
|
missileType: explType,
|
||||||
x: hit.x,
|
x: hit.x,
|
||||||
|
|
@ -597,7 +597,9 @@ export class GameEngine {
|
||||||
if (wallHit.aoeRadius && wallHit.aoeRadius > 0) {
|
if (wallHit.aoeRadius && wallHit.aoeRadius > 0) {
|
||||||
for (const m of this.world.monsters) {
|
for (const m of this.world.monsters) {
|
||||||
if (m.state === 'dead' || m.hp <= 0) continue
|
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
|
const dmg = wallHit.damage ?? 10
|
||||||
damageMonster(this.world, m.index, dmg)
|
damageMonster(this.world, m.index, dmg)
|
||||||
if (wallHit.statusEffect === 'freeze' || wallHit.missileType === 'glacialspike' || wallHit.skillId === '55' || wallHit.skillId === 'glacialspike') {
|
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 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) {
|
if (explType) {
|
||||||
const explData = CANONICAL_113C_MISSILES[explType]
|
const explData = CANONICAL_113C_MISSILES[explType]
|
||||||
const maxFrames = explData?.animLen || 12
|
const maxFrames = explData?.animLen || 16
|
||||||
this.explosions.push({
|
this.explosions.push({
|
||||||
missileType: explType,
|
missileType: explType,
|
||||||
x: wallHit.x,
|
x: wallHit.x,
|
||||||
|
|
|
||||||
|
|
@ -696,7 +696,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
animSpeed: 16,
|
animSpeed: 16,
|
||||||
loopAnim: 1,
|
loopAnim: 1,
|
||||||
numDirections: 16,
|
numDirections: 16,
|
||||||
explosionMissile: 'fireexplode',
|
explosionMissile: 'explodingarrowexp',
|
||||||
light: 7,
|
light: 7,
|
||||||
red: 255,
|
red: 255,
|
||||||
green: 178,
|
green: 178,
|
||||||
|
|
@ -728,18 +728,18 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
explodingarrowexp: {
|
explodingarrowexp: {
|
||||||
name: 'explodingarrowexp',
|
name: 'explodingarrowexp',
|
||||||
id: 47,
|
id: 42,
|
||||||
vel: 0,
|
vel: 0,
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
range: 12,
|
range: 16,
|
||||||
levRange: 0,
|
levRange: 0,
|
||||||
celFile: 'FireArrowExplode2',
|
celFile: 'ExpArrowExplode',
|
||||||
animLen: 12,
|
animLen: 16,
|
||||||
animSpeed: 16,
|
animSpeed: 16,
|
||||||
loopAnim: 0,
|
loopAnim: 0,
|
||||||
numDirections: 1,
|
numDirections: 1,
|
||||||
explosionMissile: '',
|
explosionMissile: '',
|
||||||
light: 13,
|
light: 0,
|
||||||
red: 255,
|
red: 255,
|
||||||
green: 178,
|
green: 178,
|
||||||
blue: 64,
|
blue: 64,
|
||||||
|
|
@ -4733,6 +4733,44 @@ export function tickProjectiles(
|
||||||
aoeRadius: 32,
|
aoeRadius: 32,
|
||||||
pierce: false,
|
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') {
|
} else if (projectile.missileType === 'meteorcenter') {
|
||||||
// Authentic D2 1.13c MISSMODE_SrvHit14_MeteorCenter (0x6FC5DEF0) & 0x6FC5CEC0:
|
// Authentic D2 1.13c MISSMODE_SrvHit14_MeteorCenter (0x6FC5DEF0) & 0x6FC5CEC0:
|
||||||
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
|
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
|
||||||
|
|
@ -5159,6 +5197,20 @@ export function tickProjectiles(
|
||||||
aoeRadius: 32,
|
aoeRadius: 32,
|
||||||
pierce: false,
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -5173,6 +5225,7 @@ export function tickProjectiles(
|
||||||
projectile.missileType === 'firewallmaker' ||
|
projectile.missileType === 'firewallmaker' ||
|
||||||
projectile.missileType === 'freezeexplode' ||
|
projectile.missileType === 'freezeexplode' ||
|
||||||
projectile.missileType === 'glacialspikeejecta' ||
|
projectile.missileType === 'glacialspikeejecta' ||
|
||||||
|
projectile.missileType === 'explodingarrowexp' ||
|
||||||
projectile.missileType === 'thunderstorm1' ||
|
projectile.missileType === 'thunderstorm1' ||
|
||||||
projectile.missileType === 'thunderstorm' ||
|
projectile.missileType === 'thunderstorm' ||
|
||||||
projectile.missileType === 'lightninghit' ||
|
projectile.missileType === 'lightninghit' ||
|
||||||
|
|
@ -5335,6 +5388,20 @@ export function tickProjectiles(
|
||||||
aoeRadius: 32,
|
aoeRadius: 32,
|
||||||
pierce: false,
|
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
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -5688,6 +5755,9 @@ export function calculateSkillDamage(
|
||||||
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
|
if (skillId === 38 || skillId === '38' || skillId === 'chargedbolt' || skillId === 'charged bolt') {
|
||||||
return calculateChargedBoltDamage(slvl, synergies as any)
|
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') {
|
if (skillId === 41 || skillId === '41' || skillId === 'inferno') {
|
||||||
return calculateInfernoDamage(slvl, synergies as any)
|
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. */
|
/** Result of executing a Batch 1 skill. */
|
||||||
export type SkillExecutionResult =
|
export type SkillExecutionResult =
|
||||||
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
||||||
|
|
|
||||||
|
|
@ -8075,6 +8075,7 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
||||||
'meteortail': METEORTAIL_META,
|
'meteortail': METEORTAIL_META,
|
||||||
'meteorcenter': METEORCENTER_META,
|
'meteorcenter': METEORCENTER_META,
|
||||||
'meteorexplode': METEOREXPLODE_META,
|
'meteorexplode': METEOREXPLODE_META,
|
||||||
|
'explodingarrowexp': METEOREXPLODE_META,
|
||||||
'meteorfire': METEORFIRE_META,
|
'meteorfire': METEORFIRE_META,
|
||||||
'firesmall': FIRESMALL_META,
|
'firesmall': FIRESMALL_META,
|
||||||
'nova': NOVA_META,
|
'nova': NOVA_META,
|
||||||
|
|
|
||||||
|
|
@ -2768,9 +2768,14 @@ export function castSkill(
|
||||||
const missile = getMissileTxtData('fireball')
|
const missile = getMissileTxtData('fireball')
|
||||||
speed = missile.speedPxPerSec || 500
|
speed = missile.speedPxPerSec || 500
|
||||||
range = missile.distancePx || 1000
|
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
|
delay = 0
|
||||||
aoeRadius = 48
|
aoeRadius = 80
|
||||||
missileType = 'fireball'
|
missileType = 'fireball'
|
||||||
skillName = '火球'
|
skillName = '火球'
|
||||||
} else if (skillId === 39) {
|
} else if (skillId === 39) {
|
||||||
|
|
@ -5244,6 +5249,10 @@ export async function loadMissileArtMap(
|
||||||
map.set('frozenorbbolt', iceboltArt)
|
map.set('frozenorbbolt', iceboltArt)
|
||||||
map.set('frozenorbnova', 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')) {
|
if (map.has('firebolt')) {
|
||||||
const fireboltArt = map.get('firebolt')!
|
const fireboltArt = map.get('firebolt')!
|
||||||
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
if (!map.has('hydra')) map.set('hydra', fireboltArt)
|
||||||
|
|
@ -7520,7 +7529,11 @@ if (typeof window !== 'undefined') {
|
||||||
pushEntity(exp.x, exp.y, () => {
|
pushEntity(exp.x, exp.y, () => {
|
||||||
const art =
|
const art =
|
||||||
missileArtMap.get(exp.missileType) ??
|
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)
|
drawExplosion(renderer, exp, art)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,7 @@ describe('Diablo II 1.13c Missile Skills Suite', () => {
|
||||||
// TIER 1: Direct 1:1 Projectiles
|
// TIER 1: Direct 1:1 Projectiles
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
describe('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)
|
const fbData = getMissileTxtData('fireball', parsedMissiles)
|
||||||
expect(fbData.vel).toBe(20)
|
expect(fbData.vel).toBe(20)
|
||||||
expect(fbData.range).toBe(50)
|
expect(fbData.range).toBe(50)
|
||||||
|
|
@ -125,7 +125,7 @@ describe('Diablo II 1.13c Missile Skills Suite', () => {
|
||||||
|
|
||||||
const engine = createTestEngine([
|
const engine = createTestEngine([
|
||||||
{ x: 150, y: 100, hp: 100 }, // direct target
|
{ 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
|
{ 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)
|
expect(engine.projectiles.length).toBe(1)
|
||||||
const proj = engine.projectiles[0]!
|
const proj = engine.projectiles[0]!
|
||||||
expect(proj.missileType).toBe('fireball')
|
expect(proj.missileType).toBe('fireball')
|
||||||
expect(proj.aoeRadius).toBe(48)
|
expect(proj.aoeRadius).toBe(80)
|
||||||
expect(proj.damage).toBe(35)
|
expect(proj.damage).toBe(10)
|
||||||
|
|
||||||
// Advance until impact
|
// Advance until impact
|
||||||
for (let t = 0; t < 5; t++) {
|
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)', () => {
|
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)
|
// Origin at (100, 100)
|
||||||
// Monster A at (140, 100): dx=40, dy=0 -> groundDist = 40 <= 48 (HIT)
|
// Monster A at (160, 100): dx=60, dy=0 -> groundDist = 60 <= 80 (HIT)
|
||||||
// Monster B at (100, 120): dx=0, dy=20 -> groundDist = 20 / 0.5 = 40 <= 48 (HIT)
|
// Monster B at (100, 135): dx=0, dy=35 -> groundDist = 35 / 0.5 = 70 <= 80 (HIT)
|
||||||
// Monster C at (100, 130): dx=0, dy=30 -> groundDist = 30 / 0.5 = 60 > 48 (MISS, even though Euclidean dy=30 < 48!)
|
// 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([
|
const engine = createTestEngine([
|
||||||
{ x: 100, y: 100, hp: 100 }, // direct hit target
|
{ x: 100, y: 100, hp: 100 }, // direct hit target
|
||||||
{ x: 140, y: 100, hp: 100 }, // A: horizontal within AoE
|
{ x: 160, 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: 135, hp: 100 }, // B: vertical within isometric AoE (dy=35 -> ground 70)
|
||||||
{ x: 100, y: 130, hp: 100 }, // C: vertical outside isometric AoE (dy=30 -> ground 60)
|
{ x: 100, y: 145, hp: 100 }, // C: vertical outside isometric AoE (dy=45 -> ground 90)
|
||||||
])
|
])
|
||||||
const status = { textContent: '' } as HTMLElement
|
const status = { textContent: '' } as HTMLElement
|
||||||
castSkill(47, 100, 100, { engine, runtime: {} as any, hudManager: null, status })
|
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