mirror of
https://github.com/tiennm99/ai-coding-workflow-labs.git
synced 2026-08-13 13:22:58 +00:00
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.
This commit is contained in:
@@ -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.
|
||||
+124
-1
@@ -1 +1,124 @@
|
||||
# try-oh-my-claudecode
|
||||
# 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.
|
||||
@@ -0,0 +1,49 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OMC Neon Arena</title>
|
||||
<link rel="stylesheet" href="./styles/main.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<canvas id="game" width="1280" height="720"></canvas>
|
||||
|
||||
<div id="hud" class="hud">
|
||||
<div class="hud__slot" data-slot="score">Score: <span data-hud="score">0</span></div>
|
||||
<div class="hud__slot" data-slot="wave">Wave: <span data-hud="wave">1</span></div>
|
||||
<div class="hud__slot" data-slot="health">HP: <span data-hud="health">100</span></div>
|
||||
<div class="hud__slot" data-slot="combo">Combo: <span data-hud="combo">x1</span></div>
|
||||
</div>
|
||||
|
||||
<div id="overlay-menu" class="overlay overlay--menu" data-overlay="menu">
|
||||
<div class="overlay__panel">
|
||||
<h1 class="overlay__title">OMC Neon Arena</h1>
|
||||
<p class="overlay__subtitle">WASD / arrows to move · mouse to aim · click or space to shoot · P to pause</p>
|
||||
<button class="overlay__button" data-action="start">Start</button>
|
||||
<p class="overlay__meta">Best: <span data-hud="best">0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="overlay-pause" class="overlay overlay--pause hidden" data-overlay="pause">
|
||||
<div class="overlay__panel">
|
||||
<h2 class="overlay__title">Paused</h2>
|
||||
<button class="overlay__button" data-action="resume">Resume</button>
|
||||
<button class="overlay__button" data-action="reset">Restart</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="overlay-gameover" class="overlay overlay--gameover hidden" data-overlay="gameover">
|
||||
<div class="overlay__panel">
|
||||
<h2 class="overlay__title">You Died</h2>
|
||||
<p class="overlay__meta">Score: <span data-hud="final-score">0</span></p>
|
||||
<p class="overlay__meta">Best: <span data-hud="final-best">0</span></p>
|
||||
<button class="overlay__button" data-action="reset">Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = '<span class="hud__label">HP</span>';
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user