305 lines
10 KiB
TypeScript
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')
|
|
})
|
|
})
|