feat: v2 — power-ups, dash, boss waves, starfield, floaters, settings

Adds Chicken-Invaders-style booster drops (rapidfire/spread/pierce/
shield/heal/bomb) with magnetism pickup, Shift-dash with invuln +
ghost trail, boss fight every 5th wave (radial + aimed attacks),
shooter enemy with projectiles from wave 3+, parallax starfield,
combo score floaters, and a pause-screen settings overlay (volume
+ shake toggle) persisted to localStorage. Built in parallel by a
4-worker OMC team; blockers found by verifier were patched in place.
This commit is contained in:
2026-04-19 09:56:27 +07:00
parent ff3d1bf199
commit de63dd095a
20 changed files with 2025 additions and 70 deletions
+66
View File
@@ -0,0 +1,66 @@
# OMC Neon Arena — V2 Features
Feature round adding Chicken-Invaders-style booster drops and other arcade polish. Keeps all existing v1 files and their public surface working.
## Scope
1. **Power-up drops (boosters).** Enemies drop floating pickups on death. Six types:
- `rapidfire` — cyan — halves shoot cooldown for 8s
- `spread` — magenta — shoots 3-way fan for 8s
- `pierce` — white — bullets pass through enemies (max 3 hits) for 8s
- `shield` — yellow — one free hit absorbed (stacks, max 2)
- `heal` — lime — instantly +1 HP (capped at maxHealth)
- `bomb` — red — instant screen clear: all enemies + enemy bullets take 3 dmg, big FX
Drop odds: chaser 15%, splitter 20%, bruiser 40%, shooter 25%, mini 5%, boss 100% (drops 2). Pickups float gently and drift toward the player when within 120 px. Expire after 12s with fade. Pickup triggers a small burst + sound.
2. **Dash ability.** Shift key. 180ms duration, 900ms cooldown, 480 px/s forward-velocity boost. Invulnerable during dash. Emits 5 ghost-trail particles. HUD shows dash-ready pip.
3. **Boss fight** every 5th wave (wave 5, 10, 15, …). Single large enemy, hp = 40 + 20·floor(wave/5), radius 48, score 500. Attacks rotate: (a) 8-way radial burst every 3s, (b) aimed 3-shot at player every 1.5s, (c) during the last HP third, faster cadence. Drops 2 power-ups guaranteed on death.
4. **Shooter enemy type** from wave 3+. hp 2, radius 14, speed 70, score 30, color #ff8ac2. Keeps distance ~260 px; fires aimed bullet every 1.8s.
5. **Enemy bullets.** Speed 180 px/s, 4 s TTL, glowing dot tracer. Collide only with player (damage via existing onPlayerHit path). Lives in its own pool `game.enemyBullets`.
6. **Parallax starfield background** — 3 layers of drifting dots behind the grid. Subtle (~1/3/7 px-per-sec downward drift). Additive blend.
7. **Floating combo text.** On enemy kill, spawn `+<points> ×<mult>!` floater at the kill position. Rises 40 px, fades out over 0.9s. Color matches the combo mult (white → cyan → magenta → yellow at higher mults). Also spawn on bomb/boss-defeat.
8. **Settings.** New overlay reachable from pause screen: master volume (0100), screen-shake toggle. Persists to `localStorage` key `omc-neon-arena.settings`. Applied live.
## Architecture & File Plan
**New files (owned by the worker that creates them):**
- `src/game/powerup.js` — Pickup entity + PowerupPool + spawn helper.
- `src/game/buffs.js` — BuffManager: addBuff(kind, duration), hasBuff(kind), consumeShield(), tick(dt), active() → list for HUD.
- `src/game/boss.js` — Boss class + attack patterns.
- `src/game/enemy_bullet.js` — EnemyBullet + pool.
- `src/game/starfield.js` — Parallax 3-layer drift.
- `src/game/floaters.js` — FloaterPool + emit(text, x, y, color).
- `src/game/settings.js` — loadSettings(), saveSettings(), on/off screen shake flag, volume 0..1.
**Modified files (single owner to avoid conflicts):**
- `src/game/game.js`**worker-1 owns.** Adds: import stubs for the seven new modules, instances `this.powerups/buffs/boss/enemyBullets/starfield/floaters/settings`. Wires update/render in correct order (starfield → grid → entities → floaters → HUD). onEnemyKilled → drop powerup check + floater emit. onBossDefeated → 2 drops. `applyBomb()` helper used by bomb power-up.
- `src/game/player.js`**worker-1 owns.** Reads BuffManager for shoot cadence / spread / pierce. Dash on Shift. Shield consumption in onPlayerHit.
- `src/game/bullet.js`**worker-1 owns.** Supports a `pierce` budget (pass-through hits remaining).
- `src/game/enemy.js`**worker-2 owns.** Adds `shooter` type + enemy-bullet emission.
- `src/game/waves.js`**worker-2 owns.** Boss cadence every 5 waves; shooter injection from wave 3+.
- `src/game/collision.js`**worker-2 owns.** Enemy bullets vs player; bullet-vs-enemy pierce handling; bullet-vs-boss.
- `src/ui/hud.js`**worker-3 owns.** setBuffs(list) → icons with countdown rings; setDashReady(bool); setBossHealth(0..1 | null); setWaveBanner(text, duration).
- `styles/main.css`**worker-4 owns.** Buff icons, boss HP bar, wave banner, settings overlay. Polish classes for floaters if they use DOM (they will use canvas).
- `index.html`**worker-4 owns.** Settings overlay markup + HUD slots for buffs/boss-bar/wave-banner.
- `src/ui/menu.js`**worker-4 owns.** showSettings()/hideSettings(), wires volume slider + shake toggle.
- `src/main.js`**worker-1 owns.** Hooks Shift key into Player.tryDash, wires menu.settings handlers to game.settings.
## Contracts
- `game.powerups`, `game.enemyBullets`, `game.floaters`, `game.buffs`, `game.boss`, `game.starfield`, `game.settings` must all exist on Game after v2.
- `game.onEnemyKilled(enemy)` still receives an Enemy instance. It internally calls powerup drop + floater emit + boss-defeat logic.
- `game.applyBomb()` clears enemies (3 damage each) and enemy bullets, emits large particles, plays sound.
- `HUD.setBuffs([{kind:'rapidfire', remaining:0..1}, …])` — remaining is fraction 0..1 for countdown ring.
- `game.settings` is a live object `{ volume:number, shake:boolean }` mutated by UI; Game reads each frame.
## Parallel Execution Plan
- worker-1 runs first (blocks 2, 3, 4). Creates all new stub files with stable exports + implements the full power-ups/dash/bullet pierce/Game wiring.
- worker-2, 3, 4 fan out in parallel once #1 lands.
+26
View File
@@ -15,6 +15,10 @@
<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 class="hud-buffs" data-hud="buffs"></div>
<div class="dash-pip hidden" data-hud="dash"></div>
<div class="boss-bar hidden" data-hud="boss"><span class="label">BOSS</span><div class="boss-bar__fill"></div></div>
<div class="wave-banner" data-hud="banner"></div>
</div>
<div id="overlay-menu" class="overlay overlay--menu" data-overlay="menu">
@@ -31,9 +35,31 @@
<h2 class="overlay__title">Paused</h2>
<button class="overlay__button" data-action="resume">Resume</button>
<button class="overlay__button" data-action="reset">Restart</button>
<button class="overlay__button overlay__button--settings" data-action="open-settings">Settings</button>
</div>
</div>
<section class="overlay hidden" data-overlay="settings" id="overlay-settings">
<div class="overlay__panel">
<h2 class="overlay__title">Settings</h2>
<div class="settings-row">
<label class="settings-label" for="settings-volume">Volume</label>
<div class="settings-control">
<input type="range" min="0" max="100" id="settings-volume" data-setting="volume" class="settings-slider" />
<span class="settings-value" id="settings-volume-val">70</span>
</div>
</div>
<div class="settings-row">
<label class="settings-label" for="settings-shake">Screen Shake</label>
<div class="settings-control">
<input type="checkbox" id="settings-shake" data-setting="shake" class="settings-toggle" />
<label class="settings-toggle-label" for="settings-shake"></label>
</div>
</div>
<button class="overlay__button" data-action="settings-back">Back</button>
</div>
</section>
<div id="overlay-gameover" class="overlay overlay--gameover hidden" data-overlay="gameover">
<div class="overlay__panel">
<h2 class="overlay__title">You Died</h2>
+8 -1
View File
@@ -13,6 +13,13 @@ export class AudioBus {
constructor() {
this.ctx = null;
this.enabled = true;
this.master = 1;
}
setMasterVolume(v) {
const n = Number(v);
if (!Number.isFinite(n)) return;
this.master = Math.max(0, Math.min(1, n));
}
_ensureCtx() {
@@ -48,7 +55,7 @@ export class AudioBus {
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.setValueAtTime(voice.gain * this.master, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + voice.dur);
osc.connect(gain).connect(ctx.destination);
osc.start(now);
+225
View File
@@ -0,0 +1,225 @@
// Boss: large hex/diamond enemy with two attack patterns.
// - Every 3s: 8-way radial burst of enemy bullets.
// - Every 1.5s: aimed 3-shot fan at the player (+/-10 deg).
// When HP <= 33% of max, both cadences multiply by 0.6 (fire faster).
import { Entity } from "../engine/entity.js";
import { spawnEnemyBullet } from "./enemy_bullet.js";
const BOSS_RADIUS = 48;
const BOSS_SPEED_X = 90;
const BOSS_SPEED_Y = 10;
const BOSS_COLOR = "#ff66aa";
const BULLET_SPEED = 180;
const RADIAL_INTERVAL = 3.0;
const AIMED_INTERVAL = 1.5;
const RAGE_CADENCE = 0.6;
const AIM_FAN = (10 * Math.PI) / 180;
export class Boss extends Entity {
constructor(opts = {}) {
super({
x: opts.x ?? 640,
y: opts.y ?? 120,
radius: opts.radius ?? BOSS_RADIUS,
});
const hp = opts.hp ?? 40;
this.maxHealth = hp;
this.health = hp;
// Aliases for design-doc naming.
this.maxHp = hp;
this.hp = hp;
this.score = opts.score ?? 500;
this.color = opts.color ?? BOSS_COLOR;
this.type = "boss";
this.alive = true;
this._sinePhase = Math.random() * Math.PI * 2;
this._baseX = this.pos.x;
this._radialTimer = RADIAL_INTERVAL * 0.6;
this._aimedTimer = AIMED_INTERVAL * 0.6;
this.hitFlash = 0;
this.wobble = 0;
}
_inRage() {
return this.health <= this.maxHealth / 3;
}
update(dt, game) {
if (!this.alive) return;
this.wobble += dt * 2;
if (this.hitFlash > 0) this.hitFlash -= dt;
this._sinePhase += dt * 0.9;
const canvasW = game?.canvas?.width ?? 1280;
const canvasH = game?.canvas?.height ?? 720;
const amp = Math.min(280, canvasW * 0.3);
const midX = canvasW / 2;
this.pos.x = midX + Math.sin(this._sinePhase) * amp;
if (this.pos.y < canvasH * 0.32) {
this.pos.y += BOSS_SPEED_Y * dt;
}
const r = this.radius;
if (this.pos.x < r) this.pos.x = r;
if (this.pos.x > canvasW - r) this.pos.x = canvasW - r;
const rage = this._inRage() ? RAGE_CADENCE : 1;
this._radialTimer -= dt;
if (this._radialTimer <= 0) {
this._radialTimer = RADIAL_INTERVAL * rage;
this._fireRadial(game);
}
this._aimedTimer -= dt;
if (this._aimedTimer <= 0) {
this._aimedTimer = AIMED_INTERVAL * rage;
this._fireAimed(game);
}
}
_fireRadial(game) {
if (!game) return;
const count = 8;
for (let i = 0; i < count; i++) {
const a = (i / count) * Math.PI * 2;
spawnEnemyBullet(game, {
x: this.pos.x,
y: this.pos.y,
vx: Math.cos(a) * BULLET_SPEED,
vy: Math.sin(a) * BULLET_SPEED,
color: this.color,
});
}
if (game.audio?.play) game.audio.play("shoot");
}
_fireAimed(game) {
if (!game?.player) return;
const px = game.player.pos.x;
const py = game.player.pos.y;
const dx = px - this.pos.x;
const dy = py - this.pos.y;
const len = Math.hypot(dx, dy) || 1;
const baseX = dx / len;
const baseY = dy / len;
const offsets = [-AIM_FAN, 0, AIM_FAN];
for (const off of offsets) {
const cos = Math.cos(off);
const sin = Math.sin(off);
const ax = baseX * cos - baseY * sin;
const ay = baseX * sin + baseY * cos;
spawnEnemyBullet(game, {
x: this.pos.x + ax * (this.radius + 4),
y: this.pos.y + ay * (this.radius + 4),
vx: ax * BULLET_SPEED,
vy: ay * BULLET_SPEED,
color: this.color,
});
}
if (game.audio?.play) game.audio.play("shoot");
}
takeDamage(amount = 1) {
this.health -= amount;
this.hp = this.health;
this.hitFlash = 0.12;
if (this.health <= 0) {
this.health = 0;
this.hp = 0;
this.alive = false;
return true;
}
return false;
}
render(ctx) {
const r = this.radius;
const flashing = this.hitFlash > 0;
const color = flashing ? "#ffffff" : this.color;
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.shadowColor = this.color;
ctx.shadowBlur = 36;
// Outer hex.
ctx.strokeStyle = color;
ctx.fillStyle = flashing ? "rgba(255,255,255,0.6)" : "rgba(255,102,170,0.18)";
ctx.lineWidth = 3;
ctx.beginPath();
const sides = 6;
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2 + this.wobble * 0.2;
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();
ctx.fill();
ctx.stroke();
// Inner diamond.
ctx.shadowBlur = 22;
ctx.beginPath();
const inner = r * 0.62;
const diaPhase = -this.wobble * 0.4;
for (let i = 0; i < 4; i++) {
const a = (i / 4) * Math.PI * 2 + diaPhase;
const x = this.pos.x + Math.cos(a) * inner;
const y = this.pos.y + Math.sin(a) * inner;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
// Segmented inner core — slices shown by remaining HP.
const segments = 6;
const frac = Math.max(0, this.health / this.maxHealth);
const filled = Math.ceil(segments * frac);
const coreR = r * 0.36;
ctx.shadowBlur = 12;
for (let i = 0; i < segments; i++) {
const a0 = (i / segments) * Math.PI * 2 + this.wobble * 0.5;
const a1 = ((i + 1) / segments) * Math.PI * 2 + this.wobble * 0.5;
ctx.beginPath();
ctx.moveTo(this.pos.x, this.pos.y);
ctx.arc(this.pos.x, this.pos.y, coreR, a0, a1);
ctx.closePath();
if (i < filled) {
ctx.fillStyle = flashing ? "#ffffff" : this.color;
ctx.fill();
}
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 1;
ctx.stroke();
}
// Glow center pip.
ctx.shadowBlur = 20;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, r * 0.1, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
export function spawnBoss(game, wave) {
const w = game?.canvas?.width ?? 1280;
const waveNum = typeof wave === "number" ? wave : (game?.waves?.current ?? 1);
const hp = 40 + 20 * Math.floor(waveNum / 5);
const b = new Boss({
x: w / 2,
y: 120,
hp,
});
b.alive = true;
return b;
}
+59
View File
@@ -0,0 +1,59 @@
// BuffManager: tracks timed buffs (rapidfire/spread/pierce) and stacked shield.
const TIMED_KINDS = ["rapidfire", "spread", "pierce"];
const SHIELD_MAX = 2;
export class BuffManager {
constructor() {
this.timed = new Map();
this.shield = 0;
}
clear() {
this.timed.clear();
this.shield = 0;
}
addBuff(kind, duration = 8) {
if (kind === "shield") {
this.shield = Math.min(SHIELD_MAX, this.shield + 1);
return;
}
if (!TIMED_KINDS.includes(kind)) return;
const existing = this.timed.get(kind) || { remaining: 0, total: duration };
const remaining = Math.max(existing.remaining, duration);
this.timed.set(kind, { remaining, total: Math.max(existing.total, duration) });
}
tick(dt) {
for (const [kind, entry] of this.timed) {
entry.remaining -= dt;
if (entry.remaining <= 0) this.timed.delete(kind);
}
}
hasBuff(kind) {
if (kind === "shield") return this.shield > 0;
return this.timed.has(kind);
}
consumeShield() {
if (this.shield > 0) {
this.shield -= 1;
return true;
}
return false;
}
active() {
const list = [];
for (const [kind, entry] of this.timed) {
const frac = entry.total > 0 ? Math.max(0, Math.min(1, entry.remaining / entry.total)) : 0;
list.push({ kind, remaining: frac });
}
if (this.shield > 0) {
list.push({ kind: "shield", remaining: this.shield / SHIELD_MAX, stacks: this.shield });
}
return list;
}
}
+21 -1
View File
@@ -1,4 +1,5 @@
// Bullet: straight-line projectile rendered as a short neon tracer.
// Supports a pierce budget: pass-through hits remaining before the bullet dies.
import { Entity } from "../engine/entity.js";
@@ -11,6 +12,25 @@ export class Bullet extends Entity {
this.ttl = opts.ttl ?? DEFAULT_TTL;
this.owner = opts.owner ?? "player";
this.color = opts.color || "#9ff";
this.pierce = opts.pierce ?? 0;
this._hitIds = null;
}
onHit(target) {
if (target && typeof target.id === "number") {
if (!this._hitIds) this._hitIds = new Set();
this._hitIds.add(target.id);
}
if (this.pierce > 0) {
this.pierce -= 1;
return;
}
this.alive = false;
}
hasHit(target) {
if (!this._hitIds || !target || typeof target.id !== "number") return false;
return this._hitIds.has(target.id);
}
update(dt, game) {
@@ -42,7 +62,7 @@ export class Bullet extends Entity {
ctx.shadowColor = this.color;
ctx.shadowBlur = 14;
ctx.strokeStyle = this.color;
ctx.lineWidth = 3;
ctx.lineWidth = this.pierce > 0 ? 4 : 3;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
+85 -28
View File
@@ -1,61 +1,118 @@
// Simple O(n^2) circle-vs-circle collision resolution for bullets/player/enemies.
// Circle-vs-circle collision resolution.
// V2: enemy bullets vs player, player bullets vs boss, bullet pierce handling.
import { EnemyTypes, spawnSplitterChildren } from "./enemy.js";
function circleHit(a, b) {
const dx = a.pos.x - b.pos.x;
const dy = a.pos.y - b.pos.y;
const r = a.radius + b.radius;
return dx * dx + dy * dy <= r * r;
}
export function resolveCollisions(game) {
const bullets = game.bullets.items;
const enemies = game.enemies.items;
const player = game.player;
const boss = game.boss;
// Player bullets vs enemies + boss (with pierce).
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);
if (typeof b.hasHit === "function" && b.hasHit(e)) continue;
if (!circleHit(b, e)) continue;
const killed = e.takeDamage(b.damage);
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 (typeof b.onHit === "function") {
b.onHit(e);
} else {
b.alive = false;
}
if (killed) {
game.onEnemyKilled(e);
if (e.type === EnemyTypes.SPLITTER) {
for (const child of spawnSplitterChildren(e)) {
game.enemies.add(child);
}
}
}
if (!b.alive) break;
}
if (!b.alive) continue;
if (boss && boss.alive) {
const alreadyHit = typeof b.hasHit === "function" && b.hasHit(boss);
if (!alreadyHit && circleHit(b, boss)) {
const dmg = b.damage || 1;
const killed = boss.takeDamage(dmg);
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,
color: boss.color || "#ff66aa",
count: 8,
speedMin: 80,
speedMax: 220,
lifetime: 0.3,
radius: 3,
});
if (killed) {
game.onEnemyKilled(e);
if (e.type === EnemyTypes.SPLITTER) {
for (const child of spawnSplitterChildren(e)) {
game.enemies.add(child);
}
if (typeof b.onHit === "function") {
b.onHit(boss);
} else {
b.alive = false;
}
if (killed || boss.health <= 0) {
if (typeof game.onBossDefeated === "function") {
game.onBossDefeated(boss);
}
}
break;
}
}
}
// Enemy bullets vs player.
const enemyBullets = game.enemyBullets?.items ?? [];
if (player && player.alive) {
for (let i = 0; i < enemyBullets.length; i++) {
const eb = enemyBullets[i];
if (!eb.alive) continue;
if (!circleHit(eb, player)) continue;
if (typeof game.onPlayerHit === "function") {
game.onPlayerHit(eb);
}
eb.alive = false;
}
}
// Player vs enemy bodies.
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;
if (!circleHit(player, e)) continue;
const took = player.takeDamage(1, e.pos.x, e.pos.y);
if (took) {
game.onPlayerHit(e);
}
break;
}
}
}
+57 -4
View File
@@ -1,12 +1,15 @@
// Enemy types: chaser, bruiser, splitter. All seek the player.
// Enemy types: chaser, bruiser, splitter, mini, shooter. All track the player.
// Shooter keeps distance (~260 px) and fires aimed enemy bullets.
import { Entity } from "../engine/entity.js";
import { spawnEnemyBullet } from "./enemy_bullet.js";
export const EnemyTypes = {
CHASER: "chaser",
BRUISER: "bruiser",
SPLITTER: "splitter",
MINI: "mini",
SHOOTER: "shooter",
};
const STATS = {
@@ -14,8 +17,13 @@ const STATS = {
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 },
shooter: { speed: 70, hp: 2, radius: 14, color: "#ff8ac2", score: 30 },
};
const SHOOTER_DESIRED_DIST = 260;
const SHOOTER_FIRE_INTERVAL = 1.8;
const SHOOTER_BULLET_SPEED = 180;
export class Enemy extends Entity {
constructor(opts = {}) {
const type = opts.type || EnemyTypes.CHASER;
@@ -29,6 +37,7 @@ export class Enemy extends Entity {
this.score = s.score;
this.hitFlash = 0;
this.wobble = Math.random() * Math.PI * 2;
this.fireTimer = type === EnemyTypes.SHOOTER ? 0.8 + Math.random() * 0.6 : 0;
}
update(dt, game) {
@@ -36,9 +45,35 @@ export class Enemy extends Entity {
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;
const dist = Math.hypot(dx, dy) || 1;
if (this.type === EnemyTypes.SHOOTER) {
const diff = dist - SHOOTER_DESIRED_DIST;
const deadband = 30;
let dir = 0;
if (diff > deadband) dir = 1;
else if (diff < -deadband) dir = -1;
this.vel.x = (dx / dist) * this.speed * dir;
this.vel.y = (dy / dist) * this.speed * dir;
this.fireTimer -= dt;
if (this.fireTimer <= 0 && target.alive) {
this.fireTimer = SHOOTER_FIRE_INTERVAL;
spawnEnemyBullet(game, {
x: this.pos.x,
y: this.pos.y,
targetX: target.pos.x,
targetY: target.pos.y,
speed: SHOOTER_BULLET_SPEED,
color: this.color,
});
if (game.audio?.play) game.audio.play("shoot");
}
} else {
this.vel.x = (dx / dist) * this.speed;
this.vel.y = (dy / dist) * this.speed;
}
this.pos.x += this.vel.x * dt;
this.pos.y += this.vel.y * dt;
this.wobble += dt * 4;
@@ -84,10 +119,28 @@ export class Enemy extends Entity {
else ctx.lineTo(x, y);
}
ctx.closePath();
} else if (this.type === EnemyTypes.SHOOTER) {
const sides = 3;
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2 - Math.PI / 2 + this.wobble * 0.2;
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();
if (this.type === EnemyTypes.SHOOTER) {
ctx.shadowBlur = 8;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, r * 0.28, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
+100
View File
@@ -0,0 +1,100 @@
// EnemyBullet: glowing tracer fired by shooters and the boss.
// Travels in a straight line, expires after TTL, collides only with the player.
import { Entity } from "../engine/entity.js";
const DEFAULT_TTL = 4;
const DEFAULT_SPEED = 180;
const DEFAULT_COLOR = "#ff8ac2";
export class EnemyBullet extends Entity {
constructor(opts = {}) {
super({ radius: opts.radius ?? 5, ...opts });
this.damage = opts.damage ?? 1;
this.ttl = opts.ttl ?? DEFAULT_TTL;
this.color = opts.color ?? DEFAULT_COLOR;
this.owner = "enemy";
}
update(dt, game) {
this.ttl -= dt;
if (this.ttl <= 0) {
this.alive = false;
return;
}
this.pos.x += this.vel.x * dt;
this.pos.y += this.vel.y * dt;
if (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 = 10;
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 = 16;
ctx.strokeStyle = this.color;
ctx.lineWidth = 2.5;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(this.pos.x, this.pos.y);
ctx.stroke();
ctx.shadowBlur = 10;
ctx.fillStyle = this.color;
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ffffff";
ctx.shadowBlur = 0;
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius * 0.45, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
}
export function spawnEnemyBullet(game, opts = {}) {
if (!game || !game.enemyBullets) return null;
const {
x = 0,
y = 0,
vx = 0,
vy = 0,
speed = DEFAULT_SPEED,
targetX,
targetY,
color = DEFAULT_COLOR,
ttl,
radius,
damage,
} = opts;
let ux = vx;
let uy = vy;
if (targetX != null && targetY != null) {
const dx = targetX - x;
const dy = targetY - y;
const len = Math.hypot(dx, dy) || 1;
ux = (dx / len) * speed;
uy = (dy / len) * speed;
}
const bullet = new EnemyBullet({ x, y, vx: ux, vy: uy, color, ttl, radius, damage });
game.enemyBullets.add(bullet);
return bullet;
}
+75
View File
@@ -0,0 +1,75 @@
// FloaterPool: canvas-rendered rising text floaters. Max 64 pooled entries.
const MAX = 64;
export class FloaterPool {
constructor() {
this.items = [];
}
emit({ x, y, text, color = "#fff", size, lifetime, rise }) {
const isBig = size === "big";
const lt = lifetime ?? (isBig ? 1.6 : 0.9);
const rs = rise ?? (isBig ? 70 : 40);
const fontPx = isBig ? 36 : 16;
if (this.items.length >= MAX) {
// Evict the oldest (first) entry
this.items.shift();
}
this.items.push({
x,
y,
text,
color,
fontPx,
lifetime: lt,
remaining: lt,
rise: rs,
alpha: 1,
scale: 1,
});
}
update(dt) {
for (let i = this.items.length - 1; i >= 0; i--) {
const f = this.items[i];
f.remaining -= dt;
if (f.remaining <= 0) {
this.items.splice(i, 1);
continue;
}
const t = 1 - f.remaining / f.lifetime; // 0..1 progress
f.y -= f.rise * dt;
f.alpha = Math.max(0, 1 - t * t * 1.4);
f.scale = 1 + t * 0.25;
}
}
render(ctx) {
if (this.items.length === 0) return;
ctx.save();
for (let i = 0; i < this.items.length; i++) {
const f = this.items[i];
if (f.alpha <= 0) continue;
ctx.save();
ctx.globalAlpha = f.alpha;
ctx.translate(f.x, f.y);
ctx.scale(f.scale, f.scale);
ctx.font = `bold ${f.fontPx}px monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.shadowColor = f.color;
ctx.shadowBlur = 8;
ctx.fillStyle = f.color;
ctx.fillText(f.text, 0, 0);
ctx.restore();
}
ctx.restore();
}
clear() {
this.items.length = 0;
}
}
+231 -1
View File
@@ -1,5 +1,6 @@
// Game: ties player, enemies, bullets, waves, collision, particles, audio,
// scoring/combo, screen shake, and the state machine together.
// V2 adds: powerups, buffs, boss, enemy bullets, starfield, floaters, settings.
import { EntityPool } from "../engine/entity.js";
import { Player } from "./player.js";
@@ -7,6 +8,11 @@ import { WaveManager } from "./waves.js";
import { ParticleSystem } from "./particles.js";
import { AudioBus } from "./audio.js";
import { resolveCollisions } from "./collision.js";
import { BuffManager } from "./buffs.js";
import { Powerup, maybeDrop, applyPowerup, randomKind } from "./powerup.js";
import { Starfield } from "./starfield.js";
import { FloaterPool } from "./floaters.js";
import { loadSettings } from "./settings.js";
export const GameState = {
MENU: "menu",
@@ -18,6 +24,15 @@ export const GameState = {
const COMBO_WINDOW = 2.0;
const SHAKE_DECAY = 10;
const COMBO_COLORS = ["#ffffff", "#22e4ff", "#ff3df0", "#ffd84d"];
function comboColor(mult) {
if (mult <= 1) return COMBO_COLORS[0];
if (mult === 2) return COMBO_COLORS[1];
if (mult === 3) return COMBO_COLORS[2];
return COMBO_COLORS[3];
}
export class Game {
constructor({ canvas, input, hud, highscore } = {}) {
this.canvas = canvas;
@@ -37,6 +52,18 @@ export class Game {
this.waves = new WaveManager();
this.audio = new AudioBus();
this.powerups = new EntityPool();
this.enemyBullets = new EntityPool();
this.buffs = new BuffManager();
this.boss = null;
this.starfield = new Starfield(canvas?.width ?? 1280, canvas?.height ?? 720);
this.floaters = new FloaterPool();
this.settings = loadSettings();
if (this.audio && typeof this.audio.setMasterVolume === "function") {
this.audio.setMasterVolume(this.settings.volume);
}
this.shake = 0;
this.time = 0;
@@ -84,10 +111,19 @@ export class Game {
this.enemies.clear();
this.bullets.clear();
this.particles.clear();
this.powerups.clear();
this.enemyBullets.clear();
if (this.floaters && typeof this.floaters.clear === "function") this.floaters.clear();
if (this.buffs) this.buffs.clear();
this.boss = null;
this.waves.reset();
this._syncHud();
}
setBossActive(boss) {
this.boss = boss || null;
}
_syncHud() {
if (!this.hud) return;
this.hud.setScore(this.score);
@@ -95,6 +131,9 @@ export class Game {
this.hud.setHealth(this.player.health, this.player.maxHealth);
this.hud.setCombo(this._multiplier());
if (this.highscore) this.hud.setBest(this.highscore.get());
if (typeof this.hud.setBuffs === "function") this.hud.setBuffs(this.buffs.active());
if (typeof this.hud.setDashReady === "function") this.hud.setDashReady(this.player.dashReady);
if (typeof this.hud.setBossHealth === "function") this.hud.setBossHealth(null);
}
_multiplier() {
@@ -119,13 +158,151 @@ export class Game {
radius: 3.5,
});
if (this.audio) this.audio.play("explode");
if (this.floaters && typeof this.floaters.emit === "function") {
this.floaters.emit({
x: enemy.pos.x,
y: enemy.pos.y,
text: `+${gained} x${mult}!`,
color: comboColor(mult),
});
}
const kind = maybeDrop(enemy.type);
if (kind) {
this.powerups.add(new Powerup({ kind, x: enemy.pos.x, y: enemy.pos.y }));
}
if (this.hud) {
this.hud.setScore(this.score);
this.hud.setCombo(this._multiplier());
}
}
onBossDefeated(boss) {
if (!boss) return;
this.score += 500;
this.shake = Math.min(32, this.shake + 20);
this.particles.emit({
x: boss.pos.x,
y: boss.pos.y,
color: boss.color || "#ff66aa",
count: 80,
speedMin: 140,
speedMax: 520,
lifetime: 0.9,
radius: 4.5,
});
if (this.audio) this.audio.play("explode");
if (this.floaters && typeof this.floaters.emit === "function") {
this.floaters.emit({
x: boss.pos.x,
y: boss.pos.y,
text: "BOSS DEFEATED",
color: "#ffd84d",
size: "big",
});
}
for (let i = 0; i < 2; i++) {
const angle = (i / 2) * Math.PI * 2 + Math.random() * 0.4;
const r = 40;
const x = boss.pos.x + Math.cos(angle) * r;
const y = boss.pos.y + Math.sin(angle) * r;
this.powerups.add(new Powerup({ kind: randomKind(), x, y }));
}
this.boss = null;
if (this.hud) {
this.hud.setScore(this.score);
if (typeof this.hud.setBossHealth === "function") this.hud.setBossHealth(null);
}
}
applyBomb() {
const enemies = this.enemies.items;
for (let i = 0; i < enemies.length; i++) {
const e = enemies[i];
if (!e.alive) continue;
const killed = typeof e.takeDamage === "function" ? e.takeDamage(3) : false;
if (killed) this.onEnemyKilled(e);
}
if (this.boss && this.boss.alive && typeof this.boss.takeDamage === "function") {
const killed = this.boss.takeDamage(3);
if (killed) this.onBossDefeated(this.boss);
}
this.enemyBullets.clear();
this.particles.emit({
x: this.player.pos.x,
y: this.player.pos.y,
color: "#ff5a5a",
count: 120,
speedMin: 220,
speedMax: 720,
lifetime: 0.85,
radius: 4,
});
this.shake = Math.min(32, this.shake + 20);
if (this.audio) this.audio.play("explode");
}
onPickup(kind, x, y) {
applyPowerup(this, kind);
this.particles.emit({
x: x ?? this.player.pos.x,
y: y ?? this.player.pos.y,
color: "#fff",
count: 14,
speedMin: 80,
speedMax: 260,
lifetime: 0.45,
radius: 3,
});
if (this.floaters && typeof this.floaters.emit === "function") {
this.floaters.emit({
x: x ?? this.player.pos.x,
y: y ?? this.player.pos.y,
text: String(kind).toUpperCase(),
color: "#ffd84d",
});
}
if (this.audio) this.audio.play("wave");
}
onPlayerHit(enemy) {
if (this.buffs && this.buffs.consumeShield()) {
this.shake = Math.min(18, this.shake + 6);
this.particles.emit({
x: this.player.pos.x,
y: this.player.pos.y,
color: "#ffd84d",
count: 24,
speedMin: 100,
speedMax: 320,
lifetime: 0.5,
radius: 3.5,
});
if (enemy && typeof enemy.pos?.x === "number") {
const dx = this.player.pos.x - enemy.pos.x;
const dy = this.player.pos.y - enemy.pos.y;
const len = Math.hypot(dx, dy) || 1;
this.player.knockback.x = (dx / len) * 240;
this.player.knockback.y = (dy / len) * 240;
}
this.player.invuln = Math.max(this.player.invuln, 0.6);
if (this.audio) this.audio.play("hit");
if (this.floaters && typeof this.floaters.emit === "function") {
this.floaters.emit({
x: this.player.pos.x,
y: this.player.pos.y,
text: "BLOCKED",
color: "#ffd84d",
});
}
return;
}
this.combo = 0;
this.comboTimer = 0;
this.shake = Math.min(22, this.shake + 10);
@@ -165,6 +342,10 @@ export class Game {
if (this.state !== GameState.PLAYING) return;
this.time += dt;
if (this.audio && typeof this.audio.setMasterVolume === "function") {
this.audio.setMasterVolume(this.settings.volume);
}
if (this.comboTimer > 0) {
this.comboTimer -= dt;
if (this.comboTimer <= 0) {
@@ -180,6 +361,11 @@ export class Game {
const prevWave = this.waves.current;
if (this.starfield && typeof this.starfield.update === "function") {
this.starfield.update(dt);
}
if (this.buffs) this.buffs.tick(dt);
this.player.update(dt, this);
const bullets = this.bullets.items;
@@ -188,6 +374,16 @@ export class Game {
const enemies = this.enemies.items;
for (let i = 0; i < enemies.length; i++) enemies[i].update(dt, this);
const eb = this.enemyBullets.items;
for (let i = 0; i < eb.length; i++) eb[i].update(dt, this);
const pus = this.powerups.items;
for (let i = 0; i < pus.length; i++) pus[i].update(dt, this);
if (this.boss && this.boss.alive && typeof this.boss.update === "function") {
this.boss.update(dt, this);
}
resolveCollisions(this);
this.waves.update(dt, this);
@@ -197,8 +393,24 @@ export class Game {
}
this.particles.update(dt);
if (this.floaters && typeof this.floaters.update === "function") this.floaters.update(dt);
this.bullets.filterAlive();
this.enemies.filterAlive();
this.enemyBullets.filterAlive();
this.powerups.filterAlive();
if (this.boss && !this.boss.alive) {
this.boss = null;
}
if (this.hud) {
if (typeof this.hud.setBuffs === "function") this.hud.setBuffs(this.buffs.active());
if (typeof this.hud.setDashReady === "function") this.hud.setDashReady(this.player.dashReady);
if (typeof this.hud.setBossHealth === "function") {
this.hud.setBossHealth(this.boss ? Math.max(0, this.boss.health / this.boss.maxHealth) : null);
}
}
if (!this.player.alive) {
this._gameOver();
@@ -237,26 +449,44 @@ export class Game {
let ox = 0;
let oy = 0;
if (this.shake > 0.1) {
if (this.shake > 0.1 && this.settings.shake !== false) {
ox = (Math.random() - 0.5) * this.shake;
oy = (Math.random() - 0.5) * this.shake;
ctx.translate(ox, oy);
}
if (this.starfield && typeof this.starfield.render === "function") {
this.starfield.render(ctx);
}
this._drawGrid(ctx);
if (this.state !== GameState.MENU) {
const pus = this.powerups.items;
for (let i = 0; i < pus.length; i++) pus[i].render(ctx);
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.boss && this.boss.alive && typeof this.boss.render === "function") {
this.boss.render(ctx);
}
const eb = this.enemyBullets.items;
for (let i = 0; i < eb.length; i++) eb[i].render(ctx);
if (this.player.alive || this.state === GameState.GAMEOVER) {
this.player.render(ctx);
}
this.particles.render(ctx);
if (this.floaters && typeof this.floaters.render === "function") {
this.floaters.render(ctx);
}
}
ctx.restore();
+130 -20
View File
@@ -1,4 +1,5 @@
// Player: WASD/arrow movement, mouse aim, click/space to shoot, invuln after hit.
// V2: buff-aware shooting (rapidfire/spread/pierce) + Shift dash (180ms, 900ms cd).
import { Entity } from "../engine/entity.js";
import { Bullet } from "./bullet.js";
@@ -8,6 +9,12 @@ const SHOOT_COOLDOWN = 0.14;
const INVULN_TIME = 0.9;
const KNOCKBACK_DECAY = 6;
const DASH_DURATION = 0.18;
const DASH_COOLDOWN = 0.9;
const DASH_SPEED = 480;
const SPREAD_ANGLE = (12 * Math.PI) / 180;
const PIERCE_BUDGET = 3;
export class Player extends Entity {
constructor(opts = {}) {
super({ x: 640, y: 360, radius: 14, ...opts });
@@ -17,6 +24,14 @@ export class Player extends Entity {
this.cooldown = 0;
this.knockback = { x: 0, y: 0 };
this.aim = { x: 1, y: 0 };
this.dashTime = 0;
this.dashCooldownRemaining = 0;
this.dashDir = { x: 1, y: 0 };
this._dashFxTimer = 0;
}
get dashReady() {
return this.dashCooldownRemaining <= 0 && this.dashTime <= 0;
}
reset() {
@@ -30,6 +45,43 @@ export class Player extends Entity {
this.knockback.x = 0;
this.knockback.y = 0;
this.alive = true;
this.dashTime = 0;
this.dashCooldownRemaining = 0;
this._dashFxTimer = 0;
}
tryDash(game) {
if (!this.dashReady || !this.alive) return false;
let dx = this.vel.x;
let dy = this.vel.y;
if (Math.hypot(dx, dy) < 1) {
dx = this.aim.x;
dy = this.aim.y;
}
const len = Math.hypot(dx, dy) || 1;
this.dashDir.x = dx / len;
this.dashDir.y = dy / len;
this.dashTime = DASH_DURATION;
this.dashCooldownRemaining = DASH_COOLDOWN;
this.invuln = Math.max(this.invuln, DASH_DURATION + 0.05);
this._dashFxTimer = 0;
if (game?.particles) {
game.particles.emit({
x: this.pos.x,
y: this.pos.y,
color: "#0ff",
count: 5,
speedMin: 60,
speedMax: 180,
lifetime: 0.3,
radius: 3,
});
}
if (game?.audio) game.audio.play("shoot");
return true;
}
update(dt, game) {
@@ -37,6 +89,14 @@ export class Player extends Entity {
const canvas = game.canvas;
if (!input || !canvas) return;
if (input.wasPressed && (input.wasPressed("ShiftLeft") || input.wasPressed("ShiftRight"))) {
this.tryDash(game);
}
if (this.dashCooldownRemaining > 0) {
this.dashCooldownRemaining = Math.max(0, this.dashCooldownRemaining - dt);
}
let mx = 0;
let my = 0;
if (input.anyDown(["KeyW", "ArrowUp"])) my -= 1;
@@ -49,8 +109,30 @@ export class Player extends Entity {
mx /= len;
my /= len;
}
this.vel.x = mx * SPEED;
this.vel.y = my * SPEED;
if (this.dashTime > 0) {
this.vel.x = this.dashDir.x * DASH_SPEED;
this.vel.y = this.dashDir.y * DASH_SPEED;
this.dashTime -= dt;
this._dashFxTimer -= dt;
if (this._dashFxTimer <= 0 && game?.particles) {
this._dashFxTimer = 0.035;
game.particles.emit({
x: this.pos.x,
y: this.pos.y,
color: "#7ff",
count: 1,
speedMin: 10,
speedMax: 40,
lifetime: 0.25,
radius: 3,
});
}
} else {
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;
@@ -82,28 +164,56 @@ export class Player extends Entity {
const wantsShoot = mouse.down || input.isDown("Space");
if (wantsShoot && this.cooldown <= 0) {
this.shoot(game);
this.cooldown = SHOOT_COOLDOWN;
const rapid = game.buffs && game.buffs.hasBuff("rapidfire");
this.cooldown = rapid ? SHOOT_COOLDOWN * 0.5 : SHOOT_COOLDOWN;
}
}
shoot(game) {
_spawnBullet(game, angleOffset) {
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" });
const cos = Math.cos(angleOffset);
const sin = Math.sin(angleOffset);
const ax = this.aim.x * cos - this.aim.y * sin;
const ay = this.aim.x * sin + this.aim.y * cos;
const vx = ax * speed;
const vy = ay * speed;
const spawnX = this.pos.x + ax * (this.radius + 2);
const spawnY = this.pos.y + ay * (this.radius + 2);
const pierce = game.buffs && game.buffs.hasBuff("pierce") ? PIERCE_BUDGET : 0;
const bullet = new Bullet({ x: spawnX, y: spawnY, vx, vy, owner: "player", pierce });
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,
});
return { spawnX, spawnY };
}
shoot(game) {
const spread = game.buffs && game.buffs.hasBuff("spread");
if (spread) {
const { spawnX, spawnY } = this._spawnBullet(game, 0);
this._spawnBullet(game, -SPREAD_ANGLE);
this._spawnBullet(game, SPREAD_ANGLE);
game.particles.emit({
x: spawnX,
y: spawnY,
color: "#f4f",
count: 6,
speedMin: 40,
speedMax: 140,
lifetime: 0.18,
radius: 2.5,
});
} else {
const { spawnX, spawnY } = this._spawnBullet(game, 0);
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");
}
@@ -131,7 +241,7 @@ export class Player extends Entity {
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.shadowColor = "#0ff";
ctx.shadowBlur = 22;
ctx.shadowBlur = this.dashTime > 0 ? 32 : 22;
ctx.fillStyle = flicker ? "#6ff" : "#0ff";
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y, this.radius, 0, Math.PI * 2);
+176
View File
@@ -0,0 +1,176 @@
// Powerup: floating pickup that drifts, magnetizes to nearby player, expires.
import { Entity } from "../engine/entity.js";
const TTL = 12;
const MAGNET_RANGE = 120;
const MAGNET_ACCEL = 380;
const DRIFT_SPEED = 20;
const FADE_START = 2.0;
const PICKUP_RADIUS = 14;
const KIND_COLORS = {
rapidfire: "#22e4ff",
spread: "#ff3df0",
pierce: "#ffffff",
shield: "#ffd84d",
heal: "#7dff5c",
bomb: "#ff5a5a",
};
const KIND_GLYPHS = {
rapidfire: "R",
spread: "S",
pierce: "P",
shield: "H",
heal: "+",
bomb: "B",
};
const DROP_ODDS = {
chaser: 0.15,
splitter: 0.2,
bruiser: 0.4,
shooter: 0.25,
mini: 0.05,
boss: 1.0,
};
const KIND_POOL = ["rapidfire", "spread", "pierce", "shield", "heal", "bomb"];
export class Powerup extends Entity {
constructor({ kind = "rapidfire", x = 0, y = 0 } = {}) {
super({ x, y, radius: PICKUP_RADIUS });
this.kind = kind;
this.ttl = TTL;
this.bobPhase = Math.random() * Math.PI * 2;
const angle = Math.random() * Math.PI * 2;
this.vel.x = Math.cos(angle) * DRIFT_SPEED;
this.vel.y = Math.sin(angle) * DRIFT_SPEED;
}
update(dt, game) {
this.ttl -= dt;
if (this.ttl <= 0) {
this.alive = false;
return;
}
this.bobPhase += dt * 3;
const player = game?.player;
if (player && player.alive) {
const dx = player.pos.x - this.pos.x;
const dy = player.pos.y - this.pos.y;
const distSq = dx * dx + dy * dy;
if (distSq < MAGNET_RANGE * MAGNET_RANGE) {
const dist = Math.sqrt(distSq) || 1;
this.vel.x += (dx / dist) * MAGNET_ACCEL * dt;
this.vel.y += (dy / dist) * MAGNET_ACCEL * dt;
}
}
const maxSpeed = 320;
const sp = Math.hypot(this.vel.x, this.vel.y);
if (sp > maxSpeed) {
this.vel.x = (this.vel.x / sp) * maxSpeed;
this.vel.y = (this.vel.y / sp) * maxSpeed;
} else {
const drag = Math.exp(-0.6 * dt);
this.vel.x *= drag;
this.vel.y *= drag;
}
this.pos.x += this.vel.x * dt;
this.pos.y += this.vel.y * dt;
if (game?.canvas) {
const w = game.canvas.width;
const h = game.canvas.height;
const r = this.radius;
if (this.pos.x < r) { this.pos.x = r; this.vel.x = Math.abs(this.vel.x) * 0.5; }
if (this.pos.y < r) { this.pos.y = r; this.vel.y = Math.abs(this.vel.y) * 0.5; }
if (this.pos.x > w - r) { this.pos.x = w - r; this.vel.x = -Math.abs(this.vel.x) * 0.5; }
if (this.pos.y > h - r) { this.pos.y = h - r; this.vel.y = -Math.abs(this.vel.y) * 0.5; }
}
if (player && player.alive) {
const dx = player.pos.x - this.pos.x;
const dy = player.pos.y - this.pos.y;
const r = player.radius + this.radius;
if (dx * dx + dy * dy <= r * r) {
this.alive = false;
if (typeof game.onPickup === "function") game.onPickup(this.kind, this.pos.x, this.pos.y);
}
}
}
render(ctx) {
const color = KIND_COLORS[this.kind] || "#fff";
const glyph = KIND_GLYPHS[this.kind] || "?";
const bob = Math.sin(this.bobPhase) * 2;
const pulse = 1 + Math.sin(this.bobPhase * 1.5) * 0.1;
const alpha = this.ttl < FADE_START ? Math.max(0, this.ttl / FADE_START) : 1;
ctx.save();
ctx.globalAlpha = alpha;
ctx.globalCompositeOperation = "lighter";
ctx.shadowColor = color;
ctx.shadowBlur = 18;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y + bob, this.radius * pulse, 0, Math.PI * 2);
ctx.stroke();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(this.pos.x, this.pos.y + bob, this.radius * 0.55, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 8;
ctx.fillStyle = color;
ctx.font = "bold 14px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(glyph, this.pos.x, this.pos.y + bob);
ctx.restore();
}
}
export function maybeDrop(enemyType) {
const odds = DROP_ODDS[enemyType];
if (odds == null) return null;
if (Math.random() >= odds) return null;
return KIND_POOL[Math.floor(Math.random() * KIND_POOL.length)];
}
export function randomKind() {
return KIND_POOL[Math.floor(Math.random() * KIND_POOL.length)];
}
export function applyPowerup(game, kind) {
if (!game || !kind) return;
switch (kind) {
case "rapidfire":
case "spread":
case "pierce":
if (game.buffs) game.buffs.addBuff(kind, 8);
break;
case "shield":
if (game.buffs) game.buffs.addBuff("shield");
break;
case "heal":
if (game.player) {
game.player.health = Math.min(game.player.maxHealth, game.player.health + 1);
if (game.hud) game.hud.setHealth(game.player.health, game.player.maxHealth);
}
break;
case "bomb":
if (typeof game.applyBomb === "function") game.applyBomb();
break;
default:
break;
}
}
export { KIND_COLORS, KIND_GLYPHS, KIND_POOL };
+32
View File
@@ -0,0 +1,32 @@
// Settings stub — worker-4 replaces with real localStorage-backed impl.
const STORAGE_KEY = "omc-neon-arena.settings";
const DEFAULTS = { volume: 0.7, shake: true };
export function loadSettings() {
try {
if (typeof localStorage === "undefined") return { ...DEFAULTS };
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULTS };
const parsed = JSON.parse(raw);
return {
volume: typeof parsed.volume === "number" ? Math.max(0, Math.min(1, parsed.volume)) : DEFAULTS.volume,
shake: typeof parsed.shake === "boolean" ? parsed.shake : DEFAULTS.shake,
};
} catch {
return { ...DEFAULTS };
}
}
export function saveSettings(settings) {
try {
if (typeof localStorage === "undefined") return;
const clean = {
volume: Math.max(0, Math.min(1, Number(settings?.volume ?? DEFAULTS.volume))),
shake: settings?.shake !== false,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(clean));
} catch {
// ignore quota / disabled storage
}
}
+67
View File
@@ -0,0 +1,67 @@
// Parallax 3-layer starfield. update(dt) + render(ctx) called once per frame.
// render is called inside shake translate but before grid. Uses additive blend.
const LAYERS = [
{ count: 40, radius: 0.6, drift: 7, jitter: 0.4, alpha: 0.35 },
{ count: 25, radius: 1.0, drift: 3, jitter: 0.2, alpha: 0.50 },
{ count: 12, radius: 1.6, drift: 1, jitter: 0.1, alpha: 0.70 },
];
function rand(min, max) {
return min + Math.random() * (max - min);
}
export class Starfield {
constructor(width = 1280, height = 720) {
this.width = width;
this.height = height;
this._dots = LAYERS.map((layer) => {
const dots = [];
for (let i = 0; i < layer.count; i++) {
dots.push({ x: rand(0, width), y: rand(0, height), vx: rand(-layer.jitter, layer.jitter) });
}
return dots;
});
}
resize(w, h) {
this.width = w;
this.height = h;
}
update(dt) {
LAYERS.forEach((layer, li) => {
const dots = this._dots[li];
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
d.y += layer.drift * dt;
d.x += d.vx;
if (d.y > this.height) d.y -= this.height;
if (d.y < 0) d.y += this.height;
if (d.x < 0) d.x += this.width;
if (d.x > this.width) d.x -= this.width;
}
});
}
render(ctx) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
LAYERS.forEach((layer, li) => {
const dots = this._dots[li];
const color = li === 0
? `rgba(100,180,255,${layer.alpha})`
: li === 1
? `rgba(160,210,255,${layer.alpha})`
: `rgba(230,240,255,${layer.alpha})`;
ctx.fillStyle = color;
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
ctx.beginPath();
ctx.arc(d.x, d.y, layer.radius, 0, Math.PI * 2);
ctx.fill();
}
});
ctx.restore();
}
}
+55 -1
View File
@@ -1,9 +1,22 @@
// Wave spawner: schedules enemies with stagger, advances wave when field is clear.
// Wave spawner: schedules enemies with stagger, advances when field is clear.
// V2: boss wave every 5 waves; shooters injected from wave 3+.
import { Enemy, EnemyTypes } from "./enemy.js";
import { spawnBoss } from "./boss.js";
const INTERMISSION = 2.0;
const SPAWN_STAGGER = 0.35;
const MAX_SHOOTERS = 4;
function isBossWave(wave) {
return wave > 0 && wave % 5 === 0;
}
function shooterCount(wave) {
if (wave < 3) return 0;
const n = 1 + Math.floor((wave - 3) / 2);
return Math.min(MAX_SHOOTERS, n);
}
export class WaveManager {
constructor() {
@@ -12,6 +25,8 @@ export class WaveManager {
this.spawnTimer = 0;
this.intermission = 0;
this.waveActive = false;
this.isBoss = false;
this.bossSpawned = false;
this._buildQueue(this.wave);
}
@@ -21,6 +36,8 @@ export class WaveManager {
this.spawnTimer = 0;
this.intermission = 0;
this.waveActive = false;
this.isBoss = false;
this.bossSpawned = false;
this._buildQueue(this.wave);
this.waveActive = true;
}
@@ -31,6 +48,15 @@ export class WaveManager {
_buildQueue(wave) {
const q = [];
this.isBoss = isBossWave(wave);
this.bossSpawned = false;
if (this.isBoss) {
this.queue = q;
this.spawnTimer = 0.4;
return;
}
if (wave === 1) {
for (let i = 0; i < 8; i++) q.push(EnemyTypes.CHASER);
} else if (wave === 2) {
@@ -44,6 +70,10 @@ export class WaveManager {
for (let i = 0; i < bruisers; i++) q.push(EnemyTypes.BRUISER);
for (let i = 0; i < splitters; i++) q.push(EnemyTypes.SPLITTER);
}
const shooters = shooterCount(wave);
for (let i = 0; i < shooters; i++) q.push(EnemyTypes.SHOOTER);
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]];
@@ -83,6 +113,9 @@ export class WaveManager {
this._buildQueue(this.wave);
this.waveActive = true;
if (game.hud) game.hud.setWave(this.wave);
if (typeof game._emit === "function") {
game._emit("waveStart", { wave: this.wave, isBoss: this.isBoss });
}
}
return;
}
@@ -91,6 +124,27 @@ export class WaveManager {
this.waveActive = true;
}
if (this.isBoss) {
if (!this.bossSpawned) {
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
const boss = spawnBoss(game, this.wave);
if (typeof game.setBossActive === "function") {
game.setBossActive(boss);
} else {
game.boss = boss;
}
this.bossSpawned = true;
}
return;
}
if (game.boss == null) {
this.waveActive = false;
this.intermission = INTERMISSION;
}
return;
}
if (this.queue.length > 0) {
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
+34 -10
View File
@@ -4,9 +4,10 @@ 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 { HUD, wireV2Hud } from "./ui/hud.js";
import { Menu } from "./ui/menu.js";
import { HighScore } from "./ui/highscore.js";
import { saveSettings } from "./game/settings.js";
const canvasEl = document.getElementById("game");
if (!canvasEl) throw new Error("#game canvas not found");
@@ -18,18 +19,32 @@ const highscore = new HighScore();
const game = new Game({ canvas, input, hud, highscore });
hud.setBest(highscore.get());
wireV2Hud(game, hud);
const menu = new Menu(document, {
start: () => {
game.start();
const menu = new Menu(
document,
{
start: () => {
game.start();
},
resume: () => {
game.resume();
},
reset: () => {
game.start();
},
},
resume: () => {
game.resume();
{
initialSettings: game.settings,
onSettingChange: (key, value) => {
game.settings[key] = value;
saveSettings(game.settings);
if (key === "volume" && game.audio && typeof game.audio.setMasterVolume === "function") {
game.audio.setMasterVolume(value);
}
},
},
reset: () => {
game.start();
},
});
);
function renderOverlay(state) {
if (state === GameState.MENU) menu.show("menu");
@@ -41,12 +56,21 @@ function renderOverlay(state) {
game.on("stateChange", renderOverlay);
renderOverlay(game.state);
if (game.audio && typeof game.audio.setMasterVolume === "function") {
game.audio.setMasterVolume(game.settings.volume);
}
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();
}
if (input.wasPressed("ShiftLeft") || input.wasPressed("ShiftRight")) {
if (game.state === GameState.PLAYING && typeof game.player.tryDash === "function") {
game.player.tryDash(game);
}
}
game.update(dt);
input.endFrame();
},
+133 -3
View File
@@ -1,4 +1,5 @@
// HUD — renders score, wave, health pips, and combo into the DOM.
// V2 adds: setBuffs, setDashReady, setBossHealth, setWaveBanner + sync helpers.
const PIP_COUNT = 10;
@@ -17,13 +18,13 @@ export class HUD {
this._maxHealth = 100;
this._buildPips();
this._bannerTimer = null;
}
_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");
@@ -39,16 +40,16 @@ export class HUD {
}
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;
}
// --- V1 API (unchanged) ---
setScore(v) {
if (this.nodes.score) this.nodes.score.textContent = String(v);
}
@@ -86,4 +87,133 @@ export class HUD {
setFinalScore(v) {
if (this.nodes.finalScore) this.nodes.finalScore.textContent = String(v);
}
// --- V2 API ---
_ensureNode(key, tag = "div") {
let el = this.root.querySelector(`[data-hud="${key}"]`);
if (!el) {
el = document.createElement(tag);
el.setAttribute("data-hud", key);
const hudRoot = this.root.getElementById ? this.root.getElementById("hud") : this.root.querySelector("#hud");
if (hudRoot) hudRoot.appendChild(el);
}
return el;
}
setBuffs(list) {
let container = this.root.querySelector('[data-hud="buffs"]');
if (!container) {
container = document.createElement("div");
container.setAttribute("data-hud", "buffs");
container.className = "hud-buffs";
const hudRoot = this.root.getElementById
? this.root.getElementById("hud")
: this.root.querySelector("#hud");
if (hudRoot) hudRoot.appendChild(container);
}
// Reconcile chips
const existing = Array.from(container.children);
const listLen = list ? list.length : 0;
// Remove extras
while (container.children.length > listLen) {
container.removeChild(container.lastChild);
}
for (let i = 0; i < listLen; i++) {
const { kind, remaining = 0, stacks } = list[i];
let chip = container.children[i];
if (!chip) {
chip = document.createElement("div");
chip.className = "hud-buff-chip";
container.appendChild(chip);
}
chip.setAttribute("data-kind", kind);
chip.style.setProperty("--p", String(remaining));
if (kind === "shield") {
chip.textContent = String(stacks != null ? stacks : "");
} else {
chip.textContent = "";
}
}
}
setDashReady(ready, cooldown = 0) {
const el = this._ensureNode("dash");
el.classList.toggle("ready", !!ready);
el.style.setProperty("--p", String(cooldown));
}
setBossHealth(frac) {
const el = this._ensureNode("boss");
if (frac === null || frac === undefined) {
el.classList.add("hidden");
} else {
el.classList.remove("hidden");
el.style.setProperty("--p", String(Math.max(0, Math.min(1, frac))));
if (!el.querySelector(".boss-label")) {
const label = document.createElement("span");
label.className = "boss-label";
label.textContent = "BOSS";
el.appendChild(label);
}
}
}
setWaveBanner(text, duration = 1.6) {
let el = this.root.querySelector('[data-hud="banner"]');
if (!el) {
el = document.createElement("div");
el.setAttribute("data-hud", "banner");
el.className = "hud-wave-banner";
const hudRoot = this.root.getElementById
? this.root.getElementById("hud")
: this.root.querySelector("#hud");
if (hudRoot) hudRoot.appendChild(el);
}
el.textContent = text;
el.classList.add("show");
if (this._bannerTimer) clearTimeout(this._bannerTimer);
this._bannerTimer = setTimeout(() => {
el.classList.remove("show");
this._bannerTimer = null;
}, duration * 1000);
}
}
// syncHudPerFrame: call every frame while playing
export function syncHudPerFrame(game, hud) {
if (!game || !hud) return;
if (typeof hud.setBuffs === "function" && game.buffs) {
hud.setBuffs(game.buffs.active());
}
if (typeof hud.setDashReady === "function" && game.player) {
const cooldown = game.player.dashCooldownRemaining != null
? Math.max(0, Math.min(1, game.player.dashCooldownRemaining / 0.9))
: 0;
hud.setDashReady(game.player.dashReady, cooldown);
}
if (typeof hud.setBossHealth === "function") {
const boss = game.boss;
const frac = boss && boss.alive && boss.maxHealth > 0
? Math.max(0, Math.min(1, boss.health / boss.maxHealth))
: null;
hud.setBossHealth(frac);
}
}
// wireV2Hud: subscribe to wave events for banner display
export function wireV2Hud(game, hud) {
if (!game || !hud) return;
if (typeof game.on !== "function") return;
game.on("waveStart", ({ wave, isBoss } = {}) => {
if (typeof hud.setWaveBanner === "function") {
hud.setWaveBanner(isBoss ? "BOSS WAVE" : "WAVE " + wave);
}
});
}
+70 -1
View File
@@ -5,21 +5,69 @@
const OVERLAYS = ["menu", "pause", "gameover"];
export class Menu {
constructor(root = document, handlers = {}) {
constructor(root = document, handlers = {}, { onSettingChange, initialSettings } = {}) {
this.root = root;
this.handlers = handlers;
this.onSettingChange = typeof onSettingChange === "function" ? onSettingChange : null;
this.elements = {};
for (const name of OVERLAYS) {
this.elements[name] = root.querySelector(`[data-overlay="${name}"]`);
}
this.elements.settings = root.querySelector(`[data-overlay="settings"]`);
root.addEventListener("click", (e) => {
const target = e.target.closest("[data-action]");
if (!target) return;
const action = target.getAttribute("data-action");
if (action === "open-settings") {
this._hidePause();
this.showSettings();
return;
}
if (action === "settings-back") {
this.hideSettings();
this._showPause();
return;
}
const handler = this.handlers[action];
if (typeof handler === "function") handler();
});
// Volume slider
const volSlider = root.querySelector("#settings-volume");
const volVal = root.querySelector("#settings-volume-val");
if (volSlider) {
volSlider.addEventListener("input", (e) => {
const v = Number(e.target.value);
if (volVal) volVal.textContent = v;
if (this.onSettingChange) this.onSettingChange("volume", v / 100);
});
}
// Shake toggle
const shakeToggle = root.querySelector("#settings-shake");
if (shakeToggle) {
shakeToggle.addEventListener("change", (e) => {
if (this.onSettingChange) this.onSettingChange("shake", e.target.checked);
});
}
// Init controls from initialSettings
if (initialSettings) this.initSettings(initialSettings);
}
initSettings(settings) {
const volSlider = this.root.querySelector("#settings-volume");
const volVal = this.root.querySelector("#settings-volume-val");
const shakeToggle = this.root.querySelector("#settings-shake");
if (volSlider && settings.volume != null) {
const pct = Math.round(settings.volume * 100);
volSlider.value = pct;
if (volVal) volVal.textContent = pct;
}
if (shakeToggle && settings.shake != null) {
shakeToggle.checked = settings.shake;
}
}
show(name) {
@@ -28,6 +76,8 @@ export class Menu {
if (!el) continue;
el.classList.toggle("hidden", key !== name);
}
// Always hide settings when showing a named overlay
if (this.elements.settings) this.elements.settings.classList.add("hidden");
}
hideAll() {
@@ -35,5 +85,24 @@ export class Menu {
const el = this.elements[key];
if (el) el.classList.add("hidden");
}
if (this.elements.settings) this.elements.settings.classList.add("hidden");
}
showSettings() {
if (this.elements.settings) this.elements.settings.classList.remove("hidden");
}
hideSettings() {
if (this.elements.settings) this.elements.settings.classList.add("hidden");
}
_showPause() {
const el = this.elements.pause;
if (el) el.classList.remove("hidden");
}
_hidePause() {
const el = this.elements.pause;
if (el) el.classList.add("hidden");
}
}
+375
View File
@@ -348,6 +348,381 @@ body {
text-shadow: 0 0 10px var(--magenta);
}
/* ── V2 HUD: Buff chips ──────────────────────────────────────────────────── */
.hud-buffs {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: row;
gap: 8px;
pointer-events: none;
z-index: 2;
/* sits in grid col 2 row 1, but absolute so it doesn't displace wave slot */
}
.buff-chip {
position: relative;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
border: 1px solid var(--chip-color, var(--cyan));
box-shadow: 0 0 6px var(--chip-color, var(--cyan)), 0 0 14px rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: bold;
color: var(--chip-color, var(--cyan));
text-shadow: 0 0 5px var(--chip-color, var(--cyan));
text-transform: uppercase;
overflow: hidden;
flex-shrink: 0;
}
/* countdown ring via conic-gradient on ::before */
.buff-chip::before {
content: "";
position: absolute;
inset: 2px;
border-radius: 50%;
background: conic-gradient(
var(--chip-color, var(--cyan)) calc(var(--p, 1) * 360deg),
rgba(255, 255, 255, 0.08) 0
);
z-index: 0;
}
/* inner glyph sits above the ring */
.buff-chip > span {
position: relative;
z-index: 1;
}
/* color palette per kind */
.buff-chip[data-kind="rapidfire"] { --chip-color: var(--cyan); }
.buff-chip[data-kind="spread"] { --chip-color: var(--magenta); }
.buff-chip[data-kind="pierce"] { --chip-color: #ffffff; }
.buff-chip[data-kind="shield"] { --chip-color: var(--yellow); }
.buff-chip[data-kind="heal"] { --chip-color: var(--lime); }
.buff-chip[data-kind="bomb"] { --chip-color: #ff5a5a; }
/* ── V2 HUD: Dash pip ────────────────────────────────────────────────────── */
.dash-pip {
position: absolute;
bottom: 0.75rem;
right: 1rem;
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
border: 1px solid rgba(34, 228, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
color: rgba(34, 228, 255, 0.5);
pointer-events: none;
z-index: 2;
overflow: hidden;
}
.dash-pip::before {
content: "";
position: absolute;
inset: 3px;
border-radius: 50%;
background: conic-gradient(
var(--cyan) calc(var(--p, 0) * 360deg),
rgba(34, 228, 255, 0.08) 0
);
}
.dash-pip > span {
position: relative;
z-index: 1;
font-size: 1.1rem;
line-height: 1;
}
.dash-pip.ready {
border-color: var(--cyan);
color: var(--cyan);
text-shadow: 0 0 8px var(--cyan);
animation: dash-pulse 0.8s ease-in-out infinite alternate;
}
@keyframes dash-pulse {
from { box-shadow: 0 0 6px var(--cyan), 0 0 12px rgba(34, 228, 255, 0.3); }
to { box-shadow: 0 0 14px var(--cyan), 0 0 28px rgba(34, 228, 255, 0.6); }
}
/* ── V2 HUD: Boss health bar ─────────────────────────────────────────────── */
.boss-bar {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
width: 60%;
max-width: 800px;
pointer-events: none;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
/* offset downward to sit below wave slot */
margin-top: 2rem;
}
.boss-bar .label {
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--cyan);
text-shadow: 0 0 5px var(--cyan);
}
.boss-bar__fill-track {
width: 100%;
height: 22px;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(34, 228, 255, 0.3);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.boss-bar__fill {
height: 22px;
width: 100%;
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(34, 228, 255, 0.3);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.boss-bar__fill::after {
content: "";
position: absolute;
top: 0;
left: 0;
height: 100%;
width: calc(var(--p, 1) * 100%);
background: linear-gradient(90deg, rgba(34, 228, 255, 0.6), var(--cyan));
box-shadow: 0 0 8px var(--cyan);
transition: width 0.15s ease;
border-radius: 2px;
}
/* danger pulse when HP < 33% */
.boss-bar[data-danger="true"] .boss-bar__fill::after {
background: linear-gradient(90deg, rgba(255, 61, 240, 0.6), var(--magenta));
box-shadow: 0 0 8px var(--magenta);
animation: boss-danger 0.5s ease-in-out infinite alternate;
}
@keyframes boss-danger {
from { opacity: 1; }
to { opacity: 0.6; }
}
/* ── V2 HUD: Wave banner ─────────────────────────────────────────────────── */
.wave-banner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: clamp(3rem, 8vw, 5rem);
font-weight: bold;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--cyan);
text-shadow: 0 0 20px var(--cyan), 0 0 50px rgba(34, 228, 255, 0.5);
pointer-events: none;
z-index: 5;
opacity: 0;
white-space: nowrap;
}
.wave-banner.show {
animation: wave-flash 1.6s ease forwards;
}
@keyframes wave-flash {
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.7); }
15% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
75% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.05); }
}
/* ── V2 Settings overlay ─────────────────────────────────────────────────── */
.overlay--settings .overlay__title,
#overlay-settings .overlay__title {
color: var(--cyan);
text-shadow: var(--glow-c);
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 0.9rem 0;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(34, 228, 255, 0.1);
}
.settings-label {
font-size: 0.8rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
flex-shrink: 0;
}
.settings-control {
display: flex;
align-items: center;
gap: 0.6rem;
}
.settings-value {
font-size: 0.75rem;
color: var(--cyan);
text-shadow: 0 0 5px var(--cyan);
min-width: 2ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* Neon slider */
.settings-slider {
-webkit-appearance: none;
appearance: none;
width: 140px;
height: 4px;
background: rgba(34, 228, 255, 0.15);
border-radius: 2px;
outline: none;
cursor: pointer;
position: relative;
}
.settings-slider::-webkit-slider-runnable-track {
height: 4px;
border-radius: 2px;
background: linear-gradient(
to right,
var(--cyan) 0%,
var(--cyan) calc(var(--pct, 70%) * 1%),
rgba(34, 228, 255, 0.15) calc(var(--pct, 70%) * 1%),
rgba(34, 228, 255, 0.15) 100%
);
}
.settings-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--cyan);
border: 2px solid var(--bg);
box-shadow: 0 0 6px var(--cyan), 0 0 12px rgba(34, 228, 255, 0.4);
margin-top: -5px;
cursor: pointer;
transition: box-shadow 0.15s;
}
.settings-slider::-webkit-slider-thumb:hover {
box-shadow: 0 0 10px var(--cyan), 0 0 20px rgba(34, 228, 255, 0.6);
}
.settings-slider::-moz-range-track {
height: 4px;
border-radius: 2px;
background: rgba(34, 228, 255, 0.15);
}
.settings-slider::-moz-range-progress {
height: 4px;
border-radius: 2px;
background: var(--cyan);
}
.settings-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--cyan);
border: 2px solid var(--bg);
box-shadow: 0 0 6px var(--cyan);
cursor: pointer;
}
/* Neon toggle checkbox */
.settings-toggle {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
.settings-toggle-label {
display: block;
width: 40px;
height: 22px;
background: rgba(34, 228, 255, 0.1);
border: 1px solid rgba(34, 228, 255, 0.3);
border-radius: 11px;
cursor: pointer;
position: relative;
transition: background 0.2s, border-color 0.2s, box-shadow 0.2s;
}
.settings-toggle-label::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(34, 228, 255, 0.4);
transition: transform 0.2s, background 0.2s, box-shadow 0.2s;
}
.settings-toggle:checked + .settings-toggle-label {
background: rgba(34, 228, 255, 0.2);
border-color: var(--cyan);
box-shadow: 0 0 8px rgba(34, 228, 255, 0.4);
}
.settings-toggle:checked + .settings-toggle-label::after {
transform: translateX(18px);
background: var(--cyan);
box-shadow: 0 0 6px var(--cyan);
}
/* Settings button on pause overlay */
.overlay__button--settings {
border-color: var(--yellow);
color: var(--yellow);
text-shadow: 0 0 6px var(--yellow);
box-shadow: 0 0 8px rgba(255, 216, 77, 0.2), inset 0 0 8px rgba(255, 216, 77, 0.05);
}
.overlay__button--settings:hover {
background: rgba(255, 216, 77, 0.1);
box-shadow: 0 0 16px rgba(255, 216, 77, 0.5), inset 0 0 12px rgba(255, 216, 77, 0.1);
color: #fff;
text-shadow: 0 0 10px var(--yellow);
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.overlay__panel {