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