diablo2-web/tests/act-scene-monster-dispatch....

305 lines
10 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import { ADDITIVE_MONSTER_TOKENS as SCENE_TOKENS } from '../src/scene/act-scene.ts'
import { ADDITIVE_MONSTER_TOKENS } from '../src/game/monster-art.ts'
import {
executeMonsterRenderLoop,
MockRenderer,
type MonsterSimInput,
type AnimatorSimInput,
} from '../scripts/verify-monster-dispatch.ts'
import type { AtlasFrame } from '../src/render/atlas.ts'
import type { AtlasHandle } from '../src/render/renderer.ts'
import type { LoadedMonsterArt } from '../src/game/monster-art.ts'
function makeFrame(w: number, h: number, ax?: number, ay?: number): AtlasFrame {
return {
x: 0,
y: 0,
width: w,
height: h,
...(ax !== undefined ? { anchorX: ax } : {}),
...(ay !== undefined ? { anchorY: ay } : {}),
}
}
function makeArt(params: {
token: string
blendMode?: 'normal' | 'additive' | undefined
hasGlow?: boolean
glowFrameSize?: [number, number]
directions?: number
}): LoadedMonsterArt {
const dirs = params.directions ?? 8
const totalGroups = 64
const groups: AtlasFrame[][] = []
for (let d = 0; d < totalGroups; d++) {
groups.push([makeFrame(64, 64, -32, -60)])
}
let glowGroups: AtlasFrame[][] | undefined = undefined
if (params.hasGlow) {
glowGroups = []
const [gw, gh] = params.glowFrameSize ?? [64, 64]
for (let d = 0; d < totalGroups; d++) {
glowGroups.push([makeFrame(gw, gh, -32, -60)])
}
}
return {
token: params.token,
handle: 88 as unknown as AtlasHandle,
groups,
...(glowGroups !== undefined ? { glowGroups } : {}),
...(params.blendMode !== undefined ? { blendMode: params.blendMode } : {}),
directions: dirs,
walkOffset: 0,
standOffset: dirs,
walkFrames: 1,
standFrames: 1,
layerFailures: [],
}
}
describe('Monster Rendering Dispatch in act-scene.ts (Milestone 2 / R2)', () => {
it('re-exports ADDITIVE_MONSTER_TOKENS from monster-art.ts matching ground truth', () => {
expect(SCENE_TOKENS).toBe(ADDITIVE_MONSTER_TOKENS)
const expected = ['WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW']
for (const t of expected) {
expect(SCENE_TOKENS.has(t)).toBe(true)
}
expect(SCENE_TOKENS.size).toBe(expected.length)
})
it('Case 1: Pure additive monster (WR, WW) renders single draw call with blendMode: "additive"', () => {
const renderer = new MockRenderer()
for (const token of ['WR', 'WW', 'wr', 'ww']) {
renderer.clear()
const art = makeArt({ token, blendMode: 'additive' })
const artMap = new Map<string, LoadedMonsterArt>([['mon', art]])
const monster: MonsterSimInput = {
index: 1,
state: 'chase',
corpseTicks: 0,
facing: 0,
x: 400,
y: 500,
stats: { id: 'mon' },
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(1)
const call = renderer.drawCalls[0]!
expect(call.options.blendMode).toBe('additive')
expect(call.options.atlas).toBe(art.handle)
expect(call.drawX).toBe(400 - 32)
expect(call.drawY).toBe(500 - 60)
}
})
it('Case 2: Composite monster (FR) renders base pass (normal) + glow pass (additive) at identical coordinates', () => {
const renderer = new MockRenderer()
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [64, 64] })
const artMap = new Map<string, LoadedMonsterArt>([['fingermage', art]])
const monster: MonsterSimInput = {
index: 2,
state: 'chase',
corpseTicks: 0,
facing: 2,
x: 700,
y: 800,
stats: { id: 'fingermage' },
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(2)
const [baseCall, glowCall] = renderer.drawCalls
// Base pass
expect(baseCall!.options.blendMode).toBe('normal')
expect(baseCall!.options.atlas).toBe(art.handle)
// Glow pass
expect(glowCall!.options.blendMode).toBe('additive')
expect(glowCall!.options.atlas).toBe(art.handle)
// Zero-drift alignment
expect(baseCall!.drawX).toBe(glowCall!.drawX)
expect(baseCall!.drawY).toBe(glowCall!.drawY)
expect(baseCall!.drawX).toBe(700 - 32)
expect(baseCall!.drawY).toBe(800 - 60)
})
it('Case 3a: Legacy unbaked packs with tokens in ADDITIVE_MONSTER_TOKENS fall back to blendMode: "additive"', () => {
const renderer = new MockRenderer()
const tokens = ['WR', 'WW', 'FR', 'K9', '17', '46', 'FI', 'X3', 'X4', 'XW']
for (const token of tokens) {
for (const t of [token, token.toLowerCase()]) {
renderer.clear()
const art = makeArt({ token: t, blendMode: undefined, hasGlow: false })
const artMap = new Map<string, LoadedMonsterArt>([['testmon', art]])
const monster: MonsterSimInput = {
index: 3,
state: 'idle',
corpseTicks: 0,
facing: 0,
x: 100,
y: 200,
stats: { id: 'testmon' },
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(1)
expect(renderer.drawCalls[0]!.options.blendMode).toBe('additive')
}
}
})
it('Case 3b: Legacy unbaked packs with regular monsters default to blendMode: "normal"', () => {
const renderer = new MockRenderer()
const tokens = ['FA', 'ZM', 'GO', 'DK', 'SK', 'CR', 'MS']
for (const token of tokens) {
renderer.clear()
const art = makeArt({ token, blendMode: undefined, hasGlow: false })
const artMap = new Map<string, LoadedMonsterArt>([['regmon', art]])
const monster: MonsterSimInput = {
index: 4,
state: 'idle',
corpseTicks: 0,
facing: 0,
x: 100,
y: 200,
stats: { id: 'regmon' },
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(1)
expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal')
}
})
it('Case 4: Corpse frame with 1x1 empty dimensions cleanly skips glow draw call', () => {
const renderer = new MockRenderer()
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [1, 1] })
const artMap = new Map<string, LoadedMonsterArt>([['corpse_fr', art]])
const monster: MonsterSimInput = {
index: 5,
state: 'dead',
corpseTicks: 100,
facing: 0,
x: 300,
y: 400,
stats: { id: 'corpse_fr' },
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(1)
expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal')
})
it('Case 5: Dead monster with corpseTicks <= 0 is completely skipped', () => {
const renderer = new MockRenderer()
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true })
const artMap = new Map<string, LoadedMonsterArt>([['dead_fr', art]])
const monster: MonsterSimInput = {
index: 6,
state: 'dead',
corpseTicks: 0,
facing: 0,
x: 300,
y: 400,
stats: { id: 'dead_fr' },
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(0)
})
it('Case 6: Chilled monster tint applies to base body without polluting additive glow pass', () => {
const renderer = new MockRenderer()
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [64, 64] })
const artMap = new Map<string, LoadedMonsterArt>([['chilled_fr', art]])
const monster: MonsterSimInput = {
index: 7,
state: 'chase',
corpseTicks: 0,
facing: 0,
x: 200,
y: 300,
stats: { id: 'chilled_fr' },
chillTicks: 50,
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(2)
const [baseCall, glowCall] = renderer.drawCalls
expect(baseCall!.options.paletteRow).toBe(1)
expect(baseCall!.options.tint).toBeDefined()
expect(baseCall!.options.blendMode).toBe('normal')
expect(glowCall!.options.paletteRow).toBeUndefined()
expect(glowCall!.options.tint).toBeUndefined()
expect(glowCall!.options.blendMode).toBe('additive')
})
it('Case 7: Animator clip calculation correctly indexes group and frame', () => {
const renderer = new MockRenderer()
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true })
const artMap = new Map<string, LoadedMonsterArt>([['clip_fr', art]])
const animators = new Map<number, AnimatorSimInput>([
[8, { clip: { group: 16, directions: 8, frames: 10 }, frameIndex: 3 }],
])
const monster: MonsterSimInput = {
index: 8,
state: 'attack',
corpseTicks: 0,
facing: 4,
x: 100,
y: 100,
stats: { id: 'clip_fr' },
}
executeMonsterRenderLoop([monster], artMap, animators, 0, renderer)
expect(renderer.drawCalls).toHaveLength(2)
expect(renderer.drawCalls[0]!.options.blendMode).toBe('normal')
expect(renderer.drawCalls[1]!.options.blendMode).toBe('additive')
})
it('Case 8: Frozen monster applies ice blue tint [0.25, 0.6, 1.0, 1.0] and paletteRow: 1 to base body without polluting additive glow pass', () => {
const renderer = new MockRenderer()
const art = makeArt({ token: 'FR', blendMode: 'normal', hasGlow: true, glowFrameSize: [64, 64] })
const artMap = new Map<string, LoadedMonsterArt>([['frozen_fr', art]])
const monster: MonsterSimInput = {
index: 9,
state: 'chase',
corpseTicks: 0,
facing: 0,
x: 200,
y: 300,
stats: { id: 'frozen_fr' },
frozenTicks: 75,
}
executeMonsterRenderLoop([monster], artMap, new Map(), 0, renderer)
expect(renderer.drawCalls).toHaveLength(2)
const [baseCall, glowCall] = renderer.drawCalls
expect(baseCall!.options.paletteRow).toBe(1)
expect(baseCall!.options.tint).toEqual([0.25, 0.6, 1.0, 1.0])
expect(baseCall!.options.blendMode).toBe('normal')
expect(glowCall!.options.paletteRow).toBeUndefined()
expect(glowCall!.options.tint).toBeUndefined()
expect(glowCall!.options.blendMode).toBe('additive')
})
})