From d0209942bec2bccadcda1801637ca48ba6a4b43b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 19 Apr 2026 09:27:32 +0700 Subject: [PATCH] feat: add OMC Neon Arena webgame demo Browser arcade survival shooter built to showcase oh-my-claudecode multi-agent orchestration: engine, gameplay, UI, and docs delivered in parallel by specialist workers with dependency-gated task graph. --- oh-my-claudecode/DESIGN.md | 47 +++ oh-my-claudecode/README.md | 125 +++++++- oh-my-claudecode/index.html | 49 ++++ oh-my-claudecode/src/engine/canvas.js | 67 +++++ oh-my-claudecode/src/engine/entity.js | 60 ++++ oh-my-claudecode/src/engine/input.js | 86 ++++++ oh-my-claudecode/src/engine/loop.js | 74 +++++ oh-my-claudecode/src/game/audio.js | 57 ++++ oh-my-claudecode/src/game/bullet.js | 53 ++++ oh-my-claudecode/src/game/collision.js | 61 ++++ oh-my-claudecode/src/game/enemy.js | 108 +++++++ oh-my-claudecode/src/game/game.js | 264 +++++++++++++++++ oh-my-claudecode/src/game/particles.js | 63 +++++ oh-my-claudecode/src/game/player.js | 152 ++++++++++ oh-my-claudecode/src/game/waves.js | 107 +++++++ oh-my-claudecode/src/main.js | 69 +++++ oh-my-claudecode/src/ui/highscore.js | 34 +++ oh-my-claudecode/src/ui/hud.js | 89 ++++++ oh-my-claudecode/src/ui/menu.js | 39 +++ oh-my-claudecode/styles/main.css | 378 +++++++++++++++++++++++++ 20 files changed, 1981 insertions(+), 1 deletion(-) create mode 100644 oh-my-claudecode/DESIGN.md create mode 100644 oh-my-claudecode/index.html create mode 100644 oh-my-claudecode/src/engine/canvas.js create mode 100644 oh-my-claudecode/src/engine/entity.js create mode 100644 oh-my-claudecode/src/engine/input.js create mode 100644 oh-my-claudecode/src/engine/loop.js create mode 100644 oh-my-claudecode/src/game/audio.js create mode 100644 oh-my-claudecode/src/game/bullet.js create mode 100644 oh-my-claudecode/src/game/collision.js create mode 100644 oh-my-claudecode/src/game/enemy.js create mode 100644 oh-my-claudecode/src/game/game.js create mode 100644 oh-my-claudecode/src/game/particles.js create mode 100644 oh-my-claudecode/src/game/player.js create mode 100644 oh-my-claudecode/src/game/waves.js create mode 100644 oh-my-claudecode/src/main.js create mode 100644 oh-my-claudecode/src/ui/highscore.js create mode 100644 oh-my-claudecode/src/ui/hud.js create mode 100644 oh-my-claudecode/src/ui/menu.js create mode 100644 oh-my-claudecode/styles/main.css diff --git a/oh-my-claudecode/DESIGN.md b/oh-my-claudecode/DESIGN.md new file mode 100644 index 0000000..4d166e7 --- /dev/null +++ b/oh-my-claudecode/DESIGN.md @@ -0,0 +1,47 @@ +# OMC Neon Arena — Design Document + +## Concept +A top-down arcade survival shooter rendered on HTML5 canvas. The player controls a glowing "agent" orb, surviving endless waves of enemy "bugs." Built as a demo for **oh-my-claudecode** multi-agent orchestration — each subsystem was authored by a specialized teammate. + +## Tech Stack +- **No build step.** Pure HTML + CSS + vanilla JS (ES modules). +- Canvas 2D for rendering, Web Audio API for procedural SFX, `localStorage` for high-score persistence. +- Served directly — open `index.html` in a browser or use a static server. + +## File Layout (target) +``` +index.html Entry HTML, canvas, HUD DOM +styles/main.css Neon theme, HUD, overlays, responsive +src/main.js Bootstraps engine, game, UI +src/engine/loop.js Fixed-timestep game loop +src/engine/input.js Keyboard + pointer input +src/engine/entity.js Entity / component base +src/game/player.js Player movement, shooting, health +src/game/enemy.js Enemy types + AI +src/game/waves.js Wave spawner, difficulty curve +src/game/bullet.js Projectiles +src/game/particles.js Particle FX +src/game/audio.js Procedural Web Audio SFX +src/ui/hud.js Score, health, wave +src/ui/menu.js Start, pause, game-over overlays +src/ui/highscore.js localStorage high-score +README.md Project-level intro +``` + +## Gameplay +- **Controls:** WASD/arrows move, mouse aim, click/space shoot, P pause. +- **Waves:** Increasing enemy counts and speeds. Three enemy types: chaser (fast/weak), bruiser (slow/tanky), splitter (splits on death). +- **Scoring:** Per-kill points scaled by wave. Combo multiplier when kills chain within 2s. +- **Death:** Overlay with score, best score, restart. + +## Visual Language +- Dark background with subtle grid. +- Neon cyan/magenta/yellow palette; glow via `shadowBlur`. +- Screen shake on hits, particles on explosions. + +## Task Decomposition +1. Scaffold + engine core (index.html, engine loop, input, entities). +2. Gameplay: player, enemies, bullets, waves, collisions. +3. UI/HUD: CSS theme, HUD overlay, menu/pause/game-over screens. +4. Audio + particles polish. +5. README with a "built with OMC" section. diff --git a/oh-my-claudecode/README.md b/oh-my-claudecode/README.md index 1c6cc19..81ed408 100644 --- a/oh-my-claudecode/README.md +++ b/oh-my-claudecode/README.md @@ -1 +1,124 @@ -# try-oh-my-claudecode \ No newline at end of file +# OMC Neon Arena + +A top-down arcade survival shooter built with **oh-my-claudecode** multi-agent orchestration. Survive endless waves of neon enemies, chase the combo multiplier, and compete for the high score. + +## Built with oh-my-claudecode + +This entire game was designed and implemented by a specialized multi-agent OMC team: + +- **worker-1**: Engine & scaffold (HTML5 canvas, fixed-timestep loop, input handling) +- **worker-2**: Gameplay systems (player, enemies, bullets, waves, collision, scoring) +- **worker-3**: UI & visual theme (HUD, menus, neon CSS aesthetic) +- **worker-4**: Documentation (this README) + +**Pipeline**: strategic planning → parallel execution (engine + gameplay + UI in lockstep) → verification → release. + +## How to Run + +No build step. Open the game in your browser: + +**Option 1:** Use a static server +```bash +# Python 3 +python -m http.server + +# Or Node.js +npx serve +``` + +Then visit `http://localhost:8000` (or the port shown). + +**Option 2:** Open directly +Open `index.html` in your browser. (Note: Chrome and modern browsers may block module imports from `file://` — use a server for best compatibility.) + +## Controls + +| Action | Key | +|--------|-----| +| Move | **WASD** or **Arrow Keys** | +| Aim | **Mouse** | +| Shoot | **Click** or **Space** | +| Pause | **P** or **Escape** | + +## Gameplay + +### Player +- **Health**: 3 HP per run +- **Speed**: 260 px/s +- **Shoot Cooldown**: 140ms (≈7 shots/s) +- **Bullet Speed**: 520 px/s +- **Invulnerability**: 0.9s after hit + +### Enemy Types + +| Type | HP | Speed | Color | Score | +|------|----|----|-------|-------| +| **Chaser** | 1 | 110 px/s | Magenta | 10 pts | +| **Bruiser** | 4 | 55 px/s | Yellow | 40 pts | +| **Splitter** | 2 | 90 px/s | Lime | 25 pts → splits into 2 **Minis** (1 HP, 5 pts each) | + +All enemies seek the player. Splitters explode into two faster mini-enemies when defeated. + +### Waves + +| Wave | Contents | Spawning | +|------|----------|----------| +| **1** | 8 chasers | 0.35s stagger | +| **2** | 10 chasers + 2 bruisers | 0.35s stagger | +| **3+** | Scales: 8 + wave×2 chasers, floor(wave/2) bruisers, max(0, wave−2) splitters | 0.35s stagger | + +Between waves: 2 second intermission to catch your breath. + +### Scoring & Combos + +- **Base Points**: Enemy score × combo multiplier +- **Combo Multiplier**: 1 + floor(combo/5) + - Earn 1 combo per kill + - Combo resets if you take 2+ seconds without a kill + - Max multiplier at 5 kills = 2×, at 10 kills = 3×, etc. +- **Example**: Kill a bruiser (40 pts) with 7 combo stacks → 40 × (1 + floor(7/5)) = 40 × 2 = 80 pts + +## Architecture + +The game uses a modular, file-scoped design to allow parallel development: + +``` +src/ +├── main.js Entry point, bootstraps engine + game + UI +├── engine/ +│ ├── loop.js Fixed-timestep game loop, render loop, pause state +│ ├── input.js Keyboard (WASD/arrows) + pointer input +│ └── entity.js Base Entity class, EntityPool (free-list allocator) +├── game/ +│ ├── game.js Game state machine, collision dispatch, scoring/combo logic, shake FX +│ ├── player.js Player movement, shooting, health, knockback, invulnerability +│ ├── enemy.js Enemy types (chaser, bruiser, splitter, mini), pathfinding +│ ├── waves.js Wave scheduler, difficulty curve, enemy queue +│ ├── bullet.js Projectile entity, lifetime, collision tag +│ ├── particles.js Particle emitter system, pools, lifetime decay +│ ├── collision.js Broad/narrow phase collision resolution +│ └── audio.js Procedural Web Audio SFX (shoot, explode, hurt, wave, gameover) +└── ui/ + ├── hud.js Score, health, wave, combo display updates + ├── menu.js Start, pause, game-over overlay state + └── highscore.js localStorage high-score persistence + +styles/ +└── main.css Neon cyan/magenta/yellow palette, glows, overlays, responsive layout + +index.html Canvas host, HUD/menu/overlay DOM, ES module entry +``` + +## What OMC Demonstrated + +- **Parallel Task Decomposition**: Three subsystems (engine, gameplay, UI) developed independently without merge conflicts. +- **Specialist Routing**: Each agent played to its strengths — engine builder, gameplay designer, UI/visual specialist. +- **File-Scoped Isolation**: Strict module boundaries prevented stepping on toes; no shared mutable state between lanes. +- **Dependency Ordering**: Engine completed first, gameplay built on top, UI integrated last — critical path was visible and managed. +- **Verification Gates**: Each subsystem verified before integration; final pass checked gameplay balance and visual polish. + +This is a proof-of-concept that multi-agent orchestration scales to interactive, real-time applications. + +## License + +Unlicensed. Built as a demo for oh-my-claudecode. \ No newline at end of file diff --git a/oh-my-claudecode/index.html b/oh-my-claudecode/index.html new file mode 100644 index 0000000..b709303 --- /dev/null +++ b/oh-my-claudecode/index.html @@ -0,0 +1,49 @@ + + + + + + OMC Neon Arena + + + +
+ + +
+
Score: 0
+
Wave: 1
+
HP: 100
+
Combo: x1
+
+ +
+
+

OMC Neon Arena

+

WASD / arrows to move · mouse to aim · click or space to shoot · P to pause

+ +

Best: 0

+
+
+ + + + +
+ + + + diff --git a/oh-my-claudecode/src/engine/canvas.js b/oh-my-claudecode/src/engine/canvas.js new file mode 100644 index 0000000..3e161f1 --- /dev/null +++ b/oh-my-claudecode/src/engine/canvas.js @@ -0,0 +1,67 @@ +// Canvas bootstrap: device-pixel-ratio aware resize, exposes a stable logical size. + +const LOGICAL_WIDTH = 1280; +const LOGICAL_HEIGHT = 720; + +export function createCanvas(canvasEl) { + if (!canvasEl) throw new Error("createCanvas: canvas element is required"); + + const ctx = canvasEl.getContext("2d"); + if (!ctx) throw new Error("createCanvas: 2D context not available"); + + const state = { + canvas: canvasEl, + ctx, + width: LOGICAL_WIDTH, + height: LOGICAL_HEIGHT, + dpr: 1, + }; + + function resize() { + const dpr = window.devicePixelRatio || 1; + const rect = canvasEl.getBoundingClientRect(); + const cssWidth = rect.width || LOGICAL_WIDTH; + const cssHeight = rect.height || LOGICAL_HEIGHT; + + canvasEl.width = Math.round(cssWidth * dpr); + canvasEl.height = Math.round(cssHeight * dpr); + + const scaleX = canvasEl.width / LOGICAL_WIDTH; + const scaleY = canvasEl.height / LOGICAL_HEIGHT; + const scale = Math.min(scaleX, scaleY); + + ctx.setTransform(scale, 0, 0, scale, 0, 0); + + state.dpr = dpr; + state.width = LOGICAL_WIDTH; + state.height = LOGICAL_HEIGHT; + state.cssWidth = cssWidth; + state.cssHeight = cssHeight; + state.displayScale = scale; + } + + window.addEventListener("resize", resize, { passive: true }); + resize(); + + return { + get ctx() { + return state.ctx; + }, + get canvas() { + return state.canvas; + }, + get width() { + return state.width; + }, + get height() { + return state.height; + }, + get dpr() { + return state.dpr; + }, + get displayScale() { + return state.displayScale; + }, + resize, + }; +} diff --git a/oh-my-claudecode/src/engine/entity.js b/oh-my-claudecode/src/engine/entity.js new file mode 100644 index 0000000..eaf63b9 --- /dev/null +++ b/oh-my-claudecode/src/engine/entity.js @@ -0,0 +1,60 @@ +// Entity base + EntityPool. Pools are not object pools (no recycling) — they +// just own a list and expose filterAlive() for sweeping dead entities. + +let nextId = 1; + +export class Entity { + constructor({ x = 0, y = 0, vx = 0, vy = 0, radius = 8 } = {}) { + this.id = nextId++; + this.pos = { x, y }; + this.vel = { x: vx, y: vy }; + this.radius = radius; + this.alive = true; + } + + update(/* dt */) {} + render(/* ctx */) {} + + kill() { + this.alive = false; + } +} + +export class EntityPool { + constructor() { + this.items = []; + } + + add(entity) { + this.items.push(entity); + return entity; + } + + remove(entity) { + const idx = this.items.indexOf(entity); + if (idx !== -1) this.items.splice(idx, 1); + } + + clear() { + this.items.length = 0; + } + + forEach(fn) { + for (let i = 0; i < this.items.length; i++) fn(this.items[i], i); + } + + filterAlive() { + let write = 0; + for (let read = 0; read < this.items.length; read++) { + const item = this.items[read]; + if (item.alive) { + this.items[write++] = item; + } + } + this.items.length = write; + } + + get size() { + return this.items.length; + } +} diff --git a/oh-my-claudecode/src/engine/input.js b/oh-my-claudecode/src/engine/input.js new file mode 100644 index 0000000..284f7a6 --- /dev/null +++ b/oh-my-claudecode/src/engine/input.js @@ -0,0 +1,86 @@ +// Keyboard + pointer input manager. Tracks pressed keys, mouse position in +// canvas-logical coordinates, and mouse button state. + +export function createInput({ canvas, logicalWidth = 1280, logicalHeight = 720 } = {}) { + if (!canvas) throw new Error("createInput: canvas is required"); + + const keys = new Set(); + const pressedThisFrame = new Set(); + const releasedThisFrame = new Set(); + const mouse = { x: logicalWidth / 2, y: logicalHeight / 2, down: false }; + + function onKeyDown(e) { + if (!keys.has(e.code)) pressedThisFrame.add(e.code); + keys.add(e.code); + if (["Space", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.code)) { + e.preventDefault(); + } + } + + function onKeyUp(e) { + keys.delete(e.code); + releasedThisFrame.add(e.code); + } + + function updateMouseFromEvent(e) { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + mouse.x = ((e.clientX - rect.left) / rect.width) * logicalWidth; + mouse.y = ((e.clientY - rect.top) / rect.height) * logicalHeight; + } + + function onMouseMove(e) { + updateMouseFromEvent(e); + } + + function onMouseDown(e) { + updateMouseFromEvent(e); + mouse.down = true; + } + + function onMouseUp() { + mouse.down = false; + } + + function onBlur() { + keys.clear(); + mouse.down = false; + } + + window.addEventListener("keydown", onKeyDown); + window.addEventListener("keyup", onKeyUp); + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mousedown", onMouseDown); + window.addEventListener("mouseup", onMouseUp); + window.addEventListener("blur", onBlur); + + return { + isDown(code) { + return keys.has(code); + }, + wasPressed(code) { + return pressedThisFrame.has(code); + }, + wasReleased(code) { + return releasedThisFrame.has(code); + }, + anyDown(codes) { + return codes.some((c) => keys.has(c)); + }, + get mouse() { + return mouse; + }, + endFrame() { + pressedThisFrame.clear(); + releasedThisFrame.clear(); + }, + destroy() { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener("keyup", onKeyUp); + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mousedown", onMouseDown); + window.removeEventListener("mouseup", onMouseUp); + window.removeEventListener("blur", onBlur); + }, + }; +} diff --git a/oh-my-claudecode/src/engine/loop.js b/oh-my-claudecode/src/engine/loop.js new file mode 100644 index 0000000..fb88210 --- /dev/null +++ b/oh-my-claudecode/src/engine/loop.js @@ -0,0 +1,74 @@ +// Fixed-timestep game loop with requestAnimationFrame. +// Caps frame delta to avoid the "spiral of death" when the tab is backgrounded. + +const MAX_FRAME_DT = 0.25; + +export function startLoop({ update, render, fixedDt = 1 / 60 } = {}) { + if (typeof update !== "function") throw new Error("startLoop: update is required"); + if (typeof render !== "function") throw new Error("startLoop: render is required"); + + let accumulator = 0; + let lastTime = 0; + let rafId = 0; + let running = true; + let frames = 0; + let fpsWindowStart = 0; + let fps = 0; + + function frame(timeMs) { + rafId = requestAnimationFrame(frame); + + if (!lastTime) { + lastTime = timeMs; + fpsWindowStart = timeMs; + return; + } + + let dt = (timeMs - lastTime) / 1000; + lastTime = timeMs; + if (dt > MAX_FRAME_DT) dt = MAX_FRAME_DT; + + if (running) { + accumulator += dt; + let steps = 0; + while (accumulator >= fixedDt && steps < 8) { + update(fixedDt); + accumulator -= fixedDt; + steps += 1; + } + + const alpha = accumulator / fixedDt; + render(alpha, dt); + + frames += 1; + if (timeMs - fpsWindowStart >= 1000) { + fps = frames; + frames = 0; + fpsWindowStart = timeMs; + } + } + } + + rafId = requestAnimationFrame(frame); + + return { + pause() { + running = false; + }, + resume() { + running = true; + lastTime = 0; + accumulator = 0; + }, + stop() { + cancelAnimationFrame(rafId); + running = false; + }, + get running() { + return running; + }, + get fps() { + return fps; + }, + }; +} diff --git a/oh-my-claudecode/src/game/audio.js b/oh-my-claudecode/src/game/audio.js new file mode 100644 index 0000000..1908c3e --- /dev/null +++ b/oh-my-claudecode/src/game/audio.js @@ -0,0 +1,57 @@ +// Minimal procedural SFX. Lazy AudioContext so we don't breach autoplay rules. + +const VOICES = { + shoot: { freq: 740, freqEnd: 280, dur: 0.07, type: "square", gain: 0.05 }, + hit: { freq: 220, freqEnd: 90, dur: 0.12, type: "sawtooth", gain: 0.08 }, + explode: { freq: 160, freqEnd: 40, dur: 0.22, type: "triangle", gain: 0.12 }, + hurt: { freq: 120, freqEnd: 60, dur: 0.3, type: "sawtooth", gain: 0.16 }, + wave: { freq: 440, freqEnd: 880, dur: 0.25, type: "sine", gain: 0.09 }, + gameover: { freq: 320, freqEnd: 60, dur: 0.9, type: "sawtooth", gain: 0.18 }, +}; + +export class AudioBus { + constructor() { + this.ctx = null; + this.enabled = true; + } + + _ensureCtx() { + if (this.ctx) return this.ctx; + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return null; + try { + this.ctx = new AC(); + } catch { + this.ctx = null; + } + return this.ctx; + } + + resume() { + const ctx = this._ensureCtx(); + if (ctx && ctx.state === "suspended") ctx.resume().catch(() => {}); + } + + setEnabled(v) { + this.enabled = !!v; + } + + play(name) { + if (!this.enabled) return; + const ctx = this._ensureCtx(); + if (!ctx) return; + const voice = VOICES[name]; + if (!voice) return; + const now = ctx.currentTime; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.type = voice.type; + osc.frequency.setValueAtTime(voice.freq, now); + osc.frequency.exponentialRampToValueAtTime(Math.max(20, voice.freqEnd), now + voice.dur); + gain.gain.setValueAtTime(voice.gain, now); + gain.gain.exponentialRampToValueAtTime(0.0001, now + voice.dur); + osc.connect(gain).connect(ctx.destination); + osc.start(now); + osc.stop(now + voice.dur + 0.02); + } +} diff --git a/oh-my-claudecode/src/game/bullet.js b/oh-my-claudecode/src/game/bullet.js new file mode 100644 index 0000000..6be8730 --- /dev/null +++ b/oh-my-claudecode/src/game/bullet.js @@ -0,0 +1,53 @@ +// Bullet: straight-line projectile rendered as a short neon tracer. + +import { Entity } from "../engine/entity.js"; + +const DEFAULT_TTL = 1.2; + +export class Bullet extends Entity { + constructor(opts = {}) { + super({ radius: 4, ...opts }); + this.damage = opts.damage ?? 1; + this.ttl = opts.ttl ?? DEFAULT_TTL; + this.owner = opts.owner ?? "player"; + this.color = opts.color || "#9ff"; + } + + update(dt, game) { + this.pos.x += this.vel.x * dt; + this.pos.y += this.vel.y * dt; + this.ttl -= dt; + if (this.ttl <= 0) { + this.alive = false; + return; + } + if (game && game.canvas) { + const w = game.canvas.width; + const h = game.canvas.height; + if (this.pos.x < -20 || this.pos.x > w + 20 || this.pos.y < -20 || this.pos.y > h + 20) { + this.alive = false; + } + } + } + + render(ctx) { + const len = Math.hypot(this.vel.x, this.vel.y) || 1; + const nx = this.vel.x / len; + const ny = this.vel.y / len; + const tailLen = 14; + const x1 = this.pos.x - nx * tailLen; + const y1 = this.pos.y - ny * tailLen; + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + ctx.shadowColor = this.color; + ctx.shadowBlur = 14; + ctx.strokeStyle = this.color; + ctx.lineWidth = 3; + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(this.pos.x, this.pos.y); + ctx.stroke(); + ctx.restore(); + } +} diff --git a/oh-my-claudecode/src/game/collision.js b/oh-my-claudecode/src/game/collision.js new file mode 100644 index 0000000..b038311 --- /dev/null +++ b/oh-my-claudecode/src/game/collision.js @@ -0,0 +1,61 @@ +// Simple O(n^2) circle-vs-circle collision resolution for bullets/player/enemies. + +import { EnemyTypes, spawnSplitterChildren } from "./enemy.js"; + +export function resolveCollisions(game) { + const bullets = game.bullets.items; + const enemies = game.enemies.items; + const player = game.player; + + for (let i = 0; i < bullets.length; i++) { + const b = bullets[i]; + if (!b.alive) continue; + for (let j = 0; j < enemies.length; j++) { + const e = enemies[j]; + if (!e.alive) continue; + const dx = b.pos.x - e.pos.x; + const dy = b.pos.y - e.pos.y; + const r = b.radius + e.radius; + if (dx * dx + dy * dy <= r * r) { + const killed = e.takeDamage(b.damage); + b.alive = false; + game.particles.emit({ + x: b.pos.x, + y: b.pos.y, + color: e.color, + count: 6, + speedMin: 60, + speedMax: 180, + lifetime: 0.25, + radius: 2.5, + }); + if (killed) { + game.onEnemyKilled(e); + if (e.type === EnemyTypes.SPLITTER) { + for (const child of spawnSplitterChildren(e)) { + game.enemies.add(child); + } + } + } + break; + } + } + } + + if (player && player.alive && player.invuln <= 0) { + for (let j = 0; j < enemies.length; j++) { + const e = enemies[j]; + if (!e.alive) continue; + const dx = player.pos.x - e.pos.x; + const dy = player.pos.y - e.pos.y; + const r = player.radius + e.radius; + if (dx * dx + dy * dy <= r * r) { + const took = player.takeDamage(1, e.pos.x, e.pos.y); + if (took) { + game.onPlayerHit(e); + } + break; + } + } + } +} diff --git a/oh-my-claudecode/src/game/enemy.js b/oh-my-claudecode/src/game/enemy.js new file mode 100644 index 0000000..0a74c10 --- /dev/null +++ b/oh-my-claudecode/src/game/enemy.js @@ -0,0 +1,108 @@ +// Enemy types: chaser, bruiser, splitter. All seek the player. + +import { Entity } from "../engine/entity.js"; + +export const EnemyTypes = { + CHASER: "chaser", + BRUISER: "bruiser", + SPLITTER: "splitter", + MINI: "mini", +}; + +const STATS = { + chaser: { speed: 110, hp: 1, radius: 12, color: "#f3f", score: 10 }, + bruiser: { speed: 55, hp: 4, radius: 22, color: "#ff3", score: 40 }, + splitter: { speed: 90, hp: 2, radius: 18, color: "#8f4", score: 25 }, + mini: { speed: 140, hp: 1, radius: 8, color: "#f8c", score: 5 }, +}; + +export class Enemy extends Entity { + constructor(opts = {}) { + const type = opts.type || EnemyTypes.CHASER; + const s = STATS[type] || STATS.chaser; + super({ radius: s.radius, ...opts }); + this.type = type; + this.speed = s.speed; + this.maxHealth = s.hp; + this.health = s.hp; + this.color = s.color; + this.score = s.score; + this.hitFlash = 0; + this.wobble = Math.random() * Math.PI * 2; + } + + update(dt, game) { + const target = game.player; + if (!target) return; + const dx = target.pos.x - this.pos.x; + const dy = target.pos.y - this.pos.y; + const len = Math.hypot(dx, dy) || 1; + this.vel.x = (dx / len) * this.speed; + this.vel.y = (dy / len) * this.speed; + this.pos.x += this.vel.x * dt; + this.pos.y += this.vel.y * dt; + this.wobble += dt * 4; + if (this.hitFlash > 0) this.hitFlash -= dt; + } + + takeDamage(amount) { + this.health -= amount; + this.hitFlash = 0.08; + if (this.health <= 0) { + this.alive = false; + return true; + } + return false; + } + + render(ctx) { + const wob = 1 + Math.sin(this.wobble) * 0.06; + const r = this.radius * wob; + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + ctx.shadowColor = this.color; + ctx.shadowBlur = 18; + ctx.fillStyle = this.hitFlash > 0 ? "#fff" : this.color; + ctx.beginPath(); + if (this.type === EnemyTypes.BRUISER) { + const sides = 6; + for (let i = 0; i < sides; i++) { + const a = (i / sides) * Math.PI * 2 + this.wobble * 0.3; + const x = this.pos.x + Math.cos(a) * r; + const y = this.pos.y + Math.sin(a) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + } else if (this.type === EnemyTypes.SPLITTER) { + const sides = 4; + for (let i = 0; i < sides; i++) { + const a = (i / sides) * Math.PI * 2 + this.wobble * 0.4; + const x = this.pos.x + Math.cos(a) * r; + const y = this.pos.y + Math.sin(a) * r; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + } else { + ctx.arc(this.pos.x, this.pos.y, r, 0, Math.PI * 2); + } + ctx.fill(); + ctx.restore(); + } +} + +export function spawnSplitterChildren(parent) { + const children = []; + for (let i = 0; i < 2; i++) { + const angle = Math.random() * Math.PI * 2; + const offset = parent.radius * 0.6; + const child = new Enemy({ + type: EnemyTypes.MINI, + x: parent.pos.x + Math.cos(angle) * offset, + y: parent.pos.y + Math.sin(angle) * offset, + }); + children.push(child); + } + return children; +} diff --git a/oh-my-claudecode/src/game/game.js b/oh-my-claudecode/src/game/game.js new file mode 100644 index 0000000..0b5dc2c --- /dev/null +++ b/oh-my-claudecode/src/game/game.js @@ -0,0 +1,264 @@ +// Game: ties player, enemies, bullets, waves, collision, particles, audio, +// scoring/combo, screen shake, and the state machine together. + +import { EntityPool } from "../engine/entity.js"; +import { Player } from "./player.js"; +import { WaveManager } from "./waves.js"; +import { ParticleSystem } from "./particles.js"; +import { AudioBus } from "./audio.js"; +import { resolveCollisions } from "./collision.js"; + +export const GameState = { + MENU: "menu", + PLAYING: "playing", + PAUSED: "paused", + GAMEOVER: "gameover", +}; + +const COMBO_WINDOW = 2.0; +const SHAKE_DECAY = 10; + +export class Game { + constructor({ canvas, input, hud, highscore } = {}) { + this.canvas = canvas; + this.input = input; + this.hud = hud; + this.highscore = highscore; + + this.state = GameState.MENU; + this.score = 0; + this.combo = 0; + this.comboTimer = 0; + + this.player = new Player(); + this.enemies = new EntityPool(); + this.bullets = new EntityPool(); + this.particles = new ParticleSystem(); + this.waves = new WaveManager(); + this.audio = new AudioBus(); + + this.shake = 0; + this.time = 0; + + this.listeners = { stateChange: [] }; + } + + on(event, fn) { + if (!this.listeners[event]) this.listeners[event] = []; + this.listeners[event].push(fn); + } + + _emit(event, payload) { + const list = this.listeners[event]; + if (!list) return; + for (const fn of list) fn(payload); + } + + _setState(next) { + if (this.state === next) return; + this.state = next; + this._emit("stateChange", next); + } + + start() { + this.reset(); + if (this.audio) this.audio.resume(); + this._setState(GameState.PLAYING); + } + + pause() { + if (this.state === GameState.PLAYING) this._setState(GameState.PAUSED); + } + + resume() { + if (this.state === GameState.PAUSED) this._setState(GameState.PLAYING); + } + + reset() { + this.score = 0; + this.combo = 0; + this.comboTimer = 0; + this.shake = 0; + this.time = 0; + this.player.reset(); + this.enemies.clear(); + this.bullets.clear(); + this.particles.clear(); + this.waves.reset(); + this._syncHud(); + } + + _syncHud() { + if (!this.hud) return; + this.hud.setScore(this.score); + this.hud.setWave(this.waves.current); + this.hud.setHealth(this.player.health, this.player.maxHealth); + this.hud.setCombo(this._multiplier()); + if (this.highscore) this.hud.setBest(this.highscore.get()); + } + + _multiplier() { + return 1 + Math.floor(this.combo / 5); + } + + onEnemyKilled(enemy) { + const mult = this._multiplier(); + const gained = enemy.score * mult; + this.score += gained; + this.combo += 1; + this.comboTimer = COMBO_WINDOW; + this.shake = Math.min(12, this.shake + 3); + this.particles.emit({ + x: enemy.pos.x, + y: enemy.pos.y, + color: enemy.color, + count: 18, + speedMin: 80, + speedMax: 280, + lifetime: 0.55, + radius: 3.5, + }); + if (this.audio) this.audio.play("explode"); + if (this.hud) { + this.hud.setScore(this.score); + this.hud.setCombo(this._multiplier()); + } + } + + onPlayerHit(enemy) { + this.combo = 0; + this.comboTimer = 0; + this.shake = Math.min(22, this.shake + 10); + this.particles.emit({ + x: this.player.pos.x, + y: this.player.pos.y, + color: "#f44", + count: 22, + speedMin: 100, + speedMax: 320, + lifetime: 0.6, + radius: 3.5, + }); + if (this.audio) this.audio.play("hurt"); + if (this.hud) { + this.hud.setHealth(this.player.health, this.player.maxHealth); + this.hud.setCombo(this._multiplier()); + } + if (!this.player.alive || this.player.health <= 0) { + this._gameOver(); + } + } + + _gameOver() { + if (this.highscore) { + this.highscore.submit(this.score); + if (this.hud) this.hud.setBest(this.highscore.get()); + } + if (this.hud && typeof this.hud.setFinalScore === "function") { + this.hud.setFinalScore(this.score); + } + if (this.audio) this.audio.play("gameover"); + this._setState(GameState.GAMEOVER); + } + + update(dt) { + if (this.state !== GameState.PLAYING) return; + this.time += dt; + + if (this.comboTimer > 0) { + this.comboTimer -= dt; + if (this.comboTimer <= 0) { + this.combo = 0; + if (this.hud) this.hud.setCombo(this._multiplier()); + } + } + + if (this.shake > 0) { + this.shake *= Math.exp(-SHAKE_DECAY * dt); + if (this.shake < 0.05) this.shake = 0; + } + + const prevWave = this.waves.current; + + this.player.update(dt, this); + + const bullets = this.bullets.items; + for (let i = 0; i < bullets.length; i++) bullets[i].update(dt, this); + + const enemies = this.enemies.items; + for (let i = 0; i < enemies.length; i++) enemies[i].update(dt, this); + + resolveCollisions(this); + + this.waves.update(dt, this); + if (this.waves.current !== prevWave) { + if (this.audio) this.audio.play("wave"); + this.shake = Math.min(18, this.shake + 6); + } + + this.particles.update(dt); + this.bullets.filterAlive(); + this.enemies.filterAlive(); + + if (!this.player.alive) { + this._gameOver(); + } + } + + _drawGrid(ctx) { + const w = this.canvas.width; + const h = this.canvas.height; + const step = 64; + ctx.save(); + ctx.strokeStyle = "rgba(80, 180, 255, 0.08)"; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x <= w; x += step) { + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + } + for (let y = 0; y <= h; y += step) { + ctx.moveTo(0, y); + ctx.lineTo(w, y); + } + ctx.stroke(); + ctx.restore(); + } + + render() { + const ctx = this.canvas?.ctx; + if (!ctx) return; + const w = this.canvas.width; + const h = this.canvas.height; + + ctx.save(); + ctx.fillStyle = "#070712"; + ctx.fillRect(0, 0, w, h); + + let ox = 0; + let oy = 0; + if (this.shake > 0.1) { + ox = (Math.random() - 0.5) * this.shake; + oy = (Math.random() - 0.5) * this.shake; + ctx.translate(ox, oy); + } + + this._drawGrid(ctx); + + if (this.state !== GameState.MENU) { + const bullets = this.bullets.items; + for (let i = 0; i < bullets.length; i++) bullets[i].render(ctx); + + const enemies = this.enemies.items; + for (let i = 0; i < enemies.length; i++) enemies[i].render(ctx); + + if (this.player.alive || this.state === GameState.GAMEOVER) { + this.player.render(ctx); + } + + this.particles.render(ctx); + } + + ctx.restore(); + } +} diff --git a/oh-my-claudecode/src/game/particles.js b/oh-my-claudecode/src/game/particles.js new file mode 100644 index 0000000..e922082 --- /dev/null +++ b/oh-my-claudecode/src/game/particles.js @@ -0,0 +1,63 @@ +// Particle system: additive neon bursts with shrinking radius. + +export class ParticleSystem { + constructor() { + this.particles = []; + } + + emit({ x, y, color = "#fff", count = 10, speedMin = 60, speedMax = 240, lifetime = 0.6, radius = 3 } = {}) { + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2; + const speed = speedMin + Math.random() * (speedMax - speedMin); + this.particles.push({ + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + life: lifetime, + maxLife: lifetime, + color, + radius, + }); + } + } + + update(dt) { + const drag = Math.exp(-2.5 * dt); + let write = 0; + for (let i = 0; i < this.particles.length; i++) { + const p = this.particles[i]; + p.life -= dt; + if (p.life <= 0) continue; + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vx *= drag; + p.vy *= drag; + this.particles[write++] = p; + } + this.particles.length = write; + } + + render(ctx) { + if (this.particles.length === 0) return; + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + for (const p of this.particles) { + const t = p.life / p.maxLife; + const r = p.radius * t; + if (r <= 0) continue; + ctx.globalAlpha = Math.max(0, Math.min(1, t)); + ctx.shadowColor = p.color; + ctx.shadowBlur = 12; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(p.x, p.y, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.restore(); + } + + clear() { + this.particles.length = 0; + } +} diff --git a/oh-my-claudecode/src/game/player.js b/oh-my-claudecode/src/game/player.js new file mode 100644 index 0000000..3509612 --- /dev/null +++ b/oh-my-claudecode/src/game/player.js @@ -0,0 +1,152 @@ +// Player: WASD/arrow movement, mouse aim, click/space to shoot, invuln after hit. + +import { Entity } from "../engine/entity.js"; +import { Bullet } from "./bullet.js"; + +const SPEED = 260; +const SHOOT_COOLDOWN = 0.14; +const INVULN_TIME = 0.9; +const KNOCKBACK_DECAY = 6; + +export class Player extends Entity { + constructor(opts = {}) { + super({ x: 640, y: 360, radius: 14, ...opts }); + this.maxHealth = 3; + this.health = 3; + this.invuln = 0; + this.cooldown = 0; + this.knockback = { x: 0, y: 0 }; + this.aim = { x: 1, y: 0 }; + } + + reset() { + this.health = this.maxHealth; + this.pos.x = 640; + this.pos.y = 360; + this.vel.x = 0; + this.vel.y = 0; + this.invuln = 0; + this.cooldown = 0; + this.knockback.x = 0; + this.knockback.y = 0; + this.alive = true; + } + + update(dt, game) { + const input = game.input; + const canvas = game.canvas; + if (!input || !canvas) return; + + let mx = 0; + let my = 0; + if (input.anyDown(["KeyW", "ArrowUp"])) my -= 1; + if (input.anyDown(["KeyS", "ArrowDown"])) my += 1; + if (input.anyDown(["KeyA", "ArrowLeft"])) mx -= 1; + if (input.anyDown(["KeyD", "ArrowRight"])) mx += 1; + + const len = Math.hypot(mx, my); + if (len > 0) { + mx /= len; + my /= len; + } + this.vel.x = mx * SPEED; + this.vel.y = my * SPEED; + + this.pos.x += (this.vel.x + this.knockback.x) * dt; + this.pos.y += (this.vel.y + this.knockback.y) * dt; + + const decay = Math.exp(-KNOCKBACK_DECAY * dt); + this.knockback.x *= decay; + this.knockback.y *= decay; + if (Math.abs(this.knockback.x) < 1) this.knockback.x = 0; + if (Math.abs(this.knockback.y) < 1) this.knockback.y = 0; + + const r = this.radius; + if (this.pos.x < r) this.pos.x = r; + if (this.pos.y < r) this.pos.y = r; + if (this.pos.x > canvas.width - r) this.pos.x = canvas.width - r; + if (this.pos.y > canvas.height - r) this.pos.y = canvas.height - r; + + const mouse = input.mouse; + const dx = mouse.x - this.pos.x; + const dy = mouse.y - this.pos.y; + const aimLen = Math.hypot(dx, dy); + if (aimLen > 0.001) { + this.aim.x = dx / aimLen; + this.aim.y = dy / aimLen; + } + + if (this.cooldown > 0) this.cooldown -= dt; + if (this.invuln > 0) this.invuln -= dt; + + const wantsShoot = mouse.down || input.isDown("Space"); + if (wantsShoot && this.cooldown <= 0) { + this.shoot(game); + this.cooldown = SHOOT_COOLDOWN; + } + } + + shoot(game) { + const speed = 520; + const vx = this.aim.x * speed; + const vy = this.aim.y * speed; + const spawnX = this.pos.x + this.aim.x * (this.radius + 2); + const spawnY = this.pos.y + this.aim.y * (this.radius + 2); + const bullet = new Bullet({ x: spawnX, y: spawnY, vx, vy, owner: "player" }); + game.bullets.add(bullet); + game.particles.emit({ + x: spawnX, + y: spawnY, + color: "#7ff", + count: 4, + speedMin: 40, + speedMax: 140, + lifetime: 0.18, + radius: 2.5, + }); + if (game.audio) game.audio.play("shoot"); + } + + takeDamage(amount, fromX, fromY) { + if (this.invuln > 0) return false; + this.health -= amount; + this.invuln = INVULN_TIME; + if (typeof fromX === "number" && typeof fromY === "number") { + const dx = this.pos.x - fromX; + const dy = this.pos.y - fromY; + const len = Math.hypot(dx, dy) || 1; + const force = 320; + this.knockback.x = (dx / len) * force; + this.knockback.y = (dy / len) * force; + } + if (this.health <= 0) { + this.health = 0; + this.alive = false; + } + return true; + } + + render(ctx) { + const flicker = this.invuln > 0 && Math.floor(this.invuln * 24) % 2 === 0; + ctx.save(); + ctx.globalCompositeOperation = "lighter"; + ctx.shadowColor = "#0ff"; + ctx.shadowBlur = 22; + ctx.fillStyle = flicker ? "#6ff" : "#0ff"; + ctx.beginPath(); + ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2); + ctx.fill(); + + ctx.shadowBlur = 0; + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(this.pos.x, this.pos.y); + ctx.lineTo( + this.pos.x + this.aim.x * (this.radius + 10), + this.pos.y + this.aim.y * (this.radius + 10), + ); + ctx.stroke(); + ctx.restore(); + } +} diff --git a/oh-my-claudecode/src/game/waves.js b/oh-my-claudecode/src/game/waves.js new file mode 100644 index 0000000..1684311 --- /dev/null +++ b/oh-my-claudecode/src/game/waves.js @@ -0,0 +1,107 @@ +// Wave spawner: schedules enemies with stagger, advances wave when field is clear. + +import { Enemy, EnemyTypes } from "./enemy.js"; + +const INTERMISSION = 2.0; +const SPAWN_STAGGER = 0.35; + +export class WaveManager { + constructor() { + this.wave = 1; + this.queue = []; + this.spawnTimer = 0; + this.intermission = 0; + this.waveActive = false; + this._buildQueue(this.wave); + } + + reset() { + this.wave = 1; + this.queue.length = 0; + this.spawnTimer = 0; + this.intermission = 0; + this.waveActive = false; + this._buildQueue(this.wave); + this.waveActive = true; + } + + get current() { + return this.wave; + } + + _buildQueue(wave) { + const q = []; + if (wave === 1) { + for (let i = 0; i < 8; i++) q.push(EnemyTypes.CHASER); + } else if (wave === 2) { + for (let i = 0; i < 10; i++) q.push(EnemyTypes.CHASER); + for (let i = 0; i < 2; i++) q.push(EnemyTypes.BRUISER); + } else { + const chasers = 8 + wave * 2; + const bruisers = Math.floor(wave / 2); + const splitters = Math.max(0, wave - 2); + for (let i = 0; i < chasers; i++) q.push(EnemyTypes.CHASER); + for (let i = 0; i < bruisers; i++) q.push(EnemyTypes.BRUISER); + for (let i = 0; i < splitters; i++) q.push(EnemyTypes.SPLITTER); + } + for (let i = q.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [q[i], q[j]] = [q[j], q[i]]; + } + this.queue = q; + this.spawnTimer = 0.4; + } + + _spawnOffscreen(type, canvas) { + const w = canvas.width; + const h = canvas.height; + const edge = Math.floor(Math.random() * 4); + let x = 0; + let y = 0; + const pad = 40; + if (edge === 0) { + x = Math.random() * w; + y = -pad; + } else if (edge === 1) { + x = w + pad; + y = Math.random() * h; + } else if (edge === 2) { + x = Math.random() * w; + y = h + pad; + } else { + x = -pad; + y = Math.random() * h; + } + return new Enemy({ type, x, y }); + } + + update(dt, game) { + if (this.intermission > 0) { + this.intermission -= dt; + if (this.intermission <= 0) { + this.wave += 1; + this._buildQueue(this.wave); + this.waveActive = true; + if (game.hud) game.hud.setWave(this.wave); + } + return; + } + + if (!this.waveActive) { + this.waveActive = true; + } + + if (this.queue.length > 0) { + this.spawnTimer -= dt; + if (this.spawnTimer <= 0) { + const type = this.queue.shift(); + const enemy = this._spawnOffscreen(type, game.canvas); + game.enemies.add(enemy); + this.spawnTimer = SPAWN_STAGGER; + } + } else if (game.enemies.size === 0) { + this.waveActive = false; + this.intermission = INTERMISSION; + } + } +} diff --git a/oh-my-claudecode/src/main.js b/oh-my-claudecode/src/main.js new file mode 100644 index 0000000..5adfeae --- /dev/null +++ b/oh-my-claudecode/src/main.js @@ -0,0 +1,69 @@ +// Boots engine + game + UI and wires overlays, input, and the main loop. + +import { createCanvas } from "./engine/canvas.js"; +import { createInput } from "./engine/input.js"; +import { startLoop } from "./engine/loop.js"; +import { Game, GameState } from "./game/game.js"; +import { HUD } from "./ui/hud.js"; +import { Menu } from "./ui/menu.js"; +import { HighScore } from "./ui/highscore.js"; + +const canvasEl = document.getElementById("game"); +if (!canvasEl) throw new Error("#game canvas not found"); + +const canvas = createCanvas(canvasEl); +const input = createInput({ canvas: canvasEl, logicalWidth: canvas.width, logicalHeight: canvas.height }); +const hud = new HUD(document); +const highscore = new HighScore(); + +const game = new Game({ canvas, input, hud, highscore }); +hud.setBest(highscore.get()); + +const menu = new Menu(document, { + start: () => { + game.start(); + }, + resume: () => { + game.resume(); + }, + reset: () => { + game.start(); + }, +}); + +function renderOverlay(state) { + if (state === GameState.MENU) menu.show("menu"); + else if (state === GameState.PAUSED) menu.show("pause"); + else if (state === GameState.GAMEOVER) menu.show("gameover"); + else menu.hideAll(); +} + +game.on("stateChange", renderOverlay); +renderOverlay(game.state); + +const loop = startLoop({ + update: (dt) => { + if (input.wasPressed("KeyP") || input.wasPressed("Escape")) { + if (game.state === GameState.PLAYING) game.pause(); + else if (game.state === GameState.PAUSED) game.resume(); + } + game.update(dt); + input.endFrame(); + }, + render: (alpha, frameDt) => { + game.render(alpha, frameDt); + }, +}); + +// Expose a small debug handle for other workers + console tinkering. +window.__omc = { game, loop, canvas, input, hud, highscore }; + +let lastFpsLog = 0; +setInterval(() => { + const now = performance.now(); + if (now - lastFpsLog > 4500) { + lastFpsLog = now; + // eslint-disable-next-line no-console + console.log(`[omc] fps=${loop.fps} state=${game.state}`); + } +}, 5000); diff --git a/oh-my-claudecode/src/ui/highscore.js b/oh-my-claudecode/src/ui/highscore.js new file mode 100644 index 0000000..1ae9d1f --- /dev/null +++ b/oh-my-claudecode/src/ui/highscore.js @@ -0,0 +1,34 @@ +// localStorage-backed high score. Safe-guarded so the game still runs if +// storage is blocked (private mode / disabled cookies). + +const KEY = "omc-neon-arena.best"; + +export class HighScore { + constructor() { + this.value = 0; + try { + const raw = window.localStorage.getItem(KEY); + const parsed = raw == null ? 0 : parseInt(raw, 10); + this.value = Number.isFinite(parsed) ? parsed : 0; + } catch { + this.value = 0; + } + } + + get() { + return this.value; + } + + submit(score) { + if (score > this.value) { + this.value = score; + try { + window.localStorage.setItem(KEY, String(score)); + } catch { + /* ignore storage errors */ + } + return true; + } + return false; + } +} diff --git a/oh-my-claudecode/src/ui/hud.js b/oh-my-claudecode/src/ui/hud.js new file mode 100644 index 0000000..3d19b61 --- /dev/null +++ b/oh-my-claudecode/src/ui/hud.js @@ -0,0 +1,89 @@ +// HUD — renders score, wave, health pips, and combo into the DOM. + +const PIP_COUNT = 10; + +export class HUD { + constructor(root = document) { + this.root = root; + this.nodes = { + score: root.querySelector('[data-hud="score"]'), + wave: root.querySelector('[data-hud="wave"]'), + health: root.querySelector('[data-hud="health"]'), + combo: root.querySelector('[data-hud="combo"]'), + best: root.querySelector('[data-hud="best"]'), + finalScore: root.querySelector('[data-hud="final-score"]'), + finalBest: root.querySelector('[data-hud="final-best"]'), + }; + + this._maxHealth = 100; + this._buildPips(); + } + + _buildPips() { + const healthSlot = this.root.querySelector('[data-slot="health"]'); + if (!healthSlot) return; + + // Replace bare text with label + pip strip + healthSlot.innerHTML = 'HP'; + + const strip = document.createElement("div"); + strip.className = "health-pips"; + strip.setAttribute("aria-label", "health"); + + this._pips = []; + for (let i = 0; i < PIP_COUNT; i++) { + const pip = document.createElement("span"); + pip.className = "health-pip"; + strip.appendChild(pip); + this._pips.push(pip); + } + healthSlot.appendChild(strip); + + // Append the numeric span back for screen-readers / game logic reads + const numeric = document.createElement("span"); + numeric.setAttribute("data-hud", "health"); + numeric.style.display = "none"; + healthSlot.appendChild(numeric); + + // Update our node reference to the hidden numeric span + this.nodes.health = numeric; + } + + setScore(v) { + if (this.nodes.score) this.nodes.score.textContent = String(v); + } + + setWave(v) { + if (this.nodes.wave) this.nodes.wave.textContent = String(v); + } + + setHealth(v, max) { + if (max != null) this._maxHealth = max; + const hp = Math.max(0, Math.round(v)); + if (this.nodes.health) this.nodes.health.textContent = String(hp); + + if (!this._pips) return; + const ratio = this._maxHealth > 0 ? hp / this._maxHealth : 0; + const filled = Math.round(ratio * PIP_COUNT); + const isDanger = ratio <= 0.3; + + this._pips.forEach((pip, i) => { + const active = i < filled; + pip.classList.toggle("empty", !active); + pip.classList.toggle("danger", active && isDanger); + }); + } + + setCombo(v) { + if (this.nodes.combo) this.nodes.combo.textContent = `x${v}`; + } + + setBest(v) { + if (this.nodes.best) this.nodes.best.textContent = String(v); + if (this.nodes.finalBest) this.nodes.finalBest.textContent = String(v); + } + + setFinalScore(v) { + if (this.nodes.finalScore) this.nodes.finalScore.textContent = String(v); + } +} diff --git a/oh-my-claudecode/src/ui/menu.js b/oh-my-claudecode/src/ui/menu.js new file mode 100644 index 0000000..e5eed5a --- /dev/null +++ b/oh-my-claudecode/src/ui/menu.js @@ -0,0 +1,39 @@ +// Overlay manager: shows/hides the three overlay panels (menu/pause/gameover) +// and wires their buttons to callbacks. Designer (worker-3) may restyle, but +// the JS interface here stays stable. + +const OVERLAYS = ["menu", "pause", "gameover"]; + +export class Menu { + constructor(root = document, handlers = {}) { + this.root = root; + this.handlers = handlers; + this.elements = {}; + for (const name of OVERLAYS) { + this.elements[name] = root.querySelector(`[data-overlay="${name}"]`); + } + + root.addEventListener("click", (e) => { + const target = e.target.closest("[data-action]"); + if (!target) return; + const action = target.getAttribute("data-action"); + const handler = this.handlers[action]; + if (typeof handler === "function") handler(); + }); + } + + show(name) { + for (const key of OVERLAYS) { + const el = this.elements[key]; + if (!el) continue; + el.classList.toggle("hidden", key !== name); + } + } + + hideAll() { + for (const key of OVERLAYS) { + const el = this.elements[key]; + if (el) el.classList.add("hidden"); + } + } +} diff --git a/oh-my-claudecode/styles/main.css b/oh-my-claudecode/styles/main.css new file mode 100644 index 0000000..54b2795 --- /dev/null +++ b/oh-my-claudecode/styles/main.css @@ -0,0 +1,378 @@ +/* OMC Neon Arena — full neon arcade theme (worker-3) */ + +/* ── Palette tokens ───────────────────────────────────────────────────────── */ +:root { + --bg: #05060a; + --grid: #101828; + --cyan: #22e4ff; + --magenta: #ff3df0; + --yellow: #ffd84d; + --lime: #7dff6d; + --text: #e7f0ff; + --dim: rgba(231, 240, 255, 0.55); + --panel-bg: rgba(5, 8, 18, 0.92); + --glow-c: 0 0 8px var(--cyan), 0 0 24px rgba(34, 228, 255, 0.4); + --glow-m: 0 0 8px var(--magenta), 0 0 24px rgba(255, 61, 240, 0.4); +} + +/* ── Reset ───────────────────────────────────────────────────────────────── */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* ── Base ────────────────────────────────────────────────────────────────── */ +html, +body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: "Courier New", Courier, monospace; + overflow: hidden; + user-select: none; +} + +/* ── App shell ───────────────────────────────────────────────────────────── */ +#app { + position: relative; + width: 100%; + height: 100%; + display: grid; + place-items: center; + /* subtle radial vignette */ + background: radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.7) 100%); +} + +/* ── Canvas ──────────────────────────────────────────────────────────────── */ +#game { + display: block; + width: min(100vw, calc(100vh * 16 / 9)); + height: min(100vh, calc(100vw * 9 / 16)); + max-width: 1280px; + max-height: 720px; + background: var(--bg); + image-rendering: pixelated; + cursor: crosshair; + box-shadow: + 0 0 0 1px rgba(34, 228, 255, 0.18), + 0 0 32px rgba(34, 228, 255, 0.12), + 0 0 80px rgba(34, 228, 255, 0.06); +} + +/* ── HUD ─────────────────────────────────────────────────────────────────── */ +.hud { + position: absolute; + inset: 0; + pointer-events: none; + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-rows: auto 1fr auto; + padding: 0.75rem 1rem; + gap: 0.4rem; +} + +/* score + combo: top-left */ +.hud__slot[data-slot="score"] { + grid-column: 1; + grid-row: 1; + justify-self: start; +} +.hud__slot[data-slot="combo"] { + grid-column: 1; + grid-row: 2; + align-self: start; + margin-top: 0.15rem; +} + +/* wave: top-center */ +.hud__slot[data-slot="wave"] { + grid-column: 2; + grid-row: 1; + justify-self: center; +} + +/* health: bottom-left */ +.hud__slot[data-slot="health"] { + grid-column: 1; + grid-row: 3; + justify-self: start; + align-self: end; +} + +.hud__slot { + background: rgba(0, 0, 0, 0.45); + border: 1px solid rgba(34, 228, 255, 0.18); + padding: 0.2rem 0.55rem; + border-radius: 3px; + font-size: 0.75rem; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--dim); + font-variant-numeric: tabular-nums; + backdrop-filter: blur(4px); +} + +.hud__slot [data-hud] { + color: var(--cyan); + text-shadow: 0 0 6px var(--cyan); + font-weight: bold; +} + +/* combo highlight when active */ +.hud__slot[data-slot="combo"] [data-hud="combo"] { + color: var(--yellow); + text-shadow: 0 0 6px var(--yellow); +} + +/* wave number stands out in magenta */ +.hud__slot[data-slot="wave"] [data-hud="wave"] { + color: var(--magenta); + text-shadow: 0 0 6px var(--magenta); +} + +/* ── Health pips ─────────────────────────────────────────────────────────── */ +.hud__slot[data-slot="health"] { + display: flex; + align-items: center; + gap: 0.4rem; +} + +.hud__slot[data-slot="health"] .hud__label { + color: var(--dim); +} + +.health-pips { + display: flex; + gap: 3px; + align-items: center; +} + +.health-pip { + width: 10px; + height: 10px; + border-radius: 2px; + background: var(--lime); + box-shadow: 0 0 5px var(--lime), 0 0 10px rgba(125, 255, 109, 0.35); + transition: background 0.15s, box-shadow 0.15s, opacity 0.15s; +} + +.health-pip.empty { + background: rgba(125, 255, 109, 0.12); + box-shadow: none; + opacity: 0.35; +} + +.health-pip.danger { + background: var(--magenta); + box-shadow: 0 0 5px var(--magenta), 0 0 10px rgba(255, 61, 240, 0.4); + animation: pip-pulse 0.6s ease-in-out infinite alternate; +} + +@keyframes pip-pulse { + from { opacity: 1; } + to { opacity: 0.45; } +} + +/* ── Overlay base ────────────────────────────────────────────────────────── */ +.overlay { + position: absolute; + inset: 0; + display: grid; + place-items: center; + z-index: 10; + backdrop-filter: blur(3px); + background: rgba(0, 0, 0, 0.62); +} + +.overlay.hidden { + display: none; +} + +.overlay__panel { + background: var(--panel-bg); + border: 1px solid rgba(34, 228, 255, 0.25); + border-radius: 6px; + padding: 2rem 2.5rem; + text-align: center; + max-width: 30rem; + width: 90%; + box-shadow: + 0 0 0 1px rgba(34, 228, 255, 0.08), + 0 0 40px rgba(34, 228, 255, 0.08), + inset 0 1px 0 rgba(34, 228, 255, 0.12); + position: relative; + overflow: hidden; +} + +/* subtle scan-line shimmer on panel */ +.overlay__panel::before { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 180deg, + transparent 0px, + transparent 3px, + rgba(34, 228, 255, 0.025) 3px, + rgba(34, 228, 255, 0.025) 4px + ); + pointer-events: none; + border-radius: inherit; +} + +/* ── Overlay title ───────────────────────────────────────────────────────── */ +.overlay__title { + margin: 0 0 0.6rem; + font-size: clamp(1.6rem, 5vw, 2.4rem); + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--cyan); + text-shadow: var(--glow-c); +} + +/* animated glow pulse on the main menu title only */ +.overlay--menu .overlay__title { + animation: title-glow 2.4s ease-in-out infinite alternate; +} + +@keyframes title-glow { + from { + text-shadow: 0 0 6px var(--cyan), 0 0 18px rgba(34, 228, 255, 0.4); + color: var(--cyan); + } + to { + text-shadow: + 0 0 12px var(--cyan), + 0 0 40px rgba(34, 228, 255, 0.7), + 0 0 80px rgba(34, 228, 255, 0.3); + color: #a8f7ff; + } +} + +/* pause title in magenta */ +.overlay--pause .overlay__title { + color: var(--magenta); + text-shadow: var(--glow-m); +} + +/* gameover title in yellow */ +.overlay--gameover .overlay__title { + color: var(--yellow); + text-shadow: 0 0 8px var(--yellow), 0 0 24px rgba(255, 216, 77, 0.45); +} + +/* ── Overlay text ────────────────────────────────────────────────────────── */ +.overlay__subtitle { + margin: 0 0 1.2rem; + color: var(--dim); + font-size: 0.8rem; + letter-spacing: 0.04em; + line-height: 1.6; +} + +.overlay__meta { + margin: 0.45rem 0; + color: var(--dim); + font-size: 0.85rem; + letter-spacing: 0.03em; +} + +.overlay__meta [data-hud] { + color: var(--cyan); + text-shadow: 0 0 5px var(--cyan); +} + +/* ── Buttons ─────────────────────────────────────────────────────────────── */ +.overlay__button { + display: inline-block; + margin: 0.55rem 0.3rem 0; + padding: 0.55rem 1.4rem; + border: 1px solid var(--cyan); + background: transparent; + color: var(--cyan); + font: inherit; + font-size: 0.85rem; + letter-spacing: 0.1em; + text-transform: uppercase; + cursor: pointer; + border-radius: 3px; + text-shadow: 0 0 6px var(--cyan); + box-shadow: 0 0 8px rgba(34, 228, 255, 0.2), inset 0 0 8px rgba(34, 228, 255, 0.05); + transition: background 0.18s, box-shadow 0.18s, transform 0.1s, color 0.18s; + position: relative; + overflow: hidden; +} + +.overlay__button::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(135deg, rgba(34, 228, 255, 0.1) 0%, transparent 60%); + opacity: 0; + transition: opacity 0.18s; +} + +.overlay__button:hover { + background: rgba(34, 228, 255, 0.1); + box-shadow: 0 0 16px rgba(34, 228, 255, 0.5), inset 0 0 12px rgba(34, 228, 255, 0.1); + transform: translateY(-1px) scale(1.03); + color: #fff; + text-shadow: 0 0 10px var(--cyan); +} + +.overlay__button:hover::before { + opacity: 1; +} + +.overlay__button:active { + transform: translateY(0) scale(0.97); + box-shadow: 0 0 8px rgba(34, 228, 255, 0.3); +} + +/* restart / try-again button gets magenta treatment */ +.overlay__button[data-action="reset"] { + border-color: var(--magenta); + color: var(--magenta); + text-shadow: 0 0 6px var(--magenta); + box-shadow: 0 0 8px rgba(255, 61, 240, 0.2), inset 0 0 8px rgba(255, 61, 240, 0.05); +} + +.overlay__button[data-action="reset"]:hover { + background: rgba(255, 61, 240, 0.1); + box-shadow: 0 0 16px rgba(255, 61, 240, 0.5), inset 0 0 12px rgba(255, 61, 240, 0.1); + color: #fff; + text-shadow: 0 0 10px var(--magenta); +} + +/* ── Responsive ──────────────────────────────────────────────────────────── */ +@media (max-width: 640px) { + .overlay__panel { + padding: 1.4rem 1.2rem; + } + + .overlay__title { + font-size: 1.4rem; + } + + .hud__slot { + font-size: 0.65rem; + padding: 0.15rem 0.4rem; + } + + .health-pip { + width: 8px; + height: 8px; + } +} + +@media (max-width: 400px) { + .overlay__button { + display: block; + width: 100%; + margin: 0.4rem 0 0; + } +}