diff --git a/oh-my-claudecode/DESIGN_V3.md b/oh-my-claudecode/DESIGN_V3.md new file mode 100644 index 0000000..24ff0a1 --- /dev/null +++ b/oh-my-claudecode/DESIGN_V3.md @@ -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 +" 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. diff --git a/oh-my-claudecode/index.html b/oh-my-claudecode/index.html index 6a0e249..ee059e0 100644 --- a/oh-my-claudecode/index.html +++ b/oh-my-claudecode/index.html @@ -65,6 +65,7 @@

You Died

Score: 0

Best: 0

+
    diff --git a/oh-my-claudecode/src/game/bonus.js b/oh-my-claudecode/src/game/bonus.js new file mode 100644 index 0000000..87af1c4 --- /dev/null +++ b/oh-my-claudecode/src/game/bonus.js @@ -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); + } + } +} diff --git a/oh-my-claudecode/src/game/buffs.js b/oh-my-claudecode/src/game/buffs.js index a0b9c44..b5b83f5 100644 --- a/oh-my-claudecode/src/game/buffs.js +++ b/oh-my-claudecode/src/game/buffs.js @@ -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; } diff --git a/oh-my-claudecode/src/game/collision.js b/oh-my-claudecode/src/game/collision.js index f829a80..ba9b630 100644 --- a/oh-my-claudecode/src/game/collision.js +++ b/oh-my-claudecode/src/game/collision.js @@ -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. diff --git a/oh-my-claudecode/src/game/drones.js b/oh-my-claudecode/src/game/drones.js new file mode 100644 index 0000000..378530a --- /dev/null +++ b/oh-my-claudecode/src/game/drones.js @@ -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(); + } +} diff --git a/oh-my-claudecode/src/game/game.js b/oh-my-claudecode/src/game/game.js index 17cc7f1..71386bf 100644 --- a/oh-my-claudecode/src/game/game.js +++ b/oh-my-claudecode/src/game/game.js @@ -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); } diff --git a/oh-my-claudecode/src/game/hitstop.js b/oh-my-claudecode/src/game/hitstop.js new file mode 100644 index 0000000..64d6960 --- /dev/null +++ b/oh-my-claudecode/src/game/hitstop.js @@ -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; diff --git a/oh-my-claudecode/src/game/medals.js b/oh-my-claudecode/src/game/medals.js new file mode 100644 index 0000000..8f9f9eb --- /dev/null +++ b/oh-my-claudecode/src/game/medals.js @@ -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(); + } +} diff --git a/oh-my-claudecode/src/game/meteor.js b/oh-my-claudecode/src/game/meteor.js new file mode 100644 index 0000000..a0a9766 --- /dev/null +++ b/oh-my-claudecode/src/game/meteor.js @@ -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(); + } +} diff --git a/oh-my-claudecode/src/game/player.js b/oh-my-claudecode/src/game/player.js index e0c3d81..4e83186 100644 --- a/oh-my-claudecode/src/game/player.js +++ b/oh-my-claudecode/src/game/player.js @@ -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, diff --git a/oh-my-claudecode/src/game/powerup.js b/oh-my-claudecode/src/game/powerup.js index 1811904..e70bb7f 100644 --- a/oh-my-claudecode/src/game/powerup.js +++ b/oh-my-claudecode/src/game/powerup.js @@ -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; } diff --git a/oh-my-claudecode/src/game/tiers.js b/oh-my-claudecode/src/game/tiers.js new file mode 100644 index 0000000..d8b61ed --- /dev/null +++ b/oh-my-claudecode/src/game/tiers.js @@ -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; +} diff --git a/oh-my-claudecode/src/game/waves.js b/oh-my-claudecode/src/game/waves.js index 5501661..936d5cd 100644 --- a/oh-my-claudecode/src/game/waves.js +++ b/oh-my-claudecode/src/game/waves.js @@ -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); + } } } } diff --git a/oh-my-claudecode/src/main.js b/oh-my-claudecode/src/main.js index 80629cd..579f62b 100644 --- a/oh-my-claudecode/src/main.js +++ b/oh-my-claudecode/src/main.js @@ -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(() => { diff --git a/oh-my-claudecode/src/ui/hud.js b/oh-my-claudecode/src/ui/hud.js index 13c3b49..8983b52 100644 --- a/oh-my-claudecode/src/ui/hud.js +++ b/oh-my-claudecode/src/ui/hud.js @@ -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); + } + }); +} diff --git a/oh-my-claudecode/src/ui/leaderboard.js b/oh-my-claudecode/src/ui/leaderboard.js new file mode 100644 index 0000000..42d228d --- /dev/null +++ b/oh-my-claudecode/src/ui/leaderboard.js @@ -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() }; + } +} diff --git a/oh-my-claudecode/src/ui/menu.js b/oh-my-claudecode/src/ui/menu.js index 752fd3c..4de60f5 100644 --- a/oh-my-claudecode/src/ui/menu.js +++ b/oh-my-claudecode/src/ui/menu.js @@ -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 = `${i + 1}`; + ol.appendChild(li); + continue; + } + const rankBadge = `${i + 1}`; + const scoreEl = `${entry.score.toLocaleString()}`; + const dateEl = `${_formatDate(entry.date, now)}`; + 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); } diff --git a/oh-my-claudecode/styles/main.css b/oh-my-claudecode/styles/main.css index 8ccf271..932ee92 100644 --- a/oh-my-claudecode/styles/main.css +++ b/oh-my-claudecode/styles/main.css @@ -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 {