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:
rshtirmer
2026-02-23 17:16:53 -05:00
parent 1e4571d18c
commit da5c7add68
24 changed files with 4657 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
output/
+147
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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();
+176
View File
@@ -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();
+135
View File
@@ -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);
}
}
}
+69
View File
@@ -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 });
}
}
+81
View File
@@ -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 = '';
}
}
+32
View File
@@ -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');
}
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
},
build: {
target: 'esnext',
},
});