QR code & clickable URL on TV lobby, BOOST card-only, remove METEOR_STRIKE/TELEPORT, fix vite base paths for production, Dockerfile with frontend builds, Makefile with podman

This commit is contained in:
2026-06-24 20:32:34 +02:00
parent 5e2ab43ca3
commit 8531864b25
30 changed files with 3036 additions and 629 deletions
+436
View File
@@ -0,0 +1,436 @@
// Shared audio engine — Web Audio API based, no asset files.
// Generates short procedural sounds for UI feedback and game events.
// `navigator.vibrate` is used opportunistically (Android only; iOS Safari no-op).
export type SoundName =
| 'tap' // soft button tap
| 'cardSelect' // card hover
| 'cardUse' // card activated
| 'queueRemove' // action removed from queue
| 'go' // submit / launch
| 'countdown' // 3-2-1 beep
| 'warning' // low-timer ping
| 'eliminated' // you were killed
| 'win' // you won
| 'round' // new round / planning start
| 'phaseChange' // planning → executing
| 'move' // ship moves
| 'turn' // ship turns
| 'collision' // obstacle hit
| 'explosion' // ship destroyed
| 'deathLine' // wall advances
| 'fanfare' // winner reveal
| 'shield' // shield bubble
| 'meteor' // meteor strikes
| 'boost' // boost forward
| 'emp' // EMP shock
| 'jump' // jump / teleport
| 'mine' // mine drop
| 'phaseShift' // phase shift activate
| 'turn_sweep' // smoother 180° rotation
| 'move_punch'; // extra low-end for MOVE_FORWARD hit
const HAPTIC_PATTERNS: Partial<Record<SoundName, number | number[]>> = {
tap: 10,
cardSelect: 5,
cardUse: 15,
queueRemove: 8,
go: [25, 10, 40],
countdown: 15,
warning: [10, 30, 10],
eliminated: [200, 80, 200],
win: [60, 30, 60, 30, 120],
round: 20,
phaseChange: 25,
move: 5,
turn: 5,
collision: [30, 20, 30],
explosion: [120, 60, 80],
deathLine: 25,
fanfare: [40, 30, 40, 30, 40, 30, 160],
shield: 12,
meteor: [40, 20, 60],
boost: 18,
emp: [25, 15, 35],
jump: 12,
mine: 18,
phaseShift: 14,
turn_sweep: 8,
move_punch: 6,
};
const STORAGE_KEY = 'spacerace:audio-muted';
export class AudioEngine {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private muted: boolean;
constructor() {
this.muted = false;
try {
this.muted = localStorage.getItem(STORAGE_KEY) === '1';
} catch { /* localStorage might be unavailable */ }
}
/**
* Must be called on a user gesture (click/touch) to satisfy browser
* autoplay policy. Safe to call multiple times.
*/
unlock(): void {
if (this.ctx) {
if (this.ctx.state === 'suspended') this.ctx.resume();
return;
}
const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
const Ctor = w.AudioContext || w.webkitAudioContext;
if (!Ctor) return;
this.ctx = new Ctor();
this.master = this.ctx.createGain();
this.master.gain.value = this.muted ? 0 : 0.5;
this.master.connect(this.ctx.destination);
}
setMuted(muted: boolean): void {
this.muted = muted;
if (this.master) this.master.gain.value = muted ? 0 : 0.5;
try { localStorage.setItem(STORAGE_KEY, muted ? '1' : '0'); } catch { /* ignore */ }
}
isMuted(): boolean { return this.muted; }
/**
* Play sound. No-op if the audio context hasn't been unlocked yet.
*/
play(name: SoundName): void {
if (!this.ctx || !this.master) return;
const now = this.ctx.currentTime;
switch (name) {
case 'tap': this.synthTap(now, 660, 0.04); break;
case 'cardSelect': this.synthTap(now, 880, 0.05, 'sine'); break;
case 'cardUse': this.synthSweep(now, 520, 880, 0.1, 'sine'); break;
case 'queueRemove': this.synthSweep(now, 300, 180, 0.08, 'square'); break;
case 'go': this.synthGo(now); break;
case 'countdown': this.synthTap(now, 1200, 0.06, 'square'); break;
case 'warning': this.synthWarning(now); break;
case 'eliminated': this.synthSweep(now, 220, 60, 0.5, 'sawtooth'); break;
case 'win': this.synthFanfare(now, [523.25, 659.25, 783.99], 0.9); break;
case 'round': this.synthTap(now, 660, 0.18, 'triangle'); break;
case 'phaseChange': this.synthTap(now, 330, 0.22, 'triangle'); break;
case 'move': this.synthMove(now); break;
case 'move_punch': this.synthNoise(now, 0.08, 120); this.synthTap(now, 140, 0.06, 'triangle'); break;
case 'turn': this.synthTap(now, 880, 0.04, 'triangle'); break;
case 'turn_sweep': this.synthSweep(now, 700, 1100, 0.18, 'sine'); break;
case 'collision': this.synthNoise(now, 0.22, 200); break;
case 'explosion': this.synthExplosion(now); break;
case 'deathLine': this.synthSweep(now, 180, 90, 0.35, 'sawtooth'); break;
case 'fanfare': this.synthFanfare(now, [523.25, 659.25, 783.99, 1046.5], 1.2); break;
case 'shield': this.synthShield(now); break;
case 'meteor': this.synthMeteor(now); break;
case 'boost': this.synthBoost(now); break;
case 'emp': this.synthEmp(now); break;
case 'jump': this.synthJump(now); break;
case 'mine': this.synthMine(now); break;
case 'phaseShift': this.synthPhaseShift(now); break;
}
}
/**
* Sustained engine sound for a ship move. Plays for the full duration so
* the move feels powerful. `pitch` raises the engine tone (1 = normal,
* 1.4 = BOOST). Schedules a matching `settle` thump at the end.
*/
playEngine(durationSec: number, opts: { pitch?: number; peak?: number; settle?: boolean } = {}): void {
if (!this.ctx || !this.master) return;
const pitch = opts.pitch ?? 1;
const peak = opts.peak ?? 0.5;
this.synthEngine(this.ctx.currentTime, durationSec, pitch, peak);
if (opts.settle !== false) {
// Schedule settle slightly before the engine fades out
const settleAt = this.ctx.currentTime + Math.max(0, durationSec - 0.12);
this.synthSettle(settleAt, pitch);
}
}
vibrate(name: SoundName): void {
const pattern = HAPTIC_PATTERNS[name];
if (!pattern) return;
if (typeof navigator === 'undefined') return;
const nav = navigator as Navigator & { vibrate?: (p: number | number[]) => boolean };
if (typeof nav.vibrate !== 'function') return;
try { nav.vibrate(pattern); } catch { /* ignore */ }
}
/** Combined play + vibrate, single call for most feedback. */
feedback(name: SoundName, opts: { sound?: boolean; haptic?: boolean } = {}): void {
if (opts.sound !== false) this.play(name);
if (opts.haptic !== false) this.vibrate(name);
}
// ── Synth primitives ─────────────────────────────────────────────
private envGain(t: number, attack: number, hold: number, release: number, peak = 0.6): GainNode {
if (!this.ctx || !this.master) throw new Error('AudioContext not initialized');
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(peak, t + attack);
g.gain.setValueAtTime(peak, t + attack + hold);
g.gain.exponentialRampToValueAtTime(0.0001, t + attack + hold + release);
g.connect(this.master);
return g;
}
private synthTap(t: number, freq: number, duration: number, type: OscillatorType = 'square'): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
const g = this.envGain(t, 0.005, duration * 0.5, duration * 0.5, 0.45);
osc.connect(g);
osc.start(t);
osc.stop(t + duration + 0.02);
}
private synthSweep(t: number, fromHz: number, toHz: number, duration: number, type: OscillatorType): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(fromHz, t);
osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 20), t + duration);
const g = this.envGain(t, 0.01, duration * 0.4, duration * 0.6, 0.45);
osc.connect(g);
osc.start(t);
osc.stop(t + duration + 0.02);
}
private synthNoise(t: number, duration: number, freqHint: number): void {
if (!this.ctx) return;
const sampleRate = this.ctx.sampleRate;
const buffer = this.ctx.createBuffer(1, Math.max(1, Math.floor(sampleRate * duration)), sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = freqHint;
filter.Q.value = 1;
const g = this.envGain(t, 0.005, duration * 0.3, duration * 0.7, 0.55);
src.connect(filter);
filter.connect(g);
src.start(t);
src.stop(t + duration + 0.02);
}
private synthExplosion(t: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(160, t);
osc.frequency.exponentialRampToValueAtTime(30, t + 0.4);
const og = this.envGain(t, 0.005, 0.05, 0.4, 0.5);
osc.connect(og);
osc.start(t);
osc.stop(t + 0.5);
this.synthNoise(t, 0.45, 300);
}
private synthWarning(t: number): void {
if (!this.ctx) return;
for (let i = 0; i < 2; i++) {
const start = t + i * 0.16;
const osc = this.ctx.createOscillator();
osc.type = 'square';
osc.frequency.setValueAtTime(880, start);
const g = this.envGain(start, 0.003, 0.04, 0.06, 0.4);
osc.connect(g);
osc.start(start);
osc.stop(start + 0.12);
}
}
private synthGo(t: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(180, t);
osc.frequency.exponentialRampToValueAtTime(900, t + 0.22);
const g = this.envGain(t, 0.005, 0.05, 0.18, 0.5);
osc.connect(g);
osc.start(t);
osc.stop(t + 0.3);
this.synthNoise(t, 0.25, 1500);
}
private synthFanfare(t: number, freqs: number[], duration: number): void {
if (!this.ctx) return;
for (let i = 0; i < freqs.length; i++) {
const start = t + i * 0.09;
const osc = this.ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freqs[i], start);
const g = this.envGain(start, 0.01, 0.05, duration * 0.7, 0.45);
osc.connect(g);
osc.start(start);
osc.stop(start + duration + 0.1);
}
}
// Card-specific synths ───────────────────────────────────────────
private synthMove(t: number): void {
this.synthNoise(t, 0.14, 500);
this.runOsc(t, 'sawtooth', 90, 90, 0.25, 0.08, 0.35);
}
private synthShield(t: number): void {
this.runOsc(t, 'sine', 660, 1320, 0.02, 0.1, 0.35);
this.runOsc(t + 0.04, 'triangle', 1320, 1320, 0.005, 0.18, 0.18);
}
private synthMeteor(t: number): void {
this.runOsc(t, 'sawtooth', 880, 100, 0.5, 0, 0.45);
// Impact noise at t + 0.45
this.synthNoise(t + 0.45, 0.4, 250);
}
private synthBoost(t: number): void {
this.runOsc(t, 'sawtooth', 200, 1100, 0.005, 0.18, 0.45);
this.synthNoise(t, 0.25, 1500);
}
private synthEmp(t: number): void {
for (let i = 0; i < 3; i++) {
const start = t + i * 0.08;
this.runOsc(start, 'square', 1400, 200, 0.005, 0.04, 0.35);
}
this.runOsc(t + 0.24, 'sawtooth', 90, 40, 0.01, 0.2, 0.3);
}
private synthJump(t: number): void {
this.runOsc(t, 'sine', 440, 1320, 0.01, 0.05, 0.4);
this.runOsc(t + 0.08, 'triangle', 660, 220, 0.04, 0.1, 0.18);
}
private synthMine(t: number): void {
this.runOsc(t, 'square', 1500, 1500, 0.002, 0.02, 0.3);
this.runOsc(t + 0.04, 'sine', 220, 60, 0.01, 0.18, 0.4);
}
private synthPhaseShift(t: number): void {
if (!this.ctx) return;
const osc1 = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
osc1.type = 'sine'; osc2.type = 'sine';
osc1.frequency.setValueAtTime(330, t);
osc2.frequency.setValueAtTime(335, t);
osc1.frequency.exponentialRampToValueAtTime(880, t + 0.3);
osc2.frequency.exponentialRampToValueAtTime(890, t + 0.3);
const g = this.envGain(t, 0.02, 0.1, 0.25, 0.3);
osc1.connect(g); osc2.connect(g);
osc1.start(t); osc2.start(t);
osc1.stop(t + 0.45); osc2.stop(t + 0.45);
}
/**
* One-shot oscillator with frequency sweep + envelope. Auto-stops after
* attack + hold + release + 50ms.
*/
private runOsc(t: number, type: OscillatorType, fromHz: number, toHz: number, attack: number, hold: number, peak: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(fromHz, t);
if (toHz !== fromHz) {
osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 20), t + attack + hold);
}
const g = this.envGain(t, attack, hold, 0.2, peak);
osc.connect(g);
osc.start(t);
const total = attack + hold + 0.25;
osc.stop(t + total);
}
/**
* Sustained engine drone that lasts the full `durationSec`. Layered:
* - low sawtooth (throat)
* - filtered noise (rumble)
* - mid triangle (whine, frequency follows `pitch`)
* Envelope: short attack, sustain, soft release.
*/
private synthEngine(t: number, durationSec: number, pitch: number, peak: number): void {
if (!this.ctx) return;
const dur = Math.max(0.2, durationSec);
const release = 0.18;
const sustain = Math.max(0, dur - release - 0.02);
// Throat: low sawtooth (60-90Hz)
const osc1 = this.ctx.createOscillator();
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(60 * pitch, t);
osc1.frequency.exponentialRampToValueAtTime(90 * pitch, t + 0.08);
osc1.frequency.exponentialRampToValueAtTime(70 * pitch, t + dur);
const g1 = this.envGain(t, 0.015, sustain, release, peak * 0.55);
osc1.connect(g1);
osc1.start(t);
osc1.stop(t + dur + 0.05);
// Whine: mid triangle, slightly detuned
const osc2 = this.ctx.createOscillator();
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(180 * pitch, t);
osc2.frequency.exponentialRampToValueAtTime(280 * pitch, t + 0.06);
osc2.frequency.exponentialRampToValueAtTime(220 * pitch, t + dur);
const g2 = this.envGain(t, 0.02, sustain, release, peak * 0.4);
osc2.connect(g2);
osc2.start(t);
osc2.stop(t + dur + 0.05);
// Rumble: filtered noise
const sampleRate = this.ctx.sampleRate;
const buffer = this.ctx.createBuffer(1, Math.max(1, Math.floor(sampleRate * dur)), sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 350 * pitch;
filter.Q.value = 1.2;
const g3 = this.envGain(t, 0.03, sustain, release, peak * 0.5);
src.connect(filter);
filter.connect(g3);
src.start(t);
src.stop(t + dur + 0.05);
}
/**
* Settling thump at the end of an engine sound — like a ship coming to
* rest on its struts. Soft, low, brief.
*/
private synthSettle(t: number, pitch: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = 'sine';
const startHz = 220 * pitch;
osc.frequency.setValueAtTime(startHz, t);
osc.frequency.exponentialRampToValueAtTime(Math.max(80, startHz / 3), t + 0.12);
const g = this.envGain(t, 0.003, 0.04, 0.1, 0.4);
osc.connect(g);
osc.start(t);
osc.stop(t + 0.25);
// Tiny click on top
const click = this.ctx.createOscillator();
click.type = 'square';
click.frequency.setValueAtTime(900 * pitch, t);
const g2 = this.envGain(t, 0.001, 0.008, 0.04, 0.18);
click.connect(g2);
click.start(t);
click.stop(t + 0.08);
}
}
+2
View File
@@ -1 +1,3 @@
export * from './types.js';
export * from './theme.js';
export * from './audio.js';
+67
View File
@@ -0,0 +1,67 @@
// Centralized design tokens shared between TV and Controller.
// TV (Phaser) reads the numeric values directly;
// Controller mirrors them into CSS custom properties in style.css.
export const COLORS = {
// Backgrounds
bgDeep: 0x050514, // canvas / page base
bgPanel: 0x0a0a24, // raised surfaces
bgPanelAlt: 0x111133, // input fields
bgGlass: 0x0d0d28, // translucent overlays
// Accent / brand
primary: 0x00e5ff, // cyan — main brand
primaryDim: 0x007a99,
accent: 0xff2bd6, // magenta — CTAs, "GO!"
accentDim: 0x7a1465,
// Semantic
success: 0x00ff9c, // AP full, alive
warning: 0xffcc00, // mid-timer
danger: 0xff3b6b, // low timer, death line
neutral: 0x6a6a8a,
// Text
text: 0xffffff,
textDim: 0xa0a0c8,
textMuted: 0x6a6a8a,
// Obstacles
asteroid: 0x7a7a8c,
meteor: 0xff6a1a,
mine: 0xffaa00,
// Player colors (used by ship sprites and controller accents)
player: [0x00e5ff, 0xff3b6b, 0x00ff9c, 0xffaa00, 0xff2bd6, 0xfff200],
} as const;
export const PLAYER_HEX = COLORS.player.map((c) => `#${c.toString(16).padStart(6, '0')}`);
export const FONTS = {
display: '"Orbitron", "Rajdhani", system-ui, sans-serif',
body: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
mono: '"JetBrains Mono", "Fira Code", monospace',
} as const;
export const SPACING = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
} as const;
export const RADII = {
sm: 6,
md: 12,
lg: 20,
pill: 999,
} as const;
// Used by TV HUD/scene tween helpers
export const TIMING = {
fast: 150,
normal: 250,
slow: 600,
} as const;
+4 -19
View File
@@ -58,13 +58,11 @@ export interface Ship {
// ── Cards ──
export type CardType =
| 'METEOR_STRIKE'
| 'SHIELD'
| 'BOOST'
| 'EMP'
| 'JUMP'
| 'MINE'
| 'TELEPORT'
| 'PHASE_SHIFT';
export interface CardDef {
@@ -75,12 +73,6 @@ export interface CardDef {
}
export const CARD_DEFS: Record<CardType, CardDef> = {
METEOR_STRIKE: {
type: 'METEOR_STRIKE',
name: 'Meteor Strike',
description: 'Summon a meteor on any tile within 3 tiles of your ship',
apCost: 2,
},
SHIELD: {
type: 'SHIELD',
name: 'Shield',
@@ -111,12 +103,6 @@ export const CARD_DEFS: Record<CardType, CardDef> = {
description: 'Drop a mine on your current tile. Explodes next round.',
apCost: 1,
},
TELEPORT: {
type: 'TELEPORT',
name: 'Teleport',
description: 'Swap positions with any player within 5 tiles',
apCost: 3,
},
PHASE_SHIFT: {
type: 'PHASE_SHIFT',
name: 'Phase Shift',
@@ -126,12 +112,11 @@ export const CARD_DEFS: Record<CardType, CardDef> = {
};
// ── Actions ──
export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'BOOST' | 'CARD';
export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'CARD';
export interface Action {
type: ActionType;
card?: CardType; // when type === 'CARD'
target?: Position; // for targeted cards (METEOR_STRIKE, TELEPORT)
}
export function actionApCost(action: Action): number {
@@ -140,7 +125,6 @@ export function actionApCost(action: Action): number {
case 'TURN_LEFT': return 1;
case 'TURN_RIGHT': return 1;
case 'TURN_180': return 2;
case 'BOOST': return 2;
case 'CARD':
return action.card ? CARD_DEFS[action.card].apCost : 0;
default: return 0;
@@ -227,11 +211,12 @@ export interface MobileToServerEvents {
// Execution updates
export type ShipUpdate =
| { type: 'move'; shipId: string; from: Position; to: Position }
| { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' }
| { type: 'turn'; shipId: string; direction: Direction }
| { type: 'eliminated'; shipId: string; position: Position; reason: string }
| { type: 'collision'; shipId: string; position: Position }
| { type: 'shield_used'; shipId: string };
| { type: 'shield_used'; shipId: string }
| { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string };
export type GridUpdate =
| { type: 'tile_change'; position: Position; tile: TileType }