Add browser debugging support

This commit is contained in:
2026-06-22 10:50:03 +09:00
parent 00f7af66e0
commit 48a1a9ccbb
8 changed files with 323 additions and 34 deletions

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Heros Web (Chrome)",
"type": "pwa-chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src",
"preLaunchTask": "dev server",
"sourceMaps": true,
"skipFiles": ["<node_internals>/**", "${workspaceFolder}/node_modules/**"]
}
]
}

27
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "dev server",
"type": "shell",
"command": "pnpm dev",
"isBackground": true,
"problemMatcher": {
"owner": "vite",
"pattern": [
{
"regexp": ".",
"file": 1,
"location": 2,
"message": 3
}
],
"background": {
"activeOnStart": true,
"beginsPattern": ".*",
"endsPattern": "Local:"
}
}
}
]
}

33
docs/debugging.md Normal file
View File

@@ -0,0 +1,33 @@
# Debugging
## VS Code
1. Open this folder in VS Code.
2. Run `Debug Heros Web (Chrome)` from Run and Debug.
3. VS Code starts the Vite dev server and opens Chrome at `http://localhost:5173`.
4. Set breakpoints in `src/game/scenes/*.ts`.
## Browser Console
Development builds expose:
```js
window.__HEROS_GAME__
window.__HEROS_DEBUG__
```
Useful commands:
```js
__HEROS_DEBUG__.activeScenes()
__HEROS_DEBUG__.battle()
__HEROS_DEBUG__.goToBattle()
__HEROS_DEBUG__.toggleBattleOverlay()
```
## Battle Hotkeys
- `F9`: Toggle battle debug overlay.
- `F10`: Log battle state to the browser console.
The battle debug state includes turn, phase, selected unit, pending move, acted units, unit coordinates, attack intents, and bond progress.

View File

@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"debug": "vite --host 0.0.0.0 --debug",
"build": "tsc && vite build", "build": "tsc && vite build",
"generate:audio": "node scripts/generate-bgm.mjs", "generate:audio": "node scripts/generate-bgm.mjs",
"preview": "vite preview --host 0.0.0.0", "preview": "vite preview --host 0.0.0.0",

View File

@@ -1,58 +1,128 @@
import { spawn } from 'node:child_process';
import { chromium } from 'playwright'; import { chromium } from 'playwright';
const targetUrl = process.env.VERIFY_URL ?? 'http://localhost:5173/'; const targetUrl = process.env.VERIFY_URL ?? 'http://localhost:5173/';
const browser = await chromium.launch({ headless: true }); let serverProcess;
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); let browser;
await page.goto(targetUrl, { waitUntil: 'networkidle' }); try {
await page.waitForSelector('canvas'); serverProcess = await ensureLocalServer(targetUrl);
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined);
await page.waitForTimeout(500);
await page.mouse.click(962, 240); browser = await chromium.launch({ headless: true });
await page.waitForTimeout(250); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
for (let i = 0; i < 20; i += 1) { await page.goto(targetUrl, { waitUntil: 'networkidle' });
const isBattleActive = await page.evaluate(() => { await page.waitForSelector('canvas');
await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined);
await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined);
await page.waitForTimeout(500);
const debugBeforeBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []);
if (!debugBeforeBattle.includes('TitleScene')) {
throw new Error(`Expected TitleScene before starting. Active scenes: ${debugBeforeBattle.join(', ')}`);
}
await page.mouse.click(962, 240);
await page.waitForTimeout(250);
for (let i = 0; i < 20; i += 1) {
const isBattleActive = await page.evaluate(() => {
const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? [];
return activeScenes.some((scene) => scene.scene.key === 'BattleScene');
});
if (isBattleActive) {
break;
}
await page.keyboard.press('Space');
await page.waitForTimeout(220);
}
await page.waitForFunction(() => {
const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? [];
return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); return activeScenes.some((scene) => scene.scene.key === 'BattleScene');
}); });
if (isBattleActive) { await page.keyboard.press('F9');
break; await page.waitForTimeout(100);
await page.screenshot({ path: 'dist/verification-battle.png', fullPage: true });
const result = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const battleState = window.__HEROS_DEBUG__?.battle();
return {
activeScenes,
battleState,
canvasWidth: canvas?.width ?? 0,
canvasHeight: canvas?.height ?? 0
};
});
if (result.canvasWidth !== 1280 || result.canvasHeight !== 720) {
throw new Error(`Unexpected canvas size: ${result.canvasWidth}x${result.canvasHeight}`);
} }
await page.keyboard.press('Space'); if (!result.activeScenes.includes('BattleScene')) {
await page.waitForTimeout(220); throw new Error(`BattleScene was not active. Active scenes: ${result.activeScenes.join(', ')}`);
}
if (!result.battleState || result.battleState.scene !== 'BattleScene') {
throw new Error(`Debug battle state was not available: ${JSON.stringify(result.battleState)}`);
}
console.log(`Verified title-to-battle flow and debug API at ${targetUrl}`);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
} }
await page.waitForFunction(() => { async function ensureLocalServer(url) {
const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; if (await canReach(url)) {
return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); return undefined;
}); }
await page.screenshot({ path: 'dist/verification-battle.png', fullPage: true }); const parsed = new URL(url);
const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
if (!isLocal) {
throw new Error(`No server responded at ${url}`);
}
const result = await page.evaluate(() => { const stderr = [];
const canvas = document.querySelector('canvas'); const child = spawn(process.execPath, ['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '5173'], {
const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true).map((scene) => scene.scene.key) ?? []; cwd: process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe']
});
return { child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
activeScenes, child.stdout.on('data', () => {});
canvasWidth: canvas?.width ?? 0,
canvasHeight: canvas?.height ?? 0
};
});
await browser.close(); for (let i = 0; i < 80; i += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
if (result.canvasWidth !== 1280 || result.canvasHeight !== 720) { child.kill();
throw new Error(`Unexpected canvas size: ${result.canvasWidth}x${result.canvasHeight}`); throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
} }
if (!result.activeScenes.includes('BattleScene')) { async function canReach(url) {
throw new Error(`BattleScene was not active. Active scenes: ${result.activeScenes.join(', ')}`); try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
return response.ok;
} catch {
return false;
}
} }
console.log(`Verified title-to-battle flow at ${targetUrl}`); function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -152,6 +152,7 @@ export class BattleScene extends Phaser.Scene {
private bondStates = new Map<string, BondState>(); private bondStates = new Map<string, BondState>();
private attackIntents: AttackIntent[] = []; private attackIntents: AttackIntent[] = [];
private pendingMove?: PendingMove; private pendingMove?: PendingMove;
private debugOverlay?: Phaser.GameObjects.Text;
constructor() { constructor() {
super('BattleScene'); super('BattleScene');
@@ -168,6 +169,7 @@ export class BattleScene extends Phaser.Scene {
this.handleRightClick(pointer); this.handleRightClick(pointer);
} }
}); });
this.installDebugHotkeys();
this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0); this.add.rectangle(0, 0, width, height, 0x080b0d).setOrigin(0);
this.drawMap(); this.drawMap();
@@ -1473,4 +1475,99 @@ export class BattleScene extends Phaser.Scene {
this.renderRosterPanel(this.rosterTab, text); this.renderRosterPanel(this.rosterTab, text);
} }
getDebugState() {
return {
scene: this.scene.key,
turnNumber: this.turnNumber,
activeFaction: this.activeFaction,
phase: this.phase,
selectedUnitId: this.selectedUnit?.id ?? null,
pendingMove: this.pendingMove
? {
unitId: this.pendingMove.unit.id,
from: { x: this.pendingMove.fromX, y: this.pendingMove.fromY },
to: { x: this.pendingMove.toX, y: this.pendingMove.toY }
}
: null,
actedUnitIds: Array.from(this.actedUnitIds),
attackIntents: this.attackIntents.map((intent) => ({ ...intent })),
units: firstBattleUnits.map((unit) => ({
id: unit.id,
name: unit.name,
faction: unit.faction,
classKey: unit.classKey,
hp: unit.hp,
maxHp: unit.maxHp,
x: unit.x,
y: unit.y,
acted: this.actedUnitIds.has(unit.id)
})),
bonds: Array.from(this.bondStates.values()).map((bond) => ({
id: bond.id,
unitIds: bond.unitIds,
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}))
};
}
toggleDebugOverlay() {
if (this.debugOverlay) {
this.debugOverlay.destroy();
this.debugOverlay = undefined;
return;
}
this.debugOverlay = this.add.text(12, 12, '', {
fontFamily: 'Consolas, monospace',
fontSize: '13px',
color: '#dff7ff',
backgroundColor: 'rgba(0, 0, 0, 0.72)',
padding: { x: 8, y: 6 }
});
this.debugOverlay.setDepth(1000);
this.updateDebugOverlay();
}
private installDebugHotkeys() {
if (!import.meta.env.DEV) {
return;
}
this.input.keyboard?.on('keydown-F9', () => this.toggleDebugOverlay());
this.input.keyboard?.on('keydown-F10', () => {
console.info('[Battle Debug]', this.getDebugState());
this.updateDebugOverlay();
});
this.input.on('pointermove', () => this.updateDebugOverlay());
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
this.debugOverlay?.destroy();
this.debugOverlay = undefined;
});
}
private updateDebugOverlay() {
if (!this.debugOverlay) {
return;
}
const pointerTile = this.pointerToTile(this.input.activePointer);
const selected = this.selectedUnit ? `${this.selectedUnit.name} (${this.selectedUnit.x},${this.selectedUnit.y})` : 'none';
const pending = this.pendingMove
? `${this.pendingMove.unit.name}: (${this.pendingMove.fromX},${this.pendingMove.fromY}) -> (${this.pendingMove.toX},${this.pendingMove.toY})`
: 'none';
this.debugOverlay.setText([
'Heros Web Debug',
`scene=${this.scene.key}`,
`turn=${this.turnNumber} faction=${this.activeFaction} phase=${this.phase}`,
`selected=${selected}`,
`pendingMove=${pending}`,
`acted=${Array.from(this.actedUnitIds).join(',') || 'none'}`,
`pointerTile=${pointerTile ? `${pointerTile.x},${pointerTile.y}` : 'outside'}`,
'F9 toggle overlay / F10 log state'
]);
}
} }

View File

@@ -10,9 +10,24 @@ import './styles/global.css';
declare global { declare global {
interface Window { interface Window {
__HEROS_GAME__?: Phaser.Game; __HEROS_GAME__?: Phaser.Game;
__HEROS_DEBUG__?: HerosDebugApi;
} }
} }
type DebugBattleScene = Phaser.Scene & {
getDebugState?: () => unknown;
toggleDebugOverlay?: () => void;
};
type HerosDebugApi = {
game: Phaser.Game;
activeScenes: () => string[];
battle: () => unknown;
goToBattle: () => void;
scene: (key: string) => Phaser.Scene | undefined;
toggleBattleOverlay: () => void;
};
const config: Phaser.Types.Core.GameConfig = { const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO, type: Phaser.AUTO,
parent: 'game', parent: 'game',
@@ -32,4 +47,24 @@ const game = new Phaser.Game(config);
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
window.__HEROS_GAME__ = game; window.__HEROS_GAME__ = game;
window.__HEROS_DEBUG__ = createDebugApi(game);
console.info('[Heros Debug] Use window.__HEROS_DEBUG__.battle() or press F9 in battle.');
}
function createDebugApi(game: Phaser.Game): HerosDebugApi {
const scene = (key: string) => game.scene.getScene(key);
const battleScene = () => scene('BattleScene') as DebugBattleScene | undefined;
return {
game,
activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key),
battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' },
goToBattle: () => {
game.scene.start('BattleScene');
},
scene,
toggleBattleOverlay: () => {
battleScene()?.toggleDebugOverlay?.();
}
};
} }

11
vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173
},
build: {
sourcemap: true
}
});