feat(char): pack and render all 7 hero classes with R8 atlases and tests
This commit is contained in:
parent
6e4d0382ec
commit
f24ad292ca
10
acts.html
10
acts.html
|
|
@ -27,6 +27,7 @@
|
|||
optgroup { background: #1c1813; color: #c8a15a; }
|
||||
option { background: #1c1813; }
|
||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||
#status:empty { display: none; }
|
||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; }
|
||||
.hint { color: #6d5a34; }
|
||||
|
|
@ -38,6 +39,15 @@
|
|||
<span id="hud">—</span>
|
||||
</header>
|
||||
<div id="controls" class="overlay">
|
||||
<label>职业 <select id="hero" disabled>
|
||||
<option value="so">法师 (Sorceress)</option>
|
||||
<option value="am">亚马逊 (Amazon)</option>
|
||||
<option value="ne">死灵法师 (Necromancer)</option>
|
||||
<option value="pa">圣骑士 (Paladin)</option>
|
||||
<option value="ba">野蛮人 (Barbarian)</option>
|
||||
<option value="dz">德鲁伊 (Druid)</option>
|
||||
<option value="ai">刺客 (Assassin)</option>
|
||||
</select></label>
|
||||
<label>章节 <select id="act" disabled>
|
||||
<option value="1">第一章</option>
|
||||
<option value="2">第二章</option>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
/**
|
||||
* Bake all 7 Diablo II character classes into web-native indexed PNG and raw R8 atlases (Schema 2).
|
||||
*
|
||||
* Classes:
|
||||
* - so: Sorceress (法师 / 女巫)
|
||||
* - am: Amazon (亚马逊)
|
||||
* - ne: Necromancer (死灵法师)
|
||||
* - pa: Paladin (圣骑士)
|
||||
* - ba: Barbarian (野蛮人)
|
||||
* - dz: Druid (德鲁伊)
|
||||
* - ai: Assassin (刺客)
|
||||
*/
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||
import { loadCharacterSheet, type CharacterSheet } from '../src/game/character.ts'
|
||||
import type { SpriteSheet } from '../src/formats/sprite.ts'
|
||||
import { packSheetToR8, type PackedEntityR8 } from './pack-entity-assets.ts'
|
||||
import { encodeIndexedPng } from './png.ts'
|
||||
|
||||
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
|
||||
const PLAYER_MODES = ['wl', 'nu', 'rn', 'a1', 'a2', 'sc', 'gh', 'dt', 'dd', 'tn', 'tw'] as const
|
||||
const LOOPING_PLAYER_MODES = new Set(['nu', 'wl', 'rn', 'tn', 'tw'])
|
||||
|
||||
export const HERO_SPECS = [
|
||||
{ token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress' },
|
||||
{ token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon' },
|
||||
{ token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer' },
|
||||
{ token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin' },
|
||||
{ token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian' },
|
||||
{ token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid' },
|
||||
{ token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin' },
|
||||
] as const
|
||||
|
||||
export async function bakeAllCharacters(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise<void> {
|
||||
const t0 = performance.now()
|
||||
const archives = new MountedArchives()
|
||||
for (const name of MOUNTS) {
|
||||
try {
|
||||
const archive = await MpqArchive.open(await fileSource(join(archiveDir, name)))
|
||||
archives.add(name, archive)
|
||||
} catch (err) {
|
||||
console.warn(`skip ${name}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const animDataJsonPath = join(outDir, 'anim', 'animdata.json')
|
||||
if (!existsSync(animDataJsonPath)) {
|
||||
throw new Error(`AnimData JSON missing in ${join(outDir, 'anim')}`)
|
||||
}
|
||||
const animData = JSON.parse(readFileSync(animDataJsonPath, 'utf8'))
|
||||
|
||||
const globalEntityDir = join(outDir, 'entities')
|
||||
await mkdir(globalEntityDir, { recursive: true })
|
||||
|
||||
// Read palettes for Act 1..5
|
||||
const actPalettes: { act: number; palette: Uint8Array; dir: string }[] = []
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
try {
|
||||
const pl2 = decodePl2(await archives.read(`data\\global\\palette\\act${String(act)}\\pal.pl2`))
|
||||
const actEntityDir = join(outDir, `act${String(act)}`, 'entities')
|
||||
await mkdir(actEntityDir, { recursive: true })
|
||||
actPalettes.push({ act, palette: pl2.rgb, dir: actEntityDir })
|
||||
} catch (err) {
|
||||
console.warn(`Cannot read palette for act ${act}: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const spec of HERO_SPECS) {
|
||||
const { token, nameZh, nameEn } = spec
|
||||
const charT0 = performance.now()
|
||||
console.log(`Baking character: ${nameZh} (${nameEn}, token: ${token})...`)
|
||||
|
||||
const charSheets: CharacterSheet[] = []
|
||||
const charClips: Record<string, {
|
||||
group: number
|
||||
directions: number
|
||||
frames: number
|
||||
speed: number
|
||||
loop: boolean
|
||||
events: { f: number; c: number }[]
|
||||
}> = {}
|
||||
|
||||
let charGroupOffset = 0
|
||||
let totalCharMembers = 0
|
||||
|
||||
for (const mode of PLAYER_MODES) {
|
||||
let modeToLoad = mode
|
||||
let cofName = `${token.toUpperCase()}${mode.toUpperCase()}HTH`
|
||||
if (!animData.clips[cofName] && mode === 'a2') {
|
||||
modeToLoad = 'a1'
|
||||
cofName = `${token.toUpperCase()}A1HTH`
|
||||
}
|
||||
|
||||
const sheet = await loadCharacterSheet(archives, token, modeToLoad, 'hth')
|
||||
charSheets.push(sheet)
|
||||
totalCharMembers += sheet.members.length
|
||||
|
||||
const animRecord = animData.clips[cofName]
|
||||
const speed = animRecord?.speed ?? 256
|
||||
const events = (animRecord?.events ?? []).map(([f, c]: [number, number]) => ({ f, c }))
|
||||
charClips[mode] = {
|
||||
group: charGroupOffset,
|
||||
directions: sheet.directions,
|
||||
frames: sheet.framesPerDirection,
|
||||
speed,
|
||||
loop: LOOPING_PLAYER_MODES.has(mode),
|
||||
events,
|
||||
}
|
||||
charGroupOffset += sheet.sheet.groups.length
|
||||
}
|
||||
|
||||
const charJoined: SpriteSheet = {
|
||||
groups: charSheets.flatMap(s => s.sheet.groups),
|
||||
width: null,
|
||||
}
|
||||
|
||||
const packedCharR8 = packSheetToR8(charJoined)
|
||||
|
||||
// Save global R8 atlas
|
||||
await writeFile(join(globalEntityDir, `char-${token}.r8`), packedCharR8.r8)
|
||||
await writeFile(
|
||||
join(globalEntityDir, `char-${token}.json`),
|
||||
JSON.stringify({
|
||||
schema: 2,
|
||||
token,
|
||||
file: `char-${token}.r8`,
|
||||
format: 'r8',
|
||||
width: packedCharR8.width,
|
||||
height: packedCharR8.height,
|
||||
directions: charClips.wl?.directions ?? 16,
|
||||
walk: charClips.wl?.frames ?? 8,
|
||||
stand: charClips.nu?.frames ?? 8,
|
||||
walkOffset: 0,
|
||||
standOffset: charClips.nu?.group ?? 16,
|
||||
members: totalCharMembers,
|
||||
clips: charClips,
|
||||
groups: packedCharR8.groups,
|
||||
}, null, 2)
|
||||
)
|
||||
|
||||
// Save per-Act PNGs
|
||||
for (const { palette, dir } of actPalettes) {
|
||||
const charPng = encodeIndexedPng({
|
||||
width: packedCharR8.width,
|
||||
height: packedCharR8.height,
|
||||
pixels: packedCharR8.r8,
|
||||
palette,
|
||||
transparentIndex: 0,
|
||||
})
|
||||
const charPngName = `char-${token}.png`
|
||||
await writeFile(join(dir, charPngName), charPng)
|
||||
await writeFile(
|
||||
join(dir, `char-${token}.json`),
|
||||
JSON.stringify({
|
||||
schema: 2,
|
||||
token,
|
||||
file: charPngName,
|
||||
width: packedCharR8.width,
|
||||
height: packedCharR8.height,
|
||||
directions: charClips.wl?.directions ?? 16,
|
||||
clips: charClips,
|
||||
walk: charClips.wl?.group ?? 0,
|
||||
stand: charClips.nu?.group ?? 16,
|
||||
members: totalCharMembers,
|
||||
groups: packedCharR8.groups,
|
||||
}, null, 2)
|
||||
)
|
||||
}
|
||||
|
||||
console.log(` ✓ ${nameZh} (${token}) finished in ${(performance.now() - charT0).toFixed(0)} ms (atlas: ${packedCharR8.width}x${packedCharR8.height})`)
|
||||
}
|
||||
|
||||
console.log(`All 7 characters baked successfully in ${(performance.now() - t0).toFixed(0)} ms!`)
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
bakeAllCharacters().catch(err => {
|
||||
console.error('Failed to bake characters:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* scripts/verify-controlbar-browser.ts
|
||||
*
|
||||
* Headless Chrome browser verification of ControlBar XP bar rendering & hover interactions:
|
||||
* 1. Offscreen HTML Canvas Pixel Audit:
|
||||
* - Real OffscreenCanvas in Google Chrome.
|
||||
* - Renders 800CtrlPnl7 panel + ControlBarHud.
|
||||
* - Extracts real pixel data via getImageData().
|
||||
* - Verifies groove [256, 560, 120, 4] colors.
|
||||
* - Verifies gold trim borders at y=559 and y=564 have ZERO pixel bleed.
|
||||
* 2. Real DOM Mouse Event Projection across DPRs & Viewports:
|
||||
* - Viewports: 800x600, 1920x1080.
|
||||
* - DPRs: 1.0, 1.25, 1.5, 2.0.
|
||||
* - Inspects window.__d2webHudInstance.controlBar.hoveredElement.
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
async function main(): Promise<void> {
|
||||
console.log('=== Headless Chrome ControlBar Stress Test ===')
|
||||
|
||||
// 1. Start Vite dev server
|
||||
const server = await createServer({
|
||||
root: process.cwd(),
|
||||
server: { port: 0, host: '127.0.0.1' },
|
||||
})
|
||||
await server.listen()
|
||||
const address = server.httpServer?.address()
|
||||
const port = typeof address === 'object' && address ? address.port : 5173
|
||||
const baseUrl = `http://127.0.0.1:${port}`
|
||||
console.log(`1. Vite server running at ${baseUrl}`)
|
||||
|
||||
// 2. Launch headless Chrome with CDP
|
||||
const debugPort = 9248
|
||||
const chromeProc = spawn('/usr/bin/google-chrome', [
|
||||
'--headless=new',
|
||||
`--remote-debugging-port=${debugPort}`,
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--enable-webgl',
|
||||
'--window-size=1920,1080',
|
||||
`${baseUrl}/acts.html?act=1`,
|
||||
])
|
||||
|
||||
try {
|
||||
let wsUrl: string | null = null
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await sleep(300)
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
|
||||
const pages = (await res.json()) as { type: string; url: string; webSocketDebuggerUrl?: string }[]
|
||||
const page = pages.find(p => p.type === 'page' && p.url.includes('acts.html'))
|
||||
if (page?.webSocketDebuggerUrl) {
|
||||
wsUrl = page.webSocketDebuggerUrl
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!wsUrl) throw new Error('Failed to connect to Chrome CDP')
|
||||
console.log('2. Connected to Chrome CDP:', wsUrl)
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
await new Promise<void>(resolve => { ws.onopen = () => resolve() })
|
||||
|
||||
let idCounter = 1
|
||||
const sendCommand = <T = any>(method: string, params: Record<string, unknown> = {}): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = idCounter++
|
||||
const handler = (event: MessageEvent) => {
|
||||
const msg = JSON.parse(String(event.data))
|
||||
if (msg.id === id) {
|
||||
ws.removeEventListener('message', handler)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result as T)
|
||||
}
|
||||
}
|
||||
ws.addEventListener('message', handler)
|
||||
ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
|
||||
const evalJs = async <T = any>(expression: string): Promise<T> => {
|
||||
const res = await sendCommand<{ result: { value: T }; exceptionDetails?: any }>('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
})
|
||||
if (res.exceptionDetails) {
|
||||
throw new Error(`JS exception: ${JSON.stringify(res.exceptionDetails)}`)
|
||||
}
|
||||
return res.result.value
|
||||
}
|
||||
|
||||
await sendCommand('Runtime.enable')
|
||||
await sendCommand('Page.enable')
|
||||
|
||||
// Wait for HudManager to be ready
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const ready = await evalJs<boolean>(`Boolean(window.__d2webHud?.ready && window.__d2webHudInstance)`)
|
||||
if (ready) break
|
||||
await sleep(300)
|
||||
}
|
||||
|
||||
console.log('3. HudManager ready in Chrome!')
|
||||
|
||||
// Test 1: Real OffscreenCanvas rendering & pixel audit inside Chrome
|
||||
console.log('\n--- Test 1: Chrome OffscreenCanvas Pixel Audit ---')
|
||||
const pixelAuditResult = await evalJs<{
|
||||
bleedTop559Count: number
|
||||
bleedBot564Count: number
|
||||
grooveBackgroundRgb: number[]
|
||||
grooveFilledRgb: number[]
|
||||
tickNotchRgb: number[]
|
||||
tickPositions: number[]
|
||||
}>(`(() => {
|
||||
const hudInst = window.__d2webHudInstance;
|
||||
const ctrlBar = hudInst.controlBar;
|
||||
const ctrlImg = hudInst.images.get('ctrlPnl');
|
||||
|
||||
// Create base OffscreenCanvas with only background image
|
||||
const baseCanvas = new OffscreenCanvas(800, 600);
|
||||
const baseCtx = baseCanvas.getContext('2d');
|
||||
baseCtx.drawImage(ctrlImg, 0, 496);
|
||||
const baseImgData = baseCtx.getImageData(0, 0, 800, 600).data;
|
||||
|
||||
// Create test OffscreenCanvas and render ControlBar with 50% XP
|
||||
const testCanvas = new OffscreenCanvas(800, 600);
|
||||
const testCtx = testCanvas.getContext('2d');
|
||||
const testState = {
|
||||
level: 1,
|
||||
xp: 1500,
|
||||
currentLevelXp: 1000,
|
||||
nextLevelXp: 2000,
|
||||
stamina: 100,
|
||||
maxStamina: 100,
|
||||
isRunning: false,
|
||||
unspentStatPoints: 0,
|
||||
unspentSkillPoints: 0,
|
||||
};
|
||||
|
||||
const assetBundle = {
|
||||
ctrlPnlImg: hudInst.images.get('ctrlPnl') ?? null,
|
||||
runBtnImg: hudInst.images.get('runbutton') ?? null,
|
||||
menuBtnImg: hudInst.images.get('menubutton') ?? null,
|
||||
miniPanelImg: hudInst.images.get('minipanel') ?? null,
|
||||
miniPanelBtnsImg: hudInst.images.get('minipanelBtns') ?? null,
|
||||
levelBtnImg: hudInst.images.get('levelBtn') ?? null,
|
||||
levelSocketImg: hudInst.images.get('levelSocket') ?? null,
|
||||
};
|
||||
|
||||
ctrlBar.draw(testCtx, testState, assetBundle, hudInst.font);
|
||||
const testImgData = testCtx.getImageData(0, 0, 800, 600).data;
|
||||
|
||||
let bleedTop559Count = 0;
|
||||
let bleedBot564Count = 0;
|
||||
|
||||
// Check row y=559 for x in [256..375]
|
||||
for (let x = 256; x <= 375; x++) {
|
||||
const idx = (559 * 800 + x) * 4;
|
||||
if (
|
||||
testImgData[idx] !== baseImgData[idx] ||
|
||||
testImgData[idx + 1] !== baseImgData[idx + 1] ||
|
||||
testImgData[idx + 2] !== baseImgData[idx + 2]
|
||||
) {
|
||||
bleedTop559Count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check row y=564 for x in [256..375]
|
||||
for (let x = 256; x <= 375; x++) {
|
||||
const idx = (564 * 800 + x) * 4;
|
||||
if (
|
||||
testImgData[idx] !== baseImgData[idx] ||
|
||||
testImgData[idx + 1] !== baseImgData[idx + 1] ||
|
||||
testImgData[idx + 2] !== baseImgData[idx + 2]
|
||||
) {
|
||||
bleedBot564Count++;
|
||||
}
|
||||
}
|
||||
|
||||
// Sample groove pixels
|
||||
// Filled area (x=260, y=561)
|
||||
const filledIdx = (561 * 800 + 260) * 4;
|
||||
const grooveFilledRgb = [testImgData[filledIdx], testImgData[filledIdx + 1], testImgData[filledIdx + 2]];
|
||||
|
||||
// Unfilled area (x=350, y=561)
|
||||
const unfilledIdx = (561 * 800 + 350) * 4;
|
||||
const grooveBackgroundRgb = [testImgData[unfilledIdx], testImgData[unfilledIdx + 1], testImgData[unfilledIdx + 2]];
|
||||
|
||||
// Tick notch at x=268, y=561
|
||||
const notchIdx = (561 * 800 + 268) * 4;
|
||||
const tickNotchRgb = [testImgData[notchIdx], testImgData[notchIdx + 1], testImgData[notchIdx + 2]];
|
||||
|
||||
// Verify all 9 ticks
|
||||
const tickPositions = [];
|
||||
for (let i = 1; i < 10; i++) {
|
||||
const tx = 256 + i * 12;
|
||||
const tidx = (561 * 800 + tx) * 4;
|
||||
tickPositions.push(testImgData[tidx]);
|
||||
}
|
||||
|
||||
return {
|
||||
bleedTop559Count,
|
||||
bleedBot564Count,
|
||||
grooveBackgroundRgb,
|
||||
grooveFilledRgb,
|
||||
tickNotchRgb,
|
||||
tickPositions,
|
||||
};
|
||||
})()`)
|
||||
|
||||
console.log('Pixel audit result from Chrome:', JSON.stringify(pixelAuditResult, null, 2))
|
||||
|
||||
// Test 2: Hover Hit-Testing in real Chrome across DPRs & Resolutions
|
||||
console.log('\n--- Test 2: Hover Hit-Testing in Chrome across DPRs & Resolutions ---')
|
||||
const resolutions = [
|
||||
{ w: 800, h: 600 },
|
||||
{ w: 1920, h: 1080 },
|
||||
]
|
||||
const dprs = [1.0, 1.25, 1.5, 2.0]
|
||||
|
||||
for (const res of resolutions) {
|
||||
for (const dpr of dprs) {
|
||||
// Set emulation device metrics
|
||||
await sendCommand('Emulation.setDeviceMetricsOverride', {
|
||||
width: res.w,
|
||||
height: res.h,
|
||||
deviceScaleFactor: dpr,
|
||||
mobile: false,
|
||||
})
|
||||
await sleep(100)
|
||||
|
||||
// Evaluate hover at key points
|
||||
const hoverResult = await evalJs<{
|
||||
centerHover: string | null
|
||||
outsideHover: string | null
|
||||
leftEdgeHover: string | null
|
||||
rightEdgeHover: string | null
|
||||
}>(`(() => {
|
||||
const inst = window.__d2webHudInstance;
|
||||
const rect = inst.hudCanvas.getBoundingClientRect();
|
||||
|
||||
// Calculate client coords for center
|
||||
const scale = Math.min(rect.width / 800, rect.height / 600);
|
||||
const offX = Math.max(0, (rect.width - 800 * scale) / 2);
|
||||
const offY = Math.max(0, rect.height - 600 * scale);
|
||||
|
||||
const clientCenterX = 316 * scale + offX;
|
||||
const clientCenterY = 562 * scale + offY;
|
||||
const mappedCenter = inst.clientToLogical(clientCenterX, clientCenterY);
|
||||
inst.controlBar.handleMouseMove(mappedCenter.x, mappedCenter.y);
|
||||
const centerHover = inst.controlBar.hoveredElement;
|
||||
|
||||
// Test point outside (above groove)
|
||||
const clientOutsideY = 550 * scale + offY;
|
||||
const mappedOutside = inst.clientToLogical(clientCenterX, clientOutsideY);
|
||||
inst.controlBar.handleMouseMove(mappedOutside.x, mappedOutside.y);
|
||||
const outsideHover = inst.controlBar.hoveredElement;
|
||||
|
||||
// Left edge (x = 256.5 to avoid IEEE 754 epsilon boundary)
|
||||
const clientLeftX = 256.5 * scale + offX;
|
||||
const mappedLeft = inst.clientToLogical(clientLeftX, clientCenterY);
|
||||
inst.controlBar.handleMouseMove(mappedLeft.x, mappedLeft.y);
|
||||
const leftEdgeHover = inst.controlBar.hoveredElement;
|
||||
|
||||
// Right edge (x = 375.5)
|
||||
const clientRightX = 375.5 * scale + offX;
|
||||
const mappedRight = inst.clientToLogical(clientRightX, clientCenterY);
|
||||
inst.controlBar.handleMouseMove(mappedRight.x, mappedRight.y);
|
||||
const rightEdgeHover = inst.controlBar.hoveredElement;
|
||||
|
||||
return {
|
||||
centerHover,
|
||||
outsideHover,
|
||||
leftEdgeHover,
|
||||
rightEdgeHover,
|
||||
};
|
||||
})()`)
|
||||
|
||||
console.log(`Resolution ${res.w}x${res.h} @ DPR ${dpr}:`, hoverResult)
|
||||
}
|
||||
}
|
||||
|
||||
} finally {
|
||||
chromeProc.kill('SIGKILL')
|
||||
await server.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Empirical verification harness for Milestone M1 (Issue #146: ControlBar XP bar alignment & 10-segment ticks).
|
||||
*
|
||||
* Runs headless verification of:
|
||||
* 1. Geometry of the 9 divider ticks: strictly at x = 256 + 12*i, height 4.
|
||||
* 2. Hit-testing boundary edge cases: (256, 560), (375, 563), (255, 560), (376, 560), (256, 557), (256, 558), (256, 566), (256, 567).
|
||||
* 3. All XP values from 0.0 to 1.0 (0%, 5%, 10%, 12.5%, 50%, 99.9%, 100%, negative, >1.0 overflow).
|
||||
*/
|
||||
|
||||
import {
|
||||
ControlBarHud,
|
||||
EXP_BAR_BOUNDS,
|
||||
EXP_BAR_HOVER_BOUNDS,
|
||||
type ControlBarState,
|
||||
} from '../src/ui/control-bar.ts'
|
||||
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
interface DrawCall {
|
||||
fillStyle: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
function createMockCtx(): { ctx: CanvasRenderingContext2D; rects: DrawCall[] } {
|
||||
const rects: DrawCall[] = []
|
||||
let currentFillStyle = '#000000'
|
||||
|
||||
const ctx = {
|
||||
get fillStyle() {
|
||||
return currentFillStyle
|
||||
},
|
||||
set fillStyle(val: any) {
|
||||
currentFillStyle = String(val)
|
||||
},
|
||||
fillRect(x: number, y: number, w: number, h: number) {
|
||||
rects.push({ fillStyle: currentFillStyle, x, y, w, h })
|
||||
},
|
||||
createLinearGradient() {
|
||||
return { addColorStop: () => {} }
|
||||
},
|
||||
drawImage() {},
|
||||
strokeRect() {},
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
return { ctx, rects }
|
||||
}
|
||||
|
||||
function createDummyState(xp: number, cur: number, next: number): ControlBarState {
|
||||
return {
|
||||
level: 85,
|
||||
xp,
|
||||
currentLevelXp: cur,
|
||||
nextLevelXp: next,
|
||||
stamina: 465,
|
||||
maxStamina: 465,
|
||||
isRunning: true,
|
||||
unspentStatPoints: 0,
|
||||
unspentSkillPoints: 0,
|
||||
}
|
||||
}
|
||||
|
||||
async function runEmpiricalVerification() {
|
||||
console.log('=== [M1 EMPIRICAL VERIFICATION HARNESS: ISSUE #146] ===\n')
|
||||
|
||||
let allPassed = true
|
||||
function assert(condition: boolean, msg: string) {
|
||||
if (condition) {
|
||||
console.log(` [PASS] ${msg}`)
|
||||
} else {
|
||||
console.error(` [FAIL] ${msg}`)
|
||||
allPassed = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- SECTION 1: GEOMETRY & BOUNDS ---
|
||||
console.log('1. Checking EXP_BAR_BOUNDS & EXP_BAR_HOVER_BOUNDS:')
|
||||
assert(EXP_BAR_BOUNDS.x === 256, `EXP_BAR_BOUNDS.x === 256 (actual: ${EXP_BAR_BOUNDS.x})`)
|
||||
assert(EXP_BAR_BOUNDS.y === 560, `EXP_BAR_BOUNDS.y === 560 (actual: ${EXP_BAR_BOUNDS.y})`)
|
||||
assert(EXP_BAR_BOUNDS.width === 120, `EXP_BAR_BOUNDS.width === 120 (actual: ${EXP_BAR_BOUNDS.width})`)
|
||||
assert(EXP_BAR_BOUNDS.height === 4, `EXP_BAR_BOUNDS.height === 4 (actual: ${EXP_BAR_BOUNDS.height})`)
|
||||
|
||||
assert(EXP_BAR_HOVER_BOUNDS.x === 256, `EXP_BAR_HOVER_BOUNDS.x === 256 (actual: ${EXP_BAR_HOVER_BOUNDS.x})`)
|
||||
assert(EXP_BAR_HOVER_BOUNDS.y === 558, `EXP_BAR_HOVER_BOUNDS.y === 558 (actual: ${EXP_BAR_HOVER_BOUNDS.y})`)
|
||||
assert(EXP_BAR_HOVER_BOUNDS.width === 120, `EXP_BAR_HOVER_BOUNDS.width === 120 (actual: ${EXP_BAR_HOVER_BOUNDS.width})`)
|
||||
assert(EXP_BAR_HOVER_BOUNDS.height === 8, `EXP_BAR_HOVER_BOUNDS.height === 8 (actual: ${EXP_BAR_HOVER_BOUNDS.height})`)
|
||||
|
||||
// --- SECTION 2: 9 DIVIDER TICKS GEOMETRY ---
|
||||
console.log('\n2. Checking 9 Divider Ticks Geometry (strictly at x = 256 + 12*i, height 4):')
|
||||
const bar = new ControlBarHud()
|
||||
const { ctx: ctxTicks, rects: rectsTicks } = createMockCtx()
|
||||
bar.draw(ctxTicks, createDummyState(500, 0, 1000), {
|
||||
ctrlPnlImg: null,
|
||||
runBtnImg: null,
|
||||
menuBtnImg: null,
|
||||
miniPanelImg: null,
|
||||
miniPanelBtnsImg: null,
|
||||
levelBtnImg: null,
|
||||
levelSocketImg: null,
|
||||
}, new D2FontRenderer())
|
||||
|
||||
const tickRects = rectsTicks.filter(r => r.fillStyle === '#1e1812' && r.y === 560 && r.w === 1 && r.h === 4)
|
||||
assert(tickRects.length === 9, `Rendered exactly 9 divider ticks (actual: ${tickRects.length})`)
|
||||
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const expectedX = 256 + 12 * i
|
||||
const tick = tickRects[i - 1]
|
||||
const matches = tick && tick.x === expectedX && tick.y === 560 && tick.w === 1 && tick.h === 4
|
||||
assert(!!matches, `Tick #${i}: x=${expectedX}, y=560, w=1, h=4 (actual: ${JSON.stringify(tick)})`)
|
||||
}
|
||||
|
||||
// --- SECTION 3: HIT-TESTING BOUNDARY EDGE CASES ---
|
||||
console.log('\n3. Checking Hit-Testing Boundary Edge Cases:')
|
||||
const hitCases = [
|
||||
{ x: 256, y: 560, expected: 'exp', desc: '(256, 560) Top-left of inner groove' },
|
||||
{ x: 375, y: 563, expected: 'exp', desc: '(375, 563) Bottom-right interior of groove' },
|
||||
{ x: 255, y: 560, expected: null, desc: '(255, 560) 1px left of groove' },
|
||||
{ x: 376, y: 560, expected: 'exp', desc: '(376, 560) Right edge boundary (x = 256+120)' },
|
||||
{ x: 256, y: 557, expected: null, desc: '(256, 557) 1px above padded hover bounds' },
|
||||
{ x: 256, y: 558, expected: 'exp', desc: '(256, 558) Top edge of padded hover bounds' },
|
||||
{ x: 256, y: 566, expected: 'exp', desc: '(256, 566) Bottom edge of padded hover bounds' },
|
||||
{ x: 256, y: 567, expected: null, desc: '(256, 567) 1px below padded hover bounds' },
|
||||
]
|
||||
|
||||
for (const hc of hitCases) {
|
||||
bar.handleMouseMove(hc.x, hc.y)
|
||||
const actual = bar.hoveredElement
|
||||
assert(
|
||||
actual === hc.expected,
|
||||
`${hc.desc} -> expected: ${hc.expected}, actual: ${actual}`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- SECTION 4: XP RENDERING & VALUE SWEEPS ---
|
||||
console.log('\n4. Checking XP Fill Widths for Various XP Values (0.0 to 1.0, negative, overflow):')
|
||||
const xpCases = [
|
||||
{ label: '0% (0.0)', xp: 0, cur: 0, next: 1000, expectedW: 0 },
|
||||
{ label: '5% (0.05)', xp: 50, cur: 0, next: 1000, expectedW: 6 },
|
||||
{ label: '10% (0.10)', xp: 100, cur: 0, next: 1000, expectedW: 12 },
|
||||
{ label: '12.5% (0.125)', xp: 125, cur: 0, next: 1000, expectedW: 15 },
|
||||
{ label: '50% (0.50)', xp: 500, cur: 0, next: 1000, expectedW: 60 },
|
||||
{ label: '99.9% (0.999)', xp: 999, cur: 0, next: 1000, expectedW: 120 },
|
||||
{ label: '100% (1.0)', xp: 1000, cur: 0, next: 1000, expectedW: 120 },
|
||||
{ label: 'Negative XP (-500)', xp: -500, cur: 0, next: 1000, expectedW: 0 },
|
||||
{ label: 'XP below currentLevelXp', xp: 200, cur: 300, next: 1000, expectedW: 0 },
|
||||
{ label: 'XP overflow (>1.0: 1500)', xp: 1500, cur: 0, next: 1000, expectedW: 120 },
|
||||
{ label: 'Massive XP overflow (10000000)', xp: 10000000, cur: 0, next: 1000, expectedW: 120 },
|
||||
]
|
||||
|
||||
for (const xc of xpCases) {
|
||||
const { ctx, rects } = createMockCtx()
|
||||
bar.draw(ctx, createDummyState(xc.xp, xc.cur, xc.next), {
|
||||
ctrlPnlImg: null,
|
||||
runBtnImg: null,
|
||||
menuBtnImg: null,
|
||||
miniPanelImg: null,
|
||||
miniPanelBtnsImg: null,
|
||||
levelBtnImg: null,
|
||||
levelSocketImg: null,
|
||||
}, new D2FontRenderer())
|
||||
|
||||
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === 560)
|
||||
const actualW = fillRect ? fillRect.w : -1
|
||||
assert(
|
||||
actualW === xc.expectedW,
|
||||
`${xc.label}: expected width ${xc.expectedW}, actual width ${actualW}`,
|
||||
)
|
||||
}
|
||||
|
||||
// --- SECTION 5: DIFFERENTIAL FUZZING ---
|
||||
console.log('\n5. Differential Fuzzing Across 1,000 Step Fractions:')
|
||||
let fuzzerFailures = 0
|
||||
for (let i = 0; i <= 1000; i++) {
|
||||
const fraction = i / 1000
|
||||
const xp = fraction * 50000
|
||||
const { ctx, rects } = createMockCtx()
|
||||
bar.draw(ctx, createDummyState(xp, 0, 50000), {
|
||||
ctrlPnlImg: null,
|
||||
runBtnImg: null,
|
||||
menuBtnImg: null,
|
||||
miniPanelImg: null,
|
||||
miniPanelBtnsImg: null,
|
||||
levelBtnImg: null,
|
||||
levelSocketImg: null,
|
||||
}, new D2FontRenderer())
|
||||
|
||||
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === 560)
|
||||
const expectedW = Math.round(120 * fraction)
|
||||
if (!fillRect || fillRect.w !== expectedW || fillRect.w < 0 || fillRect.w > 120) {
|
||||
fuzzerFailures++
|
||||
}
|
||||
}
|
||||
assert(fuzzerFailures === 0, `1,000 differential fuzz iterations had 0 mismatches`)
|
||||
|
||||
console.log(`\n=== FINAL VERDICT: ${allPassed ? 'ALL CHECKS PASSED (APPROVE)' : 'FAILED (REJECT)'} ===\n`)
|
||||
if (!allPassed) {
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
runEmpiricalVerification().catch(err => {
|
||||
console.error('Unhandled verification error:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -128,9 +128,13 @@ export async function loadCharacterSheet(
|
|||
if (component === undefined) {
|
||||
return { sprite: null, member: undefined, note: `layer type ${String(layer.type)} has no component directory` }
|
||||
}
|
||||
const member = findLayerSprite(names, root, lower, animation, weapon, component)
|
||||
const layerWeapon = (layer.weaponClass && layer.weaponClass.trim().length > 0) ? layer.weaponClass.trim() : weapon
|
||||
let member = findLayerSprite(names, root, lower, animation, layerWeapon, component)
|
||||
if (member === undefined && layerWeapon !== weapon) {
|
||||
member = findLayerSprite(names, root, lower, animation, weapon, component)
|
||||
}
|
||||
if (member === undefined) {
|
||||
return { sprite: null, member: undefined, note: `no sprite for component ${component} (${animation}${weapon})` }
|
||||
return { sprite: null, member: undefined, note: `no sprite for component ${component} (${animation}${layerWeapon})` }
|
||||
}
|
||||
try {
|
||||
const dcc = decodeDcc(await archives.read(member))
|
||||
|
|
|
|||
|
|
@ -239,6 +239,10 @@ export interface ActSceneState {
|
|||
characterFrames: number
|
||||
/** Last group the character path selected (walk or stand, times 8 directions). */
|
||||
characterGroup: number
|
||||
/** Active hero class token (e.g. 'so', 'am', 'ne', 'pa', 'ba', 'dz', 'ai'). */
|
||||
heroToken?: string
|
||||
/** Active hero class English name (e.g. 'Sorceress', 'Barbarian'). */
|
||||
heroName?: string
|
||||
/** Bounding box of the player sprite drawn this frame in scene coordinates. */
|
||||
playerDrawRect?: { x: number; y: number; w: number; h: number } | undefined
|
||||
/** Equipped or active weapon class code (e.g. 'hth', '1hs', '2hs'). */
|
||||
|
|
@ -311,14 +315,114 @@ declare global {
|
|||
interface Window {
|
||||
/** Act scene state for automation and debugging. */
|
||||
__d2webAct?: ActSceneState
|
||||
__d2webActScene?: {
|
||||
switchHero: (token: string) => Promise<boolean>
|
||||
getHero: () => string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface HeroClassInfo {
|
||||
readonly token: string
|
||||
readonly code: string
|
||||
readonly nameZh: string
|
||||
readonly nameEn: string
|
||||
readonly defaultLeftSkill: number
|
||||
readonly defaultRightSkill: number
|
||||
readonly charIdentity: {
|
||||
readonly name: string
|
||||
readonly classNameZh: string
|
||||
readonly classNameEn: string
|
||||
readonly str: number
|
||||
readonly dex: number
|
||||
readonly vit: number
|
||||
readonly ene: number
|
||||
readonly baseMaxHp: number
|
||||
readonly baseMaxMana: number
|
||||
}
|
||||
}
|
||||
|
||||
export const HERO_CLASSES: Record<string, HeroClassInfo> = {
|
||||
so: {
|
||||
token: 'so', code: 'sor', nameZh: '法师', nameEn: 'Sorceress',
|
||||
defaultLeftSkill: 47, defaultRightSkill: 64,
|
||||
charIdentity: {
|
||||
name: 'Isendra', classNameZh: '女法师', classNameEn: 'Sorceress',
|
||||
str: 156, dex: 75, vit: 315, ene: 95, baseMaxHp: 850, baseMaxMana: 620,
|
||||
},
|
||||
},
|
||||
am: {
|
||||
token: 'am', code: 'ama', nameZh: '亚马逊', nameEn: 'Amazon',
|
||||
defaultLeftSkill: 6, defaultRightSkill: 7,
|
||||
charIdentity: {
|
||||
name: 'Cassia', classNameZh: '亚马逊', classNameEn: 'Amazon',
|
||||
str: 135, dex: 210, vit: 250, ene: 50, baseMaxHp: 920, baseMaxMana: 320,
|
||||
},
|
||||
},
|
||||
ne: {
|
||||
token: 'ne', code: 'nec', nameZh: '死灵法师', nameEn: 'Necromancer',
|
||||
defaultLeftSkill: 67, defaultRightSkill: 70,
|
||||
charIdentity: {
|
||||
name: 'Xul', classNameZh: '死灵法师', classNameEn: 'Necromancer',
|
||||
str: 120, dex: 80, vit: 290, ene: 120, baseMaxHp: 780, baseMaxMana: 580,
|
||||
},
|
||||
},
|
||||
pa: {
|
||||
token: 'pa', code: 'pal', nameZh: '圣骑士', nameEn: 'Paladin',
|
||||
defaultLeftSkill: 96, defaultRightSkill: 112,
|
||||
charIdentity: {
|
||||
name: 'Cartographus', classNameZh: '圣骑士', classNameEn: 'Paladin',
|
||||
str: 175, dex: 120, vit: 340, ene: 45, baseMaxHp: 1150, baseMaxMana: 380,
|
||||
},
|
||||
},
|
||||
ba: {
|
||||
token: 'ba', code: 'bar', nameZh: '野蛮人', nameEn: 'Barbarian',
|
||||
defaultLeftSkill: 126, defaultRightSkill: 133,
|
||||
charIdentity: {
|
||||
name: 'Sonya', classNameZh: '野蛮人', classNameEn: 'Barbarian',
|
||||
str: 220, dex: 110, vit: 380, ene: 30, baseMaxHp: 1420, baseMaxMana: 260,
|
||||
},
|
||||
},
|
||||
dz: {
|
||||
token: 'dz', code: 'dru', nameZh: '德鲁伊', nameEn: 'Druid',
|
||||
defaultLeftSkill: 225, defaultRightSkill: 229,
|
||||
charIdentity: {
|
||||
name: 'Loreov', classNameZh: '德鲁伊', classNameEn: 'Druid',
|
||||
str: 165, dex: 90, vit: 330, ene: 70, baseMaxHp: 1050, baseMaxMana: 420,
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
token: 'ai', code: 'ass', nameZh: '刺客', nameEn: 'Assassin',
|
||||
defaultLeftSkill: 254, defaultRightSkill: 251,
|
||||
charIdentity: {
|
||||
name: 'Natalya', classNameZh: '刺客', classNameEn: 'Assassin',
|
||||
str: 150, dex: 180, vit: 280, ene: 60, baseMaxHp: 960, baseMaxMana: 360,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeHeroToken(input: string | null | undefined): string {
|
||||
if (!input) return 'so'
|
||||
const lower = input.toLowerCase().trim()
|
||||
if (HERO_CLASSES[lower]) return lower
|
||||
const codeMap: Record<string, string> = {
|
||||
sor: 'so', sorceress: 'so',
|
||||
ama: 'am', amazon: 'am',
|
||||
nec: 'ne', necromancer: 'ne',
|
||||
pal: 'pa', paladin: 'pa',
|
||||
bar: 'ba', barbarian: 'ba',
|
||||
dru: 'dz', druid: 'dz', dr: 'dz',
|
||||
ass: 'ai', assassin: 'ai', as: 'ai',
|
||||
}
|
||||
return codeMap[lower] ?? 'so'
|
||||
}
|
||||
|
||||
const state: ActSceneState = {
|
||||
ready: false, source: '', act: 1, level: '', variant: '', variants: [], quadrant: '', quadrants: [], base: '', roofs: 0, roofAlpha: 1,
|
||||
cellsX: 0, cellsY: 0, widthPx: 0, heightPx: 0, floors: 0, walls: 0, frames: 0,
|
||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||
heroToken: 'so', heroName: 'Sorceress',
|
||||
playerDrawRect: undefined, weaponClass: 'hth', playerClip: 'nu',
|
||||
skippedDraws: 0, objects: 0, loadMs: 0,
|
||||
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
|
||||
|
|
@ -2083,17 +2187,19 @@ async function loadMonsterArtMap(
|
|||
|
||||
async function loadCharacterArt(
|
||||
_bases: readonly string[],
|
||||
_palette: Palette,
|
||||
_palette: Palette | null,
|
||||
renderer: SpriteRenderer,
|
||||
packBase?: string,
|
||||
act?: number,
|
||||
token = 'so',
|
||||
): Promise<LoadedCharacter | null> {
|
||||
const heroToken = normalizeHeroToken(token)
|
||||
if (packBase !== undefined) {
|
||||
// 1. Prefer global act-independent raw 8-bit (.r8) index texture
|
||||
try {
|
||||
const [jsonResp, r8Resp] = await Promise.all([
|
||||
fetch(`${packBase}/entities/char-so.json`),
|
||||
fetch(`${packBase}/entities/char-so.r8`),
|
||||
fetch(`${packBase}/entities/char-${heroToken}.json`),
|
||||
fetch(`${packBase}/entities/char-${heroToken}.r8`),
|
||||
])
|
||||
if (jsonResp.ok && r8Resp.ok) {
|
||||
const meta = (await jsonResp.json()) as PackedCharJson
|
||||
|
|
@ -2125,8 +2231,8 @@ async function loadCharacterArt(
|
|||
const entitiesUrl = `${packBase}/act${String(act)}/entities`
|
||||
try {
|
||||
const [jsonResp, pngResp] = await Promise.all([
|
||||
fetch(`${entitiesUrl}/char-so.json`),
|
||||
fetch(`${entitiesUrl}/char-so.png`),
|
||||
fetch(`${entitiesUrl}/char-${heroToken}.json`),
|
||||
fetch(`${entitiesUrl}/char-${heroToken}.png`),
|
||||
])
|
||||
if (jsonResp.ok && pngResp.ok) {
|
||||
const meta = (await jsonResp.json()) as PackedCharJson
|
||||
|
|
@ -2160,7 +2266,12 @@ async function loadCharacterArt(
|
|||
}
|
||||
}
|
||||
|
||||
console.warn('character art unavailable in pre-baked packs')
|
||||
// Fallback to 'so' if requested hero failed
|
||||
if (heroToken !== 'so') {
|
||||
return loadCharacterArt(_bases, _palette, renderer, packBase, act, 'so')
|
||||
}
|
||||
|
||||
console.warn(`character art unavailable for ${heroToken} in pre-baked packs`)
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -2298,6 +2409,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
||||
const status = document.querySelector<HTMLElement>('#status')!
|
||||
const hud = document.querySelector<HTMLElement>('#hud')!
|
||||
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
|
||||
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
|
||||
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
|
||||
|
||||
|
|
@ -2305,10 +2417,102 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
input.attach()
|
||||
let skippedDraws = 0
|
||||
|
||||
let currentHeroToken = normalizeHeroToken(param('hero', param('class', 'so')))
|
||||
state.heroToken = currentHeroToken
|
||||
state.heroName = HERO_CLASSES[currentHeroToken]?.nameEn ?? 'Sorceress'
|
||||
|
||||
let activePalette: Palette | null = runtime.palette
|
||||
let packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||||
let packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||||
|
||||
let character: LoadedCharacter | null = null
|
||||
let monsterArtMap = new Map<string, LoadedMonsterArt>()
|
||||
let monsterWalkFrame = 0
|
||||
const playerAnimator = new ActorAnimator()
|
||||
|
||||
const switchHero = async (newHeroToken: string): Promise<boolean> => {
|
||||
const nextToken = normalizeHeroToken(newHeroToken)
|
||||
if (nextToken === currentHeroToken && character !== null) return true
|
||||
const nextInfo = HERO_CLASSES[nextToken] ?? HERO_CLASSES.so
|
||||
status.textContent = `正在切换角色至 ${nextInfo.nameZh} …`
|
||||
const nextChar = await loadCharacterArt(
|
||||
runtime.charBases,
|
||||
activePalette,
|
||||
renderer,
|
||||
packEntityBase,
|
||||
packEntityAct,
|
||||
nextToken,
|
||||
)
|
||||
if (nextChar !== null) {
|
||||
if (character?.handle) {
|
||||
renderer.deleteAtlas(character.handle)
|
||||
}
|
||||
character = nextChar
|
||||
currentHeroToken = nextToken
|
||||
state.heroToken = nextToken
|
||||
state.heroName = nextInfo.nameEn
|
||||
state.character = true
|
||||
state.characterMembers = nextChar.members
|
||||
if (hudManager !== null) {
|
||||
if (hudManager.charSheet) {
|
||||
hudManager.charSheet.attrs.name = nextInfo.charIdentity.name
|
||||
hudManager.charSheet.attrs.classNameZh = nextInfo.charIdentity.classNameZh
|
||||
hudManager.charSheet.attrs.classNameEn = nextInfo.charIdentity.classNameEn
|
||||
hudManager.charSheet.attrs.str = nextInfo.charIdentity.str
|
||||
hudManager.charSheet.attrs.dex = nextInfo.charIdentity.dex
|
||||
hudManager.charSheet.attrs.vit = nextInfo.charIdentity.vit
|
||||
hudManager.charSheet.attrs.ene = nextInfo.charIdentity.ene
|
||||
hudManager.charSheet.attrs.baseMaxHp = nextInfo.charIdentity.baseMaxHp
|
||||
hudManager.charSheet.attrs.baseMaxMana = nextInfo.charIdentity.baseMaxMana
|
||||
}
|
||||
hudManager.maxHp = nextInfo.charIdentity.baseMaxHp
|
||||
hudManager.hp = nextInfo.charIdentity.baseMaxHp
|
||||
hudManager.maxMana = nextInfo.charIdentity.baseMaxMana
|
||||
hudManager.mana = nextInfo.charIdentity.baseMaxMana
|
||||
hudManager.hotkeys.leftSkillId = nextInfo.defaultLeftSkill
|
||||
hudManager.hotkeys.rightSkillId = nextInfo.defaultRightSkill
|
||||
hudManager.syncPublishedState()
|
||||
}
|
||||
if (heroSelect !== null && heroSelect.value !== nextToken) {
|
||||
heroSelect.value = nextToken
|
||||
}
|
||||
try {
|
||||
const url = new URL(location.href)
|
||||
url.searchParams.set('hero', nextToken)
|
||||
history.replaceState(null, '', url.toString())
|
||||
} catch {
|
||||
// ignore in non-browser context
|
||||
}
|
||||
if (nextChar.clips?.nu) {
|
||||
playerAnimator.play('nu', nextChar.clips.nu, { restart: true })
|
||||
}
|
||||
status.textContent = `已切换角色为 ${nextInfo.nameZh}(${String(nextChar.members)} 层素材)。`
|
||||
setTimeout(() => {
|
||||
if (status.textContent.startsWith('已切换角色为')) {
|
||||
status.textContent = ''
|
||||
}
|
||||
}, 3000)
|
||||
return true
|
||||
}
|
||||
status.textContent = `切换角色 ${nextInfo.nameZh} 失败。`
|
||||
return false
|
||||
}
|
||||
|
||||
if (heroSelect !== null) {
|
||||
heroSelect.disabled = false
|
||||
heroSelect.value = currentHeroToken
|
||||
heroSelect.onchange = async () => {
|
||||
await switchHero(heroSelect.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__d2webActScene = {
|
||||
switchHero,
|
||||
getHero: () => currentHeroToken,
|
||||
}
|
||||
;(state as any).switchHero = switchHero
|
||||
}
|
||||
const monsterAnimators = new Map<number, ActorAnimator>()
|
||||
const camera = new ViewportCamera(canvas, runtime.widthPx, runtime.heightPx)
|
||||
camera.attach()
|
||||
|
|
@ -2627,16 +2831,16 @@ if (typeof window !== 'undefined') {
|
|||
|
||||
if (next.palette !== null) {
|
||||
renderer.setPalette(next.palette.rgb)
|
||||
const paletteForEntities = next.palette
|
||||
const packEntityBase = next.source === 'pack' ? next.base : undefined
|
||||
const packEntityAct = next.source === 'pack' ? next.act : undefined
|
||||
activePalette = next.palette
|
||||
packEntityBase = next.source === 'pack' ? next.base : undefined
|
||||
packEntityAct = next.source === 'pack' ? next.act : undefined
|
||||
const packMonsterIds = next.monsterPacks.flatMap(p => p.members.map(m => m.id))
|
||||
const activeMonsterIds = Array.from(new Set([...next.monsterTypes, ...packMonsterIds]))
|
||||
if (activeMonsterIds.length > 0) {
|
||||
const loaded = await loadMonsterArtMap(
|
||||
DEFAULT_BASES,
|
||||
activeMonsterIds,
|
||||
paletteForEntities,
|
||||
activePalette,
|
||||
renderer,
|
||||
packEntityBase,
|
||||
packEntityAct,
|
||||
|
|
@ -3378,9 +3582,9 @@ if (typeof window !== 'undefined') {
|
|||
preloadRemaining(runtime, renderer)
|
||||
if (runtime.palette !== null) {
|
||||
renderer.setPalette(runtime.palette.rgb)
|
||||
const paletteForEntities = runtime.palette
|
||||
const packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||||
const packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||||
activePalette = runtime.palette
|
||||
packEntityBase = runtime.source === 'pack' ? runtime.base : undefined
|
||||
packEntityAct = runtime.source === 'pack' ? runtime.act : undefined
|
||||
// Collect monster types to load
|
||||
const packMonsterIds = runtime.monsterPacks.flatMap(p => p.members.map(m => m.id))
|
||||
const activeMonsterIds = Array.from(new Set([...runtime.monsterTypes, ...packMonsterIds]))
|
||||
|
|
@ -3389,7 +3593,7 @@ if (typeof window !== 'undefined') {
|
|||
const loaded = await loadMonsterArtMap(
|
||||
DEFAULT_BASES,
|
||||
activeMonsterIds,
|
||||
paletteForEntities,
|
||||
activePalette,
|
||||
renderer,
|
||||
packEntityBase,
|
||||
packEntityAct,
|
||||
|
|
@ -3400,31 +3604,43 @@ if (typeof window !== 'undefined') {
|
|||
|
||||
const loadedChar = await loadCharacterArt(
|
||||
runtime.charBases,
|
||||
paletteForEntities,
|
||||
activePalette,
|
||||
renderer,
|
||||
packEntityBase,
|
||||
packEntityAct,
|
||||
currentHeroToken,
|
||||
)
|
||||
character = loadedChar
|
||||
state.character = loadedChar !== null
|
||||
if (loadedChar !== null) state.characterMembers = loadedChar.members
|
||||
const heroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
|
||||
if (hudManager !== null) {
|
||||
if (hudManager.charSheet) {
|
||||
hudManager.charSheet.attrs.name = heroInfo.charIdentity.name
|
||||
hudManager.charSheet.attrs.classNameZh = heroInfo.charIdentity.classNameZh
|
||||
hudManager.charSheet.attrs.classNameEn = heroInfo.charIdentity.classNameEn
|
||||
hudManager.charSheet.attrs.str = heroInfo.charIdentity.str
|
||||
hudManager.charSheet.attrs.dex = heroInfo.charIdentity.dex
|
||||
hudManager.charSheet.attrs.vit = heroInfo.charIdentity.vit
|
||||
hudManager.charSheet.attrs.ene = heroInfo.charIdentity.ene
|
||||
hudManager.charSheet.attrs.baseMaxHp = heroInfo.charIdentity.baseMaxHp
|
||||
hudManager.charSheet.attrs.baseMaxMana = heroInfo.charIdentity.baseMaxMana
|
||||
}
|
||||
hudManager.maxHp = heroInfo.charIdentity.baseMaxHp
|
||||
hudManager.hp = heroInfo.charIdentity.baseMaxHp
|
||||
hudManager.maxMana = heroInfo.charIdentity.baseMaxMana
|
||||
hudManager.mana = heroInfo.charIdentity.baseMaxMana
|
||||
hudManager.hotkeys.leftSkillId = heroInfo.defaultLeftSkill
|
||||
hudManager.hotkeys.rightSkillId = heroInfo.defaultRightSkill
|
||||
hudManager.syncPublishedState()
|
||||
}
|
||||
}
|
||||
|
||||
loop.start()
|
||||
reportMonsterPlan(runtime)
|
||||
state.ready = true
|
||||
state.loadMs = Math.round(performance.now() - started)
|
||||
void variantSelect
|
||||
status.textContent = 'WASD / 方向键移动。'
|
||||
+ (runtime.source === 'pack'
|
||||
? `资源包:首屏 ${String(state.pagesLoaded)}/${String(state.pagesTotal)} 页,${String(state.loadMs)} ms 出画面,其余页后台补。`
|
||||
: '本次直接读归档并解码(较慢):资源包不可用时会走这条路。')
|
||||
+ (state.character
|
||||
? ` 角色为真·女法师(${runtime.source === 'pack' ? '预烘焙图集' : 'DCC+COF 合成'},${String(state.characterMembers)} 层)。`
|
||||
: ' 角色暂为占位棋子:角色素材不可用时会退回这里。')
|
||||
+ (runtime.objectDrawables.length > 0
|
||||
? ` 对象层已绘制(火把/箱子/传送点等 ${String(runtime.objectDrawables.length)} 处)。`
|
||||
: ' 可破坏物品的位置与美术成员已进包,静态帧待接。')
|
||||
status.textContent = ''
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
loop.stop()
|
||||
|
|
@ -3615,9 +3831,19 @@ async function populatePackSelectors(packBase: string, current: { act: number; l
|
|||
async function boot(): Promise<void> {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
||||
const status = document.querySelector<HTMLElement>('#status')!
|
||||
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
|
||||
const actSelect = document.querySelector<HTMLSelectElement>('#act')!
|
||||
const sceneSelect = document.querySelector<HTMLSelectElement>('#scene')!
|
||||
const variantSelect = document.querySelector<HTMLSelectElement>('#variant')!
|
||||
if (heroSelect !== null) {
|
||||
heroSelect.disabled = false
|
||||
heroSelect.value = normalizeHeroToken(param('hero', param('class', 'so')))
|
||||
heroSelect.onchange = () => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
params.set('hero', heroSelect.value)
|
||||
location.search = params.toString()
|
||||
}
|
||||
}
|
||||
actSelect.disabled = false
|
||||
sceneSelect.disabled = true
|
||||
variantSelect.disabled = true
|
||||
|
|
@ -3690,6 +3916,7 @@ async function boot(): Promise<void> {
|
|||
} catch (err) {
|
||||
state.error = (err as Error).message
|
||||
status.textContent = `启动失败:${state.error}`
|
||||
if (heroSelect !== null) heroSelect.disabled = false
|
||||
actSelect.disabled = false
|
||||
sceneSelect.disabled = false
|
||||
variantSelect.disabled = false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,652 @@
|
|||
/**
|
||||
* tests/challenger-m1-controlbar-stress.test.ts
|
||||
*
|
||||
* Empirical Challenger Verification Suite for ControlBar (Issue #146 / Milestone 1 Batch 2).
|
||||
*
|
||||
* Requirements Tested:
|
||||
* 1. Offscreen Canvas Rendering & Pixel Audit:
|
||||
* - Decode authentic `public/ui/ctrlpnl-800.png` background panel.
|
||||
* - Simulate Canvas 2D rendering of ControlBarHud across all xpRatio states (0%..100%, adversarial values).
|
||||
* - Inspect pixel data inside the groove area `[256, 560, 120, 4]`:
|
||||
* - Background pixels: `#070605` (7, 6, 5)
|
||||
* - Filled progress pixels: `#d8d0a8` (216, 208, 168)
|
||||
* - 9 tick notches at x = 256 + 12*i (i=1..9): `#1e1812` (30, 24, 18) across all 4 rows (y=560..563)
|
||||
* - Surrounding Border Bleed Audit:
|
||||
* - Top border row (y = 559, x in [256..375]): 100% match with base panel (ZERO bleed into gold trim)
|
||||
* - Bottom border row (y = 564, x in [256..375]): 100% match with base panel (ZERO bleed into gold trim)
|
||||
* - Left border (x = 255, y in [560..563]): 100% match with base panel (ZERO bleed)
|
||||
* - Right border (x = 376, y in [560..563]): 100% match with base panel (ZERO bleed)
|
||||
* - Corner pixels: (255, 559), (376, 559), (255, 564), (376, 564): 100% match with base panel
|
||||
* 2. Hover Hit-Testing across DPRs and Aspect Ratios:
|
||||
* - DPRs: 1.0, 1.25, 1.5, 2.0
|
||||
* - Resolutions: 800x600 (4:3), 1920x1080 (16:9), 1280x720 (16:9), 2560x1440 (16:9), 1280x800 (16:10), 2560x1080 (21:9)
|
||||
* - Logical boundary testing on all 4 corners and center of `EXP_BAR_HOVER_BOUNDS` (x in [256, 376], y in [558, 566])
|
||||
* - Client integer pixel projection (physical browser mouse coordinates):
|
||||
* - 100% of client pixels in [ceil(clientLeft), floor(clientRight)] trigger 'exp'
|
||||
* - All client pixels outside trigger non-exp
|
||||
* - Round-trip coordinate invariance: clientToLogical(logicalToClient(x, y)) === (x, y)
|
||||
* - IEEE 754 precision boundary analysis at sub-pixel scale (3e-14)
|
||||
* - 10,000-iteration random differential fuzzing harness under each display mode.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { inflateSync } from 'node:zlib'
|
||||
import {
|
||||
ControlBarHud,
|
||||
EXP_BAR_BOUNDS,
|
||||
EXP_BAR_HOVER_BOUNDS,
|
||||
type ControlBarState,
|
||||
} from '../src/ui/control-bar.ts'
|
||||
import {
|
||||
computeHudLayout,
|
||||
clientToLogical,
|
||||
logicalToClient,
|
||||
} from '../src/ui/hud-manager.ts'
|
||||
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
// --- Helpers: Decode ctrlpnl-800.png from disk into RGBA buffer ---
|
||||
|
||||
interface DecodedImage {
|
||||
width: number
|
||||
height: number
|
||||
rgba: Uint8ClampedArray
|
||||
}
|
||||
|
||||
function decodeCtrlPnlPng(): DecodedImage {
|
||||
const buf = readFileSync('public/ui/ctrlpnl-800.png')
|
||||
let offset = 8
|
||||
let width = 0
|
||||
let height = 0
|
||||
let plte: Uint8Array | null = null
|
||||
let trns: Uint8Array | null = null
|
||||
const idats: Buffer[] = []
|
||||
|
||||
while (offset < buf.length) {
|
||||
const len = buf.readUInt32BE(offset)
|
||||
const type = buf.toString('ascii', offset + 4, offset + 8)
|
||||
const data = buf.subarray(offset + 8, offset + 8 + len)
|
||||
if (type === 'IHDR') {
|
||||
width = data.readUInt32BE(0)
|
||||
height = data.readUInt32BE(4)
|
||||
} else if (type === 'PLTE') {
|
||||
plte = new Uint8Array(data)
|
||||
} else if (type === 'tRNS') {
|
||||
trns = new Uint8Array(data)
|
||||
} else if (type === 'IDAT') {
|
||||
idats.push(Buffer.from(data))
|
||||
}
|
||||
offset += 12 + len
|
||||
}
|
||||
|
||||
if (!plte) throw new Error('Missing PLTE in ctrlpnl-800.png')
|
||||
const decompressed = inflateSync(Buffer.concat(idats))
|
||||
const rgba = new Uint8ClampedArray(width * height * 4)
|
||||
|
||||
let at = 0
|
||||
for (let y = 0; y < height; y++) {
|
||||
at++ // skip filter type (filter 0 in this asset)
|
||||
for (let x = 0; x < width; x++) {
|
||||
const idx = decompressed[at++]!
|
||||
const outIdx = (y * width + x) * 4
|
||||
rgba[outIdx + 0] = plte[idx * 3 + 0]!
|
||||
rgba[outIdx + 1] = plte[idx * 3 + 1]!
|
||||
rgba[outIdx + 2] = plte[idx * 3 + 2]!
|
||||
rgba[outIdx + 3] = trns && idx < trns.length ? trns[idx]! : 255
|
||||
}
|
||||
}
|
||||
|
||||
return { width, height, rgba }
|
||||
}
|
||||
|
||||
// --- Software Canvas 2D Simulator ---
|
||||
|
||||
class MockOffscreenCanvas2D {
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
readonly pixels: Uint8ClampedArray
|
||||
fillStyle: string = '#000000'
|
||||
strokeStyle: string = '#000000'
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
this.width = width
|
||||
this.height = height
|
||||
this.pixels = new Uint8ClampedArray(width * height * 4)
|
||||
}
|
||||
|
||||
private parseColor(color: any): [number, number, number, number] {
|
||||
if (typeof color === 'string') {
|
||||
if (color.startsWith('#')) {
|
||||
const hex = color.slice(1)
|
||||
if (hex.length === 6) {
|
||||
return [
|
||||
parseInt(hex.slice(0, 2), 16),
|
||||
parseInt(hex.slice(2, 4), 16),
|
||||
parseInt(hex.slice(4, 6), 16),
|
||||
255,
|
||||
]
|
||||
}
|
||||
}
|
||||
if (color.startsWith('rgba')) {
|
||||
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
|
||||
if (m) {
|
||||
return [
|
||||
parseInt(m[1]!, 10),
|
||||
parseInt(m[2]!, 10),
|
||||
parseInt(m[3]!, 10),
|
||||
Math.round(parseFloat(m[4] ?? '1') * 255),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
// CanvasGradient or fallback color (stamina bar gradient)
|
||||
return [232, 168, 40, 255]
|
||||
}
|
||||
|
||||
fillRect(x: number, y: number, w: number, h: number): void {
|
||||
const [r, g, b, a] = this.parseColor(this.fillStyle)
|
||||
const startX = Math.max(0, Math.floor(x))
|
||||
const startY = Math.max(0, Math.floor(y))
|
||||
const endX = Math.min(this.width, Math.floor(x + w))
|
||||
const endY = Math.min(this.height, Math.floor(y + h))
|
||||
|
||||
for (let py = startY; py < endY; py++) {
|
||||
for (let px = startX; px < endX; px++) {
|
||||
const idx = (py * this.width + px) * 4
|
||||
this.pixels[idx + 0] = r
|
||||
this.pixels[idx + 1] = g
|
||||
this.pixels[idx + 2] = b
|
||||
this.pixels[idx + 3] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawImage(img: any, dx: number, dy: number, ..._args: any[]): void {
|
||||
if (!img || !img.rgba) return
|
||||
const srcW = img.width as number
|
||||
const srcH = img.height as number
|
||||
const srcData = img.rgba as Uint8ClampedArray
|
||||
|
||||
for (let sy = 0; sy < srcH; sy++) {
|
||||
const py = dy + sy
|
||||
if (py < 0 || py >= this.height) continue
|
||||
for (let sx = 0; sx < srcW; sx++) {
|
||||
const px = dx + sx
|
||||
if (px < 0 || px >= this.width) continue
|
||||
const srcIdx = (sy * srcW + sx) * 4
|
||||
const dstIdx = (py * this.width + px) * 4
|
||||
this.pixels[dstIdx + 0] = srcData[srcIdx + 0]!
|
||||
this.pixels[dstIdx + 1] = srcData[srcIdx + 1]!
|
||||
this.pixels[dstIdx + 2] = srcData[srcIdx + 2]!
|
||||
this.pixels[dstIdx + 3] = srcData[srcIdx + 3]!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createLinearGradient(_x0: number, _y0: number, _x1: number, _y1: number) {
|
||||
return {
|
||||
addColorStop: (_offset: number, _color: string) => {},
|
||||
}
|
||||
}
|
||||
|
||||
strokeRect(_x: number, _y: number, _w: number, _h: number): void {}
|
||||
|
||||
getPixel(x: number, y: number): { r: number; g: number; b: number; a: number } {
|
||||
const idx = (y * this.width + x) * 4
|
||||
return {
|
||||
r: this.pixels[idx + 0]!,
|
||||
g: this.pixels[idx + 1]!,
|
||||
b: this.pixels[idx + 2]!,
|
||||
a: this.pixels[idx + 3]!,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createDummyFont(): D2FontRenderer {
|
||||
const font = new D2FontRenderer()
|
||||
font.measureText = (_text: string, _font?: any) => 100
|
||||
font.drawText = (_ctx: any, _text: string, _x: number, _y: number, _opt?: any) => {}
|
||||
return font
|
||||
}
|
||||
|
||||
function makeState(xpRatio: number): ControlBarState {
|
||||
const currentLevelXp = 1000
|
||||
const nextLevelXp = 2000
|
||||
const span = nextLevelXp - currentLevelXp
|
||||
const xp = currentLevelXp + Math.round(span * xpRatio)
|
||||
return {
|
||||
level: 1,
|
||||
xp,
|
||||
currentLevelXp,
|
||||
nextLevelXp,
|
||||
stamina: 100,
|
||||
maxStamina: 100,
|
||||
isRunning: false,
|
||||
unspentStatPoints: 0,
|
||||
unspentSkillPoints: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Milestone 1 Batch 2 Challenger: ControlBar Empirical Stress Tests', () => {
|
||||
const bgImg = decodeCtrlPnlPng()
|
||||
const dummyFont = createDummyFont()
|
||||
|
||||
// --- Requirement 1: Canvas Rendering & Pixel Bleed Audit ---
|
||||
describe('Requirement 1: Offscreen Canvas Rendering & Pixel Bleed Audit', () => {
|
||||
it('verifies exact dimensions and position of authentic 800CtrlPnl7 panel', () => {
|
||||
expect(bgImg.width).toBe(800)
|
||||
expect(bgImg.height).toBe(104)
|
||||
// Panel is drawn at y=496, covering y=496..600
|
||||
expect(496 + bgImg.height).toBe(600)
|
||||
})
|
||||
|
||||
it('confirms the authentic panel has gold trim at y=559 and y=564 with a dark groove at y=560..563', () => {
|
||||
const canvas = new MockOffscreenCanvas2D(800, 600)
|
||||
canvas.drawImage(bgImg, 0, 496)
|
||||
|
||||
// Top trim (y=559) has non-zero gold trim filigree pixels
|
||||
let topNonZero = 0
|
||||
for (let x = 256; x <= 375; x++) {
|
||||
const p = canvas.getPixel(x, 559)
|
||||
if (p.r + p.g + p.b > 0) topNonZero++
|
||||
}
|
||||
expect(topNonZero).toBeGreaterThanOrEqual(40)
|
||||
|
||||
// Bottom trim (y=564) has non-zero gold trim filigree pixels
|
||||
let botNonZero = 0
|
||||
for (let x = 256; x <= 375; x++) {
|
||||
const p = canvas.getPixel(x, 564)
|
||||
if (p.r + p.g + p.b > 0) botNonZero++
|
||||
}
|
||||
expect(botNonZero).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
const testXpRatios = [0.0, 0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1.0]
|
||||
|
||||
for (const ratio of testXpRatios) {
|
||||
it(`renders xpRatio = ${(ratio * 100).toFixed(0)}% with ZERO pixel bleed into gold trim at y=559 or y=564`, () => {
|
||||
// Step 1: Base canvas with only panel art
|
||||
const baseCanvas = new MockOffscreenCanvas2D(800, 600)
|
||||
baseCanvas.drawImage(bgImg, 0, 496)
|
||||
|
||||
// Snapshot original border pixels before ControlBar draw
|
||||
const originalTopRow: { r: number; g: number; b: number }[] = []
|
||||
const originalBottomRow: { r: number; g: number; b: number }[] = []
|
||||
const originalLeftCol: { r: number; g: number; b: number }[] = []
|
||||
const originalRightCol: { r: number; g: number; b: number }[] = []
|
||||
|
||||
for (let x = 256; x <= 375; x++) {
|
||||
originalTopRow.push(baseCanvas.getPixel(x, 559))
|
||||
originalBottomRow.push(baseCanvas.getPixel(x, 564))
|
||||
}
|
||||
for (let y = 560; y <= 563; y++) {
|
||||
originalLeftCol.push(baseCanvas.getPixel(255, y))
|
||||
originalRightCol.push(baseCanvas.getPixel(376, y))
|
||||
}
|
||||
|
||||
// Step 2: Render ControlBarHud onto fresh canvas
|
||||
const canvas = new MockOffscreenCanvas2D(800, 600)
|
||||
const hud = new ControlBarHud()
|
||||
const state = makeState(ratio)
|
||||
hud.draw(
|
||||
canvas as any,
|
||||
state,
|
||||
{
|
||||
ctrlPnlImg: bgImg as any,
|
||||
runBtnImg: null,
|
||||
menuBtnImg: null,
|
||||
miniPanelImg: null,
|
||||
miniPanelBtnsImg: null,
|
||||
levelBtnImg: null,
|
||||
levelSocketImg: null,
|
||||
},
|
||||
dummyFont,
|
||||
)
|
||||
|
||||
// Step 3: Verify top border (y=559) across all 120 pixels (x in [256..375])
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const x = 256 + i
|
||||
const current = canvas.getPixel(x, 559)
|
||||
const expected = originalTopRow[i]!
|
||||
expect(
|
||||
{ x, y: 559, r: current.r, g: current.g, b: current.b },
|
||||
`Pixel bleed detected at y=559, x=${x}`,
|
||||
).toEqual({ x, y: 559, r: expected.r, g: expected.g, b: expected.b })
|
||||
}
|
||||
|
||||
// Step 4: Verify bottom border (y=564) across all 120 pixels (x in [256..375])
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const x = 256 + i
|
||||
const current = canvas.getPixel(x, 564)
|
||||
const expected = originalBottomRow[i]!
|
||||
expect(
|
||||
{ x, y: 564, r: current.r, g: current.g, b: current.b },
|
||||
`Pixel bleed detected at y=564, x=${x}`,
|
||||
).toEqual({ x, y: 564, r: expected.r, g: expected.g, b: expected.b })
|
||||
}
|
||||
|
||||
// Step 5: Verify left border (x=255) across y=560..563
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = 560 + i
|
||||
const current = canvas.getPixel(255, y)
|
||||
const expected = originalLeftCol[i]!
|
||||
expect(
|
||||
{ x: 255, y, r: current.r, g: current.g, b: current.b },
|
||||
`Pixel bleed detected at x=255, y=${y}`,
|
||||
).toEqual({ x: 255, y, r: expected.r, g: expected.g, b: expected.b })
|
||||
}
|
||||
|
||||
// Step 6: Verify right border (x=376) across y=560..563
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const y = 560 + i
|
||||
const current = canvas.getPixel(376, y)
|
||||
const expected = originalRightCol[i]!
|
||||
expect(
|
||||
{ x: 376, y, r: current.r, g: current.g, b: current.b },
|
||||
`Pixel bleed detected at x=376, y=${y}`,
|
||||
).toEqual({ x: 376, y, r: expected.r, g: expected.g, b: expected.b })
|
||||
}
|
||||
|
||||
// Step 7: Inspect groove area [256, 560, 120, 4] pixel contents
|
||||
const filledWidth = Math.round(120 * ratio)
|
||||
const tickXs = new Set([268, 280, 292, 304, 316, 328, 340, 352, 364])
|
||||
|
||||
for (let y = 560; y <= 563; y++) {
|
||||
for (let x = 256; x <= 375; x++) {
|
||||
const pixel = canvas.getPixel(x, y)
|
||||
if (tickXs.has(x)) {
|
||||
// Tick notch color: #1e1812 -> (30, 24, 18)
|
||||
expect({ x, y, r: pixel.r, g: pixel.g, b: pixel.b }).toEqual({
|
||||
x,
|
||||
y,
|
||||
r: 30,
|
||||
g: 24,
|
||||
b: 18,
|
||||
})
|
||||
} else if (x < 256 + filledWidth) {
|
||||
// Filled XP bar color: #d8d0a8 -> (216, 208, 168)
|
||||
expect({ x, y, r: pixel.r, g: pixel.g, b: pixel.b }).toEqual({
|
||||
x,
|
||||
y,
|
||||
r: 216,
|
||||
g: 208,
|
||||
b: 168,
|
||||
})
|
||||
} else {
|
||||
// Background groove color: #070605 -> (7, 6, 5)
|
||||
expect({ x, y, r: pixel.r, g: pixel.g, b: pixel.b }).toEqual({
|
||||
x,
|
||||
y,
|
||||
r: 7,
|
||||
g: 6,
|
||||
b: 5,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('verifies 101-step sweep of xpRatio from 0% to 100% maintains 100% border integrity', () => {
|
||||
const baseCanvas = new MockOffscreenCanvas2D(800, 600)
|
||||
baseCanvas.drawImage(bgImg, 0, 496)
|
||||
|
||||
const top559 = Array.from({ length: 120 }, (_, i) => baseCanvas.getPixel(256 + i, 559))
|
||||
const bot564 = Array.from({ length: 120 }, (_, i) => baseCanvas.getPixel(256 + i, 564))
|
||||
|
||||
const hud = new ControlBarHud()
|
||||
for (let step = 0; step <= 100; step++) {
|
||||
const ratio = step / 100
|
||||
const canvas = new MockOffscreenCanvas2D(800, 600)
|
||||
hud.draw(
|
||||
canvas as any,
|
||||
makeState(ratio),
|
||||
{
|
||||
ctrlPnlImg: bgImg as any,
|
||||
runBtnImg: null,
|
||||
menuBtnImg: null,
|
||||
miniPanelImg: null,
|
||||
miniPanelBtnsImg: null,
|
||||
levelBtnImg: null,
|
||||
levelSocketImg: null,
|
||||
},
|
||||
dummyFont,
|
||||
)
|
||||
|
||||
// Check top and bottom border unchanged
|
||||
for (let i = 0; i < 120; i++) {
|
||||
const x = 256 + i
|
||||
const pTop = canvas.getPixel(x, 559)
|
||||
const eTop = top559[i]!
|
||||
if (pTop.r !== eTop.r || pTop.g !== eTop.g || pTop.b !== eTop.b) {
|
||||
throw new Error(`Bleed at step ${step} (${ratio}), x=${x}, y=559`)
|
||||
}
|
||||
|
||||
const pBot = canvas.getPixel(x, 564)
|
||||
const eBot = bot564[i]!
|
||||
if (pBot.r !== eBot.r || pBot.g !== eBot.g || pBot.b !== eBot.b) {
|
||||
throw new Error(`Bleed at step ${step} (${ratio}), x=${x}, y=564`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('handles adversarial xp state inputs without crashing or bleeding', () => {
|
||||
const hud = new ControlBarHud()
|
||||
const adversarialStates: ControlBarState[] = [
|
||||
{ ...makeState(0), xp: -500 }, // Negative XP
|
||||
{ ...makeState(0), xp: 999999999 }, // Vastly overflowing XP
|
||||
{ ...makeState(0), currentLevelXp: 2000, nextLevelXp: 2000 }, // 0 xpSpan (division by zero guard)
|
||||
{ ...makeState(0), currentLevelXp: 5000, nextLevelXp: 1000 }, // Inverted level bounds
|
||||
]
|
||||
|
||||
for (const st of adversarialStates) {
|
||||
const canvas = new MockOffscreenCanvas2D(800, 600)
|
||||
canvas.drawImage(bgImg, 0, 496)
|
||||
expect(() => {
|
||||
hud.draw(
|
||||
canvas as any,
|
||||
st,
|
||||
{
|
||||
ctrlPnlImg: bgImg as any,
|
||||
runBtnImg: null,
|
||||
menuBtnImg: null,
|
||||
miniPanelImg: null,
|
||||
miniPanelBtnsImg: null,
|
||||
levelBtnImg: null,
|
||||
levelSocketImg: null,
|
||||
},
|
||||
dummyFont,
|
||||
)
|
||||
}).not.toThrow()
|
||||
|
||||
// Snapshot base before
|
||||
const base = new MockOffscreenCanvas2D(800, 600)
|
||||
base.drawImage(bgImg, 0, 496)
|
||||
|
||||
// Check y=559 and y=564 bounds are untouched
|
||||
for (let x = 256; x <= 375; x++) {
|
||||
const pt = canvas.getPixel(x, 559)
|
||||
const orig = base.getPixel(x, 559)
|
||||
expect({ r: pt.r, g: pt.g, b: pt.b, a: pt.a }).toEqual({ r: orig.r, g: orig.g, b: orig.b, a: orig.a })
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// --- Requirement 2: Hover Hit-Testing across DPRs and Aspect Ratios ---
|
||||
describe('Requirement 2: Hover Hit-Testing across DPRs and Aspect Ratios', () => {
|
||||
const testDisplayConfigs = [
|
||||
{ name: '800x600 (4:3 canonical)', w: 800, h: 600 },
|
||||
{ name: '1920x1080 (16:9 Full HD)', w: 1920, h: 1080 },
|
||||
{ name: '1280x720 (16:9 HD)', w: 1280, h: 720 },
|
||||
{ name: '2560x1440 (16:9 QHD)', w: 2560, h: 1440 },
|
||||
{ name: '1280x800 (16:10 Laptop)', w: 1280, h: 800 },
|
||||
{ name: '2560x1080 (21:9 Ultrawide)', w: 2560, h: 1080 },
|
||||
]
|
||||
const testDprs = [1.0, 1.25, 1.5, 2.0]
|
||||
|
||||
for (const cfg of testDisplayConfigs) {
|
||||
for (const dpr of testDprs) {
|
||||
describe(`${cfg.name} @ DPR ${dpr}`, () => {
|
||||
const rect = { left: 0, top: 0, width: cfg.w, height: cfg.h }
|
||||
const hud = new ControlBarHud()
|
||||
|
||||
it('ensures round-trip coordinate projection invariance', () => {
|
||||
const sampleLogicalPoints = [
|
||||
{ x: 256, y: 558 },
|
||||
{ x: 376, y: 558 },
|
||||
{ x: 256, y: 566 },
|
||||
{ x: 376, y: 566 },
|
||||
{ x: 316, y: 562 },
|
||||
{ x: 268, y: 560 },
|
||||
{ x: 364, y: 563 },
|
||||
]
|
||||
|
||||
for (const pt of sampleLogicalPoints) {
|
||||
const client = logicalToClient(pt.x, pt.y, rect, dpr)
|
||||
const roundTrip = clientToLogical(client.x, client.y, rect, dpr)
|
||||
expect(roundTrip.x).toBeCloseTo(pt.x, 8)
|
||||
expect(roundTrip.y).toBeCloseTo(pt.y, 8)
|
||||
}
|
||||
})
|
||||
|
||||
it('hits hoveredElement === "exp" for all integer client pixels on screen', () => {
|
||||
// Convert EXP_BAR_HOVER_BOUNDS to client coordinates
|
||||
const topLeftClient = logicalToClient(EXP_BAR_HOVER_BOUNDS.x, EXP_BAR_HOVER_BOUNDS.y, rect, dpr)
|
||||
const botRightClient = logicalToClient(
|
||||
EXP_BAR_HOVER_BOUNDS.x + EXP_BAR_HOVER_BOUNDS.width,
|
||||
EXP_BAR_HOVER_BOUNDS.y + EXP_BAR_HOVER_BOUNDS.height,
|
||||
rect,
|
||||
dpr,
|
||||
)
|
||||
|
||||
// Integer pixel range on client screen
|
||||
const minClientX = Math.ceil(topLeftClient.x)
|
||||
const maxClientX = Math.floor(botRightClient.x)
|
||||
const minClientY = Math.ceil(topLeftClient.y)
|
||||
const maxClientY = Math.floor(botRightClient.y)
|
||||
|
||||
// Test center
|
||||
const centerClient = logicalToClient(316, 562, rect, dpr)
|
||||
const mappedCenter = clientToLogical(centerClient.x, centerClient.y, rect, dpr)
|
||||
hud.handleMouseMove(mappedCenter.x, mappedCenter.y)
|
||||
expect(hud.hoveredElement).toBe('exp')
|
||||
|
||||
// Test horizontal span at center Y
|
||||
for (let cx = minClientX; cx <= maxClientX; cx++) {
|
||||
const mapped = clientToLogical(cx, centerClient.y, rect, dpr)
|
||||
hud.handleMouseMove(mapped.x, mapped.y)
|
||||
expect(
|
||||
hud.hoveredElement,
|
||||
`Client pixel cx=${cx} (logical x=${mapped.x}) failed to hit exp`,
|
||||
).toBe('exp')
|
||||
}
|
||||
|
||||
// Test vertical span at center X
|
||||
for (let cy = minClientY; cy <= maxClientY; cy++) {
|
||||
const mapped = clientToLogical(centerClient.x, cy, rect, dpr)
|
||||
hud.handleMouseMove(mapped.x, mapped.y)
|
||||
expect(
|
||||
hud.hoveredElement,
|
||||
`Client pixel cy=${cy} (logical y=${mapped.y}) failed to hit exp`,
|
||||
).toBe('exp')
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects points outside the client bounds', () => {
|
||||
const topLeftClient = logicalToClient(EXP_BAR_HOVER_BOUNDS.x, EXP_BAR_HOVER_BOUNDS.y, rect, dpr)
|
||||
const botRightClient = logicalToClient(
|
||||
EXP_BAR_HOVER_BOUNDS.x + EXP_BAR_HOVER_BOUNDS.width,
|
||||
EXP_BAR_HOVER_BOUNDS.y + EXP_BAR_HOVER_BOUNDS.height,
|
||||
rect,
|
||||
dpr,
|
||||
)
|
||||
|
||||
const { uiScale } = computeHudLayout(rect.width, rect.height, dpr)
|
||||
const outsideClientPoints = [
|
||||
{ x: topLeftClient.x - uiScale * 2, y: topLeftClient.y, label: '2px left' },
|
||||
{ x: botRightClient.x + uiScale * 2, y: botRightClient.y, label: '2px right' },
|
||||
{ x: topLeftClient.x, y: topLeftClient.y - uiScale * 2, label: '2px above' },
|
||||
{ x: topLeftClient.x, y: botRightClient.y + uiScale * 2, label: '2px below' },
|
||||
{ x: 0, y: 0, label: 'origin' },
|
||||
]
|
||||
|
||||
for (const pt of outsideClientPoints) {
|
||||
const mapped = clientToLogical(pt.x, pt.y, rect, dpr)
|
||||
hud.handleMouseMove(mapped.x, mapped.y)
|
||||
expect(
|
||||
hud.hoveredElement,
|
||||
`Expected non-exp for outside point ${pt.label}`,
|
||||
).not.toBe('exp')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
it('passes 10,000-iteration PRNG differential fuzzing harness', () => {
|
||||
let seed = 0x12345678
|
||||
function nextRandom(): number {
|
||||
seed = (seed + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
|
||||
const hud = new ControlBarHud()
|
||||
const resolutions = [
|
||||
{ w: 800, h: 600 },
|
||||
{ w: 1920, h: 1080 },
|
||||
{ w: 1280, h: 720 },
|
||||
]
|
||||
const dprs = [1.0, 1.25, 1.5, 2.0]
|
||||
|
||||
let totalCases = 0
|
||||
let hitCount = 0
|
||||
|
||||
for (let iter = 0; iter < 10000; iter++) {
|
||||
const res = resolutions[iter % resolutions.length]!
|
||||
const dpr = dprs[iter % dprs.length]!
|
||||
const rect = { left: 0, top: 0, width: res.w, height: res.h }
|
||||
|
||||
let clientX: number
|
||||
let clientY: number
|
||||
|
||||
if (nextRandom() < 0.5) {
|
||||
// Inset into the neighborhood of EXP_BAR_HOVER_BOUNDS
|
||||
const lx = 250 + nextRandom() * 130
|
||||
const ly = 550 + nextRandom() * 20
|
||||
const cl = logicalToClient(lx, ly, rect, dpr)
|
||||
clientX = cl.x
|
||||
clientY = cl.y
|
||||
} else {
|
||||
// Uniform across entire viewport
|
||||
clientX = nextRandom() * res.w
|
||||
clientY = nextRandom() * res.h
|
||||
}
|
||||
|
||||
const logical = clientToLogical(clientX, clientY, rect, dpr)
|
||||
hud.handleMouseMove(logical.x, logical.y)
|
||||
|
||||
// Oracle: inside EXP_BAR_HOVER_BOUNDS?
|
||||
const isExpOracle =
|
||||
logical.x >= EXP_BAR_HOVER_BOUNDS.x &&
|
||||
logical.x <= EXP_BAR_HOVER_BOUNDS.x + EXP_BAR_HOVER_BOUNDS.width &&
|
||||
logical.y >= EXP_BAR_HOVER_BOUNDS.y &&
|
||||
logical.y <= EXP_BAR_HOVER_BOUNDS.y + EXP_BAR_HOVER_BOUNDS.height
|
||||
|
||||
if (isExpOracle) {
|
||||
expect(hud.hoveredElement, `Fuzz mismatch at iter ${iter} (clientX=${clientX}, clientY=${clientY})`).toBe('exp')
|
||||
hitCount++
|
||||
} else {
|
||||
expect(hud.hoveredElement, `Fuzz false positive at iter ${iter} (clientX=${clientX}, clientY=${clientY})`).not.toBe('exp')
|
||||
}
|
||||
totalCases++
|
||||
}
|
||||
|
||||
expect(totalCases).toBe(10000)
|
||||
expect(hitCount).toBeGreaterThan(1000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
ControlBarHud,
|
||||
EXP_BAR_BOUNDS,
|
||||
EXP_BAR_HOVER_BOUNDS,
|
||||
type ControlBarState,
|
||||
} from '../src/ui/control-bar.ts'
|
||||
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
describe('M1 Empirical Stress Test: ControlBar XP Bar Alignment & 10-Segment Ticks (Issue #146)', () => {
|
||||
describe('Geometry & Invariants', () => {
|
||||
it('enforces exact EXP_BAR_BOUNDS dimensions in stone groove (x: 256, y: 560, w: 120, h: 4)', () => {
|
||||
expect(EXP_BAR_BOUNDS.x).toBe(256)
|
||||
expect(EXP_BAR_BOUNDS.y).toBe(560)
|
||||
expect(EXP_BAR_BOUNDS.width).toBe(120)
|
||||
expect(EXP_BAR_BOUNDS.height).toBe(4)
|
||||
expect(EXP_BAR_BOUNDS).toEqual({ x: 256, y: 560, width: 120, height: 4 })
|
||||
})
|
||||
|
||||
it('enforces exact EXP_BAR_HOVER_BOUNDS dimensions (x: 256, y: 558, w: 120, h: 8)', () => {
|
||||
expect(EXP_BAR_HOVER_BOUNDS.x).toBe(256)
|
||||
expect(EXP_BAR_HOVER_BOUNDS.y).toBe(558)
|
||||
expect(EXP_BAR_HOVER_BOUNDS.width).toBe(120)
|
||||
expect(EXP_BAR_HOVER_BOUNDS.height).toBe(8)
|
||||
expect(EXP_BAR_HOVER_BOUNDS).toEqual({ x: 256, y: 558, width: 120, height: 8 })
|
||||
|
||||
// Vertical padding check: expanded by 2px above and 2px below
|
||||
expect(EXP_BAR_HOVER_BOUNDS.y).toBe(EXP_BAR_BOUNDS.y - 2)
|
||||
expect(EXP_BAR_HOVER_BOUNDS.height).toBe(EXP_BAR_BOUNDS.height + 4)
|
||||
})
|
||||
|
||||
it('enforces 10 equal 12px segments totalling 120px', () => {
|
||||
const segmentCount = 10
|
||||
const segmentWidth = EXP_BAR_BOUNDS.width / segmentCount
|
||||
expect(segmentWidth).toBe(12)
|
||||
expect(segmentWidth * segmentCount).toBe(EXP_BAR_BOUNDS.width)
|
||||
})
|
||||
|
||||
it('enforces geometry of the 9 divider ticks strictly at x = 256 + 12*i, height 4', () => {
|
||||
const expectedTicks = [
|
||||
{ i: 1, x: 268, y: 560, w: 1, h: 4 },
|
||||
{ i: 2, x: 280, y: 560, w: 1, h: 4 },
|
||||
{ i: 3, x: 292, y: 560, w: 1, h: 4 },
|
||||
{ i: 4, x: 304, y: 560, w: 1, h: 4 },
|
||||
{ i: 5, x: 316, y: 560, w: 1, h: 4 },
|
||||
{ i: 6, x: 328, y: 560, w: 1, h: 4 },
|
||||
{ i: 7, x: 340, y: 560, w: 1, h: 4 },
|
||||
{ i: 8, x: 352, y: 560, w: 1, h: 4 },
|
||||
{ i: 9, x: 364, y: 560, w: 1, h: 4 },
|
||||
]
|
||||
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
const tickX = EXP_BAR_BOUNDS.x + i * 12
|
||||
const expected = expectedTicks[i - 1]!
|
||||
expect(tickX).toBe(expected.x)
|
||||
expect(tickX).toBe(256 + 12 * i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hit-Testing Boundary Edge Cases', () => {
|
||||
const bar = new ControlBarHud()
|
||||
|
||||
// Test cases specified directly in mission prompt:
|
||||
// (256, 560), (375, 563), (255, 560), (376, 560), (256, 557), (256, 558), (256, 566), (256, 567)
|
||||
it('handles mandatory prompt test coordinates correctly', () => {
|
||||
// (256, 560) -> Inside groove top-left: HIT
|
||||
bar.handleMouseMove(256, 560)
|
||||
expect(bar.hoveredElement).toBe('exp')
|
||||
|
||||
// (375, 563) -> Inside groove bottom-right: HIT
|
||||
bar.handleMouseMove(375, 563)
|
||||
expect(bar.hoveredElement).toBe('exp')
|
||||
|
||||
// (255, 560) -> 1px left of groove: MISS
|
||||
bar.handleMouseMove(255, 560)
|
||||
expect(bar.hoveredElement).toBeNull()
|
||||
|
||||
// (376, 560) -> At boundary x = 256 + 120 = 376: HIT
|
||||
bar.handleMouseMove(376, 560)
|
||||
expect(bar.hoveredElement).toBe('exp')
|
||||
|
||||
// (256, 557) -> 1px above padded hover bounds (557 < 558): MISS
|
||||
bar.handleMouseMove(256, 557)
|
||||
expect(bar.hoveredElement).toBeNull()
|
||||
|
||||
// (256, 558) -> Top edge of padded hover bounds (y = 558): HIT
|
||||
bar.handleMouseMove(256, 558)
|
||||
expect(bar.hoveredElement).toBe('exp')
|
||||
|
||||
// (256, 566) -> Bottom edge of padded hover bounds (y = 558 + 8 = 566): HIT
|
||||
bar.handleMouseMove(256, 566)
|
||||
expect(bar.hoveredElement).toBe('exp')
|
||||
|
||||
// (256, 567) -> 1px below padded hover bounds (567 > 566): MISS
|
||||
bar.handleMouseMove(256, 567)
|
||||
expect(bar.hoveredElement).toBeNull()
|
||||
})
|
||||
|
||||
it('comprehensively tests all 4 corners and outer edges of EXP_BAR_HOVER_BOUNDS', () => {
|
||||
// 4 corners of hover box [256..376] x [558..566]
|
||||
const corners = [
|
||||
{ x: 256, y: 558, name: 'top-left' },
|
||||
{ x: 376, y: 558, name: 'top-right' },
|
||||
{ x: 256, y: 566, name: 'bottom-left' },
|
||||
{ x: 376, y: 566, name: 'bottom-right' },
|
||||
]
|
||||
for (const pt of corners) {
|
||||
bar.handleMouseMove(pt.x, pt.y)
|
||||
expect(bar.hoveredElement, `Corner ${pt.name} (${pt.x}, ${pt.y}) should hit`).toBe('exp')
|
||||
}
|
||||
|
||||
// Outer boundary points (1px outside in each cardinal direction)
|
||||
const outerPoints = [
|
||||
{ x: 255, y: 558, name: 'left-of-top-left' },
|
||||
{ x: 255, y: 566, name: 'left-of-bottom-left' },
|
||||
{ x: 377, y: 558, name: 'right-of-top-right' },
|
||||
{ x: 377, y: 566, name: 'right-of-bottom-right' },
|
||||
{ x: 316, y: 557, name: 'above-center' },
|
||||
{ x: 316, y: 567, name: 'below-center' },
|
||||
]
|
||||
for (const pt of outerPoints) {
|
||||
bar.handleMouseMove(pt.x, pt.y)
|
||||
expect(bar.hoveredElement, `Outer point ${pt.name} (${pt.x}, ${pt.y}) should miss`).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Canvas Rendering & XP Value Sweeps', () => {
|
||||
interface DrawCall {
|
||||
fillStyle: string | CanvasGradient | CanvasPattern
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
function createMockCtx(): { ctx: CanvasRenderingContext2D; rects: DrawCall[] } {
|
||||
const rects: DrawCall[] = []
|
||||
let currentFillStyle: any = '#000000'
|
||||
|
||||
const ctx = {
|
||||
get fillStyle() {
|
||||
return currentFillStyle
|
||||
},
|
||||
set fillStyle(val: any) {
|
||||
currentFillStyle = val
|
||||
},
|
||||
fillRect(x: number, y: number, w: number, h: number) {
|
||||
rects.push({ fillStyle: currentFillStyle, x, y, w, h })
|
||||
},
|
||||
createLinearGradient() {
|
||||
return {
|
||||
addColorStop: () => {},
|
||||
}
|
||||
},
|
||||
drawImage() {},
|
||||
strokeRect() {},
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
return { ctx, rects }
|
||||
}
|
||||
|
||||
function createDummyState(xp: number, currentLevelXp: number, nextLevelXp: number): ControlBarState {
|
||||
return {
|
||||
level: 80,
|
||||
xp,
|
||||
currentLevelXp,
|
||||
nextLevelXp,
|
||||
stamina: 300,
|
||||
maxStamina: 300,
|
||||
isRunning: false,
|
||||
unspentStatPoints: 0,
|
||||
unspentSkillPoints: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const dummyAssets = {
|
||||
ctrlPnlImg: null,
|
||||
runBtnImg: null,
|
||||
menuBtnImg: null,
|
||||
miniPanelImg: null,
|
||||
miniPanelBtnsImg: null,
|
||||
levelBtnImg: null,
|
||||
levelSocketImg: null,
|
||||
}
|
||||
|
||||
const dummyFont = new D2FontRenderer()
|
||||
|
||||
it('renders exactly background, fill, and 9 ticks in correct order', () => {
|
||||
const bar = new ControlBarHud()
|
||||
const { ctx, rects } = createMockCtx()
|
||||
const state = createDummyState(500, 0, 1000) // 50% XP
|
||||
|
||||
bar.draw(ctx, state, dummyAssets, dummyFont)
|
||||
|
||||
// Filter rects within EXP_BAR_BOUNDS area
|
||||
const xpRects = rects.filter(r => r.y === EXP_BAR_BOUNDS.y && r.h === EXP_BAR_BOUNDS.height)
|
||||
|
||||
// Expected:
|
||||
// 1. Background bar: fillStyle '#070605', x: 256, y: 560, w: 120, h: 4
|
||||
// 2. Fill bar: fillStyle '#d8d0a8', x: 256, y: 560, w: 60, h: 4
|
||||
// 3. 9 divider ticks: fillStyle '#1e1812', x: 256 + 12*i, y: 560, w: 1, h: 4
|
||||
expect(xpRects.length).toBe(1 + 1 + 9) // 11 rects
|
||||
|
||||
// 1. Background
|
||||
expect(xpRects[0]).toEqual({
|
||||
fillStyle: '#070605',
|
||||
x: 256,
|
||||
y: 560,
|
||||
w: 120,
|
||||
h: 4,
|
||||
})
|
||||
|
||||
// 2. Fill
|
||||
expect(xpRects[1]).toEqual({
|
||||
fillStyle: '#d8d0a8',
|
||||
x: 256,
|
||||
y: 560,
|
||||
w: 60,
|
||||
h: 4,
|
||||
})
|
||||
|
||||
// 3. 9 Ticks
|
||||
for (let i = 1; i <= 9; i++) {
|
||||
expect(xpRects[1 + i]).toEqual({
|
||||
fillStyle: '#1e1812',
|
||||
x: 256 + i * 12,
|
||||
y: 560,
|
||||
w: 1,
|
||||
h: 4,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('accurately sweeps all milestone XP values: 0%, 5%, 10%, 12.5%, 50%, 99.9%, 100%, negative, overflow', () => {
|
||||
const testCases = [
|
||||
{ name: '0.0 (0%)', xp: 0, cur: 0, next: 1000, expectedW: 0 },
|
||||
{ name: '0.05 (5%)', xp: 50, cur: 0, next: 1000, expectedW: 6 },
|
||||
{ name: '0.10 (10% - 1st tick)', xp: 100, cur: 0, next: 1000, expectedW: 12 },
|
||||
{ name: '0.125 (12.5%)', xp: 125, cur: 0, next: 1000, expectedW: 15 },
|
||||
{ name: '0.20 (20% - 2nd tick)', xp: 200, cur: 0, next: 1000, expectedW: 24 },
|
||||
{ name: '0.50 (50% - 5th tick)', xp: 500, cur: 0, next: 1000, expectedW: 60 },
|
||||
{ name: '0.90 (90% - 9th tick)', xp: 900, cur: 0, next: 1000, expectedW: 108 },
|
||||
{ name: '0.999 (99.9%)', xp: 999, cur: 0, next: 1000, expectedW: 120 }, // Math.round(120 * 0.999) = 120
|
||||
{ name: '0.991 (99.1%)', xp: 991, cur: 0, next: 1000, expectedW: 119 }, // Math.round(120 * 0.991) = 119
|
||||
{ name: '1.0 (100% full)', xp: 1000, cur: 0, next: 1000, expectedW: 120 },
|
||||
{ name: 'negative XP underflow', xp: -500, cur: 0, next: 1000, expectedW: 0 },
|
||||
{ name: 'XP below current level', xp: 400, cur: 500, next: 1000, expectedW: 0 },
|
||||
{ name: 'XP overflow (>1.0)', xp: 1500, cur: 0, next: 1000, expectedW: 120 },
|
||||
{ name: 'extreme massive overflow', xp: 999999999, cur: 0, next: 1000, expectedW: 120 },
|
||||
{ name: 'degenerate zero span (cur == next)', xp: 100, cur: 100, next: 100, expectedW: 0 },
|
||||
]
|
||||
|
||||
const bar = new ControlBarHud()
|
||||
|
||||
for (const tc of testCases) {
|
||||
const { ctx, rects } = createMockCtx()
|
||||
const state = createDummyState(tc.xp, tc.cur, tc.next)
|
||||
bar.draw(ctx, state, dummyAssets, dummyFont)
|
||||
|
||||
// Find the fill rect
|
||||
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === EXP_BAR_BOUNDS.y)
|
||||
expect(fillRect, `Fill rect exists for ${tc.name}`).toBeDefined()
|
||||
expect(fillRect!.w, `Fill width for ${tc.name}`).toBe(tc.expectedW)
|
||||
expect(fillRect!.x).toBe(256)
|
||||
expect(fillRect!.y).toBe(560)
|
||||
expect(fillRect!.h).toBe(4)
|
||||
}
|
||||
})
|
||||
|
||||
it('performs differential fuzzing across 1,000 fine-grained XP fractions', () => {
|
||||
const bar = new ControlBarHud()
|
||||
let prevWidth = 0
|
||||
|
||||
for (let i = 0; i <= 1000; i++) {
|
||||
const fraction = i / 1000
|
||||
const xp = fraction * 10000
|
||||
const { ctx, rects } = createMockCtx()
|
||||
const state = createDummyState(xp, 0, 10000)
|
||||
bar.draw(ctx, state, dummyAssets, dummyFont)
|
||||
|
||||
const fillRect = rects.find(r => r.fillStyle === '#d8d0a8' && r.y === EXP_BAR_BOUNDS.y)!
|
||||
expect(fillRect.w).toBeGreaterThanOrEqual(0)
|
||||
expect(fillRect.w).toBeLessThanOrEqual(120)
|
||||
expect(fillRect.w).toBeGreaterThanOrEqual(prevWidth) // monotonic
|
||||
expect(Number.isInteger(fillRect.w)).toBe(true)
|
||||
|
||||
// Oracle formula: Math.round(120 * fraction)
|
||||
const expectedW = Math.round(120 * fraction)
|
||||
expect(fillRect.w).toBe(expectedW)
|
||||
|
||||
prevWidth = fillRect.w
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { HERO_SPECS } from '../scripts/pack-characters.ts'
|
||||
import { HERO_CLASSES, normalizeHeroToken } from '../src/scene/act-scene.ts'
|
||||
|
||||
describe('7 Playable Character Classes Artwork & Selection Parity', () => {
|
||||
const packRoot = join(process.cwd(), 'samples', 'd2-packs')
|
||||
const globalDir = join(packRoot, 'entities')
|
||||
const hasGlobalEntities = existsSync(globalDir)
|
||||
|
||||
it('verifies all 7 hero classes are defined in HERO_CLASSES with 1.13c ground truth stats', () => {
|
||||
expect(HERO_SPECS).toHaveLength(7)
|
||||
for (const spec of HERO_SPECS) {
|
||||
const hero = HERO_CLASSES[spec.token]
|
||||
expect(hero).toBeDefined()
|
||||
expect(hero.token).toBe(spec.token)
|
||||
expect(hero.code).toBe(spec.code)
|
||||
expect(hero.nameZh).toBe(spec.nameZh)
|
||||
expect(hero.nameEn).toBe(spec.nameEn)
|
||||
expect(hero.charIdentity.str).toBeGreaterThan(0)
|
||||
expect(hero.charIdentity.dex).toBeGreaterThan(0)
|
||||
expect(hero.charIdentity.vit).toBeGreaterThan(0)
|
||||
expect(hero.charIdentity.ene).toBeGreaterThan(0)
|
||||
expect(hero.charIdentity.baseMaxHp).toBeGreaterThan(0)
|
||||
expect(hero.charIdentity.baseMaxMana).toBeGreaterThan(0)
|
||||
expect(hero.defaultLeftSkill).toBeGreaterThanOrEqual(0)
|
||||
expect(hero.defaultRightSkill).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes various class identifiers (token, code, names, aliases) correctly', () => {
|
||||
// Sorceress
|
||||
expect(normalizeHeroToken('so')).toBe('so')
|
||||
expect(normalizeHeroToken('sor')).toBe('so')
|
||||
expect(normalizeHeroToken('Sorceress')).toBe('so')
|
||||
expect(normalizeHeroToken('sorceress')).toBe('so')
|
||||
expect(normalizeHeroToken('SO')).toBe('so')
|
||||
|
||||
// Amazon
|
||||
expect(normalizeHeroToken('am')).toBe('am')
|
||||
expect(normalizeHeroToken('ama')).toBe('am')
|
||||
expect(normalizeHeroToken('Amazon')).toBe('am')
|
||||
|
||||
// Necromancer
|
||||
expect(normalizeHeroToken('ne')).toBe('ne')
|
||||
expect(normalizeHeroToken('nec')).toBe('ne')
|
||||
expect(normalizeHeroToken('Necromancer')).toBe('ne')
|
||||
|
||||
// Paladin
|
||||
expect(normalizeHeroToken('pa')).toBe('pa')
|
||||
expect(normalizeHeroToken('pal')).toBe('pa')
|
||||
expect(normalizeHeroToken('Paladin')).toBe('pa')
|
||||
|
||||
// Barbarian
|
||||
expect(normalizeHeroToken('ba')).toBe('ba')
|
||||
expect(normalizeHeroToken('bar')).toBe('ba')
|
||||
expect(normalizeHeroToken('Barbarian')).toBe('ba')
|
||||
|
||||
// Druid (both canonical 'dz' and common alias 'dr')
|
||||
expect(normalizeHeroToken('dz')).toBe('dz')
|
||||
expect(normalizeHeroToken('dru')).toBe('dz')
|
||||
expect(normalizeHeroToken('dr')).toBe('dz')
|
||||
expect(normalizeHeroToken('Druid')).toBe('dz')
|
||||
|
||||
// Assassin (both canonical 'ai' and common alias 'as')
|
||||
expect(normalizeHeroToken('ai')).toBe('ai')
|
||||
expect(normalizeHeroToken('ass')).toBe('ai')
|
||||
expect(normalizeHeroToken('as')).toBe('ai')
|
||||
expect(normalizeHeroToken('Assassin')).toBe('ai')
|
||||
|
||||
// Unknown fallback to 'so'
|
||||
expect(normalizeHeroToken('unknown')).toBe('so')
|
||||
expect(normalizeHeroToken('')).toBe('so')
|
||||
})
|
||||
|
||||
it.skipIf(!hasGlobalEntities)('validates global Schema 2 raw R8 textures for all 7 hero classes', () => {
|
||||
for (const spec of HERO_SPECS) {
|
||||
const jsonPath = join(globalDir, `char-${spec.token}.json`)
|
||||
const r8Path = join(globalDir, `char-${spec.token}.r8`)
|
||||
|
||||
expect(existsSync(jsonPath), `missing json for ${spec.token}`).toBe(true)
|
||||
expect(existsSync(r8Path), `missing r8 for ${spec.token}`).toBe(true)
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
expect(meta.schema).toBe(2)
|
||||
expect(meta.file).toBe(`char-${spec.token}.r8`)
|
||||
expect(meta.format).toBe('r8')
|
||||
expect(meta.directions).toBe(16)
|
||||
expect([8, 10]).toContain(meta.walk)
|
||||
expect(meta.walkOffset).toBe(0)
|
||||
expect(meta.standOffset).toBe(16)
|
||||
expect(meta.members).toBeGreaterThanOrEqual(14)
|
||||
// In 1.13c, Assassin's death/dead animations have 8 directions; all other classes have 16
|
||||
const expectedGroups = spec.token === 'ai' ? 160 : 176
|
||||
expect(meta.groups.length).toBe(expectedGroups)
|
||||
|
||||
// Standard player modes present
|
||||
expect(meta.clips).toBeDefined()
|
||||
expect(meta.clips.nu).toBeDefined()
|
||||
expect(meta.clips.wl).toBeDefined()
|
||||
expect(meta.clips.rn).toBeDefined()
|
||||
expect(meta.clips.a1).toBeDefined()
|
||||
expect(meta.clips.gh).toBeDefined()
|
||||
expect(meta.clips.dt).toBeDefined()
|
||||
|
||||
const r8 = readFileSync(r8Path)
|
||||
expect(r8.byteLength).toBe(meta.width * meta.height)
|
||||
|
||||
let nonZero = 0
|
||||
for (let i = 0; i < r8.length; i += 1) {
|
||||
if (r8[i] !== 0) nonZero += 1
|
||||
}
|
||||
expect(nonZero).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(!hasGlobalEntities)('validates per-Act fallback PNG atlases for all 7 hero classes across Acts 1..5', () => {
|
||||
for (let act = 1; act <= 5; act += 1) {
|
||||
const actDir = join(packRoot, `act${act}`, 'entities')
|
||||
for (const spec of HERO_SPECS) {
|
||||
const jsonPath = join(actDir, `char-${spec.token}.json`)
|
||||
const pngPath = join(actDir, `char-${spec.token}.png`)
|
||||
|
||||
expect(existsSync(jsonPath), `missing act${act} json for ${spec.token}`).toBe(true)
|
||||
expect(existsSync(pngPath), `missing act${act} png for ${spec.token}`).toBe(true)
|
||||
|
||||
const meta = JSON.parse(readFileSync(jsonPath, 'utf8'))
|
||||
expect(meta.schema).toBe(2)
|
||||
expect(meta.file).toBe(`char-${spec.token}.png`)
|
||||
expect(meta.directions).toBe(16)
|
||||
const expectedGroups = spec.token === 'ai' ? 160 : 176
|
||||
expect(meta.groups.length).toBe(expectedGroups)
|
||||
|
||||
const png = readFileSync(pngPath)
|
||||
expect(png[0]).toBe(137)
|
||||
expect(png[1]).toBe(80) // P
|
||||
expect(png[2]).toBe(78) // N
|
||||
expect(png[3]).toBe(71) // G
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue