mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
feat(castle-siege): scaffold 3D tower defense game
Castle Siege Defense -- a 3D tower defense where the player defends a medieval castle against waves of marching enemies by tapping/clicking to launch arcing projectiles with splash damage. Architecture: - EventBus singleton with 18 events across game, castle, enemy, projectile, wave, score, and audio domains - GameState with wave, castleHealth, enemiesKilled, isMuted - Constants.js for all config (castle, enemy, projectile, wave, camera, level, colors) - Game.js orchestrator: init all systems, manage loop, auto-start Gameplay systems: - Castle: impressive medieval geometry (keep, 4 towers with cone roofs, walls, battlements, gate with arch, banners), damage flash feedback - EnemyManager: wave spawning with lane distribution, increasing difficulty (+3 enemies/wave, +10% speed/wave), wave pause/complete - ProjectileManager: parabolic arc trajectories, splash damage radius, cooldown, glowing impact effects - InputSystem: raycaster tap-to-fire + Space key for testing - LevelBuilder: terrain, dirt path, shadow-casting lighting, hemisphere light, sky dome, decorative trees - HUD: wave banner, castle health bar with color shifts - Menu: game over overlay with wave/score/best display Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
output/
|
||||
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>Castle Siege Defense</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background: #000; overflow: hidden; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
canvas { display: block; cursor: crosshair; }
|
||||
|
||||
/* Score HUD omitted — Play.fun widget displays score in a deadzone at the top */
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
z-index: 20;
|
||||
color: #fff;
|
||||
padding: 20px;
|
||||
padding-top: max(20px, 8vh); /* Safe zone: Play.fun widget bar is ~75px at top */
|
||||
}
|
||||
.overlay.hidden { display: none; }
|
||||
|
||||
.overlay h1 {
|
||||
font-size: clamp(28px, 7vmin, 56px);
|
||||
margin-bottom: clamp(10px, 2vmin, 24px);
|
||||
text-align: center;
|
||||
color: #ff4444;
|
||||
text-shadow: 0 2px 8px rgba(255, 0, 0, 0.3);
|
||||
}
|
||||
.overlay p {
|
||||
font-size: clamp(14px, 2.5vmin, 22px);
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
text-align: center;
|
||||
}
|
||||
.overlay .score-display {
|
||||
font-size: clamp(18px, 4vmin, 32px);
|
||||
margin: 8px 0;
|
||||
}
|
||||
.overlay .wave-display {
|
||||
font-size: clamp(16px, 3vmin, 28px);
|
||||
margin: 4px 0;
|
||||
color: #ffaa44;
|
||||
}
|
||||
.overlay .best-display {
|
||||
font-size: clamp(14px, 2.5vmin, 22px);
|
||||
color: #aaa;
|
||||
}
|
||||
.overlay button {
|
||||
margin-top: clamp(16px, 3vmin, 32px);
|
||||
padding: clamp(10px, 2vmin, 16px) clamp(28px, 6vmin, 48px);
|
||||
font-size: clamp(16px, 2.5vmin, 22px);
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background: #8B0000;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
min-width: 120px;
|
||||
transition: background 0.2s, transform 0.1s;
|
||||
}
|
||||
.overlay button:hover { background: #a52a2a; transform: scale(1.05); }
|
||||
.overlay button:active { background: #6a0000; transform: scale(0.95); }
|
||||
|
||||
/* Wave announcement banner */
|
||||
#wave-banner {
|
||||
position: fixed;
|
||||
top: max(80px, 9vh); /* Below safe zone */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #ffaa44;
|
||||
padding: 12px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: clamp(18px, 3.5vmin, 32px);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
#wave-banner.visible { opacity: 1; }
|
||||
|
||||
/* Castle health bar */
|
||||
#health-bar-container {
|
||||
position: fixed;
|
||||
bottom: max(16px, 2vh);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: clamp(200px, 40vw, 400px);
|
||||
height: clamp(16px, 2.5vmin, 24px);
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
z-index: 15;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
#health-bar-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, #ff4444, #44ff44);
|
||||
transition: width 0.3s ease;
|
||||
border-radius: 10px;
|
||||
}
|
||||
#health-bar-label {
|
||||
position: fixed;
|
||||
bottom: max(42px, 5vh);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #fff;
|
||||
font-size: clamp(12px, 1.8vmin, 16px);
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="gameover-overlay" class="overlay hidden">
|
||||
<h1>CASTLE FALLEN!</h1>
|
||||
<div class="wave-display" id="final-wave">Wave: 0</div>
|
||||
<div class="score-display" id="final-score">Score: 0</div>
|
||||
<div class="best-display" id="best-score">Best: 0</div>
|
||||
<p>Tap or click to fire projectiles at the invaders!</p>
|
||||
<button id="restart-btn">DEFEND AGAIN</button>
|
||||
</div>
|
||||
|
||||
<div id="wave-banner"></div>
|
||||
|
||||
<div id="health-bar-label">Castle Health</div>
|
||||
<div id="health-bar-container">
|
||||
<div id="health-bar-fill"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1975
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "castle-siege",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "npx playwright test",
|
||||
"test:headed": "npx playwright test --headed",
|
||||
"test:ui": "npx playwright test --ui",
|
||||
"test:update-snapshots": "npx playwright test --update-snapshots",
|
||||
"verify": "node scripts/verify-runtime.mjs",
|
||||
"validate": "node scripts/validate-architecture.mjs",
|
||||
"iterate": "node scripts/iterate-client.js --url http://localhost:3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"three": "^0.183.0",
|
||||
"@strudel/web": "^1.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.3.1",
|
||||
"@playwright/test": "^1.58.0",
|
||||
"@axe-core/playwright": "^4.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{"buttons":["Space"],"frames":10},
|
||||
{"buttons":[],"frames":30},
|
||||
{"buttons":["Space"],"frames":10},
|
||||
{"buttons":[],"frames":30},
|
||||
{"buttons":["Space"],"frames":10},
|
||||
{"buttons":[],"frames":30},
|
||||
{"buttons":["Space"],"frames":10},
|
||||
{"buttons":[],"frames":80}
|
||||
]
|
||||
@@ -0,0 +1,672 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// iterate-client.js — Tight implement→test loop for browser game development
|
||||
//
|
||||
// A standalone Playwright script that launches a browser, performs choreographed
|
||||
// actions on a game, captures screenshots + text state, and tracks console errors.
|
||||
// Designed for AI agents to verify changes after each small code edit.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/iterate-client.js --url http://localhost:3000 \
|
||||
// --actions-json '[{"buttons":["space"],"frames":4}]' \
|
||||
// --iterations 3 --pause-ms 250
|
||||
//
|
||||
// Or with an actions file:
|
||||
// node scripts/iterate-client.js --url http://localhost:3000 \
|
||||
// --actions-file scripts/example-actions.json --iterations 5
|
||||
//
|
||||
// Or a simple click:
|
||||
// node scripts/iterate-client.js --url http://localhost:3000 \
|
||||
// --click 480,270 --iterations 2
|
||||
//
|
||||
// Multi-restart testing:
|
||||
// node scripts/iterate-client.js --url http://localhost:3000 \
|
||||
// --restart-cycles 3 --restart-key Space
|
||||
//
|
||||
// Outputs:
|
||||
// <screenshot-dir>/shot-<i>.png — canvas screenshot per iteration
|
||||
// <screenshot-dir>/state-<i>.json — render_game_to_text() output per iteration
|
||||
// <screenshot-dir>/errors-<i>.json — console errors (breaks on first new error)
|
||||
// =============================================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI argument parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
url: null,
|
||||
iterations: 3,
|
||||
pauseMs: 250,
|
||||
headless: true,
|
||||
screenshotDir: 'output/iterate',
|
||||
actionsFile: null,
|
||||
actionsJson: null,
|
||||
click: null,
|
||||
clickSelector: null,
|
||||
waitForGame: true, // Wait for window.__GAME__ to be ready
|
||||
timeoutMs: 10000, // Max wait for game boot
|
||||
restartCycles: 0, // Number of restart cycles (0 = disabled)
|
||||
restartKey: 'Space', // Key to press to restart the game
|
||||
};
|
||||
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = argv[i + 1];
|
||||
if (arg === '--url' && next) { args.url = next; i++; }
|
||||
else if (arg === '--iterations' && next) { args.iterations = parseInt(next, 10); i++; }
|
||||
else if (arg === '--pause-ms' && next) { args.pauseMs = parseInt(next, 10); i++; }
|
||||
else if (arg === '--headless' && next) { args.headless = next !== '0' && next !== 'false'; i++; }
|
||||
else if (arg === '--screenshot-dir' && next) { args.screenshotDir = next; i++; }
|
||||
else if (arg === '--actions-file' && next) { args.actionsFile = next; i++; }
|
||||
else if (arg === '--actions-json' && next) { args.actionsJson = next; i++; }
|
||||
else if (arg === '--click' && next) {
|
||||
const parts = next.split(',').map(v => parseFloat(v.trim()));
|
||||
if (parts.length === 2 && parts.every(v => Number.isFinite(v))) {
|
||||
args.click = { x: parts[0], y: parts[1] };
|
||||
}
|
||||
i++;
|
||||
}
|
||||
else if (arg === '--click-selector' && next) { args.clickSelector = next; i++; }
|
||||
else if (arg === '--no-wait') { args.waitForGame = false; }
|
||||
else if (arg === '--timeout' && next) { args.timeoutMs = parseInt(next, 10); i++; }
|
||||
else if (arg === '--restart-cycles' && next) { args.restartCycles = parseInt(next, 10); i++; }
|
||||
else if (arg === '--restart-key' && next) { args.restartKey = next; i++; }
|
||||
}
|
||||
|
||||
if (!args.url) {
|
||||
console.error(`Usage: node iterate-client.js --url <game-url> [--actions-json <json> | --actions-file <path> | --click x,y]
|
||||
|
||||
Required:
|
||||
--url <url> Game URL (e.g., http://localhost:3000)
|
||||
|
||||
Actions (at least one required):
|
||||
--actions-json <json> Inline JSON array of action steps
|
||||
--actions-file <path> Path to JSON file with action steps
|
||||
--click <x,y> Single click at canvas-relative coordinates
|
||||
|
||||
Options:
|
||||
--iterations <n> Number of action→capture cycles (default: 3)
|
||||
--pause-ms <ms> Pause between iterations (default: 250)
|
||||
--headless <bool> Run headless (default: true, use 'false' for debugging)
|
||||
--screenshot-dir <dir> Output directory (default: output/iterate)
|
||||
--click-selector <sel> CSS selector to click before starting actions
|
||||
--no-wait Don't wait for window.__GAME__ to be ready
|
||||
--timeout <ms> Max wait for game boot (default: 10000)
|
||||
|
||||
Restart testing:
|
||||
--restart-cycles <n> Number of restart cycles to test (default: 0 = disabled)
|
||||
--restart-key <key> Key to press to restart (default: Space)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Key mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const KEY_MAP = {
|
||||
up: 'ArrowUp',
|
||||
down: 'ArrowDown',
|
||||
left: 'ArrowLeft',
|
||||
right: 'ArrowRight',
|
||||
enter: 'Enter',
|
||||
space: 'Space',
|
||||
escape: 'Escape',
|
||||
tab: 'Tab',
|
||||
w: 'KeyW',
|
||||
a: 'KeyA',
|
||||
s: 'KeyS',
|
||||
d: 'KeyD',
|
||||
f: 'KeyF',
|
||||
m: 'KeyM',
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function ensureDir(p) {
|
||||
fs.mkdirSync(p, { recursive: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Virtual time shim — injected before page load via addInitScript.
|
||||
// Overrides RAF/setTimeout/setInterval to provide advanceTime() even for
|
||||
// games that don't expose it natively. If the game already has advanceTime,
|
||||
// this shim's version takes precedence (injected earlier).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeVirtualTimeShim() {
|
||||
return `(() => {
|
||||
const origRAF = window.requestAnimationFrame.bind(window);
|
||||
const origSetTimeout = window.setTimeout.bind(window);
|
||||
const origSetInterval = window.setInterval.bind(window);
|
||||
|
||||
const pending = new Set();
|
||||
window.__vt_pending = pending;
|
||||
|
||||
window.setTimeout = (fn, t, ...rest) => {
|
||||
const task = {};
|
||||
pending.add(task);
|
||||
return origSetTimeout(() => { pending.delete(task); fn(...rest); }, t);
|
||||
};
|
||||
|
||||
window.setInterval = (fn, t, ...rest) => {
|
||||
const task = {};
|
||||
pending.add(task);
|
||||
return origSetInterval(() => { fn(...rest); }, t);
|
||||
};
|
||||
|
||||
window.requestAnimationFrame = (fn) => {
|
||||
const task = {};
|
||||
pending.add(task);
|
||||
return origRAF((ts) => { pending.delete(task); fn(ts); });
|
||||
};
|
||||
|
||||
// Wait for real time to elapse (game loop runs normally via patched RAF)
|
||||
window.advanceTime = (ms) => {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
function step(now) {
|
||||
if (now - start >= ms) return resolve();
|
||||
origRAF(step);
|
||||
}
|
||||
origRAF(step);
|
||||
});
|
||||
};
|
||||
|
||||
window.__drainVirtualTimePending = () => pending.size;
|
||||
})();`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas detection and screenshot capture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getCanvasHandle(page) {
|
||||
const handle = await page.evaluateHandle(() => {
|
||||
let best = null;
|
||||
let bestArea = 0;
|
||||
for (const canvas of document.querySelectorAll('canvas')) {
|
||||
const area = (canvas.width || canvas.clientWidth || 0) * (canvas.height || canvas.clientHeight || 0);
|
||||
if (area > bestArea) {
|
||||
bestArea = area;
|
||||
best = canvas;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
});
|
||||
return handle.asElement();
|
||||
}
|
||||
|
||||
async function captureCanvasPngBase64(canvas) {
|
||||
return canvas.evaluate(c => {
|
||||
if (!c || typeof c.toDataURL !== 'function') return '';
|
||||
try {
|
||||
const data = c.toDataURL('image/png');
|
||||
const idx = data.indexOf(',');
|
||||
return idx === -1 ? '' : data.slice(idx + 1);
|
||||
} catch {
|
||||
return ''; // Security: tainted canvas
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function isCanvasTransparent(canvas) {
|
||||
if (!canvas) return true;
|
||||
return canvas.evaluate(c => {
|
||||
try {
|
||||
const w = c.width || c.clientWidth || 0;
|
||||
const h = c.height || c.clientHeight || 0;
|
||||
if (!w || !h) return true;
|
||||
const size = Math.max(1, Math.min(16, w, h));
|
||||
const probe = document.createElement('canvas');
|
||||
probe.width = size;
|
||||
probe.height = size;
|
||||
const ctx = probe.getContext('2d');
|
||||
if (!ctx) return true;
|
||||
ctx.drawImage(c, 0, 0, size, size);
|
||||
const data = ctx.getImageData(0, 0, size, size).data;
|
||||
for (let i = 3; i < data.length; i += 4) {
|
||||
if (data[i] !== 0) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false; // Can't probe → assume not transparent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function captureScreenshot(page, canvas, outPath) {
|
||||
let buffer = null;
|
||||
|
||||
// Strategy 1: canvas.toDataURL (highest fidelity for WebGL)
|
||||
if (canvas) {
|
||||
const base64 = await captureCanvasPngBase64(canvas);
|
||||
if (base64) {
|
||||
buffer = Buffer.from(base64, 'base64');
|
||||
const transparent = await isCanvasTransparent(canvas);
|
||||
if (transparent) buffer = null; // Probably failed, try next strategy
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Playwright element screenshot
|
||||
if (!buffer && canvas) {
|
||||
try {
|
||||
buffer = await canvas.screenshot({ type: 'png' });
|
||||
} catch {
|
||||
buffer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Page screenshot with canvas clip
|
||||
if (!buffer) {
|
||||
const bbox = canvas ? await canvas.boundingBox() : null;
|
||||
if (bbox) {
|
||||
buffer = await page.screenshot({ type: 'png', omitBackground: false, clip: bbox });
|
||||
} else {
|
||||
buffer = await page.screenshot({ type: 'png', omitBackground: false });
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(outPath, buffer);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Console error tracking (deduplicated)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ConsoleErrorTracker {
|
||||
constructor() {
|
||||
this._seen = new Set();
|
||||
this._errors = [];
|
||||
}
|
||||
|
||||
ingest(err) {
|
||||
const key = JSON.stringify(err);
|
||||
if (this._seen.has(key)) return;
|
||||
this._seen.add(key);
|
||||
this._errors.push(err);
|
||||
}
|
||||
|
||||
drain() {
|
||||
const out = [...this._errors];
|
||||
this._errors = [];
|
||||
return out;
|
||||
}
|
||||
|
||||
get count() {
|
||||
return this._errors.length;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action choreography
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runActions(page, canvas, steps) {
|
||||
for (const step of steps) {
|
||||
const buttons = new Set(step.buttons || []);
|
||||
|
||||
// Press down all buttons
|
||||
for (const button of buttons) {
|
||||
if (button === 'left_mouse_button' || button === 'right_mouse_button') {
|
||||
const bbox = canvas ? await canvas.boundingBox() : null;
|
||||
if (!bbox) continue;
|
||||
const x = typeof step.mouse_x === 'number' ? step.mouse_x : bbox.width / 2;
|
||||
const y = typeof step.mouse_y === 'number' ? step.mouse_y : bbox.height / 2;
|
||||
await page.mouse.move(bbox.x + x, bbox.y + y);
|
||||
await page.mouse.down({ button: button === 'left_mouse_button' ? 'left' : 'right' });
|
||||
} else {
|
||||
const key = KEY_MAP[button] || button;
|
||||
await page.keyboard.down(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance frame-by-frame (each evaluate round-trip lets Playwright process
|
||||
// events, so input state is properly registered per-frame)
|
||||
const frames = step.frames || 1;
|
||||
for (let f = 0; f < frames; f++) {
|
||||
await page.evaluate(async () => {
|
||||
if (typeof window.advanceTime === 'function') {
|
||||
await window.advanceTime(1000 / 60);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Release all buttons
|
||||
for (const button of buttons) {
|
||||
if (button === 'left_mouse_button' || button === 'right_mouse_button') {
|
||||
await page.mouse.up({ button: button === 'left_mouse_button' ? 'left' : 'right' });
|
||||
} else {
|
||||
const key = KEY_MAP[button] || button;
|
||||
await page.keyboard.up(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Optional wait between steps
|
||||
if (step.wait_ms) {
|
||||
await sleep(step.wait_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parse action steps from CLI args
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadActions(args) {
|
||||
if (args.actionsFile) {
|
||||
const raw = fs.readFileSync(args.actionsFile, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : parsed.steps || [];
|
||||
}
|
||||
if (args.actionsJson) {
|
||||
const parsed = JSON.parse(args.actionsJson);
|
||||
return Array.isArray(parsed) ? parsed : parsed.steps || [];
|
||||
}
|
||||
if (args.click) {
|
||||
return [{
|
||||
buttons: ['left_mouse_button'],
|
||||
frames: 2,
|
||||
mouse_x: args.click.x,
|
||||
mouse_y: args.click.y,
|
||||
}];
|
||||
}
|
||||
// Default: single space press (works for most menu→game transitions)
|
||||
return [{ buttons: ['space'], frames: 4 }];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
const steps = loadActions(args);
|
||||
ensureDir(args.screenshotDir);
|
||||
|
||||
console.log(`iterate-client: ${args.url}`);
|
||||
console.log(` actions: ${steps.length} steps, ${args.iterations} iterations`);
|
||||
if (args.restartCycles > 0) {
|
||||
console.log(` restart: ${args.restartCycles} cycles, key: ${args.restartKey}`);
|
||||
}
|
||||
console.log(` output: ${args.screenshotDir}/`);
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: args.headless,
|
||||
args: ['--use-gl=angle', '--use-angle=swiftshader'],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
const errors = new ConsoleErrorTracker();
|
||||
|
||||
// Track console errors
|
||||
page.on('console', msg => {
|
||||
if (msg.type() !== 'error') return;
|
||||
errors.ingest({ type: 'console.error', text: msg.text() });
|
||||
});
|
||||
page.on('pageerror', err => {
|
||||
errors.ingest({ type: 'pageerror', text: String(err) });
|
||||
});
|
||||
|
||||
// Inject virtual time shim before page load — provides advanceTime() even
|
||||
// for games that don't expose it, and tracks pending async operations
|
||||
await page.addInitScript({ content: makeVirtualTimeShim() });
|
||||
|
||||
// Navigate
|
||||
await page.goto(args.url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Wait for game to be ready (our templates expose window.__GAME__)
|
||||
if (args.waitForGame) {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
// Phaser: __GAME__.isBooted && __GAME__.canvas
|
||||
const g = window.__GAME__;
|
||||
if (g && g.isBooted && g.canvas) return true;
|
||||
// Three.js: __GAME__.renderer
|
||||
if (g && g.renderer) return true;
|
||||
return false;
|
||||
}, { timeout: args.timeoutMs });
|
||||
} catch {
|
||||
console.warn(' warn: timed out waiting for game boot, proceeding anyway');
|
||||
}
|
||||
}
|
||||
|
||||
// Let initial render settle
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('resize')));
|
||||
|
||||
let canvas = await getCanvasHandle(page);
|
||||
|
||||
// Optional pre-action click (e.g., click a start button)
|
||||
if (args.clickSelector) {
|
||||
try {
|
||||
await page.click(args.clickSelector, { timeout: 5000 });
|
||||
await page.waitForTimeout(250);
|
||||
} catch (err) {
|
||||
console.warn(` warn: failed to click selector "${args.clickSelector}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for boot errors before starting iterations
|
||||
const bootErrors = errors.drain();
|
||||
if (bootErrors.length) {
|
||||
const errPath = path.join(args.screenshotDir, 'errors-boot.json');
|
||||
fs.writeFileSync(errPath, JSON.stringify(bootErrors, null, 2));
|
||||
console.error(` BOOT ERRORS (${bootErrors.length}): see ${errPath}`);
|
||||
}
|
||||
|
||||
// --- Iteration loop ---
|
||||
let hadErrors = false;
|
||||
for (let i = 0; i < args.iterations; i++) {
|
||||
if (!canvas) canvas = await getCanvasHandle(page);
|
||||
|
||||
// Run choreographed actions
|
||||
await runActions(page, canvas, steps);
|
||||
await sleep(args.pauseMs);
|
||||
|
||||
// Capture screenshot
|
||||
const shotPath = path.join(args.screenshotDir, `shot-${i}.png`);
|
||||
await captureScreenshot(page, canvas, shotPath);
|
||||
|
||||
// Capture text state
|
||||
const textState = await page.evaluate(() => {
|
||||
if (typeof window.render_game_to_text === 'function') {
|
||||
return window.render_game_to_text();
|
||||
}
|
||||
// Fallback: read __GAME_STATE__ directly
|
||||
if (window.__GAME_STATE__) {
|
||||
return JSON.stringify(window.__GAME_STATE__);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
if (textState) {
|
||||
fs.writeFileSync(path.join(args.screenshotDir, `state-${i}.json`), textState);
|
||||
}
|
||||
|
||||
const tag = textState ? ` state=${textState.slice(0, 80)}` : '';
|
||||
console.log(` [${i}] screenshot: ${shotPath}${tag}`);
|
||||
|
||||
// Check for new errors
|
||||
const freshErrors = errors.drain();
|
||||
if (freshErrors.length) {
|
||||
const errPath = path.join(args.screenshotDir, `errors-${i}.json`);
|
||||
fs.writeFileSync(errPath, JSON.stringify(freshErrors, null, 2));
|
||||
console.error(` [${i}] ERRORS (${freshErrors.length}): see ${errPath}`);
|
||||
hadErrors = true;
|
||||
break; // Stop on first new error — fix before continuing
|
||||
}
|
||||
}
|
||||
|
||||
// --- Restart cycle testing ---
|
||||
if (!hadErrors && args.restartCycles > 0) {
|
||||
console.log(`\n--- Restart cycle testing (${args.restartCycles} cycles, key: ${args.restartKey}) ---`);
|
||||
const restartIssues = [];
|
||||
|
||||
for (let cycle = 0; cycle < args.restartCycles; cycle++) {
|
||||
console.log(`\n [cycle ${cycle + 1}/${args.restartCycles}]`);
|
||||
|
||||
// Step 1: Run actions to play the game
|
||||
if (!canvas) canvas = await getCanvasHandle(page);
|
||||
await runActions(page, canvas, steps);
|
||||
await sleep(args.pauseMs);
|
||||
|
||||
// Step 2: Wait for game_over state (poll render_game_to_text every 500ms, timeout 15s)
|
||||
let gameOverReached = false;
|
||||
const pollStart = Date.now();
|
||||
const pollTimeout = 15000;
|
||||
|
||||
while (Date.now() - pollStart < pollTimeout) {
|
||||
const stateStr = await page.evaluate(() => {
|
||||
if (typeof window.render_game_to_text === 'function') {
|
||||
return window.render_game_to_text();
|
||||
}
|
||||
if (window.__GAME_STATE__) {
|
||||
return JSON.stringify(window.__GAME_STATE__);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (stateStr) {
|
||||
try {
|
||||
const state = JSON.parse(stateStr);
|
||||
if (state.mode === 'game_over' || state.gameOver === true) {
|
||||
gameOverReached = true;
|
||||
console.log(` game_over reached (${Date.now() - pollStart}ms)`);
|
||||
break;
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
}
|
||||
|
||||
// Advance time and wait before next poll
|
||||
await page.evaluate(async () => {
|
||||
if (typeof window.advanceTime === 'function') {
|
||||
await window.advanceTime(500);
|
||||
}
|
||||
});
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
if (!gameOverReached) {
|
||||
console.warn(' warn: game_over not reached within 15s, pressing restart anyway');
|
||||
}
|
||||
|
||||
// Capture pre-restart screenshot
|
||||
const preRestartPath = path.join(args.screenshotDir, `restart-${cycle}-pre.png`);
|
||||
await captureScreenshot(page, canvas, preRestartPath);
|
||||
|
||||
// Step 3: Press the restart key
|
||||
const restartKeyMapped = KEY_MAP[args.restartKey.toLowerCase()] || args.restartKey;
|
||||
await page.keyboard.press(restartKeyMapped);
|
||||
console.log(` pressed ${args.restartKey}`);
|
||||
|
||||
// Small delay for game to process restart
|
||||
await sleep(500);
|
||||
|
||||
// Also advance a few frames for the game loop to process
|
||||
await page.evaluate(async () => {
|
||||
if (typeof window.advanceTime === 'function') {
|
||||
await window.advanceTime(500);
|
||||
}
|
||||
});
|
||||
await sleep(250);
|
||||
|
||||
// Step 4: Verify state after restart
|
||||
const postStateStr = await page.evaluate(() => {
|
||||
if (typeof window.render_game_to_text === 'function') {
|
||||
return window.render_game_to_text();
|
||||
}
|
||||
if (window.__GAME_STATE__) {
|
||||
return JSON.stringify(window.__GAME_STATE__);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// Capture post-restart screenshot
|
||||
const postRestartPath = path.join(args.screenshotDir, `restart-${cycle}-post.png`);
|
||||
await captureScreenshot(page, canvas, postRestartPath);
|
||||
|
||||
if (postStateStr) {
|
||||
try {
|
||||
const postState = JSON.parse(postStateStr);
|
||||
const issues = [];
|
||||
|
||||
// Check score reset to 0
|
||||
if (typeof postState.score === 'number' && postState.score !== 0) {
|
||||
issues.push(`score not reset: expected 0, got ${postState.score}`);
|
||||
}
|
||||
|
||||
// Check mode is not game_over (should be playing, menu, or similar)
|
||||
if (postState.mode === 'game_over') {
|
||||
issues.push(`mode still game_over after restart`);
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.error(` ISSUES: ${issues.join('; ')}`);
|
||||
restartIssues.push({
|
||||
cycle: cycle + 1,
|
||||
issues,
|
||||
preRestartScreenshot: preRestartPath,
|
||||
postRestartScreenshot: postRestartPath,
|
||||
postState: postState,
|
||||
});
|
||||
} else {
|
||||
const mode = postState.mode || 'unknown';
|
||||
const score = postState.score ?? 'n/a';
|
||||
console.log(` OK — mode: ${mode}, score: ${score}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` warn: could not parse post-restart state: ${err.message}`);
|
||||
}
|
||||
} else {
|
||||
console.warn(' warn: render_game_to_text() returned null after restart');
|
||||
}
|
||||
|
||||
// Check for console errors during this cycle
|
||||
const cycleErrors = errors.drain();
|
||||
if (cycleErrors.length) {
|
||||
const errPath = path.join(args.screenshotDir, `errors-restart-${cycle}.json`);
|
||||
fs.writeFileSync(errPath, JSON.stringify(cycleErrors, null, 2));
|
||||
console.error(` ERRORS (${cycleErrors.length}): see ${errPath}`);
|
||||
hadErrors = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Write issues file if there were any
|
||||
if (restartIssues.length > 0) {
|
||||
const issuesPath = path.join(args.screenshotDir, `restart-${args.restartCycles}-issues.json`);
|
||||
fs.writeFileSync(issuesPath, JSON.stringify(restartIssues, null, 2));
|
||||
console.error(`\n Restart issues written to ${issuesPath}`);
|
||||
} else if (!hadErrors) {
|
||||
console.log(`\n All ${args.restartCycles} restart cycles passed`);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (hadErrors) {
|
||||
console.error('\niterate-client: FAILED — console errors detected');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('\niterate-client: PASSED — no errors');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// validate-architecture.mjs — Static architecture validation for Three.js games
|
||||
//
|
||||
// Checks that a game project follows the required architecture patterns:
|
||||
// - render_game_to_text() in main.js
|
||||
// - GameState.reset() in core/GameState.js
|
||||
// - SAFE_ZONE constant in core/Constants.js
|
||||
// - systems/ directory exists with at least one system file
|
||||
// - EventBus events defined in core/EventBus.js
|
||||
// - [WARN] Hardcoded magic numbers outside Constants.js
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/validate-architecture.mjs
|
||||
//
|
||||
// Exits 0 if all required checks pass, 1 if any FAIL.
|
||||
// =============================================================================
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const CWD = process.cwd();
|
||||
const SRC = path.join(CWD, 'src');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readFile(relPath) {
|
||||
const full = path.join(SRC, relPath);
|
||||
if (!fs.existsSync(full)) return null;
|
||||
return fs.readFileSync(full, 'utf-8');
|
||||
}
|
||||
|
||||
function readDir(relPath) {
|
||||
const full = path.join(SRC, relPath);
|
||||
if (!fs.existsSync(full) || !fs.statSync(full).isDirectory()) return [];
|
||||
return fs.readdirSync(full).filter(f => f.endsWith('.js'));
|
||||
}
|
||||
|
||||
function getAllJsFiles(dir, base) {
|
||||
const results = [];
|
||||
if (!fs.existsSync(dir)) return results;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
const rel = path.join(base, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...getAllJsFiles(full, rel));
|
||||
} else if (entry.name.endsWith('.js')) {
|
||||
results.push({ path: full, rel });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Checks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let warnings = 0;
|
||||
|
||||
function pass(msg) {
|
||||
console.log(`[PASS] ${msg}`);
|
||||
passed++;
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
console.log(`[FAIL] ${msg}`);
|
||||
failed++;
|
||||
}
|
||||
|
||||
function warn(msg) {
|
||||
console.log(`[WARN] ${msg}`);
|
||||
warnings++;
|
||||
}
|
||||
|
||||
console.log('=== Architecture Validation (Three.js) ===\n');
|
||||
|
||||
// Check 1: render_game_to_text() in main.js
|
||||
const mainJs = readFile('main.js');
|
||||
if (mainJs && /render_game_to_text/.test(mainJs)) {
|
||||
pass('render_game_to_text() found in main.js');
|
||||
} else if (!mainJs) {
|
||||
fail('render_game_to_text() — src/main.js not found');
|
||||
} else {
|
||||
fail('render_game_to_text() not found in main.js');
|
||||
}
|
||||
|
||||
// Check 2: GameState has reset() method
|
||||
const gameStateJs = readFile('core/GameState.js');
|
||||
if (gameStateJs && /reset\s*\(/.test(gameStateJs)) {
|
||||
pass('GameState.reset() found');
|
||||
} else if (!gameStateJs) {
|
||||
fail('GameState.reset() — src/core/GameState.js not found');
|
||||
} else {
|
||||
fail('GameState.reset() not found in GameState.js');
|
||||
}
|
||||
|
||||
// Check 3: SAFE_ZONE constant in Constants.js
|
||||
const constantsJs = readFile('core/Constants.js');
|
||||
if (constantsJs && /SAFE_ZONE/.test(constantsJs)) {
|
||||
pass('SAFE_ZONE found in Constants.js');
|
||||
} else if (!constantsJs) {
|
||||
fail('SAFE_ZONE — src/core/Constants.js not found');
|
||||
} else {
|
||||
fail('SAFE_ZONE not found in Constants.js');
|
||||
}
|
||||
|
||||
// Check 4: systems/ directory with system files (Three.js equivalent of Phaser scenes)
|
||||
const systemFiles = readDir('systems');
|
||||
if (systemFiles.length > 0) {
|
||||
pass(`systems/ directory found (${systemFiles.length} system file${systemFiles.length !== 1 ? 's' : ''}: ${systemFiles.join(', ')})`);
|
||||
} else {
|
||||
const systemsDir = path.join(SRC, 'systems');
|
||||
if (fs.existsSync(systemsDir)) {
|
||||
fail('systems/ directory exists but contains no .js files');
|
||||
} else {
|
||||
fail('systems/ directory not found — Three.js games need src/systems/ for game systems');
|
||||
}
|
||||
}
|
||||
|
||||
// Check 5: EventBus has events defined
|
||||
const eventBusJs = readFile('core/EventBus.js');
|
||||
let eventCount = 0;
|
||||
|
||||
if (eventBusJs) {
|
||||
// Count exported event constants — look for KEY: 'value' patterns in an Events object
|
||||
const eventMatches = eventBusJs.match(/[A-Z_]+\s*:\s*['"][a-z]+:[a-z_]+['"]/g);
|
||||
eventCount = eventMatches ? eventMatches.length : 0;
|
||||
|
||||
if (eventCount > 0) {
|
||||
pass(`EventBus events defined (${eventCount} events)`);
|
||||
} else {
|
||||
fail('EventBus — no event constants found in EventBus.js');
|
||||
}
|
||||
} else {
|
||||
fail('EventBus — src/core/EventBus.js not found');
|
||||
}
|
||||
|
||||
// Check 6: [WARN] Hardcoded magic numbers
|
||||
const allJsFiles = getAllJsFiles(SRC, '');
|
||||
const excludeFiles = new Set(['core/Constants.js', 'core/PixelRenderer.js']);
|
||||
// Also exclude by basename for flexibility
|
||||
const excludeBaseNames = new Set(['Constants.js', 'PixelRenderer.js']);
|
||||
|
||||
// Common non-magic numbers to allow
|
||||
const allowedNumbers = new Set([
|
||||
0, 1, 2, 3, 4, 5, 10, 16, 32, 60, 64, 100, 255, 256, 1000,
|
||||
0.5, 0.25, 0.75, 1.0, 2.0,
|
||||
-1, -2,
|
||||
]);
|
||||
|
||||
const magicNumberPattern = /(?<!\w)(-?\d+\.?\d*)\b/g;
|
||||
const suspiciousLines = [];
|
||||
|
||||
for (const { path: filePath, rel } of allJsFiles) {
|
||||
// Normalize path separators for comparison
|
||||
const normRel = rel.replace(/\\/g, '/');
|
||||
const baseName = path.basename(filePath);
|
||||
|
||||
if (excludeFiles.has(normRel) || excludeBaseNames.has(baseName)) continue;
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
||||
const line = lines[lineNum];
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip comments
|
||||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) continue;
|
||||
// Skip import/export lines
|
||||
if (trimmed.startsWith('import ') || trimmed.startsWith('export ')) continue;
|
||||
// Skip lines that are just closing braces, returns of simple values, etc.
|
||||
if (trimmed.length < 5) continue;
|
||||
|
||||
let match;
|
||||
magicNumberPattern.lastIndex = 0;
|
||||
while ((match = magicNumberPattern.exec(line)) !== null) {
|
||||
const num = parseFloat(match[1]);
|
||||
if (allowedNumbers.has(num)) continue;
|
||||
if (isNaN(num)) continue;
|
||||
// Skip proportional values (alpha, scale, ratio)
|
||||
if (Math.abs(num) > 0 && Math.abs(num) < 1) continue;
|
||||
// Skip hex color literals (0x...)
|
||||
if (/0x[0-9a-fA-F]+/.test(match[0])) continue;
|
||||
const before = line.substring(0, match.index);
|
||||
if (/0x[0-9a-fA-F]*$/.test(before)) continue;
|
||||
// Skip version-like patterns and string contents
|
||||
if (/['"`]/.test(before) && /['"`]/.test(line.substring(match.index + match[0].length))) continue;
|
||||
|
||||
suspiciousLines.push({
|
||||
file: `src/${normRel}`,
|
||||
line: lineNum + 1,
|
||||
text: trimmed,
|
||||
number: match[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (suspiciousLines.length > 0) {
|
||||
warn('Possible magic numbers:');
|
||||
// Show up to 15 suspicious lines
|
||||
const shown = suspiciousLines.slice(0, 15);
|
||||
for (const s of shown) {
|
||||
console.log(` ${s.file}:${s.line} ${s.text}`);
|
||||
}
|
||||
if (suspiciousLines.length > 15) {
|
||||
console.log(` ... and ${suspiciousLines.length - 15} more`);
|
||||
}
|
||||
} else {
|
||||
console.log('[INFO] No suspicious magic numbers detected');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const total = passed + failed;
|
||||
console.log(`\nResults: ${passed}/${total} passed, ${failed} failed, ${warnings} warning${warnings !== 1 ? 's' : ''}`);
|
||||
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
// =============================================================================
|
||||
// verify-runtime.mjs — Headless runtime verification for browser games
|
||||
//
|
||||
// Launches headless Chromium, loads the game, checks for runtime errors
|
||||
// (WebGL failures, uncaught exceptions, console errors).
|
||||
// Exit 0 = pass, Exit 1 = fail (prints errors to stderr).
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/verify-runtime.mjs
|
||||
// PORT=5173 node scripts/verify-runtime.mjs
|
||||
// =============================================================================
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const URL = `http://localhost:${PORT}`;
|
||||
const WAIT_MS = 3000;
|
||||
|
||||
async function verify() {
|
||||
const errors = [];
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage();
|
||||
|
||||
page.on('pageerror', (err) => errors.push(`PAGE ERROR: ${err.message}`));
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(`CONSOLE ERROR: ${msg.text()}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 10000 });
|
||||
if (!response || response.status() >= 400) {
|
||||
errors.push(`HTTP ${response?.status() || 'NO_RESPONSE'} loading ${URL}`);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`NAVIGATION ERROR: ${e.message}`);
|
||||
}
|
||||
|
||||
// Wait for game to initialize and render
|
||||
await page.waitForTimeout(WAIT_MS);
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Runtime verification FAILED with ${errors.length} error(s):\n`);
|
||||
errors.forEach((e, i) => console.error(` ${i + 1}. ${e}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Runtime verification PASSED — no errors detected.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
verify();
|
||||
@@ -0,0 +1,176 @@
|
||||
// =============================================================================
|
||||
// Constants.js — All magic numbers for Castle Siege Defense
|
||||
// Zero hardcoded values in game logic.
|
||||
// =============================================================================
|
||||
|
||||
export const GAME = {
|
||||
FOV: 60,
|
||||
NEAR: 0.1,
|
||||
FAR: 500,
|
||||
MAX_DELTA: 0.05,
|
||||
MAX_DPR: 2,
|
||||
};
|
||||
|
||||
export const IS_MOBILE = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||
(navigator.maxTouchPoints > 1);
|
||||
|
||||
// Play.fun SDK widget renders a 75px fixed bar at top:0, z-index:9999.
|
||||
// All HTML overlays must account for this with padding-top or safe offset.
|
||||
export const SAFE_ZONE = {
|
||||
TOP_PX: 75, // pixels — use for CSS/HTML overlays
|
||||
TOP_PERCENT: 8, // percent of viewport height
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Camera — isometric-like perspective behind and above the castle
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CAMERA = {
|
||||
POSITION_X: 0,
|
||||
POSITION_Y: 35,
|
||||
POSITION_Z: 35,
|
||||
LOOK_AT_X: 0,
|
||||
LOOK_AT_Y: 0,
|
||||
LOOK_AT_Z: -5,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Level / Battlefield
|
||||
// ---------------------------------------------------------------------------
|
||||
export const LEVEL = {
|
||||
GROUND_SIZE: 80,
|
||||
GROUND_COLOR: 0x4a7c2e,
|
||||
PATH_COLOR: 0x8B7355,
|
||||
PATH_WIDTH: 12,
|
||||
FOG_COLOR: 0x87ceeb,
|
||||
FOG_NEAR: 60,
|
||||
FOG_FAR: 200,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Castle geometry
|
||||
// ---------------------------------------------------------------------------
|
||||
export const CASTLE = {
|
||||
// Position — castle sits at the near (positive Z) end of the battlefield
|
||||
POSITION_Z: LEVEL.GROUND_SIZE / 2 - 8,
|
||||
POSITION_Y: 0,
|
||||
|
||||
// Main keep
|
||||
KEEP_WIDTH: 8,
|
||||
KEEP_HEIGHT: 10,
|
||||
KEEP_DEPTH: 8,
|
||||
KEEP_COLOR: 0xA0A0A0,
|
||||
|
||||
// Corner towers
|
||||
TOWER_RADIUS: 2.5,
|
||||
TOWER_HEIGHT: 12,
|
||||
TOWER_SEGMENTS: 8,
|
||||
TOWER_COLOR: 0x909090,
|
||||
TOWER_ROOF_COLOR: 0x8B0000,
|
||||
TOWER_ROOF_HEIGHT: 3,
|
||||
TOWER_SPREAD_X: 10,
|
||||
TOWER_SPREAD_Z: 5,
|
||||
|
||||
// Connecting walls
|
||||
WALL_HEIGHT: 7,
|
||||
WALL_THICKNESS: 1.5,
|
||||
WALL_COLOR: 0x989898,
|
||||
|
||||
// Battlements (crenellations)
|
||||
MERLON_SIZE: 0.8,
|
||||
MERLON_SPACING: 1.6,
|
||||
MERLON_COLOR: 0x888888,
|
||||
|
||||
// Gate
|
||||
GATE_WIDTH: 4,
|
||||
GATE_HEIGHT: 5,
|
||||
GATE_COLOR: 0x4a3728,
|
||||
|
||||
// Damage feedback
|
||||
DAMAGE_FLASH_DURATION: 0.15,
|
||||
DAMAGE_FLASH_COLOR: 0xff3333,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enemies
|
||||
// ---------------------------------------------------------------------------
|
||||
export const ENEMY = {
|
||||
// Body dimensions
|
||||
BODY_WIDTH: 0.8,
|
||||
BODY_HEIGHT: 1.6,
|
||||
BODY_DEPTH: 0.6,
|
||||
HEAD_RADIUS: 0.35,
|
||||
HEAD_Y_OFFSET: 1.3,
|
||||
|
||||
// Colors
|
||||
BODY_COLOR: 0x8B0000,
|
||||
HEAD_COLOR: 0xd4a574,
|
||||
SHIELD_COLOR: 0x555555,
|
||||
|
||||
// Movement
|
||||
BASE_SPEED: 4,
|
||||
SPEED_INCREASE_PER_WAVE: 0.1, // multiplier added per wave
|
||||
|
||||
// Spawn
|
||||
SPAWN_Z: -(LEVEL.GROUND_SIZE / 2 - 5),
|
||||
SPAWN_X_RANGE: LEVEL.GROUND_SIZE / 2 - 10,
|
||||
LANE_COUNT: 5,
|
||||
|
||||
// Health
|
||||
HEALTH: 1,
|
||||
|
||||
// Castle damage
|
||||
CASTLE_DAMAGE: 10,
|
||||
|
||||
// Score
|
||||
KILL_POINTS: 10,
|
||||
|
||||
// Y position (half body height)
|
||||
GROUND_Y: 0.8,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projectiles
|
||||
// ---------------------------------------------------------------------------
|
||||
export const PROJECTILE = {
|
||||
RADIUS: 0.4,
|
||||
COLOR: 0xff8800,
|
||||
GLOW_COLOR: 0xffaa33,
|
||||
ARC_HEIGHT: 15,
|
||||
TRAVEL_TIME: 0.8,
|
||||
COOLDOWN: 0.35,
|
||||
SPLASH_RADIUS: 3.5,
|
||||
|
||||
// Launch position (from castle top)
|
||||
LAUNCH_Y: 12,
|
||||
LAUNCH_Z: LEVEL.GROUND_SIZE / 2 - 8,
|
||||
|
||||
// Impact effect
|
||||
IMPACT_RADIUS: 2.0,
|
||||
IMPACT_DURATION: 0.3,
|
||||
IMPACT_COLOR: 0xff6600,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wave system
|
||||
// ---------------------------------------------------------------------------
|
||||
export const WAVE = {
|
||||
BASE_ENEMY_COUNT: 5,
|
||||
ENEMY_INCREMENT: 3,
|
||||
SPAWN_INTERVAL: 0.8, // seconds between each enemy spawn
|
||||
PAUSE_BETWEEN_WAVES: 3.0, // seconds between waves
|
||||
COMPLETION_BONUS: 50, // bonus points per wave completed
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Colors
|
||||
// ---------------------------------------------------------------------------
|
||||
export const COLORS = {
|
||||
SKY: 0x87ceeb,
|
||||
AMBIENT_LIGHT: 0xffffff,
|
||||
AMBIENT_INTENSITY: 0.7,
|
||||
DIR_LIGHT: 0xfff5e0,
|
||||
DIR_INTENSITY: 1.0,
|
||||
HEMISPHERE_SKY: 0x87ceeb,
|
||||
HEMISPHERE_GROUND: 0x4a7c2e,
|
||||
HEMISPHERE_INTENSITY: 0.3,
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// =============================================================================
|
||||
// EventBus.js — Pub/sub singleton for all cross-module communication
|
||||
// Modules never import each other directly. Events use domain:action naming.
|
||||
// =============================================================================
|
||||
|
||||
export const Events = {
|
||||
// Game lifecycle
|
||||
GAME_START: 'game:start',
|
||||
GAME_OVER: 'game:over',
|
||||
GAME_RESTART: 'game:restart',
|
||||
|
||||
// Castle
|
||||
CASTLE_HIT: 'castle:hit',
|
||||
CASTLE_DESTROYED: 'castle:destroyed',
|
||||
|
||||
// Enemies
|
||||
ENEMY_SPAWNED: 'enemy:spawned',
|
||||
ENEMY_KILLED: 'enemy:killed',
|
||||
ENEMY_REACHED_CASTLE: 'enemy:reached_castle',
|
||||
|
||||
// Projectiles
|
||||
PROJECTILE_LAUNCHED: 'projectile:launched',
|
||||
PROJECTILE_IMPACT: 'projectile:impact',
|
||||
|
||||
// Waves
|
||||
WAVE_START: 'wave:start',
|
||||
WAVE_COMPLETE: 'wave:complete',
|
||||
|
||||
// Score
|
||||
SCORE_CHANGED: 'score:changed',
|
||||
|
||||
// Audio (used by /add-audio)
|
||||
AUDIO_INIT: 'audio:init',
|
||||
MUSIC_MENU: 'music:menu',
|
||||
MUSIC_GAMEPLAY: 'music:gameplay',
|
||||
MUSIC_GAMEOVER: 'music:gameover',
|
||||
MUSIC_STOP: 'music:stop',
|
||||
};
|
||||
|
||||
class EventBus {
|
||||
constructor() {
|
||||
this.listeners = {};
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (!this.listeners[event]) return this;
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
||||
return this;
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
if (!this.listeners[event]) return this;
|
||||
this.listeners[event].forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (err) {
|
||||
console.error(`EventBus error in ${event}:`, err);
|
||||
}
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
removeAll() {
|
||||
this.listeners = {};
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
@@ -0,0 +1,135 @@
|
||||
// =============================================================================
|
||||
// Game.js — Orchestrator: init all systems, manage game loop
|
||||
// One entry point that initializes all systems and manages the game lifecycle.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { GAME, CAMERA, COLORS } from './Constants.js';
|
||||
import { eventBus, Events } from './EventBus.js';
|
||||
import { gameState } from './GameState.js';
|
||||
import { InputSystem } from '../systems/InputSystem.js';
|
||||
import { LevelBuilder } from '../level/LevelBuilder.js';
|
||||
import { Castle } from '../gameplay/Castle.js';
|
||||
import { EnemyManager } from '../gameplay/EnemyManager.js';
|
||||
import { ProjectileManager } from '../gameplay/ProjectileManager.js';
|
||||
import { Menu } from '../ui/Menu.js';
|
||||
import { HUD } from '../ui/HUD.js';
|
||||
|
||||
export class Game {
|
||||
constructor() {
|
||||
this.clock = new THREE.Clock();
|
||||
|
||||
// Renderer (DPR capped for mobile GPU performance)
|
||||
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, GAME.MAX_DPR));
|
||||
this.renderer.setClearColor(COLORS.SKY);
|
||||
this.renderer.shadowMap.enabled = true;
|
||||
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
document.body.prepend(this.renderer.domElement);
|
||||
|
||||
// Scene
|
||||
this.scene = new THREE.Scene();
|
||||
|
||||
// Camera — isometric-like perspective behind and above the castle
|
||||
this.camera = new THREE.PerspectiveCamera(
|
||||
GAME.FOV,
|
||||
window.innerWidth / window.innerHeight,
|
||||
GAME.NEAR,
|
||||
GAME.FAR
|
||||
);
|
||||
this.camera.position.set(CAMERA.POSITION_X, CAMERA.POSITION_Y, CAMERA.POSITION_Z);
|
||||
this.camera.lookAt(CAMERA.LOOK_AT_X, CAMERA.LOOK_AT_Y, CAMERA.LOOK_AT_Z);
|
||||
|
||||
// Systems & UI
|
||||
this.input = new InputSystem();
|
||||
this.input.setCamera(this.camera);
|
||||
this.level = new LevelBuilder(this.scene);
|
||||
this.menu = new Menu();
|
||||
this.hud = new HUD();
|
||||
|
||||
// Gameplay objects (created in startGame)
|
||||
this.castle = null;
|
||||
this.enemyManager = null;
|
||||
this.projectileManager = null;
|
||||
|
||||
// Events
|
||||
eventBus.on(Events.GAME_RESTART, () => this.restart());
|
||||
eventBus.on(Events.CASTLE_DESTROYED, () => this.onGameOver());
|
||||
|
||||
// Resize
|
||||
window.addEventListener('resize', () => this.onResize());
|
||||
|
||||
// Auto-start game (no title screen — Play.fun handles the chrome)
|
||||
this.startGame();
|
||||
|
||||
// Start render loop (official Three.js pattern — pauses when tab hidden)
|
||||
this.renderer.setAnimationLoop(() => this.animate());
|
||||
}
|
||||
|
||||
startGame() {
|
||||
gameState.reset();
|
||||
gameState.started = true;
|
||||
|
||||
// Create gameplay objects
|
||||
this.castle = new Castle(this.scene);
|
||||
this.enemyManager = new EnemyManager(this.scene);
|
||||
this.projectileManager = new ProjectileManager(this.scene, this.enemyManager);
|
||||
|
||||
// Wire up input
|
||||
this.input.setEnemyManager(this.enemyManager);
|
||||
this.input.setGameActive(true);
|
||||
|
||||
// Start first wave
|
||||
this.enemyManager.startFirstWave();
|
||||
|
||||
eventBus.emit(Events.GAME_START);
|
||||
}
|
||||
|
||||
onGameOver() {
|
||||
if (gameState.gameOver) return;
|
||||
gameState.gameOver = true;
|
||||
this.input.setGameActive(false);
|
||||
eventBus.emit(Events.GAME_OVER, { score: gameState.score });
|
||||
eventBus.emit(Events.MUSIC_STOP);
|
||||
}
|
||||
|
||||
restart() {
|
||||
// Clean up old gameplay objects
|
||||
if (this.castle) {
|
||||
this.castle.destroy();
|
||||
this.castle = null;
|
||||
}
|
||||
if (this.enemyManager) {
|
||||
this.enemyManager.destroyAll();
|
||||
this.enemyManager = null;
|
||||
}
|
||||
if (this.projectileManager) {
|
||||
this.projectileManager.destroyAll();
|
||||
this.projectileManager = null;
|
||||
}
|
||||
|
||||
this.startGame();
|
||||
}
|
||||
|
||||
animate() {
|
||||
const delta = Math.min(this.clock.getDelta(), GAME.MAX_DELTA);
|
||||
|
||||
this.input.update();
|
||||
|
||||
if (gameState.started && !gameState.gameOver) {
|
||||
// Update all gameplay systems
|
||||
if (this.castle) this.castle.update(delta);
|
||||
if (this.enemyManager) this.enemyManager.update(delta);
|
||||
if (this.projectileManager) this.projectileManager.update(delta);
|
||||
}
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
onResize() {
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// =============================================================================
|
||||
// GameState.js — Single centralized state object
|
||||
// Systems read from it. Events trigger mutations. Has reset() for clean restarts.
|
||||
// =============================================================================
|
||||
|
||||
class GameState {
|
||||
constructor() {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.score = 0;
|
||||
this.bestScore = this.bestScore || 0;
|
||||
this.started = false;
|
||||
this.gameOver = false;
|
||||
this.wave = 0;
|
||||
this.castleHealth = 100;
|
||||
this.maxCastleHealth = 100;
|
||||
this.enemiesKilled = 0;
|
||||
this.isMuted = false;
|
||||
}
|
||||
|
||||
addScore(points = 1) {
|
||||
this.score += points;
|
||||
if (this.score > this.bestScore) {
|
||||
this.bestScore = this.score;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gameState = new GameState();
|
||||
@@ -0,0 +1,271 @@
|
||||
// =============================================================================
|
||||
// Castle.js — Build castle geometry, handle damage, visual feedback
|
||||
// An impressive medieval castle built from composed Three.js geometries:
|
||||
// main keep, 4 corner towers with cone roofs, connecting walls, battlements, gate.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { CASTLE, ENEMY } from '../core/Constants.js';
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
|
||||
export class Castle {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.group = new THREE.Group();
|
||||
this.group.position.set(0, CASTLE.POSITION_Y, CASTLE.POSITION_Z);
|
||||
|
||||
this.flashTimer = 0;
|
||||
this.originalMaterials = [];
|
||||
this.flashMaterials = [];
|
||||
this.allMeshes = [];
|
||||
|
||||
this.buildKeep();
|
||||
this.buildTowers();
|
||||
this.buildWalls();
|
||||
this.buildBattlements();
|
||||
this.buildGate();
|
||||
this.buildBanners();
|
||||
|
||||
this.scene.add(this.group);
|
||||
|
||||
// Listen for castle damage
|
||||
eventBus.on(Events.ENEMY_REACHED_CASTLE, () => this.takeDamage());
|
||||
}
|
||||
|
||||
// --- Build Methods ---
|
||||
|
||||
buildKeep() {
|
||||
const geo = new THREE.BoxGeometry(CASTLE.KEEP_WIDTH, CASTLE.KEEP_HEIGHT, CASTLE.KEEP_DEPTH);
|
||||
const mat = new THREE.MeshLambertMaterial({ color: CASTLE.KEEP_COLOR });
|
||||
const keep = new THREE.Mesh(geo, mat);
|
||||
keep.position.y = CASTLE.KEEP_HEIGHT / 2;
|
||||
keep.castShadow = true;
|
||||
keep.receiveShadow = true;
|
||||
this.group.add(keep);
|
||||
this._trackMesh(keep);
|
||||
|
||||
// Keep roof — flat top with small raised center
|
||||
const roofGeo = new THREE.BoxGeometry(CASTLE.KEEP_WIDTH + 0.5, 0.5, CASTLE.KEEP_DEPTH + 0.5);
|
||||
const roofMat = new THREE.MeshLambertMaterial({ color: 0x777777 });
|
||||
const roof = new THREE.Mesh(roofGeo, roofMat);
|
||||
roof.position.y = CASTLE.KEEP_HEIGHT + 0.25;
|
||||
roof.castShadow = true;
|
||||
this.group.add(roof);
|
||||
this._trackMesh(roof);
|
||||
}
|
||||
|
||||
buildTowers() {
|
||||
const positions = [
|
||||
[-CASTLE.TOWER_SPREAD_X, 0, -CASTLE.TOWER_SPREAD_Z],
|
||||
[CASTLE.TOWER_SPREAD_X, 0, -CASTLE.TOWER_SPREAD_Z],
|
||||
[-CASTLE.TOWER_SPREAD_X, 0, CASTLE.TOWER_SPREAD_Z],
|
||||
[CASTLE.TOWER_SPREAD_X, 0, CASTLE.TOWER_SPREAD_Z],
|
||||
];
|
||||
|
||||
const towerGeo = new THREE.CylinderGeometry(
|
||||
CASTLE.TOWER_RADIUS, CASTLE.TOWER_RADIUS + 0.3,
|
||||
CASTLE.TOWER_HEIGHT, CASTLE.TOWER_SEGMENTS
|
||||
);
|
||||
const towerMat = new THREE.MeshLambertMaterial({ color: CASTLE.TOWER_COLOR });
|
||||
|
||||
const roofGeo = new THREE.ConeGeometry(
|
||||
CASTLE.TOWER_RADIUS + 0.5, CASTLE.TOWER_ROOF_HEIGHT, CASTLE.TOWER_SEGMENTS
|
||||
);
|
||||
const roofMat = new THREE.MeshLambertMaterial({ color: CASTLE.TOWER_ROOF_COLOR });
|
||||
|
||||
for (const [x, y, z] of positions) {
|
||||
const tower = new THREE.Mesh(towerGeo, towerMat.clone());
|
||||
tower.position.set(x, CASTLE.TOWER_HEIGHT / 2, z);
|
||||
tower.castShadow = true;
|
||||
tower.receiveShadow = true;
|
||||
this.group.add(tower);
|
||||
this._trackMesh(tower);
|
||||
|
||||
const roof = new THREE.Mesh(roofGeo, roofMat.clone());
|
||||
roof.position.set(x, CASTLE.TOWER_HEIGHT + CASTLE.TOWER_ROOF_HEIGHT / 2, z);
|
||||
roof.castShadow = true;
|
||||
this.group.add(roof);
|
||||
this._trackMesh(roof);
|
||||
}
|
||||
}
|
||||
|
||||
buildWalls() {
|
||||
const wallMat = new THREE.MeshLambertMaterial({ color: CASTLE.WALL_COLOR });
|
||||
|
||||
// Front wall (facing enemies, negative Z side)
|
||||
const frontWallWidth = CASTLE.TOWER_SPREAD_X * 2;
|
||||
this._addWall(frontWallWidth, 0, -CASTLE.TOWER_SPREAD_Z, wallMat, false);
|
||||
|
||||
// Back wall
|
||||
this._addWall(frontWallWidth, 0, CASTLE.TOWER_SPREAD_Z, wallMat, false);
|
||||
|
||||
// Left wall
|
||||
const sideWallWidth = CASTLE.TOWER_SPREAD_Z * 2;
|
||||
this._addWall(sideWallWidth, -CASTLE.TOWER_SPREAD_X, 0, wallMat, true);
|
||||
|
||||
// Right wall
|
||||
this._addWall(sideWallWidth, CASTLE.TOWER_SPREAD_X, 0, wallMat, true);
|
||||
}
|
||||
|
||||
_addWall(width, x, z, material, rotated) {
|
||||
const geo = new THREE.BoxGeometry(width, CASTLE.WALL_HEIGHT, CASTLE.WALL_THICKNESS);
|
||||
const wall = new THREE.Mesh(geo, material.clone());
|
||||
wall.position.set(x, CASTLE.WALL_HEIGHT / 2, z);
|
||||
if (rotated) {
|
||||
wall.rotation.y = Math.PI / 2;
|
||||
}
|
||||
wall.castShadow = true;
|
||||
wall.receiveShadow = true;
|
||||
this.group.add(wall);
|
||||
this._trackMesh(wall);
|
||||
}
|
||||
|
||||
buildBattlements() {
|
||||
const merlonGeo = new THREE.BoxGeometry(CASTLE.MERLON_SIZE, CASTLE.MERLON_SIZE, CASTLE.MERLON_SIZE);
|
||||
const merlonMat = new THREE.MeshLambertMaterial({ color: CASTLE.MERLON_COLOR });
|
||||
|
||||
// Front wall battlements
|
||||
const halfSpread = CASTLE.TOWER_SPREAD_X;
|
||||
for (let x = -halfSpread + 1; x < halfSpread; x += CASTLE.MERLON_SPACING) {
|
||||
const merlon = new THREE.Mesh(merlonGeo, merlonMat.clone());
|
||||
merlon.position.set(x, CASTLE.WALL_HEIGHT + CASTLE.MERLON_SIZE / 2, -CASTLE.TOWER_SPREAD_Z);
|
||||
merlon.castShadow = true;
|
||||
this.group.add(merlon);
|
||||
this._trackMesh(merlon);
|
||||
}
|
||||
|
||||
// Back wall battlements
|
||||
for (let x = -halfSpread + 1; x < halfSpread; x += CASTLE.MERLON_SPACING) {
|
||||
const merlon = new THREE.Mesh(merlonGeo, merlonMat.clone());
|
||||
merlon.position.set(x, CASTLE.WALL_HEIGHT + CASTLE.MERLON_SIZE / 2, CASTLE.TOWER_SPREAD_Z);
|
||||
merlon.castShadow = true;
|
||||
this.group.add(merlon);
|
||||
this._trackMesh(merlon);
|
||||
}
|
||||
|
||||
// Side wall battlements
|
||||
const halfSide = CASTLE.TOWER_SPREAD_Z;
|
||||
for (let z = -halfSide + 1; z < halfSide; z += CASTLE.MERLON_SPACING) {
|
||||
// Left
|
||||
const mL = new THREE.Mesh(merlonGeo, merlonMat.clone());
|
||||
mL.position.set(-CASTLE.TOWER_SPREAD_X, CASTLE.WALL_HEIGHT + CASTLE.MERLON_SIZE / 2, z);
|
||||
mL.castShadow = true;
|
||||
this.group.add(mL);
|
||||
this._trackMesh(mL);
|
||||
|
||||
// Right
|
||||
const mR = new THREE.Mesh(merlonGeo, merlonMat.clone());
|
||||
mR.position.set(CASTLE.TOWER_SPREAD_X, CASTLE.WALL_HEIGHT + CASTLE.MERLON_SIZE / 2, z);
|
||||
mR.castShadow = true;
|
||||
this.group.add(mR);
|
||||
this._trackMesh(mR);
|
||||
}
|
||||
}
|
||||
|
||||
buildGate() {
|
||||
// Dark gate section on front wall
|
||||
const gateGeo = new THREE.BoxGeometry(CASTLE.GATE_WIDTH, CASTLE.GATE_HEIGHT, CASTLE.WALL_THICKNESS + 0.1);
|
||||
const gateMat = new THREE.MeshLambertMaterial({ color: CASTLE.GATE_COLOR });
|
||||
const gate = new THREE.Mesh(gateGeo, gateMat);
|
||||
gate.position.set(0, CASTLE.GATE_HEIGHT / 2, -CASTLE.TOWER_SPREAD_Z);
|
||||
this.group.add(gate);
|
||||
this._trackMesh(gate);
|
||||
|
||||
// Gate arch (semicircle on top of the gate)
|
||||
const archGeo = new THREE.CylinderGeometry(
|
||||
CASTLE.GATE_WIDTH / 2, CASTLE.GATE_WIDTH / 2, CASTLE.WALL_THICKNESS + 0.2,
|
||||
8, 1, false, 0, Math.PI
|
||||
);
|
||||
const archMat = new THREE.MeshLambertMaterial({ color: CASTLE.GATE_COLOR });
|
||||
const arch = new THREE.Mesh(archGeo, archMat);
|
||||
arch.rotation.x = Math.PI / 2;
|
||||
arch.rotation.z = Math.PI / 2;
|
||||
arch.position.set(0, CASTLE.GATE_HEIGHT, -CASTLE.TOWER_SPREAD_Z);
|
||||
this.group.add(arch);
|
||||
this._trackMesh(arch);
|
||||
}
|
||||
|
||||
buildBanners() {
|
||||
// Small banner flags on tower tops
|
||||
const bannerGeo = new THREE.PlaneGeometry(1.2, 2);
|
||||
const bannerMat = new THREE.MeshLambertMaterial({
|
||||
color: 0xcc0000,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
|
||||
const poleGeo = new THREE.CylinderGeometry(0.05, 0.05, 3, 4);
|
||||
const poleMat = new THREE.MeshLambertMaterial({ color: 0x444444 });
|
||||
|
||||
const towerPositions = [
|
||||
[-CASTLE.TOWER_SPREAD_X, CASTLE.TOWER_SPREAD_Z],
|
||||
[CASTLE.TOWER_SPREAD_X, CASTLE.TOWER_SPREAD_Z],
|
||||
];
|
||||
|
||||
for (const [x, z] of towerPositions) {
|
||||
const poleY = CASTLE.TOWER_HEIGHT + CASTLE.TOWER_ROOF_HEIGHT + 1.5;
|
||||
const pole = new THREE.Mesh(poleGeo, poleMat);
|
||||
pole.position.set(x, poleY, z);
|
||||
this.group.add(pole);
|
||||
|
||||
const banner = new THREE.Mesh(bannerGeo, bannerMat.clone());
|
||||
banner.position.set(x + 0.7, poleY + 0.5, z);
|
||||
this.group.add(banner);
|
||||
this._trackMesh(banner);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Damage & Flash ---
|
||||
|
||||
takeDamage() {
|
||||
if (gameState.gameOver) return;
|
||||
|
||||
gameState.castleHealth -= ENEMY.CASTLE_DAMAGE;
|
||||
if (gameState.castleHealth < 0) gameState.castleHealth = 0;
|
||||
|
||||
eventBus.emit(Events.CASTLE_HIT, { health: gameState.castleHealth });
|
||||
|
||||
this.flashTimer = CASTLE.DAMAGE_FLASH_DURATION;
|
||||
|
||||
// Flash all meshes red
|
||||
for (const mesh of this.allMeshes) {
|
||||
if (mesh._origColor === undefined) {
|
||||
mesh._origColor = mesh.material.color.getHex();
|
||||
}
|
||||
mesh.material.color.setHex(CASTLE.DAMAGE_FLASH_COLOR);
|
||||
}
|
||||
|
||||
if (gameState.castleHealth <= 0) {
|
||||
eventBus.emit(Events.CASTLE_DESTROYED);
|
||||
}
|
||||
}
|
||||
|
||||
update(delta) {
|
||||
if (this.flashTimer > 0) {
|
||||
this.flashTimer -= delta;
|
||||
if (this.flashTimer <= 0) {
|
||||
// Restore original colors
|
||||
for (const mesh of this.allMeshes) {
|
||||
if (mesh._origColor !== undefined) {
|
||||
mesh.material.color.setHex(mesh._origColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_trackMesh(mesh) {
|
||||
this.allMeshes.push(mesh);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.scene.remove(this.group);
|
||||
this.group.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.geometry.dispose();
|
||||
child.material.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// =============================================================================
|
||||
// Enemy.js — Single enemy entity: spawn, march toward castle, reach castle
|
||||
// Simple soldier/knight built from composed geometries (body box, head sphere).
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { ENEMY, CASTLE, LEVEL } from '../core/Constants.js';
|
||||
|
||||
export class Enemy {
|
||||
constructor(scene, x, speed) {
|
||||
this.scene = scene;
|
||||
this.alive = true;
|
||||
this.reachedCastle = false;
|
||||
this.speed = speed;
|
||||
|
||||
this.group = new THREE.Group();
|
||||
|
||||
// Body
|
||||
const bodyGeo = new THREE.BoxGeometry(ENEMY.BODY_WIDTH, ENEMY.BODY_HEIGHT, ENEMY.BODY_DEPTH);
|
||||
const bodyMat = new THREE.MeshLambertMaterial({ color: ENEMY.BODY_COLOR });
|
||||
const body = new THREE.Mesh(bodyGeo, bodyMat);
|
||||
body.position.y = ENEMY.BODY_HEIGHT / 2;
|
||||
body.castShadow = true;
|
||||
this.group.add(body);
|
||||
|
||||
// Head
|
||||
const headGeo = new THREE.SphereGeometry(ENEMY.HEAD_RADIUS, 8, 6);
|
||||
const headMat = new THREE.MeshLambertMaterial({ color: ENEMY.HEAD_COLOR });
|
||||
const head = new THREE.Mesh(headGeo, headMat);
|
||||
head.position.y = ENEMY.HEAD_Y_OFFSET;
|
||||
head.castShadow = true;
|
||||
this.group.add(head);
|
||||
|
||||
// Small shield on front
|
||||
const shieldGeo = new THREE.BoxGeometry(0.6, 0.8, 0.1);
|
||||
const shieldMat = new THREE.MeshLambertMaterial({ color: ENEMY.SHIELD_COLOR });
|
||||
const shield = new THREE.Mesh(shieldGeo, shieldMat);
|
||||
shield.position.set(0, 0.7, ENEMY.BODY_DEPTH / 2 + 0.1);
|
||||
this.group.add(shield);
|
||||
|
||||
// Position at spawn
|
||||
this.group.position.set(x, 0, ENEMY.SPAWN_Z);
|
||||
|
||||
this.scene.add(this.group);
|
||||
}
|
||||
|
||||
update(delta) {
|
||||
if (!this.alive) return;
|
||||
|
||||
// March toward castle (positive Z direction)
|
||||
this.group.position.z += this.speed * delta;
|
||||
|
||||
// Simple walking bob
|
||||
this.group.position.y = Math.abs(Math.sin(this.group.position.z * 2)) * 0.15;
|
||||
|
||||
// Check if reached castle zone
|
||||
const castleZone = CASTLE.POSITION_Z - CASTLE.TOWER_SPREAD_Z - 1;
|
||||
if (this.group.position.z >= castleZone) {
|
||||
this.reachedCastle = true;
|
||||
this.alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
kill() {
|
||||
this.alive = false;
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return this.group.position;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.scene.remove(this.group);
|
||||
this.group.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.geometry.dispose();
|
||||
child.material.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// =============================================================================
|
||||
// EnemyManager.js — Wave spawning, enemy pool management
|
||||
// Manages all active enemies, handles wave progression, emits events.
|
||||
// =============================================================================
|
||||
|
||||
import { ENEMY, WAVE } from '../core/Constants.js';
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
import { Enemy } from './Enemy.js';
|
||||
|
||||
export class EnemyManager {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
this.enemies = [];
|
||||
this.spawnTimer = 0;
|
||||
this.waveTimer = 0;
|
||||
this.enemiesSpawnedThisWave = 0;
|
||||
this.enemiesInWave = 0;
|
||||
this.enemiesDeadThisWave = 0;
|
||||
this.waveActive = false;
|
||||
this.betweenWaves = false;
|
||||
this.currentSpeed = ENEMY.BASE_SPEED;
|
||||
|
||||
// Listen for enemy kills from projectile impacts
|
||||
eventBus.on(Events.ENEMY_KILLED, () => {
|
||||
gameState.enemiesKilled++;
|
||||
gameState.addScore(ENEMY.KILL_POINTS);
|
||||
eventBus.emit(Events.SCORE_CHANGED, { score: gameState.score });
|
||||
this.enemiesDeadThisWave++;
|
||||
this._checkWaveComplete();
|
||||
});
|
||||
}
|
||||
|
||||
startFirstWave() {
|
||||
this.startNextWave();
|
||||
}
|
||||
|
||||
startNextWave() {
|
||||
gameState.wave++;
|
||||
this.enemiesInWave = WAVE.BASE_ENEMY_COUNT + (gameState.wave - 1) * WAVE.ENEMY_INCREMENT;
|
||||
this.enemiesSpawnedThisWave = 0;
|
||||
this.enemiesDeadThisWave = 0;
|
||||
this.currentSpeed = ENEMY.BASE_SPEED * (1 + (gameState.wave - 1) * ENEMY.SPEED_INCREASE_PER_WAVE);
|
||||
this.spawnTimer = 0;
|
||||
this.waveActive = true;
|
||||
this.betweenWaves = false;
|
||||
|
||||
eventBus.emit(Events.WAVE_START, { wave: gameState.wave, count: this.enemiesInWave });
|
||||
}
|
||||
|
||||
update(delta) {
|
||||
if (gameState.gameOver) return;
|
||||
|
||||
// Between-wave pause
|
||||
if (this.betweenWaves) {
|
||||
this.waveTimer -= delta;
|
||||
if (this.waveTimer <= 0) {
|
||||
this.startNextWave();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn enemies for current wave
|
||||
if (this.waveActive && this.enemiesSpawnedThisWave < this.enemiesInWave) {
|
||||
this.spawnTimer -= delta;
|
||||
if (this.spawnTimer <= 0) {
|
||||
this._spawnEnemy();
|
||||
this.spawnTimer = WAVE.SPAWN_INTERVAL;
|
||||
}
|
||||
}
|
||||
|
||||
// Update all active enemies
|
||||
for (let i = this.enemies.length - 1; i >= 0; i--) {
|
||||
const enemy = this.enemies[i];
|
||||
enemy.update(delta);
|
||||
|
||||
if (!enemy.alive) {
|
||||
if (enemy.reachedCastle) {
|
||||
eventBus.emit(Events.ENEMY_REACHED_CASTLE);
|
||||
this.enemiesDeadThisWave++;
|
||||
this._checkWaveComplete();
|
||||
}
|
||||
enemy.destroy();
|
||||
this.enemies.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_spawnEnemy() {
|
||||
// Distribute enemies across lanes
|
||||
const laneIndex = this.enemiesSpawnedThisWave % ENEMY.LANE_COUNT;
|
||||
const laneWidth = (ENEMY.SPAWN_X_RANGE * 2) / ENEMY.LANE_COUNT;
|
||||
const x = -ENEMY.SPAWN_X_RANGE + laneWidth * laneIndex + laneWidth / 2;
|
||||
// Add small random offset within lane
|
||||
const jitter = (Math.random() - 0.5) * laneWidth * 0.6;
|
||||
|
||||
const enemy = new Enemy(this.scene, x + jitter, this.currentSpeed);
|
||||
this.enemies.push(enemy);
|
||||
this.enemiesSpawnedThisWave++;
|
||||
|
||||
eventBus.emit(Events.ENEMY_SPAWNED, { count: this.enemies.length });
|
||||
}
|
||||
|
||||
_checkWaveComplete() {
|
||||
if (this.enemiesDeadThisWave >= this.enemiesInWave && this.enemiesSpawnedThisWave >= this.enemiesInWave) {
|
||||
this.waveActive = false;
|
||||
this.betweenWaves = true;
|
||||
this.waveTimer = WAVE.PAUSE_BETWEEN_WAVES;
|
||||
|
||||
// Bonus points for completing a wave
|
||||
gameState.addScore(WAVE.COMPLETION_BONUS);
|
||||
eventBus.emit(Events.SCORE_CHANGED, { score: gameState.score });
|
||||
eventBus.emit(Events.WAVE_COMPLETE, { wave: gameState.wave });
|
||||
}
|
||||
}
|
||||
|
||||
/** Get positions of all alive enemies (used by projectile splash and Space key) */
|
||||
getAliveEnemies() {
|
||||
return this.enemies.filter(e => e.alive);
|
||||
}
|
||||
|
||||
destroyAll() {
|
||||
for (const enemy of this.enemies) {
|
||||
enemy.destroy();
|
||||
}
|
||||
this.enemies = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// =============================================================================
|
||||
// Projectile.js — Single projectile: parabolic arc trajectory, impact detection
|
||||
// Glowing sphere that arcs from castle to target, with splash damage on impact.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { PROJECTILE } from '../core/Constants.js';
|
||||
|
||||
export class Projectile {
|
||||
constructor(scene, startPos, targetPos) {
|
||||
this.scene = scene;
|
||||
this.alive = true;
|
||||
this.impacted = false;
|
||||
this.elapsed = 0;
|
||||
|
||||
// Store start and target
|
||||
this.startPos = startPos.clone();
|
||||
this.targetPos = targetPos.clone();
|
||||
|
||||
// Build glowing projectile
|
||||
this.group = new THREE.Group();
|
||||
|
||||
const geo = new THREE.SphereGeometry(PROJECTILE.RADIUS, 8, 8);
|
||||
const mat = new THREE.MeshBasicMaterial({ color: PROJECTILE.COLOR });
|
||||
this.mesh = new THREE.Mesh(geo, mat);
|
||||
this.group.add(this.mesh);
|
||||
|
||||
// Glow effect (slightly larger transparent sphere)
|
||||
const glowGeo = new THREE.SphereGeometry(PROJECTILE.RADIUS * 1.8, 8, 8);
|
||||
const glowMat = new THREE.MeshBasicMaterial({
|
||||
color: PROJECTILE.GLOW_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
});
|
||||
const glow = new THREE.Mesh(glowGeo, glowMat);
|
||||
this.group.add(glow);
|
||||
|
||||
// Trail — small point light
|
||||
this.light = new THREE.PointLight(PROJECTILE.COLOR, 1, 8);
|
||||
this.group.add(this.light);
|
||||
|
||||
this.group.position.copy(this.startPos);
|
||||
this.scene.add(this.group);
|
||||
}
|
||||
|
||||
update(delta) {
|
||||
if (!this.alive) return;
|
||||
|
||||
this.elapsed += delta;
|
||||
const t = Math.min(this.elapsed / PROJECTILE.TRAVEL_TIME, 1);
|
||||
|
||||
// Linear interpolation for x and z
|
||||
const x = this.startPos.x + (this.targetPos.x - this.startPos.x) * t;
|
||||
const z = this.startPos.z + (this.targetPos.z - this.startPos.z) * t;
|
||||
|
||||
// Parabolic arc for y: starts at startY, peaks at ARC_HEIGHT, ends at 0
|
||||
const startY = this.startPos.y;
|
||||
const endY = 0.5; // Just above ground
|
||||
const linearY = startY + (endY - startY) * t;
|
||||
const arcOffset = PROJECTILE.ARC_HEIGHT * 4 * t * (1 - t); // Parabola peaks at t=0.5
|
||||
const y = linearY + arcOffset;
|
||||
|
||||
this.group.position.set(x, y, z);
|
||||
|
||||
// Pulse the glow
|
||||
this.light.intensity = 0.5 + Math.sin(this.elapsed * 15) * 0.3;
|
||||
|
||||
// Check impact (reached target)
|
||||
if (t >= 1) {
|
||||
this.impacted = true;
|
||||
this.alive = false;
|
||||
}
|
||||
}
|
||||
|
||||
getPosition() {
|
||||
return this.group.position;
|
||||
}
|
||||
|
||||
getTargetPosition() {
|
||||
return this.targetPos;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.scene.remove(this.group);
|
||||
this.group.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.geometry.dispose();
|
||||
child.material.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// =============================================================================
|
||||
// ProjectileManager.js — Launch projectiles, manage active projectiles
|
||||
// Handles cooldown, impact detection (splash damage), and visual effects.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { PROJECTILE, CASTLE } from '../core/Constants.js';
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
import { Projectile } from './Projectile.js';
|
||||
|
||||
export class ProjectileManager {
|
||||
constructor(scene, enemyManager) {
|
||||
this.scene = scene;
|
||||
this.enemyManager = enemyManager;
|
||||
this.projectiles = [];
|
||||
this.impactEffects = [];
|
||||
this.cooldownTimer = 0;
|
||||
|
||||
// Listen for launch events
|
||||
eventBus.on(Events.PROJECTILE_LAUNCHED, (data) => this.launch(data.target));
|
||||
}
|
||||
|
||||
launch(targetPos) {
|
||||
if (gameState.gameOver) return;
|
||||
if (this.cooldownTimer > 0) return;
|
||||
|
||||
// Launch from top of castle
|
||||
const startPos = new THREE.Vector3(
|
||||
0,
|
||||
PROJECTILE.LAUNCH_Y,
|
||||
PROJECTILE.LAUNCH_Z
|
||||
);
|
||||
|
||||
const projectile = new Projectile(this.scene, startPos, targetPos);
|
||||
this.projectiles.push(projectile);
|
||||
this.cooldownTimer = PROJECTILE.COOLDOWN;
|
||||
}
|
||||
|
||||
update(delta) {
|
||||
// Cooldown
|
||||
if (this.cooldownTimer > 0) {
|
||||
this.cooldownTimer -= delta;
|
||||
}
|
||||
|
||||
// Update projectiles
|
||||
for (let i = this.projectiles.length - 1; i >= 0; i--) {
|
||||
const proj = this.projectiles[i];
|
||||
proj.update(delta);
|
||||
|
||||
if (!proj.alive) {
|
||||
if (proj.impacted) {
|
||||
this._onImpact(proj.getTargetPosition());
|
||||
}
|
||||
proj.destroy();
|
||||
this.projectiles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Update impact effects
|
||||
for (let i = this.impactEffects.length - 1; i >= 0; i--) {
|
||||
const effect = this.impactEffects[i];
|
||||
effect.timer -= delta;
|
||||
const t = 1 - (effect.timer / PROJECTILE.IMPACT_DURATION);
|
||||
|
||||
// Expand and fade
|
||||
const scale = 1 + t * 3;
|
||||
effect.mesh.scale.set(scale, scale, scale);
|
||||
effect.mesh.material.opacity = 1 - t;
|
||||
|
||||
if (effect.timer <= 0) {
|
||||
this.scene.remove(effect.mesh);
|
||||
effect.mesh.geometry.dispose();
|
||||
effect.mesh.material.dispose();
|
||||
this.impactEffects.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_onImpact(position) {
|
||||
// Visual impact effect
|
||||
const impactGeo = new THREE.SphereGeometry(PROJECTILE.IMPACT_RADIUS, 8, 8);
|
||||
const impactMat = new THREE.MeshBasicMaterial({
|
||||
color: PROJECTILE.IMPACT_COLOR,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
});
|
||||
const impactMesh = new THREE.Mesh(impactGeo, impactMat);
|
||||
impactMesh.position.copy(position);
|
||||
impactMesh.position.y = 0.5;
|
||||
this.scene.add(impactMesh);
|
||||
|
||||
this.impactEffects.push({
|
||||
mesh: impactMesh,
|
||||
timer: PROJECTILE.IMPACT_DURATION,
|
||||
});
|
||||
|
||||
// Splash damage — check all alive enemies within radius
|
||||
const aliveEnemies = this.enemyManager.getAliveEnemies();
|
||||
let killedCount = 0;
|
||||
|
||||
for (const enemy of aliveEnemies) {
|
||||
const enemyPos = enemy.getPosition();
|
||||
const dx = enemyPos.x - position.x;
|
||||
const dz = enemyPos.z - position.z;
|
||||
const dist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
if (dist <= PROJECTILE.SPLASH_RADIUS) {
|
||||
enemy.kill();
|
||||
killedCount++;
|
||||
eventBus.emit(Events.ENEMY_KILLED, { position: enemyPos.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.emit(Events.PROJECTILE_IMPACT, {
|
||||
position: position.clone(),
|
||||
killed: killedCount,
|
||||
});
|
||||
}
|
||||
|
||||
destroyAll() {
|
||||
for (const proj of this.projectiles) {
|
||||
proj.destroy();
|
||||
}
|
||||
this.projectiles = [];
|
||||
|
||||
for (const effect of this.impactEffects) {
|
||||
this.scene.remove(effect.mesh);
|
||||
effect.mesh.geometry.dispose();
|
||||
effect.mesh.material.dispose();
|
||||
}
|
||||
this.impactEffects = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// =============================================================================
|
||||
// LevelBuilder.js — Terrain, path, lighting, fog, sky
|
||||
// Builds the medieval battlefield environment.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { LEVEL, COLORS } from '../core/Constants.js';
|
||||
|
||||
export class LevelBuilder {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
|
||||
this.buildGround();
|
||||
this.buildPath();
|
||||
this.buildLighting();
|
||||
this.buildFog();
|
||||
this.buildSkyGradient();
|
||||
this.buildDecor();
|
||||
}
|
||||
|
||||
buildGround() {
|
||||
const geometry = new THREE.PlaneGeometry(LEVEL.GROUND_SIZE, LEVEL.GROUND_SIZE, 8, 8);
|
||||
const material = new THREE.MeshLambertMaterial({ color: LEVEL.GROUND_COLOR });
|
||||
this.ground = new THREE.Mesh(geometry, material);
|
||||
this.ground.rotation.x = -Math.PI / 2;
|
||||
this.ground.receiveShadow = true;
|
||||
this.ground.name = 'ground';
|
||||
this.scene.add(this.ground);
|
||||
}
|
||||
|
||||
buildPath() {
|
||||
// Dirt path from spawn end to castle
|
||||
const pathGeo = new THREE.PlaneGeometry(LEVEL.PATH_WIDTH, LEVEL.GROUND_SIZE);
|
||||
const pathMat = new THREE.MeshLambertMaterial({ color: LEVEL.PATH_COLOR });
|
||||
const path = new THREE.Mesh(pathGeo, pathMat);
|
||||
path.rotation.x = -Math.PI / 2;
|
||||
path.position.y = 0.01; // Slightly above ground to avoid z-fighting
|
||||
path.receiveShadow = true;
|
||||
this.scene.add(path);
|
||||
}
|
||||
|
||||
buildLighting() {
|
||||
// Ambient fill
|
||||
const ambient = new THREE.AmbientLight(COLORS.AMBIENT_LIGHT, COLORS.AMBIENT_INTENSITY);
|
||||
this.scene.add(ambient);
|
||||
|
||||
// Main directional light (sun) with shadows
|
||||
const directional = new THREE.DirectionalLight(COLORS.DIR_LIGHT, COLORS.DIR_INTENSITY);
|
||||
directional.position.set(15, 30, 20);
|
||||
directional.castShadow = true;
|
||||
directional.shadow.mapSize.width = 1024;
|
||||
directional.shadow.mapSize.height = 1024;
|
||||
directional.shadow.camera.near = 1;
|
||||
directional.shadow.camera.far = 100;
|
||||
directional.shadow.camera.left = -40;
|
||||
directional.shadow.camera.right = 40;
|
||||
directional.shadow.camera.top = 40;
|
||||
directional.shadow.camera.bottom = -40;
|
||||
this.scene.add(directional);
|
||||
|
||||
// Hemisphere light for natural sky/ground color bleed
|
||||
const hemi = new THREE.HemisphereLight(
|
||||
COLORS.HEMISPHERE_SKY,
|
||||
COLORS.HEMISPHERE_GROUND,
|
||||
COLORS.HEMISPHERE_INTENSITY
|
||||
);
|
||||
this.scene.add(hemi);
|
||||
}
|
||||
|
||||
buildFog() {
|
||||
this.scene.fog = new THREE.Fog(LEVEL.FOG_COLOR, LEVEL.FOG_NEAR, LEVEL.FOG_FAR);
|
||||
}
|
||||
|
||||
buildSkyGradient() {
|
||||
// Large sky dome using a gradient material
|
||||
const skyGeo = new THREE.SphereGeometry(150, 16, 16);
|
||||
const skyMat = new THREE.MeshBasicMaterial({
|
||||
color: 0x87ceeb,
|
||||
side: THREE.BackSide,
|
||||
});
|
||||
const sky = new THREE.Mesh(skyGeo, skyMat);
|
||||
this.scene.add(sky);
|
||||
}
|
||||
|
||||
buildDecor() {
|
||||
// Scatter some simple trees (cylinders + cones) around the edges
|
||||
const trunkGeo = new THREE.CylinderGeometry(0.3, 0.4, 3, 6);
|
||||
const trunkMat = new THREE.MeshLambertMaterial({ color: 0x8B4513 });
|
||||
const foliageGeo = new THREE.ConeGeometry(1.8, 4, 6);
|
||||
const foliageMat = new THREE.MeshLambertMaterial({ color: 0x2d5a27 });
|
||||
|
||||
const halfGround = LEVEL.GROUND_SIZE / 2;
|
||||
const treePositions = [
|
||||
[-25, -10], [-28, 5], [-22, -25], [-30, 15], [-26, 25],
|
||||
[25, -10], [28, 5], [22, -25], [30, 15], [26, 25],
|
||||
[-20, -35], [20, -35], [-15, 30], [15, 30],
|
||||
];
|
||||
|
||||
for (const [x, z] of treePositions) {
|
||||
if (Math.abs(x) > halfGround - 2 || Math.abs(z) > halfGround - 2) continue;
|
||||
|
||||
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
|
||||
trunk.position.set(x, 1.5, z);
|
||||
trunk.castShadow = true;
|
||||
this.scene.add(trunk);
|
||||
|
||||
const foliage = new THREE.Mesh(foliageGeo, foliageMat);
|
||||
foliage.position.set(x, 5, z);
|
||||
foliage.castShadow = true;
|
||||
this.scene.add(foliage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// =============================================================================
|
||||
// main.js — Entry point for Castle Siege Defense
|
||||
// Inits game, exposes test globals, render_game_to_text, and advanceTime.
|
||||
// =============================================================================
|
||||
|
||||
import { Game } from './core/Game.js';
|
||||
import { eventBus, Events } from './core/EventBus.js';
|
||||
import { gameState } from './core/GameState.js';
|
||||
|
||||
const game = new Game();
|
||||
|
||||
// Expose for Playwright testing
|
||||
window.__GAME__ = game;
|
||||
window.__GAME_STATE__ = gameState;
|
||||
window.__EVENT_BUS__ = eventBus;
|
||||
window.__EVENTS__ = Events;
|
||||
|
||||
// --- AI-readable game state snapshot ---
|
||||
// Returns a concise JSON string for automated agents to understand the game
|
||||
// without interpreting pixels. Extend this as you add entities and mechanics.
|
||||
window.render_game_to_text = () => {
|
||||
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
|
||||
|
||||
const payload = {
|
||||
// Coordinate system: x increases rightward, y increases upward, z toward camera
|
||||
coords: 'origin:center x:right y:up z:toward-camera',
|
||||
mode: gameState.gameOver ? 'game_over' : gameState.started ? 'playing' : 'menu',
|
||||
score: gameState.score,
|
||||
bestScore: gameState.bestScore,
|
||||
wave: gameState.wave,
|
||||
castleHealth: gameState.castleHealth,
|
||||
maxCastleHealth: gameState.maxCastleHealth,
|
||||
enemiesKilled: gameState.enemiesKilled,
|
||||
};
|
||||
|
||||
// Add active enemy info
|
||||
if (game.enemyManager) {
|
||||
const aliveEnemies = game.enemyManager.getAliveEnemies();
|
||||
payload.activeEnemies = aliveEnemies.length;
|
||||
payload.enemies = aliveEnemies.slice(0, 10).map(e => {
|
||||
const pos = e.getPosition();
|
||||
return {
|
||||
x: Math.round(pos.x * 10) / 10,
|
||||
z: Math.round(pos.z * 10) / 10,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Active projectiles
|
||||
if (game.projectileManager) {
|
||||
payload.activeProjectiles = game.projectileManager.projectiles.length;
|
||||
}
|
||||
|
||||
return JSON.stringify(payload);
|
||||
};
|
||||
|
||||
// --- Deterministic time-stepping hook ---
|
||||
// Lets automated test scripts advance the game by a precise duration.
|
||||
// The game loop runs normally via RAF; this just waits for real time to elapse.
|
||||
window.advanceTime = (ms) => {
|
||||
return new Promise((resolve) => {
|
||||
const start = performance.now();
|
||||
function step() {
|
||||
if (performance.now() - start >= ms) return resolve();
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
// =============================================================================
|
||||
// InputSystem.js — Raycasting tap-to-fire mechanic
|
||||
//
|
||||
// On click/tap: raycast to ground plane, emit projectile:launched with target.
|
||||
// Space key: fire at a random alive enemy position (for testing).
|
||||
// Mobile: tap anywhere on screen (not on game-over overlay) to fire.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { IS_MOBILE } from '../core/Constants.js';
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
|
||||
export class InputSystem {
|
||||
constructor() {
|
||||
this.keys = {};
|
||||
this.gameActive = false;
|
||||
this.camera = null;
|
||||
this.raycaster = new THREE.Raycaster();
|
||||
this.groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||||
this._spaceWasDown = false;
|
||||
|
||||
// Keyboard
|
||||
window.addEventListener('keydown', (e) => { this.keys[e.code] = true; });
|
||||
window.addEventListener('keyup', (e) => { this.keys[e.code] = false; });
|
||||
|
||||
// Mouse / Touch — fire projectile
|
||||
window.addEventListener('click', (e) => this._onPointer(e));
|
||||
window.addEventListener('touchstart', (e) => this._onTouch(e), { passive: false });
|
||||
}
|
||||
|
||||
setCamera(camera) {
|
||||
this.camera = camera;
|
||||
}
|
||||
|
||||
/** Reference to enemy manager for Space key targeting */
|
||||
setEnemyManager(enemyManager) {
|
||||
this.enemyManager = enemyManager;
|
||||
}
|
||||
|
||||
isDown(code) {
|
||||
return !!this.keys[code];
|
||||
}
|
||||
|
||||
setGameActive(active) {
|
||||
this.gameActive = active;
|
||||
}
|
||||
|
||||
update() {
|
||||
if (!this.gameActive || gameState.gameOver) return;
|
||||
|
||||
// Space key — fire at a random alive enemy
|
||||
const spaceDown = this.isDown('Space');
|
||||
if (spaceDown && !this._spaceWasDown) {
|
||||
this._fireAtRandomEnemy();
|
||||
}
|
||||
this._spaceWasDown = spaceDown;
|
||||
}
|
||||
|
||||
_onPointer(e) {
|
||||
if (!this.gameActive || gameState.gameOver || !this.camera) return;
|
||||
|
||||
// Ignore clicks on overlay elements
|
||||
if (e.target.closest('.overlay') || e.target.closest('#joystick-zone')) return;
|
||||
|
||||
const ndc = new THREE.Vector2(
|
||||
(e.clientX / window.innerWidth) * 2 - 1,
|
||||
-(e.clientY / window.innerHeight) * 2 + 1
|
||||
);
|
||||
|
||||
this._fireAtScreenPos(ndc);
|
||||
}
|
||||
|
||||
_onTouch(e) {
|
||||
if (!this.gameActive || gameState.gameOver || !this.camera) return;
|
||||
if (!IS_MOBILE) return;
|
||||
|
||||
// Ignore touches on overlay elements
|
||||
if (e.target.closest('.overlay') || e.target.closest('#joystick-zone')) return;
|
||||
|
||||
e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const ndc = new THREE.Vector2(
|
||||
(touch.clientX / window.innerWidth) * 2 - 1,
|
||||
-(touch.clientY / window.innerHeight) * 2 + 1
|
||||
);
|
||||
|
||||
this._fireAtScreenPos(ndc);
|
||||
}
|
||||
|
||||
_fireAtScreenPos(ndc) {
|
||||
this.raycaster.setFromCamera(ndc, this.camera);
|
||||
|
||||
const target = new THREE.Vector3();
|
||||
const hit = this.raycaster.ray.intersectPlane(this.groundPlane, target);
|
||||
|
||||
if (hit) {
|
||||
eventBus.emit(Events.PROJECTILE_LAUNCHED, { target });
|
||||
}
|
||||
}
|
||||
|
||||
_fireAtRandomEnemy() {
|
||||
if (!this.enemyManager) return;
|
||||
|
||||
const aliveEnemies = this.enemyManager.getAliveEnemies();
|
||||
if (aliveEnemies.length === 0) return;
|
||||
|
||||
const randomEnemy = aliveEnemies[Math.floor(Math.random() * aliveEnemies.length)];
|
||||
const pos = randomEnemy.getPosition().clone();
|
||||
|
||||
eventBus.emit(Events.PROJECTILE_LAUNCHED, { target: pos });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// =============================================================================
|
||||
// HUD.js — Wave banner and castle health bar
|
||||
// Listens to events and updates DOM elements. No in-game score display
|
||||
// (Play.fun widget handles that).
|
||||
// =============================================================================
|
||||
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
|
||||
export class HUD {
|
||||
constructor() {
|
||||
this.waveBanner = document.getElementById('wave-banner');
|
||||
this.healthBarFill = document.getElementById('health-bar-fill');
|
||||
this.healthBarContainer = document.getElementById('health-bar-container');
|
||||
this.healthBarLabel = document.getElementById('health-bar-label');
|
||||
this.waveBannerTimeout = null;
|
||||
|
||||
// Wave start — show banner
|
||||
eventBus.on(Events.WAVE_START, ({ wave, count }) => {
|
||||
this.showWaveBanner(`Wave ${wave} — ${count} enemies!`);
|
||||
});
|
||||
|
||||
// Wave complete — show banner
|
||||
eventBus.on(Events.WAVE_COMPLETE, ({ wave }) => {
|
||||
this.showWaveBanner(`Wave ${wave} Complete!`);
|
||||
});
|
||||
|
||||
// Castle hit — update health bar
|
||||
eventBus.on(Events.CASTLE_HIT, ({ health }) => {
|
||||
this.updateHealthBar(health);
|
||||
});
|
||||
|
||||
// Game over — hide HUD
|
||||
eventBus.on(Events.GAME_OVER, () => {
|
||||
this.hideHUD();
|
||||
});
|
||||
|
||||
// Game start — show HUD
|
||||
eventBus.on(Events.GAME_START, () => {
|
||||
this.showHUD();
|
||||
this.updateHealthBar(gameState.castleHealth);
|
||||
});
|
||||
}
|
||||
|
||||
showWaveBanner(text) {
|
||||
if (!this.waveBanner) return;
|
||||
this.waveBanner.textContent = text;
|
||||
this.waveBanner.classList.add('visible');
|
||||
|
||||
if (this.waveBannerTimeout) clearTimeout(this.waveBannerTimeout);
|
||||
this.waveBannerTimeout = setTimeout(() => {
|
||||
this.waveBanner.classList.remove('visible');
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
updateHealthBar(health) {
|
||||
if (!this.healthBarFill) return;
|
||||
const pct = Math.max(0, (health / gameState.maxCastleHealth) * 100);
|
||||
this.healthBarFill.style.width = pct + '%';
|
||||
|
||||
// Color shifts as health drops
|
||||
if (pct > 50) {
|
||||
this.healthBarFill.style.background = 'linear-gradient(to right, #44cc44, #88ff88)';
|
||||
} else if (pct > 25) {
|
||||
this.healthBarFill.style.background = 'linear-gradient(to right, #ccaa00, #ffcc44)';
|
||||
} else {
|
||||
this.healthBarFill.style.background = 'linear-gradient(to right, #cc2222, #ff4444)';
|
||||
}
|
||||
}
|
||||
|
||||
hideHUD() {
|
||||
if (this.healthBarContainer) this.healthBarContainer.style.display = 'none';
|
||||
if (this.healthBarLabel) this.healthBarLabel.style.display = 'none';
|
||||
if (this.waveBanner) this.waveBanner.classList.remove('visible');
|
||||
}
|
||||
|
||||
showHUD() {
|
||||
if (this.healthBarContainer) this.healthBarContainer.style.display = '';
|
||||
if (this.healthBarLabel) this.healthBarLabel.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// =============================================================================
|
||||
// Menu.js — Game over overlay with wave info
|
||||
// =============================================================================
|
||||
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
import { gameState } from '../core/GameState.js';
|
||||
|
||||
export class Menu {
|
||||
constructor() {
|
||||
this.gameoverOverlay = document.getElementById('gameover-overlay');
|
||||
this.restartBtn = document.getElementById('restart-btn');
|
||||
this.finalScoreEl = document.getElementById('final-score');
|
||||
this.bestScoreEl = document.getElementById('best-score');
|
||||
this.waveEl = document.getElementById('final-wave');
|
||||
|
||||
this.restartBtn.addEventListener('click', () => {
|
||||
this.gameoverOverlay.classList.add('hidden');
|
||||
eventBus.emit(Events.GAME_RESTART);
|
||||
});
|
||||
|
||||
eventBus.on(Events.GAME_OVER, ({ score }) => this.showGameOver(score));
|
||||
}
|
||||
|
||||
showGameOver(score) {
|
||||
this.finalScoreEl.textContent = `Score: ${score}`;
|
||||
this.bestScoreEl.textContent = `Best: ${gameState.bestScore}`;
|
||||
if (this.waveEl) {
|
||||
this.waveEl.textContent = `Wave: ${gameState.wave}`;
|
||||
}
|
||||
this.gameoverOverlay.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user