mirror of
https://github.com/tiennm99/ai-coding-workflow-labs.git
synced 2026-08-13 21:20:37 +00:00
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:
@@ -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 (0–100), 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.
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user