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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './types.js';
|
||||
export * from './theme.js';
|
||||
export * from './audio.js';
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user