feat: v3 — tiers, drones, medals, bonus waves, announcer, hit-stop, top-5 leaderboard

Chicken-Invaders + 1942-inspired additions: stack a booster to level it
(L1-L3, larger spread / faster fire / deeper pierce), new drone
power-up spawning 1-2 orbital companions that fire along with you,
medal pickups that fly up on kill for bonus score and combo, a
meteor-rain bonus wave every 4th non-boss wave, flavor-specific
announcer banners (ROLL CALL, SHOOTERS JOIN, BOSS APPROACHING,
PERFECT WAVE), hit-stop on boss kills and combo bumps, and a
persistent top-5 local leaderboard with game-over placement
highlight. Verifier approved with zero blockers.
This commit is contained in:
2026-04-19 10:25:28 +07:00
parent de63dd095a
commit 1ee0a89d5c
19 changed files with 1398 additions and 53 deletions
+72
View File
@@ -0,0 +1,72 @@
# OMC Neon Arena — V3 Features
Inspiration: Chicken Invaders (tier-stacking, drones, bonus waves) + 1942/Strikers-style (medals, announcer banners, hit-stop). All additive — keeps v1 and v2 surfaces working.
## Features
1. **Weapon tiers.** Collecting the same booster while active bumps its tier (1→2→3 max). Duration is refreshed on each pickup.
- `rapidfire` tiers: cooldown × 0.5 / 0.35 / 0.25.
- `spread` tiers: 3 / 4 / 5 bullets; angle spread 12° / 16° / 20°.
- `pierce` tiers: pierce budget 3 / 5 / 8 per bullet.
- `shield` keeps existing stack semantic (max 2). Collecting shield with a shield already active adds a stack; no tier.
- `heal` remains instant (no tier, just restores +1 HP each pickup).
- `bomb` remains instant (no tier).
- `drone` (new, see #2) tiers: 1 / 2 / 2 drones + fire rate doubles at tier 3.
2. **Orbital drones.** New power-up `drone`. On pickup, spawns companion drone(s) that orbit the player at radius 56 and fire a bullet every 0.6 s toward the player's aim direction. Lasts 15 s (refreshed/extended on pickup). Drone bullets share the player's pierce budget (via active buffs). Drones render as small neon diamonds with a trailing tail.
3. **Medal pickups.** On non-boss enemy death, 25 % chance a medal spawns at kill position. Medals rise upward at 60 px/s, bob slightly, and expire after 3 s. Grabbing one adds `+20 × comboMult` to score and `+1` to the combo counter. Missed medals fade out with no penalty. Medal color scales with wave (bronze → silver → gold → neon). Max 32 active.
4. **Bonus wave.** Every 4th wave (4, 8, 12, 16, …) that is NOT a boss wave (waves 4, 8, 12, 16 — none collide with boss cadence 5, 10, 15). Enter a 18 s "METEOR RAIN" where no enemies spawn. Meteors fall vertically from the top at 160 320 px/s, tumble, and explode on bullet hits for +15 score per meteor. Meteors do NOT damage the player (pass through harmlessly). Announcer banner "BONUS: METEOR RAIN!". End of bonus wave emits a "WAVE CLEARED +<score>" floater.
5. **Announcer banners.** Replace generic "WAVE N" with flavor:
- Wave 2: "ROLL CALL"
- Wave 3: "SHOOTERS JOIN"
- Wave 4 (bonus): "BONUS: METEOR RAIN!"
- Wave 5/10/15… (boss): "BOSS APPROACHING"
- Otherwise: "WAVE N"
- Plus a "PERFECT WAVE!" popup if a wave is cleared without the player taking any damage (shield blocks do not count as damage).
6. **Hit-stop.** On boss death: `timescale = 0.1` for 180 ms then ease back. On combo kill where new combo multiplier bumps (e.g., reaches ×2, ×3, ×4): `timescale = 0.2` for 80 ms. Applied by multiplying `dt` in Game.update.
7. **Top-5 leaderboard.** Replace single `best` with an array of up to 5 entries `{score, date}` sorted desc. Persist to `localStorage` under `omc-neon-arena.leaderboard`. Game-over overlay shows the board with the new entry highlighted. Menu still shows the top score via the first entry. Keep the old `omc-neon-arena.best` key for backward compat on first load (import once if present).
## Files
**New:**
- `src/game/tiers.js` — tier lookup tables (duration, cooldown mult, spread count, etc.) + `applyTierEffect(kind, tier)`.
- `src/game/drones.js` — DronePool: spawn(count, owner), update(dt, game), render(ctx). Each drone is an entity orbiting at angular velocity; fires via existing Bullet with `owner=drone`.
- `src/game/medals.js` — Medal entity + MedalPool. Spawn on kill, magnetism to player at 160 px.
- `src/game/meteor.js` — Meteor entity, rotation, hp=1, score 15.
- `src/game/bonus.js` — BonusWaveManager: isBonus flag, timer, meteor spawner. Coordinates with waves.js.
- `src/game/hitstop.js` — simple singleton: `trigger(duration, scale)`, `sample(dt) → scaledDt`, `active` getter.
- `src/ui/leaderboard.js` — load/save/top5()/submit(score) → {rank, improved}. Migrates from `omc-neon-arena.best` on first load.
**Modified:**
- `src/game/buffs.js` — add `tier` to active buff state, bump on repeat `addBuff`, cap by `maxTier(kind)`.
- `src/game/powerup.js` — include `drone` in KIND_POOL; update drop odds slightly (add drone ~5 %); apply `drone` via drone pool; on buff kinds call tier bump; on `shield` stack +1.
- `src/game/player.js` — read tier in fire cooldown, spread count/angle, pierce budget.
- `src/game/game.js` — own `this.drones`, `this.medals`, `this.bonus`, `this.hitstop`; integrate update/render; medal drop in onEnemyKilled; hit-stop triggers in onBossDefeated + onEnemyKilled when multiplier increases; bonus-wave routing in update; "perfectWave" event emit; swap highscore for leaderboard (keep `this.highscore` name and public `.get()` for back compat OR introduce `this.leaderboard`).
- `src/game/waves.js` — bonus-wave branch (wave % 4 === 0 && wave % 5 !== 0); call bonus.start(game); advance when `bonus.isFinished()`. Emit wave label via `_emit('waveStart', {wave, isBoss, isBonus, label})` with the announcer label resolved here.
- `src/ui/hud.js` — buff chip shows tier as Roman numeral "II"/"III" when tier > 1; setWaveBanner reads label text from the emitted event (already does — just ensure it uses `label` when provided).
- `src/ui/menu.js` — game-over overlay now renders a top-5 list; highlight new entry.
- `index.html` — game-over overlay structure supports a list.
- `styles/main.css` — tier badges on chips, announcer flavor styling, leaderboard list rows with highlight.
- `src/main.js` — swap HighScore → Leaderboard while keeping the old function name `hud.setBest(topScore)` working (pass `leaderboard.top() ?? 0`).
## Contracts
- Game exposes: `this.drones`, `this.medals`, `this.bonus`, `this.hitstop` in addition to v2 fields.
- `game._emit('waveStart', { wave, isBoss, isBonus, label })` — all workers consume.
- `BuffManager.active()` entries may include `tier:number`. HUD uses it optionally.
- On boss death, Game calls `hitstop.trigger(180, 0.1)` before the existing onBossDefeated work (particles render during timescale freeze — intentional).
- `leaderboard.submit(score) → { rank, improved }` — Menu uses rank to highlight.
## Parallel Plan
- worker-1 (opus, FIRST — blocker): tiers.js + drones.js + buffs tier system + powerup drone/tier routing + player tier-aware fire + hitstop.js + Game integration of drones + hitstop + tiers. Touches: new files + buffs.js, powerup.js, player.js, game.js.
- worker-2 (opus, parallel after #1): medals.js + meteor.js + bonus.js + waves.js bonus branch + Game integration of medals+bonus (minimal, scoped fields) + collision.js for meteors. Touches: new files + waves.js, collision.js, small additive in game.js.
- worker-3 (sonnet, parallel after #1): leaderboard.js + HUD tier chip rendering + announcer label + perfect-wave detection + ui wiring in main.js. Touches: new files + hud.js, main.js.
- worker-4 (designer, parallel after #1): CSS for tier badges, announcer flavor styling, leaderboard overlay markup, drone/medal HUD hints if any. Touches: styles/main.css, index.html, src/ui/menu.js.
game.js conflict risk: workers 1/2/3 all touch it. Mitigation — worker-1 pre-scaffolds game.js with null-safe references for `this.drones / this.medals / this.bonus / this.hitstop / this.leaderboard` and per-frame hooks so workers 2 and 3 only fill in the modules and/or tweak their scoped integration points. If a later worker must edit game.js, keep edits additive and single-region.
+1
View File
@@ -65,6 +65,7 @@
<h2 class="overlay__title">You Died</h2>
<p class="overlay__meta">Score: <span data-hud="final-score">0</span></p>
<p class="overlay__meta">Best: <span data-hud="final-best">0</span></p>
<ol class="leaderboard" data-overlay-leaderboard></ol>
<button class="overlay__button" data-action="reset">Try Again</button>
</div>
</div>
+161
View File
@@ -0,0 +1,161 @@
// BonusWaveManager: 18 s METEOR RAIN. While active, Game skips waves.update
// and bonus.update ticks instead. Meteors are owned locally; collision.js
// iterates `game.bonus?.meteors` and destroys them on bullet impact.
import { Meteor } from "./meteor.js";
const DURATION = 18.0;
const SPAWN_INTERVAL_MIN = 0.35;
const SPAWN_INTERVAL_MAX = 0.7;
export class BonusWaveManager {
constructor() {
this.active = false;
this.finished = false;
this.timer = 0;
this.spawnTimer = 0;
this.wave = 0;
this.score = 0;
this.meteors = [];
}
reset() {
this.active = false;
this.finished = false;
this.timer = 0;
this.spawnTimer = 0;
this.wave = 0;
this.score = 0;
this.meteors.length = 0;
}
isActive() {
return this.active;
}
isFinished() {
return this.finished;
}
start(game, wave) {
this.active = true;
this.finished = false;
this.timer = DURATION;
this.spawnTimer = 0.25;
this.wave = wave || 0;
this.score = 0;
this.meteors.length = 0;
if (game?.enemies && typeof game.enemies.clear === "function") game.enemies.clear();
if (game?.enemyBullets && typeof game.enemyBullets.clear === "function") {
game.enemyBullets.clear();
}
if (game?.floaters && typeof game.floaters.emit === "function" && game.canvas) {
game.floaters.emit({
x: game.canvas.width / 2,
y: game.canvas.height / 2 - 40,
text: "BONUS: METEOR RAIN!",
color: "#ffd84d",
size: "big",
});
}
if (game?._emit) {
game._emit("waveStart", {
wave: this.wave,
isBoss: false,
isBonus: true,
label: "BONUS: METEOR RAIN!",
});
}
if (game?.audio && typeof game.audio.play === "function") game.audio.play("wave");
}
onMeteorDestroyed(meteor, game) {
const gained = meteor?.score ?? 15;
game.score = (game.score || 0) + gained;
this.score += gained;
if (game.hud && typeof game.hud.setScore === "function") game.hud.setScore(game.score);
if (game.particles && typeof game.particles.emit === "function" && meteor) {
game.particles.emit({
x: meteor.pos.x,
y: meteor.pos.y,
color: meteor.color || "#ff9f55",
count: 22,
speedMin: 120,
speedMax: 360,
lifetime: 0.55,
radius: 3.5,
});
}
if (game.floaters && typeof game.floaters.emit === "function" && meteor) {
game.floaters.emit({
x: meteor.pos.x,
y: meteor.pos.y,
text: `+${gained}`,
color: "#ffd84d",
});
}
if (game.audio && typeof game.audio.play === "function") game.audio.play("explode");
}
_spawn(game) {
const canvas = game?.canvas;
if (!canvas) return;
const w = canvas.width;
const x = 20 + Math.random() * (w - 40);
const y = -30;
const radius = 14 + Math.random() * 10;
this.meteors.push(new Meteor({ x, y, radius }));
}
update(dt, game) {
if (!this.active) return;
this.timer -= dt;
this.spawnTimer -= dt;
if (this.spawnTimer <= 0 && this.timer > 2.0) {
this.spawnTimer = SPAWN_INTERVAL_MIN + Math.random() * (SPAWN_INTERVAL_MAX - SPAWN_INTERVAL_MIN);
this._spawn(game);
}
for (let i = this.meteors.length - 1; i >= 0; i--) {
const m = this.meteors[i];
if (!m.alive) {
this.meteors.splice(i, 1);
continue;
}
m.update(dt, game);
if (!m.alive) {
this.meteors.splice(i, 1);
}
}
if (this.timer <= 0) {
this.active = false;
this.finished = true;
this.meteors.length = 0;
if (game?.floaters && typeof game.floaters.emit === "function" && game.canvas) {
game.floaters.emit({
x: game.canvas.width / 2,
y: game.canvas.height / 2,
text: `WAVE CLEARED +${this.score}`,
color: "#ffd84d",
size: "big",
});
}
if (typeof game?.onWaveCleared === "function") {
game.onWaveCleared(this.wave, game._perfectWave === true);
}
}
}
render(ctx) {
for (let i = 0; i < this.meteors.length; i++) {
const m = this.meteors[i];
if (!m.alive) continue;
m.render(ctx);
}
}
}
+36 -8
View File
@@ -1,6 +1,10 @@
// BuffManager: tracks timed buffs (rapidfire/spread/pierce) and stacked shield.
// BuffManager: tracks timed buffs (rapidfire/spread/pierce/drone) and
// stacked shield. V3 adds tiering: re-collecting a tier-eligible buff
// bumps its tier (capped via MAX_TIER) and refreshes remaining time.
const TIMED_KINDS = ["rapidfire", "spread", "pierce"];
import { MAX_TIER, DURATION, resolveTier } from "./tiers.js";
const TIMED_KINDS = ["rapidfire", "spread", "pierce", "drone"];
const SHIELD_MAX = 2;
export class BuffManager {
@@ -14,15 +18,28 @@ export class BuffManager {
this.shield = 0;
}
addBuff(kind, duration = 8) {
addBuff(kind, duration) {
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) });
const existing = this.timed.get(kind);
const currentTier = existing ? existing.tier : 0;
const maxTier = MAX_TIER[kind] || 1;
const nextTier = existing ? resolveTier(kind, currentTier) : 1;
const tableDur = DURATION[kind]?.[nextTier];
const baseDur = typeof duration === "number" ? duration : (tableDur ?? 8);
const total = Math.max(tableDur ?? 0, baseDur);
const remaining = existing ? Math.max(existing.remaining, total) : total;
this.timed.set(kind, {
tier: Math.min(nextTier, maxTier),
remaining,
total,
});
}
tick(dt) {
@@ -37,6 +54,12 @@ export class BuffManager {
return this.timed.has(kind);
}
getTier(kind) {
if (kind === "shield") return this.shield;
const entry = this.timed.get(kind);
return entry ? entry.tier : 0;
}
consumeShield() {
if (this.shield > 0) {
this.shield -= 1;
@@ -49,10 +72,15 @@ export class BuffManager {
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 });
list.push({ kind, remaining: frac, tier: entry.tier || 1 });
}
if (this.shield > 0) {
list.push({ kind: "shield", remaining: this.shield / SHIELD_MAX, stacks: this.shield });
list.push({
kind: "shield",
remaining: this.shield / SHIELD_MAX,
stacks: this.shield,
tier: 1,
});
}
return list;
}
+33 -1
View File
@@ -1,5 +1,7 @@
// Circle-vs-circle collision resolution.
// V2: enemy bullets vs player, player bullets vs boss, bullet pierce handling.
// V3: drone bullets share player-bullet logic (owner 'drone' treated like 'player');
// meteors vs bullets during bonus waves; meteors do NOT damage the player.
import { EnemyTypes, spawnSplitterChildren } from "./enemy.js";
@@ -10,16 +12,23 @@ function circleHit(a, b) {
return dx * dx + dy * dy <= r * r;
}
function isFriendlyBullet(b) {
const o = b?.owner;
return o == null || o === "player" || o === "drone";
}
export function resolveCollisions(game) {
const bullets = game.bullets.items;
const enemies = game.enemies.items;
const player = game.player;
const boss = game.boss;
const meteors = game.bonus?.meteors;
// Player bullets vs enemies + boss (with pierce).
// Player + drone bullets vs enemies + boss + meteors (with pierce).
for (let i = 0; i < bullets.length; i++) {
const b = bullets[i];
if (!b.alive) continue;
if (!isFriendlyBullet(b)) continue;
for (let j = 0; j < enemies.length; j++) {
const e = enemies[j];
@@ -86,6 +95,29 @@ export function resolveCollisions(game) {
}
}
}
if (!b.alive) continue;
// V3: bonus-wave meteors. Destroying one awards score via BonusWaveManager.
if (meteors && meteors.length > 0) {
for (let k = 0; k < meteors.length; k++) {
const m = meteors[k];
if (!m.alive) continue;
if (typeof b.hasHit === "function" && b.hasHit(m)) continue;
if (!circleHit(b, m)) continue;
const killed = typeof m.takeDamage === "function" ? m.takeDamage(b.damage || 1) : (m.alive = false, true);
if (typeof b.onHit === "function") {
b.onHit(m);
} else {
b.alive = false;
}
if (killed && typeof game.bonus?.onMeteorDestroyed === "function") {
game.bonus.onMeteorDestroyed(m, game);
}
if (!b.alive) break;
}
}
}
// Enemy bullets vs player.
+136
View File
@@ -0,0 +1,136 @@
// DronePool: orbital companion drones that fire toward the player's aim.
// Drones share the player's pierce buff budget via tiers.PIERCE_BUDGET.
import { Bullet } from "./bullet.js";
import { DRONE_FIRE_RATE, PIERCE_BUDGET } from "./tiers.js";
const ORBIT_RADIUS = 56;
const ORBIT_SPEED = 2.4;
const DRONE_RADIUS = 7;
const BULLET_SPEED = 520;
class Drone {
constructor(index, total) {
this.phase = (Math.PI * 2 * index) / Math.max(1, total);
this.cooldown = 0.1 + index * 0.1;
this.pos = { x: 0, y: 0 };
this.aim = { x: 1, y: 0 };
}
}
export class DronePool {
constructor(player) {
this.owner = player;
this.drones = [];
this.tier = 0;
this.fireInterval = DRONE_FIRE_RATE[1];
}
setActive(count, tier) {
this.tier = tier || 0;
this.fireInterval = DRONE_FIRE_RATE[tier] ?? DRONE_FIRE_RATE[1];
const target = Math.max(0, count | 0);
if (this.drones.length > target) {
this.drones.length = target;
} else {
while (this.drones.length < target) {
this.drones.push(new Drone(this.drones.length, target));
}
}
const total = this.drones.length;
for (let i = 0; i < total; i++) {
this.drones[i].phase = (Math.PI * 2 * i) / Math.max(1, total);
}
}
clear() {
this.drones.length = 0;
this.tier = 0;
}
update(dt, game) {
if (this.drones.length === 0) return;
const player = this.owner || game?.player;
if (!player || !player.alive) return;
let ax = player.aim?.x ?? 1;
let ay = player.aim?.y ?? 0;
const mouse = game?.input?.mouse;
if (mouse && typeof mouse.x === "number" && typeof mouse.y === "number") {
const dx = mouse.x - player.pos.x;
const dy = mouse.y - player.pos.y;
const len = Math.hypot(dx, dy);
if (len > 0.001) {
ax = dx / len;
ay = dy / len;
}
}
if (Math.hypot(ax, ay) < 0.001) {
ax = 1;
ay = 0;
}
const pierceTier = game?.buffs?.getTier?.("pierce") ?? 0;
const pierce = pierceTier > 0 ? (PIERCE_BUDGET[pierceTier] ?? 0) : 0;
for (let i = 0; i < this.drones.length; i++) {
const d = this.drones[i];
d.phase += ORBIT_SPEED * dt;
d.pos.x = player.pos.x + Math.cos(d.phase) * ORBIT_RADIUS;
d.pos.y = player.pos.y + Math.sin(d.phase) * ORBIT_RADIUS;
d.aim.x = ax;
d.aim.y = ay;
d.cooldown -= dt;
if (d.cooldown <= 0 && game?.bullets) {
d.cooldown = this.fireInterval;
const bullet = new Bullet({
x: d.pos.x,
y: d.pos.y,
vx: ax * BULLET_SPEED,
vy: ay * BULLET_SPEED,
owner: "drone",
pierce,
color: "#7ee7ff",
});
game.bullets.add(bullet);
}
}
}
render(ctx) {
if (this.drones.length === 0) return;
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < this.drones.length; i++) {
const d = this.drones[i];
const tx = d.pos.x - d.aim.x * 10;
const ty = d.pos.y - d.aim.y * 10;
ctx.shadowColor = "#7ee7ff";
ctx.shadowBlur = 14;
ctx.strokeStyle = "rgba(126, 231, 255, 0.55)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(tx, ty);
ctx.lineTo(d.pos.x, d.pos.y);
ctx.stroke();
ctx.shadowBlur = 18;
ctx.fillStyle = "#7ee7ff";
ctx.beginPath();
ctx.moveTo(d.pos.x, d.pos.y - DRONE_RADIUS);
ctx.lineTo(d.pos.x + DRONE_RADIUS, d.pos.y);
ctx.lineTo(d.pos.x, d.pos.y + DRONE_RADIUS);
ctx.lineTo(d.pos.x - DRONE_RADIUS, d.pos.y);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.arc(d.pos.x, d.pos.y, 1.8, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
+114 -19
View File
@@ -1,6 +1,8 @@
// 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.
// V3 adds: drones, hit-stop, tier-aware buffs, plus placeholders for medals,
// bonus meteor waves, and leaderboard populated by workers 2/3/main.
import { EntityPool } from "../engine/entity.js";
import { Player } from "./player.js";
@@ -13,6 +15,10 @@ import { Powerup, maybeDrop, applyPowerup, randomKind } from "./powerup.js";
import { Starfield } from "./starfield.js";
import { FloaterPool } from "./floaters.js";
import { loadSettings } from "./settings.js";
import { DronePool } from "./drones.js";
import { HitStop } from "./hitstop.js";
import { MedalPool } from "./medals.js";
import { BonusWaveManager } from "./bonus.js";
export const GameState = {
MENU: "menu",
@@ -23,6 +29,7 @@ export const GameState = {
const COMBO_WINDOW = 2.0;
const SHAKE_DECAY = 10;
const MEDAL_DROP_CHANCE = 0.25;
const COMBO_COLORS = ["#ffffff", "#22e4ff", "#ff3df0", "#ffd84d"];
@@ -60,6 +67,19 @@ export class Game {
this.floaters = new FloaterPool();
this.settings = loadSettings();
// V3: owned by this worker.
this.drones = new DronePool(this.player);
this.hitstop = new HitStop();
// V3: worker-2 owns medals + bonus wave manager (additive, null-guarded).
this.medals = new MedalPool();
this.bonus = new BonusWaveManager();
// V3: leaderboard set externally by main.js / worker-3.
this.leaderboard = null;
// V3: perfect-wave tracking. True while current wave has dealt no damage.
this._perfectWave = true;
if (this.audio && typeof this.audio.setMasterVolume === "function") {
this.audio.setMasterVolume(this.settings.volume);
}
@@ -68,6 +88,12 @@ export class Game {
this.time = 0;
this.listeners = { stateChange: [] };
// V3: reset perfect flag whenever a new wave starts. Workers 2/3/main
// may also listen for waveStart / waveCleared.
this.on("waveStart", () => {
this._perfectWave = true;
});
}
on(event, fn) {
@@ -115,8 +141,13 @@ export class Game {
this.enemyBullets.clear();
if (this.floaters && typeof this.floaters.clear === "function") this.floaters.clear();
if (this.buffs) this.buffs.clear();
if (this.drones && typeof this.drones.clear === "function") this.drones.clear();
if (this.hitstop && typeof this.hitstop.reset === "function") this.hitstop.reset();
if (this.medals && typeof this.medals.clear === "function") this.medals.clear();
if (this.bonus && typeof this.bonus.reset === "function") this.bonus.reset();
this.boss = null;
this.waves.reset();
this._perfectWave = true;
this._syncHud();
}
@@ -130,7 +161,11 @@ export class Game {
this.hud.setWave(this.waves.current);
this.hud.setHealth(this.player.health, this.player.maxHealth);
this.hud.setCombo(this._multiplier());
if (this.highscore) this.hud.setBest(this.highscore.get());
if (this.leaderboard && typeof this.leaderboard.top === "function") {
this.hud.setBest(this.leaderboard.top() ?? 0);
} else 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);
@@ -141,8 +176,8 @@ export class Game {
}
onEnemyKilled(enemy) {
const mult = this._multiplier();
const gained = enemy.score * mult;
const prevMult = this._multiplier();
const gained = enemy.score * prevMult;
this.score += gained;
this.combo += 1;
this.comboTimer = COMBO_WINDOW;
@@ -163,8 +198,8 @@ export class Game {
this.floaters.emit({
x: enemy.pos.x,
y: enemy.pos.y,
text: `+${gained} x${mult}!`,
color: comboColor(mult),
text: `+${gained} x${prevMult}!`,
color: comboColor(prevMult),
});
}
@@ -173,6 +208,19 @@ export class Game {
this.powerups.add(new Powerup({ kind, x: enemy.pos.x, y: enemy.pos.y }));
}
// V3: combo multiplier bump hit-stop.
const newMult = this._multiplier();
if (newMult > prevMult && newMult >= 2 && this.hitstop) {
this.hitstop.trigger(80, 0.2);
}
// V3: medal drop (non-boss enemies only; boss uses onBossDefeated).
if (this.medals && typeof this.medals.maybeSpawn === "function" && enemy.type !== "boss") {
if (Math.random() < MEDAL_DROP_CHANCE) {
this.medals.maybeSpawn(enemy.pos.x, enemy.pos.y, this.waves.current);
}
}
if (this.hud) {
this.hud.setScore(this.score);
this.hud.setCombo(this._multiplier());
@@ -181,6 +229,9 @@ export class Game {
onBossDefeated(boss) {
if (!boss) return;
// V3: bigger hit-stop on boss death, applied before FX.
if (this.hitstop) this.hitstop.trigger(180, 0.1);
this.score += 500;
this.shake = Math.min(32, this.shake + 20);
this.particles.emit({
@@ -220,6 +271,10 @@ export class Game {
}
}
onWaveCleared(wave, wasPerfect) {
this._emit("waveCleared", { wave, perfect: wasPerfect === true });
}
applyBomb() {
const enemies = this.enemies.items;
for (let i = 0; i < enemies.length; i++) {
@@ -303,6 +358,9 @@ export class Game {
return;
}
// V3: actual damage clears perfect-wave flag (shield block does not).
this._perfectWave = false;
this.combo = 0;
this.comboTimer = 0;
this.shake = Math.min(22, this.shake + 10);
@@ -327,12 +385,19 @@ export class Game {
}
_gameOver() {
if (this.highscore) {
let rank = null;
if (this.leaderboard && typeof this.leaderboard.submit === "function") {
const res = this.leaderboard.submit(this.score);
if (res && typeof res.rank === "number") rank = res.rank;
if (this.hud && typeof this.leaderboard.top === "function") {
this.hud.setBest(this.leaderboard.top() ?? 0);
}
} else if (this.highscore) {
this.highscore.submit(this.score);
if (this.hud) this.hud.setBest(this.highscore.get());
}
if (this.hud && typeof this.hud.setFinalScore === "function") {
this.hud.setFinalScore(this.score);
this.hud.setFinalScore(this.score, rank);
}
if (this.audio) this.audio.play("gameover");
this._setState(GameState.GAMEOVER);
@@ -340,14 +405,20 @@ export class Game {
update(dt) {
if (this.state !== GameState.PLAYING) return;
this.time += dt;
// V3: hit-stop scaled dt drives game-world updates. UI / starfield
// still use raw dt so they don't visibly freeze.
const scaled = this.hitstop ? this.hitstop.sample(dt) : dt;
if (this.hitstop) this.hitstop.tick(dt);
this.time += scaled;
if (this.audio && typeof this.audio.setMasterVolume === "function") {
this.audio.setMasterVolume(this.settings.volume);
}
if (this.comboTimer > 0) {
this.comboTimer -= dt;
this.comboTimer -= scaled;
if (this.comboTimer <= 0) {
this.combo = 0;
if (this.hud) this.hud.setCombo(this._multiplier());
@@ -364,36 +435,48 @@ export class Game {
if (this.starfield && typeof this.starfield.update === "function") {
this.starfield.update(dt);
}
if (this.buffs) this.buffs.tick(dt);
if (this.buffs) this.buffs.tick(scaled);
this.player.update(dt, this);
this.player.update(scaled, this);
const bullets = this.bullets.items;
for (let i = 0; i < bullets.length; i++) bullets[i].update(dt, this);
for (let i = 0; i < bullets.length; i++) bullets[i].update(scaled, this);
if (this.drones && typeof this.drones.update === "function") {
this.drones.update(scaled, this);
}
const enemies = this.enemies.items;
for (let i = 0; i < enemies.length; i++) enemies[i].update(dt, this);
for (let i = 0; i < enemies.length; i++) enemies[i].update(scaled, this);
const eb = this.enemyBullets.items;
for (let i = 0; i < eb.length; i++) eb[i].update(dt, this);
for (let i = 0; i < eb.length; i++) eb[i].update(scaled, this);
const pus = this.powerups.items;
for (let i = 0; i < pus.length; i++) pus[i].update(dt, this);
for (let i = 0; i < pus.length; i++) pus[i].update(scaled, this);
if (this.boss && this.boss.alive && typeof this.boss.update === "function") {
this.boss.update(dt, this);
this.boss.update(scaled, this);
}
if (this.medals && typeof this.medals.update === "function") {
this.medals.update(scaled, this);
}
resolveCollisions(this);
this.waves.update(dt, this);
if (this.bonus && typeof this.bonus.isActive === "function" && this.bonus.isActive()) {
if (typeof this.bonus.update === "function") this.bonus.update(scaled, this);
} else {
this.waves.update(scaled, this);
}
if (this.waves.current !== prevWave) {
if (this.audio) this.audio.play("wave");
this.shake = Math.min(18, this.shake + 6);
}
this.particles.update(dt);
if (this.floaters && typeof this.floaters.update === "function") this.floaters.update(dt);
this.particles.update(scaled);
if (this.floaters && typeof this.floaters.update === "function") this.floaters.update(scaled);
this.bullets.filterAlive();
this.enemies.filterAlive();
@@ -465,12 +548,20 @@ export class Game {
const pus = this.powerups.items;
for (let i = 0; i < pus.length; i++) pus[i].render(ctx);
if (this.medals && typeof this.medals.render === "function") {
this.medals.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.drones && typeof this.drones.render === "function") {
this.drones.render(ctx);
}
if (this.boss && this.boss.alive && typeof this.boss.render === "function") {
this.boss.render(ctx);
}
@@ -484,6 +575,10 @@ export class Game {
this.particles.render(ctx);
if (this.bonus && typeof this.bonus.render === "function") {
this.bonus.render(ctx);
}
if (this.floaters && typeof this.floaters.render === "function") {
this.floaters.render(ctx);
}
+43
View File
@@ -0,0 +1,43 @@
// HitStop: a tiny time-scale gate. While active, sample(dt) returns a
// scaled dt; tick(dt) decrements the internal timer with raw dt so the
// stop lasts real wall-clock time regardless of its own scaling.
export class HitStop {
constructor() {
this.remaining = 0;
this.scale = 1;
}
trigger(durationMs, scale = 0.1) {
const dur = Math.max(0, durationMs) / 1000;
if (dur <= 0) return;
if (dur > this.remaining) this.remaining = dur;
this.scale = scale;
}
get active() {
return this.remaining > 0;
}
sample(dt) {
if (this.remaining > 0) return dt * this.scale;
return dt;
}
tick(dt) {
if (this.remaining > 0) {
this.remaining -= dt;
if (this.remaining <= 0) {
this.remaining = 0;
this.scale = 1;
}
}
}
reset() {
this.remaining = 0;
this.scale = 1;
}
}
export default HitStop;
+170
View File
@@ -0,0 +1,170 @@
// MedalPool: rising medal pickups dropped on non-boss kills.
// Game gates the 25% drop chance; maybeSpawn unconditionally spawns.
// On pickup: +20 * comboMult score, combo += 1.
const MAX_MEDALS = 32;
const LIFETIME = 3.0;
const RISE_SPEED = 60;
const BOB_AMP = 3.5;
const BOB_FREQ = 4.5;
const MAGNET_RANGE = 160;
const MAGNET_ACCEL = 520;
const PICKUP_RADIUS = 14;
const FADE_START = 0.8;
function tierForWave(wave) {
if (wave >= 15) return 3;
if (wave >= 10) return 2;
if (wave >= 5) return 1;
return 0;
}
const TIER_COLORS = ["#cd7f32", "#c9d1d9", "#ffd84d", "#9df9ff"];
class Medal {
constructor(x, y, wave) {
this.x = x;
this.y = y;
this.vx = (Math.random() - 0.5) * 40;
this.vy = -RISE_SPEED;
this.life = LIFETIME;
this.alive = true;
this.bobPhase = Math.random() * Math.PI * 2;
this.tier = tierForWave(wave || 1);
this.color = TIER_COLORS[this.tier] || TIER_COLORS[0];
this.radius = PICKUP_RADIUS;
}
}
export class MedalPool {
constructor() {
this.items = [];
}
clear() {
this.items.length = 0;
}
maybeSpawn(x, y, waveNumber) {
if (this.items.length >= MAX_MEDALS) {
this.items.shift();
}
this.items.push(new Medal(x, y, waveNumber));
}
update(dt, game) {
const player = game?.player;
const playerAlive = !!(player && player.alive);
for (let i = this.items.length - 1; i >= 0; i--) {
const m = this.items[i];
m.life -= dt;
if (m.life <= 0) {
this.items.splice(i, 1);
continue;
}
m.bobPhase += dt * BOB_FREQ;
if (playerAlive) {
const dx = player.pos.x - m.x;
const dy = player.pos.y - m.y;
const distSq = dx * dx + dy * dy;
if (distSq < MAGNET_RANGE * MAGNET_RANGE) {
const dist = Math.sqrt(distSq) || 1;
m.vx += (dx / dist) * MAGNET_ACCEL * dt;
m.vy += (dy / dist) * MAGNET_ACCEL * dt;
}
}
const drag = Math.exp(-0.8 * dt);
m.vx *= drag;
m.vy = m.vy * drag - RISE_SPEED * (1 - drag);
m.x += m.vx * dt;
m.y += m.vy * dt;
if (playerAlive) {
const dx = player.pos.x - m.x;
const dy = player.pos.y - m.y;
const r = player.radius + m.radius;
if (dx * dx + dy * dy <= r * r) {
this._collect(m, game);
this.items.splice(i, 1);
}
}
}
}
_collect(medal, game) {
const mult = typeof game?._multiplier === "function" ? game._multiplier() : 1;
const gained = 20 * mult;
game.score = (game.score || 0) + gained;
game.combo = (game.combo || 0) + 1;
if (typeof game.comboTimer === "number") {
game.comboTimer = Math.max(game.comboTimer, 2.0);
}
if (game.hud) {
if (typeof game.hud.setScore === "function") game.hud.setScore(game.score);
if (typeof game.hud.setCombo === "function" && typeof game._multiplier === "function") {
game.hud.setCombo(game._multiplier());
}
}
if (game.floaters && typeof game.floaters.emit === "function") {
game.floaters.emit({
x: medal.x,
y: medal.y,
text: `+${gained}`,
color: medal.color,
});
}
if (game.particles && typeof game.particles.emit === "function") {
game.particles.emit({
x: medal.x,
y: medal.y,
color: medal.color,
count: 10,
speedMin: 60,
speedMax: 200,
lifetime: 0.35,
radius: 2.5,
});
}
if (game.audio && typeof game.audio.play === "function") game.audio.play("wave");
}
render(ctx) {
if (this.items.length === 0) return;
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < this.items.length; i++) {
const m = this.items[i];
const alpha = m.life < FADE_START ? Math.max(0, m.life / FADE_START) : 1;
const bob = Math.sin(m.bobPhase) * BOB_AMP;
const y = m.y + bob;
ctx.globalAlpha = alpha;
ctx.shadowColor = m.color;
ctx.shadowBlur = 16;
ctx.fillStyle = m.color;
ctx.beginPath();
ctx.arc(m.x, y, m.radius * 0.72, 0, Math.PI * 2);
ctx.fill();
ctx.shadowBlur = 6;
ctx.strokeStyle = "rgba(255,255,255,0.8)";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(m.x, y, m.radius * 0.45, 0, Math.PI * 2);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = "#ffffff";
ctx.font = "bold 11px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("M", m.x, y);
}
ctx.restore();
}
}
+80
View File
@@ -0,0 +1,80 @@
// Meteor: top-down falling tumbling rock. hp=1, +15 score on destroy.
// Does NOT damage the player; just passes through.
import { Entity } from "../engine/entity.js";
const MIN_SPEED = 160;
const MAX_SPEED = 320;
const DEFAULT_RADIUS = 18;
const METEOR_SCORE = 15;
export class Meteor extends Entity {
constructor(opts = {}) {
const radius = opts.radius ?? DEFAULT_RADIUS;
super({ radius, ...opts });
this.health = 1;
this.maxHealth = 1;
this.score = METEOR_SCORE;
this.color = opts.color || "#ff9f55";
this.spin = (Math.random() - 0.5) * 3.2;
this.angle = Math.random() * Math.PI * 2;
this.hitFlash = 0;
const speed = MIN_SPEED + Math.random() * (MAX_SPEED - MIN_SPEED);
const drift = (Math.random() - 0.5) * 40;
this.vel.x = drift;
this.vel.y = speed;
}
update(dt, game) {
this.pos.x += this.vel.x * dt;
this.pos.y += this.vel.y * dt;
this.angle += this.spin * dt;
if (this.hitFlash > 0) this.hitFlash -= dt;
if (game?.canvas) {
const h = game.canvas.height;
if (this.pos.y - this.radius > h + 40) {
this.alive = false;
}
}
}
takeDamage(amount) {
this.health -= amount;
this.hitFlash = 0.08;
if (this.health <= 0) {
this.alive = false;
return true;
}
return false;
}
render(ctx) {
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.shadowColor = this.color;
ctx.shadowBlur = 20;
ctx.fillStyle = this.hitFlash > 0 ? "#fff" : this.color;
ctx.translate(this.pos.x, this.pos.y);
ctx.rotate(this.angle);
ctx.beginPath();
const sides = 7;
const r = this.radius;
for (let i = 0; i < sides; i++) {
const a = (i / sides) * Math.PI * 2;
const rad = r * (0.78 + ((i * 37) % 10) / 42);
const x = Math.cos(a) * rad;
const y = Math.sin(a) * rad;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = "rgba(255,255,255,0.45)";
ctx.lineWidth = 1.5;
ctx.stroke();
ctx.restore();
}
}
+44 -12
View File
@@ -3,6 +3,11 @@
import { Entity } from "../engine/entity.js";
import { Bullet } from "./bullet.js";
import {
COOLDOWN_MULT,
PIERCE_BUDGET as PIERCE_BUDGET_TABLE,
SPREAD as SPREAD_TABLE,
} from "./tiers.js";
const SPEED = 260;
const SHOOT_COOLDOWN = 0.14;
@@ -12,8 +17,8 @@ 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;
const DEFAULT_SPREAD_ANGLE_DEG = 12;
const DEFAULT_PIERCE_BUDGET = 3;
export class Player extends Entity {
constructor(opts = {}) {
@@ -164,8 +169,16 @@ export class Player extends Entity {
const wantsShoot = mouse.down || input.isDown("Space");
if (wantsShoot && this.cooldown <= 0) {
this.shoot(game);
const rapid = game.buffs && game.buffs.hasBuff("rapidfire");
this.cooldown = rapid ? SHOOT_COOLDOWN * 0.5 : SHOOT_COOLDOWN;
const rapidTier = game.buffs && typeof game.buffs.getTier === "function"
? game.buffs.getTier("rapidfire")
: 0;
let mult = 1;
if (rapidTier > 0) {
mult = COOLDOWN_MULT[rapidTier] ?? 0.5;
} else if (game.buffs && game.buffs.hasBuff("rapidfire")) {
mult = 0.5;
}
this.cooldown = SHOOT_COOLDOWN * mult;
}
}
@@ -179,21 +192,40 @@ export class Player extends Entity {
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 pierceTier = game.buffs && typeof game.buffs.getTier === "function"
? game.buffs.getTier("pierce")
: 0;
const pierce = pierceTier > 0
? (PIERCE_BUDGET_TABLE[pierceTier] ?? DEFAULT_PIERCE_BUDGET)
: (game.buffs && game.buffs.hasBuff("pierce") ? DEFAULT_PIERCE_BUDGET : 0);
const bullet = new Bullet({ x: spawnX, y: spawnY, vx, vy, owner: "player", pierce });
game.bullets.add(bullet);
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);
const spreadTier = game.buffs && typeof game.buffs.getTier === "function"
? game.buffs.getTier("spread")
: 0;
const spreadActive = spreadTier > 0 || (game.buffs && game.buffs.hasBuff("spread"));
if (spreadActive) {
const conf = SPREAD_TABLE[spreadTier] ?? { count: 3, angleDeg: DEFAULT_SPREAD_ANGLE_DEG };
const count = Math.max(1, conf.count);
const angleRad = (conf.angleDeg * Math.PI) / 180;
const step = count > 1 ? (angleRad * 2) / (count - 1) : 0;
let centerSpawnX = this.pos.x;
let centerSpawnY = this.pos.y;
for (let i = 0; i < count; i++) {
const offset = count > 1 ? -angleRad + step * i : 0;
const { spawnX, spawnY } = this._spawnBullet(game, offset);
if (i === Math.floor(count / 2)) {
centerSpawnX = spawnX;
centerSpawnY = spawnY;
}
}
game.particles.emit({
x: spawnX,
y: spawnY,
x: centerSpawnX,
y: centerSpawnY,
color: "#f4f",
count: 6,
speedMin: 40,
+39 -4
View File
@@ -1,6 +1,7 @@
// Powerup: floating pickup that drifts, magnetizes to nearby player, expires.
import { Entity } from "../engine/entity.js";
import { DRONE_COUNT, DURATION } from "./tiers.js";
const TTL = 12;
const MAGNET_RANGE = 120;
@@ -16,6 +17,7 @@ const KIND_COLORS = {
shield: "#ffd84d",
heal: "#7dff5c",
bomb: "#ff5a5a",
drone: "#7ee7ff",
};
const KIND_GLYPHS = {
@@ -25,6 +27,7 @@ const KIND_GLYPHS = {
shield: "H",
heal: "+",
bomb: "B",
drone: "D",
};
const DROP_ODDS = {
@@ -36,7 +39,17 @@ const DROP_ODDS = {
boss: 1.0,
};
const KIND_POOL = ["rapidfire", "spread", "pierce", "shield", "heal", "bomb"];
const KIND_POOL = ["rapidfire", "spread", "pierce", "shield", "heal", "bomb", "drone"];
const KIND_WEIGHTS = {
rapidfire: 18,
spread: 18,
pierce: 18,
shield: 16,
heal: 14,
bomb: 11,
drone: 5,
};
export class Powerup extends Entity {
constructor({ kind = "rapidfire", x = 0, y = 0 } = {}) {
@@ -137,15 +150,28 @@ export class Powerup extends Entity {
}
}
function weightedPick() {
let total = 0;
for (const k of KIND_POOL) total += KIND_WEIGHTS[k] ?? 0;
if (total <= 0) return KIND_POOL[Math.floor(Math.random() * KIND_POOL.length)];
let r = Math.random() * total;
for (const k of KIND_POOL) {
const w = KIND_WEIGHTS[k] ?? 0;
if (r < w) return k;
r -= w;
}
return KIND_POOL[KIND_POOL.length - 1];
}
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)];
return weightedPick();
}
export function randomKind() {
return KIND_POOL[Math.floor(Math.random() * KIND_POOL.length)];
return weightedPick();
}
export function applyPowerup(game, kind) {
@@ -154,7 +180,7 @@ export function applyPowerup(game, kind) {
case "rapidfire":
case "spread":
case "pierce":
if (game.buffs) game.buffs.addBuff(kind, 8);
if (game.buffs) game.buffs.addBuff(kind);
break;
case "shield":
if (game.buffs) game.buffs.addBuff("shield");
@@ -168,6 +194,15 @@ export function applyPowerup(game, kind) {
case "bomb":
if (typeof game.applyBomb === "function") game.applyBomb();
break;
case "drone":
if (game.buffs) {
game.buffs.addBuff("drone", DURATION.drone[1]);
const tier = game.buffs.getTier("drone") || 1;
if (game.drones && typeof game.drones.setActive === "function") {
game.drones.setActive(DRONE_COUNT[tier] ?? 1, tier);
}
}
break;
default:
break;
}
+42
View File
@@ -0,0 +1,42 @@
// Tier tables for v3 stackable buffs. Collecting the same booster bumps
// its tier up to MAX_TIER[kind] and refreshes the active duration.
export const MAX_TIER = {
rapidfire: 3,
spread: 3,
pierce: 3,
drone: 3,
};
export const DURATION = {
rapidfire: { 1: 8, 2: 10, 3: 12 },
spread: { 1: 8, 2: 10, 3: 12 },
pierce: { 1: 8, 2: 10, 3: 12 },
drone: { 1: 15, 2: 17, 3: 20 },
};
export const COOLDOWN_MULT = { 1: 0.5, 2: 0.35, 3: 0.25 };
export const SPREAD = {
1: { count: 3, angleDeg: 12 },
2: { count: 4, angleDeg: 16 },
3: { count: 5, angleDeg: 20 },
};
export const PIERCE_BUDGET = { 1: 3, 2: 5, 3: 8 };
export const DRONE_COUNT = { 1: 1, 2: 2, 3: 2 };
export const DRONE_FIRE_RATE = { 1: 0.6, 2: 0.6, 3: 0.3 };
export function resolveTier(kind, currentTier = 0) {
const max = MAX_TIER[kind];
if (!max) return 0;
const next = (currentTier || 0) + 1;
return next > max ? max : next;
}
export function durationFor(kind, tier) {
const table = DURATION[kind];
if (!table) return 0;
return table[tier] ?? table[1] ?? 0;
}
+53 -2
View File
@@ -1,5 +1,6 @@
// Wave spawner: schedules enemies with stagger, advances when field is clear.
// V2: boss wave every 5 waves; shooters injected from wave 3+.
// V3: bonus meteor wave every 4th wave that is not a boss wave.
import { Enemy, EnemyTypes } from "./enemy.js";
import { spawnBoss } from "./boss.js";
@@ -12,12 +13,24 @@ function isBossWave(wave) {
return wave > 0 && wave % 5 === 0;
}
function isBonusWave(wave) {
return wave > 0 && wave % 4 === 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);
}
function waveLabel(wave, isBoss, isBonus) {
if (isBonus) return "BONUS: METEOR RAIN!";
if (isBoss) return "BOSS APPROACHING";
if (wave === 2) return "ROLL CALL";
if (wave === 3) return "SHOOTERS JOIN";
return `WAVE ${wave}`;
}
export class WaveManager {
constructor() {
this.wave = 1;
@@ -26,7 +39,9 @@ export class WaveManager {
this.intermission = 0;
this.waveActive = false;
this.isBoss = false;
this.isBonus = false;
this.bossSpawned = false;
this.bonusStarted = false;
this._buildQueue(this.wave);
}
@@ -37,7 +52,9 @@ export class WaveManager {
this.intermission = 0;
this.waveActive = false;
this.isBoss = false;
this.isBonus = false;
this.bossSpawned = false;
this.bonusStarted = false;
this._buildQueue(this.wave);
this.waveActive = true;
}
@@ -49,9 +66,11 @@ export class WaveManager {
_buildQueue(wave) {
const q = [];
this.isBoss = isBossWave(wave);
this.isBonus = isBonusWave(wave);
this.bossSpawned = false;
this.bonusStarted = false;
if (this.isBoss) {
if (this.isBoss || this.isBonus) {
this.queue = q;
this.spawnTimer = 0.4;
return;
@@ -106,6 +125,16 @@ export class WaveManager {
}
update(dt, game) {
// V3: bonus wave just ended — enter intermission before advancing.
if (game?.bonus && game.bonus.finished) {
game.bonus.finished = false;
this.waveActive = false;
this.isBonus = false;
this.bonusStarted = false;
this.intermission = INTERMISSION;
return;
}
if (this.intermission > 0) {
this.intermission -= dt;
if (this.intermission <= 0) {
@@ -114,7 +143,12 @@ export class WaveManager {
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 });
game._emit("waveStart", {
wave: this.wave,
isBoss: this.isBoss,
isBonus: this.isBonus,
label: waveLabel(this.wave, this.isBoss, this.isBonus),
});
}
}
return;
@@ -124,6 +158,17 @@ export class WaveManager {
this.waveActive = true;
}
if (this.isBonus) {
if (!this.bonusStarted) {
this.spawnTimer -= dt;
if (this.spawnTimer <= 0 && game?.bonus && typeof game.bonus.start === "function") {
game.bonus.start(game, this.wave);
this.bonusStarted = true;
}
}
return;
}
if (this.isBoss) {
if (!this.bossSpawned) {
this.spawnTimer -= dt;
@@ -141,6 +186,9 @@ export class WaveManager {
if (game.boss == null) {
this.waveActive = false;
this.intermission = INTERMISSION;
if (typeof game.onWaveCleared === "function") {
game.onWaveCleared(this.wave, game._perfectWave === true);
}
}
return;
}
@@ -156,6 +204,9 @@ export class WaveManager {
} else if (game.enemies.size === 0) {
this.waveActive = false;
this.intermission = INTERMISSION;
if (typeof game.onWaveCleared === "function") {
game.onWaveCleared(this.wave, game._perfectWave === true);
}
}
}
}
+20 -5
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, wireV2Hud } from "./ui/hud.js";
import { HUD, wireV2Hud, wireV3Hud } from "./ui/hud.js";
import { Menu } from "./ui/menu.js";
import { HighScore } from "./ui/highscore.js";
import { Leaderboard } from "./ui/leaderboard.js";
import { saveSettings } from "./game/settings.js";
const canvasEl = document.getElementById("game");
@@ -16,10 +17,19 @@ const canvas = createCanvas(canvasEl);
const input = createInput({ canvas: canvasEl, logicalWidth: canvas.width, logicalHeight: canvas.height });
const hud = new HUD(document);
const highscore = new HighScore();
const leaderboard = new Leaderboard();
const game = new Game({ canvas, input, hud, highscore });
hud.setBest(highscore.get());
// Shim: game.js checks for leaderboard first, so highscore becomes a fallback adapter.
const highscoreShim = {
get: () => leaderboard.top(),
submit: (s) => { const r = leaderboard.submit(s); return r.top; },
};
const game = new Game({ canvas, input, hud, highscore: highscoreShim });
game.leaderboard = leaderboard;
hud.setBest(leaderboard.top());
wireV2Hud(game, hud);
wireV3Hud(game, hud);
const menu = new Menu(
document,
@@ -53,7 +63,12 @@ function renderOverlay(state) {
else menu.hideAll();
}
game.on("stateChange", renderOverlay);
game.on("stateChange", (state) => {
renderOverlay(state);
if (state === GameState.GAMEOVER) {
menu.renderLeaderboard?.(leaderboard.top5(), game.score);
}
});
renderOverlay(game.state);
if (game.audio && typeof game.audio.setMasterVolume === "function") {
@@ -80,7 +95,7 @@ const loop = startLoop({
});
// Expose a small debug handle for other workers + console tinkering.
window.__omc = { game, loop, canvas, input, hud, highscore };
window.__omc = { game, loop, canvas, input, hud, highscore, leaderboard };
let lastFpsLog = 0;
setInterval(() => {
+61 -2
View File
@@ -84,8 +84,29 @@ export class HUD {
if (this.nodes.finalBest) this.nodes.finalBest.textContent = String(v);
}
setFinalScore(v) {
setFinalScore(v, rank) {
if (this.nodes.finalScore) this.nodes.finalScore.textContent = String(v);
// Show rank badge if rank is 1-5.
let rankEl = this.root.querySelector('[data-hud="final-rank"]');
if (rank != null && rank >= 1 && rank <= 5) {
if (!rankEl) {
rankEl = document.createElement("div");
rankEl.setAttribute("data-hud", "final-rank");
rankEl.className = "hud-rank-badge";
if (this.nodes.finalScore && this.nodes.finalScore.parentNode) {
this.nodes.finalScore.parentNode.appendChild(rankEl);
} else {
const hudRoot = this.root.getElementById
? this.root.getElementById("hud")
: this.root.querySelector("#hud");
if (hudRoot) hudRoot.appendChild(rankEl);
}
}
rankEl.textContent = `#${rank}`;
rankEl.classList.remove("hidden");
} else if (rankEl) {
rankEl.classList.add("hidden");
}
}
// --- V2 API ---
@@ -133,10 +154,18 @@ export class HUD {
chip.setAttribute("data-kind", kind);
chip.style.setProperty("--p", String(remaining));
const tier = list[i].tier;
if (kind === "shield") {
chip.textContent = String(stacks != null ? stacks : "");
chip.removeAttribute("data-tier");
} else {
chip.textContent = "";
const romanTier = tier > 1 ? (tier === 2 ? "II" : "III") : "";
chip.textContent = romanTier;
if (tier > 1) {
chip.setAttribute("data-tier", String(tier));
} else {
chip.removeAttribute("data-tier");
}
}
}
}
@@ -175,6 +204,20 @@ export class HUD {
if (hudRoot) hudRoot.appendChild(el);
}
// Remove previous flavor classes before adding new one.
el.classList.remove("banner-boss", "banner-bonus", "banner-perfect", "banner-wave");
const upper = String(text).toUpperCase();
if (upper.includes("BOSS")) {
el.classList.add("banner-boss");
} else if (upper.includes("BONUS")) {
el.classList.add("banner-bonus");
} else if (upper.includes("PERFECT")) {
el.classList.add("banner-perfect");
} else {
el.classList.add("banner-wave");
}
el.textContent = text;
el.classList.add("show");
@@ -217,3 +260,19 @@ export function wireV2Hud(game, hud) {
}
});
}
// wireV3Hud: subscribe to V3 waveStart (uses label) and waveCleared (perfect banner).
export function wireV3Hud(game, hud) {
if (!game || !hud) return;
if (typeof game.on !== "function") return;
game.on("waveStart", ({ wave, label } = {}) => {
if (typeof hud.setWaveBanner === "function") {
hud.setWaveBanner(label || `WAVE ${wave}`);
}
});
game.on("waveCleared", ({ perfect } = {}) => {
if (perfect && typeof hud.setWaveBanner === "function") {
hud.setWaveBanner("PERFECT WAVE!", 1.2);
}
});
}
+80
View File
@@ -0,0 +1,80 @@
// Top-5 leaderboard backed by localStorage. Migrates from legacy single-best key on first load.
const KEY = "omc-neon-arena.leaderboard";
const LEGACY_KEY = "omc-neon-arena.best";
const MAX_ENTRIES = 5;
export class Leaderboard {
constructor() {
this._entries = [];
this._load();
}
_load() {
try {
const raw = window.localStorage.getItem(KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
this._entries = parsed.filter(
(e) => e && typeof e.score === "number" && typeof e.date === "string"
);
}
} else {
// Migrate from legacy single-best key.
const legacyRaw = window.localStorage.getItem(LEGACY_KEY);
if (legacyRaw != null) {
const legacyScore = parseInt(legacyRaw, 10);
if (Number.isFinite(legacyScore) && legacyScore > 0) {
this._entries = [{ score: legacyScore, date: new Date().toISOString() }];
}
try { window.localStorage.removeItem(LEGACY_KEY); } catch { /* ignore */ }
this._save();
}
}
} catch {
this._entries = [];
}
}
_save() {
try {
window.localStorage.setItem(KEY, JSON.stringify(this._entries));
} catch { /* ignore storage errors */ }
}
// Returns the top score, or 0 if no entries.
top() {
return this._entries.length > 0 ? this._entries[0].score : 0;
}
// Returns up to 5 entries sorted descending by score.
top5() {
return this._entries.slice(0, MAX_ENTRIES);
}
// Submit a score. Returns { rank, improved, top }.
// rank is 1-based position in the leaderboard (1 = best), or null if not in top 5.
// improved is true if this score is a new personal best.
submit(score) {
if (typeof score !== "number" || !Number.isFinite(score)) {
return { rank: null, improved: false, top: this.top() };
}
const prevTop = this.top();
const improved = score > prevTop;
const newEntry = { score, date: new Date().toISOString() };
this._entries.push(newEntry);
this._entries.sort((a, b) => b.score - a.score);
if (this._entries.length > MAX_ENTRIES) {
this._entries = this._entries.slice(0, MAX_ENTRIES);
}
const rank = this._entries.findIndex((e) => e === newEntry);
const finalRank = rank === -1 ? null : rank + 1;
this._save();
return { rank: finalRank, improved, top: this.top() };
}
}
+38
View File
@@ -105,4 +105,42 @@ export class Menu {
const el = this.elements.pause;
if (el) el.classList.add("hidden");
}
renderLeaderboard(top5, currentScore) {
const ol = this.root.querySelector("[data-overlay-leaderboard]");
if (!ol) return;
ol.innerHTML = "";
const now = Date.now();
const entries = Array.isArray(top5) ? top5.slice(0, 5) : [];
for (let i = 0; i < 5; i++) {
const li = document.createElement("li");
const entry = entries[i];
if (!entry) {
li.classList.add("leaderboard__row--empty");
li.innerHTML = `<span class="leaderboard__rank">${i + 1}</span><span class="leaderboard__score">—</span><span class="leaderboard__date"></span>`;
ol.appendChild(li);
continue;
}
const rankBadge = `<span class="leaderboard__rank">${i + 1}</span>`;
const scoreEl = `<span class="leaderboard__score">${entry.score.toLocaleString()}</span>`;
const dateEl = `<span class="leaderboard__date">${_formatDate(entry.date, now)}</span>`;
li.innerHTML = rankBadge + scoreEl + dateEl;
if (i === 0) li.classList.add("leaderboard__row--rank-1");
const isCurrent = entry.score === currentScore && (now - new Date(entry.date).getTime()) < 5000;
if (isCurrent) li.classList.add("leaderboard__row--current");
ol.appendChild(li);
}
}
}
function _formatDate(dateVal, now) {
if (!dateVal) return "";
const ts = typeof dateVal === "number" ? dateVal : new Date(dateVal).getTime();
if (isNaN(ts)) return "";
const diffMs = now - ts;
const diffDays = Math.floor(diffMs / 86400000);
if (diffDays === 0) return "today";
if (diffDays === 1) return "1d ago";
if (diffDays < 30) return `${diffDays}d ago`;
return new Date(ts).toISOString().slice(0, 10);
}
+175
View File
@@ -723,6 +723,181 @@ body {
text-shadow: 0 0 10px var(--yellow);
}
/* ── V3: Buff chip tier badges ───────────────────────────────────────────── */
.buff-chip[data-tier="2"]::after,
.buff-chip[data-tier="3"]::after {
position: absolute;
top: 1px;
right: 1px;
font-size: 10px;
font-weight: bold;
line-height: 1;
color: var(--chip-color, var(--cyan));
text-shadow: 0 0 6px var(--chip-color, var(--cyan));
z-index: 2;
pointer-events: none;
}
.buff-chip[data-tier="2"]::after { content: "II"; }
.buff-chip[data-tier="3"]::after { content: "III"; }
/* ── V3: Wave banner flavors ─────────────────────────────────────────────── */
@keyframes banner-shake {
0%, 100% { transform: translate(-50%, -50%) translateX(0); }
20% { transform: translate(-50%, -50%) translateX(-4px); }
40% { transform: translate(-50%, -50%) translateX(4px); }
60% { transform: translate(-50%, -50%) translateX(-3px); }
80% { transform: translate(-50%, -50%) translateX(3px); }
}
@keyframes banner-gold-shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
@keyframes banner-rainbow {
0% { color: var(--cyan); text-shadow: 0 0 20px var(--cyan), 0 0 50px rgba(34,228,255,0.5); }
25% { color: var(--magenta); text-shadow: 0 0 20px var(--magenta), 0 0 50px rgba(255,61,240,0.5); }
50% { color: var(--yellow); text-shadow: 0 0 20px var(--yellow), 0 0 50px rgba(255,216,77,0.5); }
75% { color: var(--lime); text-shadow: 0 0 20px var(--lime), 0 0 50px rgba(125,255,109,0.5); }
100% { color: var(--cyan); text-shadow: 0 0 20px var(--cyan), 0 0 50px rgba(34,228,255,0.5); }
}
.wave-banner.banner-boss.show {
background: linear-gradient(90deg, var(--magenta), #ff1a1a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: wave-flash 1.6s ease forwards, banner-shake 0.4s ease 0.15s 2;
filter: drop-shadow(0 0 12px var(--magenta));
}
.wave-banner.banner-bonus.show {
background: linear-gradient(90deg, #ffd84d 20%, #fff5a0 50%, #ffd84d 80%);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: wave-flash 1.6s ease forwards, banner-gold-shimmer 1.2s linear infinite;
filter: drop-shadow(0 0 12px var(--yellow));
}
.wave-banner.banner-bonus.show::before,
.wave-banner.banner-bonus.show::after {
content: " ★";
font-size: 0.6em;
color: var(--yellow);
-webkit-text-fill-color: var(--yellow);
text-shadow: 0 0 8px var(--yellow);
}
.wave-banner.banner-bonus.show::before { content: "★ "; }
.wave-banner.banner-perfect.show {
animation: wave-flash 1.6s ease forwards, banner-rainbow 0.8s linear infinite;
font-size: clamp(3.5rem, 9vw, 5.8rem);
}
.wave-banner.banner-wave.show {
color: var(--cyan);
text-shadow: 0 0 20px var(--cyan), 0 0 50px rgba(34,228,255,0.5);
}
/* ── V3: Leaderboard ─────────────────────────────────────────────────────── */
.leaderboard {
list-style: none;
margin: 1rem 0 0.8rem;
padding: 0;
width: 100%;
display: flex;
flex-direction: column;
gap: 4px;
}
.leaderboard li {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.3rem 0.5rem;
border: 1px solid rgba(34, 228, 255, 0.1);
border-radius: 3px;
background: rgba(0, 0, 0, 0.3);
font-size: 0.78rem;
transition: transform 0.15s, box-shadow 0.15s;
color: var(--dim);
}
.leaderboard li.leaderboard__row--empty {
opacity: 0.35;
border-color: rgba(34, 228, 255, 0.05);
}
.leaderboard__rank {
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(34, 228, 255, 0.12);
border: 1px solid rgba(34, 228, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
font-weight: bold;
color: var(--cyan);
flex-shrink: 0;
}
.leaderboard__score {
flex: 1;
text-align: right;
font-family: "Courier New", Courier, monospace;
font-variant-numeric: tabular-nums;
color: var(--text);
font-weight: bold;
letter-spacing: 0.04em;
}
.leaderboard__date {
font-size: 0.65rem;
color: var(--dim);
min-width: 5ch;
text-align: right;
opacity: 0.75;
}
.leaderboard li.leaderboard__row--current {
border-color: var(--cyan);
box-shadow: 0 0 8px rgba(34, 228, 255, 0.35), inset 0 0 6px rgba(34, 228, 255, 0.08);
color: var(--text);
transform: scale(1.04);
background: rgba(34, 228, 255, 0.06);
}
.leaderboard li.leaderboard__row--current .leaderboard__rank {
background: rgba(34, 228, 255, 0.25);
border-color: var(--cyan);
box-shadow: 0 0 6px var(--cyan);
}
.leaderboard li.leaderboard__row--rank-1 .leaderboard__rank {
background: rgba(255, 216, 77, 0.2);
border-color: var(--yellow);
color: var(--yellow);
box-shadow: 0 0 8px rgba(255, 216, 77, 0.5);
}
.leaderboard li.leaderboard__row--rank-1 .leaderboard__score {
color: var(--yellow);
text-shadow: 0 0 6px var(--yellow);
}
/* gold glow for rank-1 that is also current */
.leaderboard li.leaderboard__row--rank-1.leaderboard__row--current {
border-color: var(--yellow);
box-shadow: 0 0 12px rgba(255, 216, 77, 0.5), inset 0 0 8px rgba(255, 216, 77, 0.1);
background: rgba(255, 216, 77, 0.06);
}
/* ── Responsive ──────────────────────────────────────────────────────────── */
@media (max-width: 640px) {
.overlay__panel {