diablo2-web/tests/missile-skills-113c.test.ts

1032 lines
37 KiB
TypeScript

/**
* Diablo II: Lord of Destruction v1.13c — Comprehensive Missile Skills Test Suite
*
* Covers all missile skills across the 4 tiers:
* - Tier 1 (1:1 Direct Projectile): Fire Ball, Ice Bolt, Ice Blast, Magic Arrow, Fire Arrow, Cold Arrow, Ice Arrow
* - Tier 2 (Multi-Projectile Fan / Ring): Teeth, Multiple Shot, Poison Nova
* - Tier 3 (Kinematics: Pierce, Homing, Spiral, Jitter): Bone Spear, Blessed Hammer, Bone Spirit, Charged Bolt
* - Tier 4 (AoE Splash & Entity Filters): Glacial Spike, Exploding Arrow, Holy Bolt
* - Asset & WebGL Rendering: 22 baked DCC missile & explosion atlases, 32-dir DCC lookup, anchor offset rendering
*/
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'
import { inflateSync } from 'node:zlib'
import {
parseMissilesTxt,
getMissileTxtData,
CANONICAL_113C_MISSILES,
tickProjectiles,
type Projectile,
} from '../src/game/skills.ts'
import {
getSkillRange,
getSkillMana,
castSkill,
drawMissileProjectile,
drawExplosion,
type LoadedMissileArt,
} from '../src/scene/act-scene.ts'
import { GameEngine } from '../src/game/engine.ts'
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
import { SpriteRenderer, type AtlasHandle } from '../src/render/renderer.ts'
import { MISSILE_METAS, velocityToDccDirection } from '../src/render/missiles-meta.ts'
const DUMMY_INPUT = {
movement: { x: 0, y: 0 },
attacking: false,
pickingUp: false,
talking: false,
digits: [],
saving: false,
loading: false,
}
function createTestEngine(monsters?: { x: number; y: number; hp?: number; isUndead?: boolean; name?: string }[]) {
const engine = new GameEngine(
{
widthPx: 1000,
heightPx: 1000,
overlap: () => 0,
},
{
spawn: { x: 100, y: 100 },
stats: [],
xpTable: DEMO_EXPERIENCE,
skills: DEMO_SKILLS,
npcDefs: [],
questDefs: DEMO_QUESTS,
combatOptions: {
playerSpeed: 4,
playerReach: 50,
playerCooldownTicks: 10,
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]!
const isUndead = m.isUndead ?? false
engine.world.monsters.push({
index: i,
stats: {
id: isUndead ? 'skeleton' : 'fallen',
name: m.name ?? (isUndead ? 'Skeleton' : '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,
...(isUndead ? { isUndead: true } : {}),
})
}
}
return engine
}
describe('Diablo II 1.13c Missile Skills Suite', () => {
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
const parsedMissiles = parseMissilesTxt(missilesTxtContent)
// -------------------------------------------------------------
// TIER 1: Direct 1:1 Projectiles
// -------------------------------------------------------------
describe('Tier 1: Direct 1:1 Projectiles', () => {
it('Fire Ball (47): authentic physics, fireball missileType, 48px AoE splash', () => {
const fbData = getMissileTxtData('fireball', parsedMissiles)
expect(fbData.vel).toBe(20)
expect(fbData.range).toBe(50)
expect(fbData.speedPxPerSec).toBe(500)
expect(fbData.distancePx).toBe(1000)
expect(fbData.explosionMissile).toMatch(/fireexplode|explodingarrowexp/)
const engine = createTestEngine([
{ x: 150, y: 100, hp: 100 }, // direct target
{ x: 170, y: 100, hp: 100 }, // splash target within 48px
{ x: 300, y: 100, hp: 100 }, // out of splash radius
])
const status = { textContent: '' } as HTMLElement
castSkill(47, 200, 100, {
engine,
runtime: {} as any,
hudManager: null,
status,
})
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)
// Advance until impact
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
// Direct target hit, splash target also damaged, distant target untouched
expect(engine.world.monsters[0]!.hp).toBeLessThan(100)
expect(engine.world.monsters[1]!.hp).toBeLessThan(100)
expect(engine.world.monsters[2]!.hp).toBe(100)
// Active explosion spawned with fireexplode or explodingarrowexp
expect(engine.explosions.length).toBeGreaterThan(0)
expect(['fireexplode', 'explodingarrowexp']).toContain(engine.explosions[0]!.missileType)
})
it('Ice Bolt (39): chill status slows monster speed by 50%', () => {
const engine = createTestEngine([{ x: 140, y: 100, hp: 100 }])
const status = { textContent: '' } as HTMLElement
castSkill(39, 200, 100, {
engine,
runtime: {} as any,
hudManager: null,
status,
})
const proj = engine.projectiles[0]!
expect(proj.missileType).toBe('icebolt')
expect(proj.statusEffect).toBe('chill')
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
const target = engine.world.monsters[0]!
expect(target.chillTicks).toBeGreaterThan(0)
})
it('Ice Blast (45): freeze status incapacitates monster to idle', () => {
const engine = createTestEngine([{ x: 140, y: 100, hp: 100 }])
const status = { textContent: '' } as HTMLElement
castSkill(45, 200, 100, {
engine,
runtime: {} as any,
hudManager: null,
status,
})
const proj = engine.projectiles[0]!
expect(proj.missileType).toBe('iceblast')
expect(proj.statusEffect).toBe('freeze')
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
const target = engine.world.monsters[0]!
expect(target.frozenTicks).toBeGreaterThan(0)
expect(target.state).toBe('idle')
})
it('Amazon Elemental Arrows: Magic Arrow, Fire Arrow, Cold Arrow, Ice Arrow', () => {
const engine = createTestEngine()
const status = { textContent: '' } as HTMLElement
// Magic Arrow (6)
castSkill(6, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles[0]!.missileType).toBe('magicarrow')
engine.projectiles.length = 0
engine.world.player.cooldown = 0
// Fire Arrow (7)
castSkill(7, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles[0]!.missileType).toBe('firearrow')
engine.projectiles.length = 0
engine.world.player.cooldown = 0
// Cold Arrow (11)
castSkill(11, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles[0]!.missileType).toBe('icearrow')
expect(engine.projectiles[0]!.statusEffect).toBe('chill')
engine.projectiles.length = 0
engine.world.player.cooldown = 0
// Ice Arrow (21)
castSkill(21, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles[0]!.missileType).toBe('icearrow')
expect(engine.projectiles[0]!.statusEffect).toBe('freeze')
engine.projectiles.length = 0
})
})
// -------------------------------------------------------------
// TIER 2: Multi-Projectile Spread (Fan & 360 Nova)
// -------------------------------------------------------------
describe('Tier 2: Multi-Projectile Spread', () => {
it('Teeth (67): spawns 3 teeth missiles across fan arc', () => {
const engine = createTestEngine()
const status = { textContent: '' } as HTMLElement
castSkill(67, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles.length).toBe(3)
for (const p of engine.projectiles) {
expect(p.missileType).toBe('teeth')
}
// Check angles diverge
const angles = engine.projectiles.map(p => Math.atan2(p.vy, p.vx))
expect(angles[0]).toBeLessThan(angles[1]!)
expect(angles[1]).toBeLessThan(angles[2]!)
})
it('Multiple Shot (12): spawns 5 arrows in fan spread', () => {
const engine = createTestEngine()
const status = { textContent: '' } as HTMLElement
castSkill(12, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles.length).toBe(5)
for (const p of engine.projectiles) {
expect(p.missileType).toBe('arrow')
}
})
it('Poison Nova (92): spawns 32 missiles in 360-degree ring with poison status', () => {
const engine = createTestEngine([{ x: 100, y: 150, hp: 100 }])
const status = { textContent: '' } as HTMLElement
castSkill(92, 100, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles.length).toBe(32)
for (const p of engine.projectiles) {
expect(p.missileType).toBe('poisonnova')
expect(p.statusEffect).toBe('poison')
// In 2:1 isometric projection, vy is scaled by 0.5 compared to vx
const norm = Math.pow(p.vx, 2) + Math.pow(p.vy / 0.5, 2)
expect(Math.sqrt(norm)).toBeCloseTo(Math.hypot(p.vx, p.vy / 0.5), 1)
}
// Tick until nova strikes target
for (let t = 0; t < 6; t++) {
engine.tick(DUMMY_INPUT)
}
const target = engine.world.monsters[0]!
expect(target.poisonTicks).toBeGreaterThan(0)
})
})
// -------------------------------------------------------------
// TIER 3: Kinematic Variations (Pierce, Spiral, Homing, Jitter)
// -------------------------------------------------------------
describe('Tier 3: Kinematic Variations', () => {
it('Bone Spear (84): pierces through multiple aligned monsters', () => {
const engine = createTestEngine([
{ x: 140, y: 100, hp: 100 },
{ x: 180, y: 100, hp: 100 },
{ x: 220, y: 100, hp: 100 },
])
const status = { textContent: '' } as HTMLElement
castSkill(84, 300, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles[0]!.pierce).toBe(true)
// Advance through all 3 monsters
for (let t = 0; t < 10; t++) {
engine.tick(DUMMY_INPUT)
}
expect(engine.world.monsters[0]!.hp).toBeLessThan(100)
expect(engine.world.monsters[1]!.hp).toBeLessThan(100)
expect(engine.world.monsters[2]!.hp).toBeLessThan(100)
})
it('Blessed Hammer (112): moves along Archimedean spiral trajectory on 2:1 isometric ground plane', () => {
const engine = createTestEngine()
const status = { textContent: '' } as HTMLElement
castSkill(112, 100, 200, { engine, runtime: {} as any, hudManager: null, status })
const hammer = engine.projectiles[0]!
expect(hammer.missileType).toBe('blessedhammer')
expect(hammer.spiral).toBeDefined()
expect(hammer.pierce).toBe(true)
const startDist = Math.hypot(hammer.x - hammer.spiral!.originX, (hammer.y - hammer.spiral!.originY) / 0.5)
// Tick 5 times
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
const currentHammer = engine.projectiles[0]!
expect(currentHammer).toBeDefined()
const currentDist = Math.hypot(currentHammer.x - currentHammer.spiral!.originX, (currentHammer.y - currentHammer.spiral!.originY) / 0.5)
// Archimedean spiral radius expands outward over time
expect(currentDist).toBeGreaterThan(startDist)
// Verify that coordinates lie on the 2:1 isometric ground ellipse
const radX = currentHammer.spiral!.radius
const radY = radX * 0.5
const ellipseRatio = Math.pow((currentHammer.x - currentHammer.spiral!.originX) / radX, 2)
+ Math.pow((currentHammer.y - currentHammer.spiral!.originY) / radY, 2)
expect(ellipseRatio).toBeCloseTo(1.0, 1)
})
it('Bone Spirit (93): homes in on nearest living enemy', () => {
const engine = createTestEngine([{ x: 200, y: 160, hp: 100 }])
const status = { textContent: '' } as HTMLElement
// Cast towards (200, 100), but target is at (200, 160)
castSkill(93, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
const spirit = engine.projectiles[0]!
expect(spirit.missileType).toBe('bonespirit')
expect(spirit.homing).toBe(true)
// Track trajectory turning towards monster at y = 160
for (let t = 0; t < 6; t++) {
engine.tick(DUMMY_INPUT)
}
const currentSpirit = engine.projectiles[0]
// vy should turn positive towards y = 160
if (currentSpirit) {
expect(currentSpirit.vy).toBeGreaterThan(0)
}
})
it('Charged Bolt (38): wobbles with perpendicular sinusoidal jitter', () => {
const engine = createTestEngine()
const status = { textContent: '' } as HTMLElement
castSkill(38, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles.length).toBe(3)
for (const bolt of engine.projectiles) {
expect(bolt.missileType).toBe('chargedbolt')
expect(bolt.jitter).toBe(true)
}
})
})
// -------------------------------------------------------------
// TIER 4: AoE Splash & Entity Filters
// -------------------------------------------------------------
describe('Tier 4: AoE Splash & Entity Filters', () => {
it('Glacial Spike (55): AoE 64px freeze and damage on all monsters in radius', () => {
const engine = createTestEngine([
{ x: 150, y: 100, hp: 100 }, // direct
{ x: 175, y: 110, hp: 100 }, // within 64px
{ x: 300, y: 200, hp: 100 }, // outside
])
const status = { textContent: '' } as HTMLElement
castSkill(55, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
expect(engine.world.monsters[0]!.frozenTicks).toBeGreaterThan(0)
expect(engine.world.monsters[1]!.frozenTicks).toBeGreaterThan(0)
expect(engine.world.monsters[2]!.frozenTicks).toBeUndefined()
})
it('Holy Bolt (101): only damages Undead monsters', () => {
const engine = createTestEngine([
{ x: 140, y: 100, hp: 100, isUndead: false, name: 'Fallen' }, // Non-undead Demon
])
const status = { textContent: '' } as HTMLElement
castSkill(101, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
// Non-undead takes ZERO damage!
expect(engine.world.monsters[0]!.hp).toBe(100)
// Now with an undead Skeleton
const undeadEngine = createTestEngine([
{ x: 140, y: 100, hp: 100, isUndead: true, name: 'Skeleton' },
])
castSkill(101, 200, 100, { engine: undeadEngine, runtime: {} as any, hudManager: null, status })
for (let t = 0; t < 5; t++) {
undeadEngine.tick(DUMMY_INPUT)
}
// Undead takes full Holy Bolt damage!
expect(undeadEngine.world.monsters[0]!.hp).toBeLessThan(100)
})
it('Lightning Bolt (20): piercing lightning javelin', () => {
const engine = createTestEngine([
{ x: 140, y: 100, hp: 100 },
{ x: 180, y: 100, hp: 100 },
])
const status = { textContent: '' } as HTMLElement
castSkill(20, 200, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles[0]!.pierce).toBe(true)
expect(engine.projectiles[0]!.missileType).toBe('lightningjavelin')
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
expect(engine.world.monsters[0]!.hp).toBeLessThan(100)
expect(engine.world.monsters[1]!.hp).toBeLessThan(100)
})
})
// -------------------------------------------------------------
// ASSET & WEBGL RENDERING PARITY
// -------------------------------------------------------------
describe('Missile Assets & WebGL Rendering Parity', () => {
it('Verifies all 22 baked missile metas are exported with authentic DCC properties', () => {
const expectedMetas = [
'firebolt', 'fireball', 'fireexplode',
'icebolt', 'iceblast', 'glacialspike', 'iceexplode', 'freezeexplode',
'chargedbolt', 'teeth', 'teethexplode', 'bonespear',
'bonespirit', 'bonespiritexplode', 'poisonnova',
'holybolt', 'blessedhammer', 'magicarrow', 'firearrow', 'icearrow', 'arrow', 'lightningjavelin'
]
for (const name of expectedMetas) {
const meta = MISSILE_METAS[name]
expect(meta, `Meta missing for ${name}`).toBeDefined()
expect(meta.width).toBeGreaterThan(0)
expect(meta.height).toBeGreaterThan(0)
expect(meta.groups.length).toBe(meta.directions)
expect(meta.groups[0]!.length).toBe(meta.framesPerDirection)
}
})
it('32-direction DCC lookups resolve accurately for teeth, arrows, poison nova', () => {
// East (vx > 0, vy = 0)
const east32 = velocityToDccDirection(1, 0, 32)
expect(east32).toBeDefined()
// South (vx = 0, vy > 0)
const south32 = velocityToDccDirection(0, 1, 32)
expect(south32).toBeDefined()
// North (vx = 0, vy < 0)
const north32 = velocityToDccDirection(0, -1, 32)
expect(north32).toBeDefined()
// West (vx < 0, vy = 0)
const west32 = velocityToDccDirection(-1, 0, 32)
expect(west32).toBeDefined()
})
it('drawMissileProjectile and drawExplosion render with mock WebGL SpriteRenderer', () => {
const draws: { frame: any; x: number; y: number }[] = []
const fakeRenderer = {
draw: (frame: any, x: number, y: number) => {
draws.push({ frame, x, y })
},
drawSolid: () => {},
} as unknown as SpriteRenderer
const dummyArt: LoadedMissileArt = {
meta: MISSILE_METAS.bonespear!,
handle: 1 as unknown as AtlasHandle,
frames: [
[{ x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -16 }]
],
}
const shot: Projectile = {
skillId: '84',
missileType: 'bonespear',
x: 100,
y: 100,
vx: 24,
vy: 0,
damage: 30,
ttl: 40,
fromPlayer: true,
}
drawMissileProjectile(fakeRenderer, shot, dummyArt)
expect(draws.length).toBe(1)
expect(draws[0]!.x).toBe(100 - 16) // anchor applied!
expect(draws[0]!.y).toBe(100 - 16)
draws.length = 0
const expArt: LoadedMissileArt = {
meta: MISSILE_METAS.fireexplode!,
handle: 2 as unknown as AtlasHandle,
frames: [
[{ x: 0, y: 0, width: 48, height: 48, anchorX: -24, anchorY: -24 }]
],
}
drawExplosion(fakeRenderer, { missileType: 'fireexplode', x: 150, y: 150, frame: 0, maxFrames: 12 }, expArt)
expect(draws.length).toBe(1)
expect(draws[0]!.x).toBe(150 - 24)
expect(draws[0]!.y).toBe(150 - 24)
})
})
// -------------------------------------------------------------
// TIER 5: Isometric Ground Perspective (2:1 Ratio) & Map Coordinates Parity
// -------------------------------------------------------------
describe('Tier 5: Isometric Ground Perspective (2:1 Ratio) & Map Coordinates Parity', () => {
it('Blessed Hammer spiral trajectory forms an exact 2:1 ellipse on the ground plane across multiple ticks', () => {
const engine = createTestEngine()
const status = { textContent: '' } as HTMLElement
castSkill(112, 100, 200, { engine, runtime: {} as any, hudManager: null, status })
const hammer = engine.projectiles[0]!
const originX = hammer.spiral!.originX
const originY = hammer.spiral!.originY
for (let t = 0; t < 12; t++) {
engine.tick(DUMMY_INPUT)
const cur = engine.projectiles[0]!
expect(cur).toBeDefined()
const rX = cur.spiral!.radius
const rY = rX * 0.5 // 2:1 isometric ground foreshortening
const ellipseNorm = Math.pow((cur.x - originX) / rX, 2) + Math.pow((cur.y - originY) / rY, 2)
expect(ellipseNorm).toBeCloseTo(1.0, 2)
// Verify tangential velocity also reflects the 2:1 aspect ratio
const speedRatio = Math.abs(cur.vy) / (Math.abs(cur.vx) || 0.001)
// vy is scaled by 0.5 relative to horizontal component
expect(Math.abs(cur.vy)).toBeLessThanOrEqual(18 * 0.5 + 0.01)
}
})
it('Poison Nova 32-missile radial ring expands as a 2:1 ground ellipse', () => {
const engine = createTestEngine()
const status = { textContent: '' } as HTMLElement
castSkill(92, 100, 100, { engine, runtime: {} as any, hudManager: null, status })
expect(engine.projectiles.length).toBe(32)
for (const p of engine.projectiles) {
// Velocities must lie on the ellipse: (vx / speed)^2 + (vy / (0.5 * speed))^2 == 1
const speed = Math.hypot(p.vx, p.vy / 0.5)
const vEllipse = Math.pow(p.vx / speed, 2) + Math.pow(p.vy / (speed * 0.5), 2)
expect(vEllipse).toBeCloseTo(1.0, 2)
}
// Advance 5 ticks
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
for (const p of engine.projectiles) {
const dx = p.x - engine.world.player.x
const dy = p.y - engine.world.player.y
const groundRadius = Math.hypot(dx, dy / 0.5)
const posEllipse = Math.pow(dx / groundRadius, 2) + Math.pow(dy / (groundRadius * 0.5), 2)
expect(posEllipse).toBeCloseTo(1.0, 2)
}
})
it('AoE splash damage evaluates ground distance with 2:1 isometric metric (Math.hypot(dx, dy / 0.5) <= aoeRadius)', () => {
// Fireball has aoeRadius = 48
// 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!)
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)
])
const status = { textContent: '' } as HTMLElement
castSkill(47, 100, 100, { engine, runtime: {} as any, hudManager: null, status })
// Advance until fireball hits
for (let t = 0; t < 5; t++) {
engine.tick(DUMMY_INPUT)
}
const monsters = engine.world.monsters
expect(monsters[0]!.hp).toBeLessThan(100) // direct target damaged
expect(monsters[1]!.hp).toBeLessThan(100) // monster A damaged
expect(monsters[2]!.hp).toBeLessThan(100) // monster B damaged (within 2:1 AoE)
expect(monsters[3]!.hp).toBe(100) // monster C UNTOUCHED (outside 2:1 ground AoE)
})
it('Strictly distinguishes map simulation coordinates from rendered sprite icon anchor offsets', () => {
const draws: { frame: any; x: number; y: number }[] = []
const fakeRenderer = {
draw: (frame: any, x: number, y: number) => {
draws.push({ frame, x, y })
},
} as unknown as SpriteRenderer
const dummyArt: LoadedMissileArt = {
meta: MISSILE_METAS.blessedhammer!,
handle: 1 as unknown as AtlasHandle,
frames: [
[{ x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -20 }]
],
}
// Map coordinate on the ground plane
const mapX = 250
const mapY = 180
const shot: Projectile = {
skillId: '112',
missileType: 'blessedhammer',
x: mapX,
y: mapY,
vx: 12,
vy: 6, // 2:1 velocity
damage: 40,
ttl: 50,
fromPlayer: true,
}
drawMissileProjectile(fakeRenderer, shot, dummyArt)
expect(draws.length).toBe(1)
// The projectile coordinate (map coordinate) is unaltered
expect(shot.x).toBe(mapX)
expect(shot.y).toBe(mapY)
// The rendered icon is translated by the DCC frame anchor, NOT stretched
expect(draws[0]!.x).toBe(mapX + (-16))
expect(draws[0]!.y).toBe(mapY + (-20))
expect(draws[0]!.frame.width).toBe(32)
expect(draws[0]!.frame.height).toBe(32)
})
})
describe('Tier 6: Trans = 1 Missile Transparency & Additive Blending (Zero Black Border) Parity', () => {
function decodeRgbaPng(filePath: string): { width: number; height: number; pixels: Uint8Array } {
const buffer = fs.readFileSync(filePath)
const width = buffer.readUInt32BE(16)
const height = buffer.readUInt32BE(20)
const bitDepth = buffer.readUInt8(24)
const colorType = buffer.readUInt8(25)
expect(bitDepth).toBe(8)
expect(colorType).toBe(6) // Must be 32-bit RGBA
let offset = 8
const idatChunks: Buffer[] = []
while (offset < buffer.length) {
const len = buffer.readUInt32BE(offset)
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii')
if (type === 'IDAT') {
idatChunks.push(buffer.subarray(offset + 8, offset + 8 + len))
}
offset += 12 + len
}
const decompressed = inflateSync(Buffer.concat(idatChunks))
const stride = 4
const rowBytes = width * stride
const pixels = new Uint8Array(width * height * stride)
let srcOffset = 0
let dstOffset = 0
let prevRow: Uint8Array | null = null
for (let y = 0; y < height; y++) {
const filterType = decompressed[srcOffset++]!
const currentRow = new Uint8Array(rowBytes)
for (let x = 0; x < rowBytes; x++) {
const byte = decompressed[srcOffset++]!
const left = x >= stride ? currentRow[x - stride]! : 0
const up = prevRow ? prevRow[x]! : 0
let val = byte
if (filterType === 0) {
val = byte
} else if (filterType === 1) {
val = (byte + left) & 0xff
} else if (filterType === 2) {
val = (byte + up) & 0xff
} else if (filterType === 3) {
val = (byte + Math.floor((left + up) / 2)) & 0xff
} else if (filterType === 4) {
const upLeft = prevRow && x >= stride ? prevRow[x - stride]! : 0
const p = left + up - upLeft
const pa = Math.abs(p - left)
const pb = Math.abs(p - up)
const pc = Math.abs(p - upLeft)
let pr = left
if (pb < pa && pb <= pc) pr = up
else if (pc < pa) pr = upLeft
val = (byte + pr) & 0xff
}
currentRow[x] = val
}
pixels.set(currentRow, dstOffset)
dstOffset += rowBytes
prevRow = currentRow
}
return { width, height, pixels }
}
it('All baked Trans=1 magical missiles and explosions have colorType=6 and ZERO opaque black pixels', () => {
const magicalMissiles = [
'firebolt',
'fireball',
'fireexplode',
'icebolt',
'iceblast',
'glacialspike',
'iceexplode',
'freezeexplode',
'chargedbolt',
'teeth',
'teethexplode',
'bonespear',
'bonespirit',
'bonespiritexplode',
'poisonnova',
'holybolt',
'blessedhammer',
'magicarrow',
'firearrow',
'icearrow',
'lightningjavelin',
]
for (const name of magicalMissiles) {
const pngPath = path.resolve(__dirname, `../public/missiles/${name}.png`)
expect(fs.existsSync(pngPath), `Missing ${name}.png in public/missiles`).toBe(true)
const { width, height, pixels } = decodeRgbaPng(pngPath)
expect(width).toBeGreaterThan(0)
expect(height).toBeGreaterThan(0)
let blackNonTransparentCount = 0
let visibleCount = 0
for (let i = 0; i < pixels.length; i += 4) {
const r = pixels[i]!
const g = pixels[i + 1]!
const b = pixels[i + 2]!
const a = pixels[i + 3]!
const maxRgb = Math.max(r, g, b)
// Black fringe / dark background (max(r,g,b) <= 4, e.g. palette index 172 (4,4,4) or 0 (0,0,0))
if (maxRgb <= 4 && a > 0) {
blackNonTransparentCount++
}
if (a > 0 && maxRgb >= 30) {
visibleCount++
}
}
// Strict invariant: 0 opaque black pixels, eliminating any black border or box artifacts
expect(blackNonTransparentCount, `${name}.png contains ${blackNonTransparentCount} opaque black pixels!`).toBe(0)
expect(visibleCount, `${name}.png must contain visible spell pixels`).toBeGreaterThan(0)
}
})
it('Physical arrow.png is 32-bit RGBA and retains authentic arrow pixels', () => {
const arrowPngPath = path.resolve(__dirname, '../public/missiles/arrow.png')
expect(fs.existsSync(arrowPngPath)).toBe(true)
const { width, height, pixels } = decodeRgbaPng(arrowPngPath)
expect(width).toBeGreaterThan(0)
expect(height).toBeGreaterThan(0)
let opaqueCount = 0
for (let i = 0; i < pixels.length; i += 4) {
const a = pixels[i + 3]!
if (a > 200) opaqueCount++
}
expect(opaqueCount).toBeGreaterThan(0)
})
it('drawMissileProjectile passes blendMode: "additive" for magical missiles and normal for physical arrow', () => {
const drawCalls: any[] = []
const mockRenderer = {
draw(frame: any, x: number, y: number, options?: any) {
drawCalls.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const dummyFrame = [{ x: 0, y: 0, width: 32, height: 32, anchorX: -16, anchorY: -16 }]
// 1. Magical missile (e.g. firebolt)
const fireboltArt: LoadedMissileArt = {
meta: MISSILE_METAS.firebolt!,
handle: 1 as unknown as AtlasHandle,
frames: [dummyFrame],
}
const fireboltShot: Projectile = {
skillId: '36',
missileType: 'firebolt',
x: 100,
y: 100,
vx: 20,
vy: 0,
damage: 10,
ttl: 50,
fromPlayer: true,
}
drawMissileProjectile(mockRenderer, fireboltShot, fireboltArt)
expect(drawCalls.length).toBe(1)
expect(drawCalls[0]!.options?.blendMode).toBe('additive')
// 2. Physical arrow (arrow)
drawCalls.length = 0
const arrowArt: LoadedMissileArt = {
meta: MISSILE_METAS.arrow!,
handle: 2 as unknown as AtlasHandle,
frames: [dummyFrame],
}
const arrowShot: Projectile = {
skillId: 'arrow',
missileType: 'arrow',
x: 100,
y: 100,
vx: 20,
vy: 0,
damage: 10,
ttl: 50,
fromPlayer: true,
}
drawMissileProjectile(mockRenderer, arrowShot, arrowArt)
expect(drawCalls.length).toBe(1)
expect(drawCalls[0]!.options?.blendMode).toBeUndefined()
})
it('drawExplosion passes blendMode: "additive" for spell explosions', () => {
const drawCalls: any[] = []
const mockRenderer = {
draw(frame: any, x: number, y: number, options?: any) {
drawCalls.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const dummyFrame = [{ x: 0, y: 0, width: 48, height: 48, anchorX: -24, anchorY: -24 }]
const explodeArt: LoadedMissileArt = {
meta: MISSILE_METAS.fireexplode!,
handle: 3 as unknown as AtlasHandle,
frames: [dummyFrame],
}
drawExplosion(mockRenderer, {
missileType: 'fireexplode',
x: 150,
y: 150,
frame: 2,
maxFrames: 12,
}, explodeArt)
expect(drawCalls.length).toBe(1)
expect(drawCalls[0]!.options?.blendMode).toBe('additive')
})
it('SpriteRenderer dynamically toggles gl.blendFunc and flushes batches on blendMode transition', () => {
let nextId = 1
const blendFuncCalls: Array<[number, number]> = []
const drawElementsCalls: number[] = []
const gl: any = {
VERTEX_SHADER: 35633,
FRAGMENT_SHADER: 35632,
COMPILE_STATUS: 35713,
LINK_STATUS: 35714,
MAX_TEXTURE_SIZE: 4096,
MAX_TEXTURE_IMAGE_UNITS: 16,
TEXTURE_2D: 3553,
RGBA: 6408,
UNSIGNED_BYTE: 5121,
UNSIGNED_INT: 5125,
FLOAT: 5126,
ARRAY_BUFFER: 34962,
ELEMENT_ARRAY_BUFFER: 34963,
DYNAMIC_DRAW: 35048,
STATIC_DRAW: 35044,
TRIANGLES: 4,
TEXTURE_MIN_FILTER: 10241,
TEXTURE_MAG_FILTER: 10240,
TEXTURE_WRAP_S: 10242,
TEXTURE_WRAP_T: 10243,
CLAMP_TO_EDGE: 33071,
NEAREST: 9728,
BLEND: 3042,
ONE: 1,
SRC_ALPHA: 770,
ONE_MINUS_SRC_ALPHA: 771,
DEPTH_TEST: 2929,
COLOR_BUFFER_BIT: 16384,
TEXTURE0: 33984,
drawingBufferWidth: 800,
drawingBufferHeight: 600,
isContextLost: () => false,
createProgram: () => ({ id: nextId++ }),
deleteProgram: () => {},
attachShader: () => {},
detachShader: () => {},
linkProgram: () => {},
getProgramParameter: () => true,
getProgramInfoLog: () => '',
useProgram: () => {},
createShader: () => ({ id: nextId++ }),
shaderSource: () => {},
compileShader: () => {},
getShaderParameter: () => true,
getShaderInfoLog: () => '',
deleteShader: () => {},
createVertexArray: () => ({ id: nextId++ }),
bindVertexArray: () => {},
createBuffer: () => ({ id: nextId++ }),
bindBuffer: () => {},
bufferData: () => {},
bufferSubData: () => {},
getAttribLocation: () => 0,
enableVertexAttribArray: () => {},
vertexAttribPointer: () => {},
createTexture: () => ({ id: nextId++ }),
bindTexture: () => {},
activeTexture: () => {},
texParameteri: () => {},
pixelStorei: () => {},
texImage2D: () => {},
getParameter: (p: number) => (p === 3379 ? 4096 : p === 34930 ? 16 : 0),
getUniformLocation: () => ({}),
uniform2f: () => {},
uniform1f: () => {},
uniform1i: () => {},
uniform1iv: () => {},
enable: () => {},
disable: () => {},
blendFunc: (s: number, d: number) => {
blendFuncCalls.push([s, d])
},
viewport: () => {},
clearColor: () => {},
clear: () => {},
drawElements: (_mode: number, count: number) => {
drawElementsCalls.push(count)
},
deleteBuffer: () => {},
deleteVertexArray: () => {},
deleteTexture: () => {},
}
const canvas: any = {
getContext: () => gl,
width: 800,
height: 600,
addEventListener: () => {},
removeEventListener: () => {},
}
const renderer = new SpriteRenderer(canvas)
// Start frame
renderer.begin({ x: 0, y: 0, zoom: 1 }, [0, 0, 0])
// gl.blendFunc was set to (SRC_ALPHA, ONE_MINUS_SRC_ALPHA) on initialize
expect(blendFuncCalls.length).toBeGreaterThan(0)
expect(blendFuncCalls[0]).toEqual([gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA])
const frame = { x: 0, y: 0, width: 32, height: 32 }
// 1. Draw normal quad
renderer.draw(frame, 10, 10, { blendMode: 'normal' })
// 2. Draw additive quad -> must flush and switch blendFunc to (SRC_ALPHA, ONE)
const prevCalls = blendFuncCalls.length
renderer.draw(frame, 20, 20, { blendMode: 'additive' })
expect(blendFuncCalls.length).toBe(prevCalls + 1)
expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE])
expect(drawElementsCalls.length).toBe(1) // Flushed normal batch (6 indices)
// 3. Draw another additive quad -> should batch together without switching blendFunc
const callsAfterSecondAdditive = blendFuncCalls.length
renderer.draw(frame, 30, 30, { blendMode: 'additive' })
expect(blendFuncCalls.length).toBe(callsAfterSecondAdditive)
// 4. Draw normal quad again -> must flush additive batch and switch blendFunc back
renderer.draw(frame, 40, 40, { blendMode: 'normal' })
expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA])
expect(drawElementsCalls.length).toBe(2) // Flushed additive batch
// 5. drawSolid ensures normal blend mode
renderer.draw(frame, 50, 50, { blendMode: 'additive' })
expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE])
renderer.drawSolid(0, 0, 10, 10, [1, 0, 0, 1])
expect(blendFuncCalls[blendFuncCalls.length - 1]).toEqual([gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA])
renderer.flush()
})
})
})