-
Lobby
-
Room: ----
+
Mission Lobby
+
+ Room Code
+ ----
+
-
Players
+
Crew Manifest
Waiting for host to start...
diff --git a/controller/src/main.ts b/controller/src/main.ts
index 984d9b4..a161e22 100644
--- a/controller/src/main.ts
+++ b/controller/src/main.ts
@@ -4,10 +4,11 @@ import { PlanningScreen } from './screens/PlanningScreen.js';
import { WaitingScreen } from './screens/WaitingScreen.js';
import { SpectatorScreen } from './screens/SpectatorScreen.js';
import { LobbyScreen } from './screens/LobbyScreen.js';
-import { Action } from '@spacerace/shared';
+import { Action, AudioEngine } from '@spacerace/shared';
-class App {
+export class App {
socket: ControllerSocket;
+ audio: AudioEngine;
private currentScreen: string = 'join';
isHost: boolean = false;
roomCode: string = '';
@@ -15,10 +16,37 @@ class App {
constructor() {
this.socket = connectControllerSocket();
+ this.audio = new AudioEngine();
+ this.initMuteToggle();
this.initScreens();
this.initSocketEvents();
}
+ private initMuteToggle(): void {
+ const btn = document.getElementById('audio-toggle');
+ if (!btn) return;
+ if (this.audio.isMuted()) document.body.classList.add('audio-muted');
+
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.audio.unlock();
+ const muted = !this.audio.isMuted();
+ this.audio.setMuted(muted);
+ document.body.classList.toggle('audio-muted', muted);
+ this.audio.feedback('tap', { sound: false });
+ this.audio.vibrate(muted ? 'warning' : 'go');
+ });
+
+ // Unlock audio on the first user gesture (anywhere on the page)
+ const unlock = (): void => {
+ this.audio.unlock();
+ window.removeEventListener('pointerdown', unlock);
+ window.removeEventListener('touchstart', unlock);
+ };
+ window.addEventListener('pointerdown', unlock, { once: true });
+ window.addEventListener('touchstart', unlock, { once: true });
+ }
+
private initScreens(): void {
new JoinScreen(this);
new LobbyScreen(this);
@@ -36,36 +64,41 @@ class App {
this.isHost = data.isHost;
this.roomCode = data.roomCode;
this.playerId = data.playerId;
+ this.audio.feedback('round');
this.showScreen('lobby');
});
this.socket.on('gameStarting', () => {
+ this.audio.feedback('phaseChange');
this.showScreen('planning');
});
this.socket.on('planningRequest', (data) => {
+ this.audio.feedback('round');
this.showScreen('planning');
- // Pass data to planning screen
document.dispatchEvent(new CustomEvent('planningRequest', { detail: data }));
});
- this.socket.on('executionTick', (data) => {
+ this.socket.on('executionTick', () => {
+ this.audio.feedback('phaseChange');
this.showScreen('waiting');
- const msgEl = document.getElementById('waiting-message');
- if (msgEl && data.messages.length > 0) {
- msgEl.textContent = data.messages.join(' | ');
- }
});
this.socket.on('executionComplete', (data) => {
if (data.playerView.alive) {
this.showScreen('planning');
} else {
+ this.audio.feedback('eliminated');
this.showScreen('spectator');
}
});
this.socket.on('gameOver', (data) => {
+ if (data.winnerId === this.playerId) {
+ this.audio.feedback('win');
+ } else {
+ this.audio.feedback('eliminated');
+ }
this.showScreen('spectator');
});
}
diff --git a/controller/src/screens/JoinScreen.ts b/controller/src/screens/JoinScreen.ts
index dc358ca..d4c9329 100644
--- a/controller/src/screens/JoinScreen.ts
+++ b/controller/src/screens/JoinScreen.ts
@@ -21,21 +21,31 @@ export class JoinScreen {
roomInput.value = roomParam.toUpperCase();
}
+ // Small feedback on input focus
+ [roomInput, nameInput].forEach((input) => {
+ input.addEventListener('focus', () => this.app.audio.feedback('cardSelect'));
+ });
+
joinBtn.addEventListener('click', () => {
+ this.app.audio.unlock();
+ this.app.audio.feedback('go');
+
const roomCode = roomInput.value.trim().toUpperCase();
const playerName = nameInput.value.trim();
if (!roomCode || roomCode.length !== 4) {
errorEl.textContent = 'Enter a 4-character room code';
+ this.app.audio.feedback('warning');
return;
}
if (!playerName) {
errorEl.textContent = 'Enter your name';
+ this.app.audio.feedback('warning');
return;
}
errorEl.textContent = '';
- joinBtn.textContent = 'Joining...';
+ joinBtn.textContent = 'Joiningβ¦';
joinBtn.disabled = true;
this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => {
@@ -43,8 +53,10 @@ export class JoinScreen {
joinBtn.disabled = false;
if (!res.ok) {
errorEl.textContent = res.error || 'Failed to join';
+ this.app.audio.feedback('warning');
}
});
});
}
}
+
diff --git a/controller/src/screens/LobbyScreen.ts b/controller/src/screens/LobbyScreen.ts
index f4b8252..679bbb6 100644
--- a/controller/src/screens/LobbyScreen.ts
+++ b/controller/src/screens/LobbyScreen.ts
@@ -1,4 +1,7 @@
import { App } from '../main.js';
+import { PLAYER_HEX } from '@spacerace/shared';
+
+const AVATAR_LETTERS = ['β', 'β²', 'β', 'β ', 'β
', 'β¬’'];
export class LobbyScreen {
private app: App;
@@ -10,12 +13,14 @@ export class LobbyScreen {
this.createStartButton();
app.socket.on('roomJoined', (data) => {
- document.getElementById('lobby-room')!.textContent = `Room: ${data.roomCode}`;
+ const roomEl = document.getElementById('lobby-room');
+ if (roomEl) roomEl.textContent = data.roomCode;
this.updatePlayerList(data.players);
this.updateHostUI();
});
app.socket.on('playerJoined', (data) => {
+ this.app.audio.feedback('cardSelect');
this.appendPlayer(data.playerId, data.name);
this.updateHostUI();
});
@@ -35,20 +40,22 @@ export class LobbyScreen {
private createStartButton(): void {
const div = document.createElement('div');
div.id = 'host-controls';
- div.style.cssText = 'margin-top: 16px; text-align: center; display: none;';
+ div.style.cssText = 'margin-top: 16px; text-align: center; display: none; width: 100%; max-width: 360px;';
this.startBtn = document.createElement('button');
this.startBtn.textContent = 'π Start Race';
- this.startBtn.style.cssText = 'padding: 14px 32px; font-size: 20px; background: #00ff88; color: #000; border: none; border-radius: 8px; font-weight: bold; cursor: pointer;';
+ this.startBtn.className = 'start-btn-neon';
this.startBtn.addEventListener('click', () => {
if (!this.startBtn) return;
- this.startBtn.textContent = 'Starting...';
+ this.app.audio.feedback('go');
+ this.startBtn.textContent = 'Launchingβ¦';
this.startBtn.disabled = true;
this.app.socket.emit('mobile:startGame', { roomCode: this.app.roomCode }, (res) => {
if (!res.ok) {
this.startBtn!.textContent = 'π Start Race';
this.startBtn!.disabled = false;
+ this.app.audio.feedback('warning');
alert(res.error || 'Cannot start');
}
});
@@ -56,7 +63,6 @@ export class LobbyScreen {
div.appendChild(this.startBtn);
- // Insert before the waiting text
const lobbyScreen = document.getElementById('screen-lobby')!;
const waitingEl = document.getElementById('lobby-waiting')!;
lobbyScreen.insertBefore(div, waitingEl);
@@ -72,16 +78,11 @@ export class LobbyScreen {
const playerCount = document.getElementById('lobby-player-list')!.children.length;
if (this.startBtn) {
this.startBtn.disabled = playerCount < 2;
- if (playerCount < 2) {
- this.startBtn.style.opacity = '0.5';
- } else {
- this.startBtn.style.opacity = '1';
- }
}
} else {
div.style.display = 'none';
waitingEl.style.display = 'block';
- waitingEl.textContent = 'Waiting for host to start...';
+ waitingEl.textContent = 'Waiting for host to startβ¦';
}
}
@@ -95,11 +96,38 @@ export class LobbyScreen {
private appendPlayer(id: string, name: string): void {
const list = document.getElementById('lobby-player-list')!;
+ const colorIndex = list.children.length;
+ const color = PLAYER_HEX[colorIndex % PLAYER_HEX.length];
+
const li = document.createElement('li');
- const hostId = this.app.socket.data?.hostPlayerId;
- const crown = (this.app.isHost && id === this.app.playerId) ? ' π' : '';
- li.textContent = name + crown;
li.id = `player-${id}`;
+
+ const avatar = document.createElement('span');
+ avatar.className = 'player-avatar';
+ avatar.style.backgroundColor = color;
+ avatar.style.color = color;
+ avatar.textContent = AVATAR_LETTERS[colorIndex % AVATAR_LETTERS.length];
+ avatar.style.display = 'flex';
+ avatar.style.alignItems = 'center';
+ avatar.style.justifyContent = 'center';
+ avatar.style.fontSize = '12px';
+ avatar.style.color = '#00121a';
+ avatar.style.boxShadow = `0 0 12px ${color}`;
+
+ const nameSpan = document.createElement('span');
+ nameSpan.textContent = name;
+
+ const isMe = id === this.app.playerId;
+ li.appendChild(avatar);
+ li.appendChild(nameSpan);
+
+ if (isMe) {
+ const tag = document.createElement('span');
+ tag.style.cssText = 'margin-left: 8px; font-size: 10px; letter-spacing: 0.15em; padding: 2px 6px; border-radius: 999px; background: rgba(0,229,255,0.15); border: 1px solid rgba(0,229,255,0.4); color: var(--primary); font-family: var(--f-display); text-transform: uppercase;';
+ tag.textContent = 'You';
+ li.appendChild(tag);
+ }
+
list.appendChild(li);
}
-}
+}
\ No newline at end of file
diff --git a/controller/src/screens/PlanningScreen.ts b/controller/src/screens/PlanningScreen.ts
index 33a8877..35c3b40 100644
--- a/controller/src/screens/PlanningScreen.ts
+++ b/controller/src/screens/PlanningScreen.ts
@@ -1,17 +1,22 @@
import { App } from '../main.js';
-import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost } from '@spacerace/shared';
+import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost, PLAYER_HEX } from '@spacerace/shared';
const CARD_ICONS: Record
= {
- METEOR_STRIKE: 'βοΈ',
SHIELD: 'π‘οΈ',
BOOST: 'π',
EMP: 'β‘',
JUMP: 'π¦',
MINE: 'π£',
- TELEPORT: 'π',
PHASE_SHIFT: 'π»',
};
+const ACTION_ICONS: Record = {
+ MOVE_FORWARD: 'β',
+ TURN_LEFT: 'β°',
+ TURN_RIGHT: 'β±',
+ TURN_180: 'β»',
+};
+
export class PlanningScreen {
private app: App;
private plannedActions: Action[] = [];
@@ -42,6 +47,7 @@ export class PlanningScreen {
this.timerSeconds = data.timer;
this.setPlayerColor(data.playerView.colorIndex);
+ this.setPlayerName(data.playerView.name);
document.getElementById('round-label')!.textContent = `Round ${data.round}`;
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
@@ -63,15 +69,29 @@ export class PlanningScreen {
}
private setPlayerColor(colorIndex: number): void {
- const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44'];
const dot = document.getElementById('color-dot');
- if (dot) dot.style.backgroundColor = colors[colorIndex % colors.length];
+ if (dot) {
+ const color = PLAYER_HEX[colorIndex % PLAYER_HEX.length];
+ dot.style.backgroundColor = color;
+ dot.style.color = color;
+ }
+ }
+
+ private setPlayerName(name: string): void {
+ const el = document.getElementById('player-name');
+ if (el) el.textContent = name;
}
private updateTimer(): void {
const el = document.getElementById('timer-label')!;
el.textContent = `${this.timerSeconds}s`;
- el.style.color = this.timerSeconds <= 10 ? '#ff4444' : this.timerSeconds <= 20 ? '#ffcc00' : '#00ff88';
+ el.classList.remove('warning', 'danger');
+ if (this.timerSeconds <= 10) {
+ el.classList.add('danger');
+ if (this.timerSeconds > 0) this.app.audio.feedback('warning');
+ } else if (this.timerSeconds <= 20) {
+ el.classList.add('warning');
+ }
}
private initActionButtons(): void {
@@ -79,6 +99,7 @@ export class PlanningScreen {
grid.querySelectorAll('.act-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const actionType = (btn as HTMLElement).dataset.action!;
+ this.app.audio.feedback(actionType === 'BOOST' ? 'cardUse' : 'tap');
this.addAction({ type: actionType as Action['type'] });
});
});
@@ -86,6 +107,7 @@ export class PlanningScreen {
private initBottomButtons(): void {
document.getElementById('clear-plan-btn')!.addEventListener('click', () => {
+ this.app.audio.feedback('queueRemove');
this.plannedActions = [];
this.selectedCard = null;
this.renderQueue();
@@ -95,14 +117,16 @@ export class PlanningScreen {
document.getElementById('submit-plan-btn')!.addEventListener('click', () => {
if (this.plannedActions.length === 0) return;
+ this.app.audio.feedback('go');
const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
btn.disabled = true;
- btn.textContent = '...';
+ btn.textContent = 'β¦';
this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => {
if (!res.ok) {
btn.disabled = false;
btn.textContent = 'GO!';
+ this.app.audio.feedback('warning');
alert(res.error || 'Invalid plan');
}
});
@@ -112,12 +136,15 @@ export class PlanningScreen {
private addAction(action: Action): void {
if (!this.playerView || !this.playerView.alive) return;
- // If we have a selected card, add it as a CARD action first
if (this.selectedCard) {
const cardCost = CARD_DEFS[this.selectedCard].apCost;
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
- if (used + cardCost > this.apTotal) return;
+ if (used + cardCost > this.apTotal) {
+ this.app.audio.feedback('warning');
+ return;
+ }
+ this.app.audio.feedback('cardUse');
this.plannedActions.push({ type: 'CARD', card: this.selectedCard });
this.selectedCard = null;
this.renderCards(this.playerView.hand);
@@ -128,7 +155,10 @@ export class PlanningScreen {
const cost = actionApCost(action);
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
- if (used + cost > this.apTotal) return;
+ if (used + cost > this.apTotal) {
+ this.app.audio.feedback('warning');
+ return;
+ }
this.plannedActions.push(action);
this.renderQueue();
@@ -142,30 +172,37 @@ export class PlanningScreen {
const list = document.getElementById('queue-list')!;
list.innerHTML = '';
+ if (this.plannedActions.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'queue-empty';
+ empty.textContent = 'Pick a move to beginβ¦';
+ list.appendChild(empty);
+ return;
+ }
+
for (let i = 0; i < this.plannedActions.length; i++) {
const action = this.plannedActions[i];
const div = document.createElement('div');
div.className = 'queue-item';
- let icon = '';
- switch (action.type) {
- case 'MOVE_FORWARD': icon = 'β'; break;
- case 'TURN_LEFT': icon = 'β°'; break;
- case 'TURN_RIGHT': icon = 'β±'; break;
- case 'TURN_180': icon = 'β»'; break;
- case 'BOOST': icon = 'β‘'; break;
- case 'CARD': icon = action.card ? CARD_ICONS[action.card] : '?'; break;
- }
+ const icon = action.type === 'CARD'
+ ? (action.card ? CARD_ICONS[action.card] : '?')
+ : ACTION_ICONS[action.type] || '?';
div.textContent = icon;
div.title = action.type + (action.card ? ` ${action.card}` : '');
- // X button to remove
+ const step = document.createElement('span');
+ step.className = 'step';
+ step.textContent = String(i + 1);
+ div.appendChild(step);
+
const remove = document.createElement('span');
remove.className = 'remove-hint';
remove.textContent = 'Γ';
div.appendChild(remove);
div.addEventListener('click', () => {
+ this.app.audio.feedback('queueRemove');
this.plannedActions.splice(i, 1);
this.renderQueue();
this.updateApDisplay();
@@ -180,7 +217,6 @@ export class PlanningScreen {
const list = document.getElementById('card-list')!;
list.innerHTML = '';
- // If a card was already used in the plan, dim it
const usedCards = new Set();
for (const a of this.plannedActions) {
if (a.type === 'CARD' && a.card) usedCards.add(a.card);
@@ -188,50 +224,67 @@ export class PlanningScreen {
const availableCards = hand.filter((c) => !usedCards.has(c));
+ if (availableCards.length === 0) {
+ const empty = document.createElement('div');
+ empty.className = 'card-empty';
+ empty.textContent = 'No cards available';
+ list.appendChild(empty);
+ return;
+ }
+
for (const card of availableCards) {
const def = CARD_DEFS[card];
const div = document.createElement('div');
div.className = 'card-item';
if (this.selectedCard === card) div.classList.add('selected');
- div.innerHTML = `
- ${CARD_ICONS[card]}
- ${def.name}
- ${def.apCost} AP
- `;
+ const icon = document.createElement('div');
+ icon.className = 'card-icon';
+ icon.textContent = CARD_ICONS[card];
+
+ const name = document.createElement('div');
+ name.className = 'card-name';
+ name.textContent = def.name;
+
+ const cost = document.createElement('div');
+ cost.className = 'card-cost';
+ cost.textContent = `${def.apCost} AP`;
+
+ div.appendChild(icon);
+ div.appendChild(name);
+ div.appendChild(cost);
div.addEventListener('click', () => {
const wasSelected = this.selectedCard === card;
this.selectedCard = wasSelected ? null : card;
+ this.app.audio.feedback(wasSelected ? 'queueRemove' : 'cardSelect');
this.renderCards(hand);
});
list.appendChild(div);
}
-
- if (availableCards.length === 0) {
- list.innerHTML = 'No cards available
';
- }
}
private updateApDisplay(): void {
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
const remaining = this.apTotal - used;
- document.getElementById('ap-label')!.textContent = `AP: ${remaining}/${this.apTotal}`;
+ const apLabel = document.getElementById('ap-label')!;
+ apLabel.textContent = `AP: ${remaining}/${this.apTotal}`;
const fill = document.getElementById('ap-fill')!;
fill.style.width = `${(remaining / this.apTotal) * 100}%`;
+ fill.classList.remove('warning', 'danger');
if (remaining === 0) {
- fill.style.background = '#ff4444';
- document.getElementById('ap-label')!.style.color = '#ff4444';
+ apLabel.style.color = '';
+ fill.classList.add('danger');
} else if (remaining === this.apTotal) {
- fill.style.background = '#00ff88';
- document.getElementById('ap-label')!.style.color = '#00ff88';
+ apLabel.style.color = '';
+ fill.classList.remove('warning');
} else {
- fill.style.background = '#ffcc00';
- document.getElementById('ap-label')!.style.color = '#ffcc00';
+ apLabel.style.color = '';
+ fill.classList.add('warning');
}
}
-}
+}
\ No newline at end of file
diff --git a/controller/src/screens/SpectatorScreen.ts b/controller/src/screens/SpectatorScreen.ts
index 0b4020a..c311dea 100644
--- a/controller/src/screens/SpectatorScreen.ts
+++ b/controller/src/screens/SpectatorScreen.ts
@@ -3,12 +3,23 @@ import { App } from '../main.js';
export class SpectatorScreen {
constructor(app: App) {
app.socket.on('gameOver', (data) => {
- const h2 = document.querySelector('#screen-spectator h2')!;
+ const h2 = document.getElementById('spectator-title');
+ if (!h2) return;
if (data.winnerId === app.socket.data?.playerId) {
h2.textContent = 'π You Win!';
+ h2.classList.remove('dead');
} else {
h2.textContent = `π ${data.winnerName} Wins!`;
+ h2.classList.remove('dead');
}
});
+
+ app.socket.on('executionComplete', (data) => {
+ const h2 = document.getElementById('spectator-title');
+ if (!h2) return;
+ if (data.playerView.alive) return;
+ h2.textContent = 'π Eliminated';
+ h2.classList.add('dead');
+ });
}
-}
+}
\ No newline at end of file
diff --git a/controller/src/style.css b/controller/src/style.css
index f6fa2f2..a26964b 100644
--- a/controller/src/style.css
+++ b/controller/src/style.css
@@ -1,19 +1,121 @@
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ SpaceRace β Controller
+ Sci-Fi Neon / Cyberpunk theme
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+
+:root {
+ /* Colors β mirror shared/src/theme.ts */
+ --bg-deep: #050514;
+ --bg-panel: #0a0a24;
+ --bg-panel-alt: #111133;
+ --bg-glass: rgba(13, 13, 40, 0.72);
+ --bg-glass-strong: rgba(13, 13, 40, 0.92);
+
+ --primary: #00e5ff;
+ --primary-dim: #007a99;
+ --accent: #ff2bd6;
+ --accent-dim: #7a1465;
+
+ --success: #00ff9c;
+ --warning: #ffcc00;
+ --danger: #ff3b6b;
+ --neutral: #6a6a8a;
+
+ --text: #ffffff;
+ --text-dim: #a0a0c8;
+ --text-muted: #6a6a8a;
+
+ --player-0: #00e5ff;
+ --player-1: #ff3b6b;
+ --player-2: #00ff9c;
+ --player-3: #ffaa00;
+ --player-4: #ff2bd6;
+ --player-5: #fff200;
+
+ /* Spacing & radii */
+ --sp-xs: 4px;
+ --sp-sm: 8px;
+ --sp-md: 12px;
+ --sp-lg: 16px;
+ --sp-xl: 24px;
+ --sp-xxl: 32px;
+
+ --r-sm: 8px;
+ --r-md: 12px;
+ --r-lg: 20px;
+ --r-pill: 999px;
+
+ /* Fonts */
+ --f-display: "Orbitron", "Rajdhani", system-ui, sans-serif;
+ --f-body: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
+ --f-mono: "JetBrains Mono", "Fira Code", monospace;
+
+ /* Glow */
+ --glow-primary: 0 0 16px rgba(0, 229, 255, 0.55), 0 0 32px rgba(0, 229, 255, 0.25);
+ --glow-accent: 0 0 16px rgba(255, 43, 214, 0.55), 0 0 32px rgba(255, 43, 214, 0.25);
+ --glow-success: 0 0 14px rgba(0, 255, 156, 0.55);
+ --glow-warning: 0 0 14px rgba(255, 204, 0, 0.55);
+ --glow-danger: 0 0 16px rgba(255, 59, 107, 0.65);
+}
+
* {
margin: 0;
padding: 0;
box-sizing: border-box;
+ -webkit-tap-highlight-color: transparent;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
- font-family: monospace;
- background: #0a0a2e;
- color: #ffffff;
+ font-family: var(--f-body);
+ color: var(--text);
+ background: var(--bg-deep);
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
+ letter-spacing: 0.01em;
+}
+
+/* ββ Animated starfield background ββββββββββββββββββββββββ */
+body::before,
+body::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background-image:
+ radial-gradient(1px 1px at 12% 18%, rgba(255,255,255,0.85) 50%, transparent 51%),
+ radial-gradient(1px 1px at 28% 72%, rgba(255,255,255,0.55) 50%, transparent 51%),
+ radial-gradient(1.5px 1.5px at 47% 35%, rgba(0,229,255,0.7) 50%, transparent 51%),
+ radial-gradient(1px 1px at 63% 84%, rgba(255,255,255,0.6) 50%, transparent 51%),
+ radial-gradient(1px 1px at 78% 22%, rgba(255,43,214,0.6) 50%, transparent 51%),
+ radial-gradient(1.5px 1.5px at 88% 60%, rgba(255,255,255,0.75) 50%, transparent 51%),
+ radial-gradient(1px 1px at 5% 58%, rgba(255,255,255,0.5) 50%, transparent 51%),
+ radial-gradient(1px 1px at 36% 12%, rgba(255,255,255,0.45) 50%, transparent 51%),
+ radial-gradient(1px 1px at 55% 90%, rgba(0,229,255,0.55) 50%, transparent 51%),
+ radial-gradient(1px 1px at 82% 45%, rgba(255,255,255,0.65) 50%, transparent 51%);
+ background-size: 320px 320px;
+ background-repeat: repeat;
+ opacity: 0.9;
+ animation: drift 60s linear infinite;
+}
+body::after {
+ background-size: 220px 220px;
+ opacity: 0.5;
+ animation: drift 90s linear infinite reverse;
+}
+@keyframes drift {
+ from { transform: translate3d(0, 0, 0); }
+ to { transform: translate3d(-320px, 320px, 0); }
+}
+
+/* Subtle scanline overlay for the cyberpunk feel */
+body > * {
+ position: relative;
+ z-index: 1;
}
#app {
@@ -22,314 +124,761 @@ html, body {
position: relative;
}
+/* ββ Screens βββββββββββββββββββββββββββββββββββββββββββββββ */
.screen {
display: none;
width: 100%;
height: 100%;
- padding: 16px;
+ padding: var(--sp-lg);
overflow-y: auto;
+ overflow-x: hidden;
flex-direction: column;
position: absolute;
- top: 0;
- left: 0;
+ top: 0; left: 0;
+ animation: fadeIn 320ms ease;
+}
+.screen.active { display: flex; }
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: translateY(0); }
}
-.screen.active {
- display: flex;
+h1, h2, h3 {
+ font-family: var(--f-display);
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
}
+h1 { font-size: 28px; text-align: center; margin: 20px 0 16px; color: var(--primary); text-shadow: var(--glow-primary); }
+h2 { font-size: 22px; text-align: center; margin: 16px 0 12px; color: var(--primary); text-shadow: 0 0 12px rgba(0,229,255,0.4); }
+h3 { font-size: 13px; color: var(--text-muted); margin: 8px 0 6px; letter-spacing: 0.12em; }
-h1 { font-size: 24px; text-align: center; margin: 20px 0; color: #00ccff; }
-h2 { font-size: 20px; text-align: center; margin: 16px 0; color: #00ccff; }
-h3 { font-size: 14px; color: #8888aa; margin: 8px 0 4px; }
+/* ββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββ */
+button {
+ font-family: var(--f-display);
+ font-weight: 700;
+ font-size: 16px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ padding: 14px 18px;
+ border: 1px solid transparent;
+ border-radius: var(--r-md);
+ cursor: pointer;
+ background: linear-gradient(135deg, var(--primary) 0%, #00b4cc 100%);
+ color: #00121a;
+ box-shadow: var(--glow-primary);
+ transition: transform 120ms ease, box-shadow 200ms ease, filter 200ms ease;
+ position: relative;
+ overflow: hidden;
+}
+button::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(180deg, rgba(255,255,255,0.25), transparent 50%);
+ pointer-events: none;
+ opacity: 0.6;
+}
+button:active { transform: scale(0.97); filter: brightness(0.95); }
+button:disabled {
+ background: linear-gradient(135deg, #1a1a3a, #232345);
+ color: var(--text-muted);
+ box-shadow: none;
+ cursor: not-allowed;
+}
+button:disabled::after { display: none; }
-/* Join Screen */
-#screen-join { align-items: center; justify-content: flex-start; padding-top: 40px; }
+/* ββ Inputs ββββββββββββββββββββββββββββββββββββββββββββββββ */
+input {
+ font-family: var(--f-mono);
+ font-weight: 500;
+ font-size: 18px;
+ padding: 14px 16px;
+ border: 1px solid rgba(0, 229, 255, 0.25);
+ background: rgba(17, 17, 51, 0.7);
+ color: var(--text);
+ border-radius: var(--r-md);
+ text-align: center;
+ letter-spacing: 0.18em;
+ transition: border-color 200ms ease, box-shadow 200ms ease, background 200ms ease;
+ width: 100%;
+}
+input::placeholder { color: var(--text-muted); letter-spacing: 0.06em; }
+input:focus {
+ outline: none;
+ border-color: var(--primary);
+ box-shadow: 0 0 0 3px rgba(0, 229, 255, 0.18), var(--glow-primary);
+ background: rgba(17, 17, 51, 0.95);
+}
+#name-input { letter-spacing: 0.04em; }
+
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ Join Screen
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+#screen-join {
+ align-items: center;
+ justify-content: flex-start;
+ padding-top: 56px;
+ gap: 6px;
+}
+#screen-join h1 {
+ font-size: 34px;
+ margin: 0 0 4px;
+ background: linear-gradient(180deg, #ffffff 0%, var(--primary) 100%);
+ -webkit-background-clip: text;
+ background-clip: text;
+ -webkit-text-fill-color: transparent;
+ text-shadow: none;
+ filter: drop-shadow(0 0 12px rgba(0,229,255,0.4));
+}
+#screen-join .join-sub {
+ text-align: center;
+ font-size: 12px;
+ color: var(--text-muted);
+ letter-spacing: 0.3em;
+ text-transform: uppercase;
+ margin-bottom: 24px;
+}
.join-form {
display: flex;
flex-direction: column;
- gap: 12px;
+ gap: var(--sp-md);
width: 100%;
max-width: 320px;
+ padding: var(--sp-xl);
+ background: var(--bg-glass);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid rgba(0, 229, 255, 0.18);
+ border-radius: var(--r-lg);
+ box-shadow:
+ 0 0 0 1px rgba(0,229,255,0.05) inset,
+ 0 12px 40px rgba(0, 0, 0, 0.5);
+}
+.join-form label {
+ font-family: var(--f-display);
+ font-size: 11px;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-top: 4px;
+}
+#join-btn { margin-top: 8px; font-size: 18px; padding: 16px; }
+
+.error {
+ color: var(--danger);
+ font-size: 13px;
+ text-align: center;
+ min-height: 18px;
+ text-shadow: 0 0 8px rgba(255,59,107,0.5);
}
-input {
- padding: 14px;
- font-size: 20px;
- font-family: monospace;
- border: 2px solid #334466;
- background: #111133;
- color: #ffffff;
- border-radius: 8px;
- text-align: center;
+#qr-container {
+ margin-top: 20px;
+ display: flex;
+ justify-content: center;
+}
+#qr-container canvas, #qr-container svg {
+ background: #fff;
+ padding: 8px;
+ border-radius: var(--r-md);
+ box-shadow: var(--glow-primary);
+}
+
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ Lobby Screen
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+#screen-lobby { align-items: center; padding-top: 32px; gap: 4px; }
+
+.lobby-room-badge {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 4px;
+ margin: 8px 0 16px;
+}
+.lobby-room-label {
+ font-family: var(--f-display);
+ font-size: 11px;
+ letter-spacing: 0.3em;
+ color: var(--text-muted);
text-transform: uppercase;
}
-
-input:focus {
- border-color: #00ccff;
- outline: none;
+.lobby-room-code {
+ font-family: var(--f-mono);
+ font-weight: 700;
+ font-size: 40px;
+ letter-spacing: 0.4em;
+ color: var(--primary);
+ text-shadow: var(--glow-primary);
+ padding: 8px 18px;
+ border: 1px solid rgba(0,229,255,0.4);
+ border-radius: var(--r-md);
+ background: var(--bg-glass);
}
-#name-input { text-transform: none; }
+#lobby-players {
+ width: 100%;
+ max-width: 360px;
+ margin: 8px 0 12px;
+}
+#lobby-player-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
+#lobby-player-list li {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ background: var(--bg-glass);
+ border: 1px solid rgba(0,229,255,0.12);
+ border-radius: var(--r-md);
+ font-size: 15px;
+ font-weight: 600;
+ font-family: var(--f-body);
+ animation: fadeIn 240ms ease;
+}
+#lobby-player-list .player-avatar {
+ width: 28px; height: 28px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ box-shadow: 0 0 10px currentColor;
+ position: relative;
+}
+#lobby-player-list .player-avatar::after {
+ content: "";
+ position: absolute;
+ inset: -3px;
+ border-radius: 50%;
+ border: 1px solid currentColor;
+ opacity: 0.35;
+}
+#lobby-player-list .crown {
+ margin-left: auto;
+ font-size: 16px;
+ filter: drop-shadow(0 0 6px #ffcc00);
+}
-button {
- padding: 14px;
+#lobby-waiting {
+ color: var(--text-muted);
+ margin-top: 8px;
+ font-size: 13px;
+ text-align: center;
+ letter-spacing: 0.05em;
+ animation: pulseText 2.2s ease-in-out infinite;
+}
+@keyframes pulseText {
+ 0%, 100% { opacity: 0.55; }
+ 50% { opacity: 1; }
+}
+
+#host-controls { margin-top: 16px; text-align: center; width: 100%; max-width: 360px; }
+#host-controls button {
+ width: 100%;
+ background: linear-gradient(135deg, var(--accent) 0%, #b81fa0 100%);
+ color: #fff;
font-size: 18px;
- font-family: monospace;
- font-weight: bold;
- border: none;
- border-radius: 8px;
- cursor: pointer;
- background: #00ccff;
- color: #000;
+ padding: 16px;
+ box-shadow: var(--glow-accent);
}
-button:active { opacity: 0.8; }
-button:disabled { background: #334466; color: #666688; cursor: not-allowed; }
-
-.error { color: #ff4444; font-size: 13px; text-align: center; min-height: 18px; }
-
-/* Planning Screen */
-#screen-planning {
- gap: 0;
- padding: 0;
-}
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ Planning Screen
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+#screen-planning { gap: 0; padding: 0; }
.planning-header {
- display: flex;
- justify-content: space-between;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
align-items: center;
- padding: 12px 16px;
- background: #0d0d35;
- border-bottom: 2px solid #1a1a55;
+ padding: 10px 14px;
+ background: linear-gradient(180deg, rgba(13,13,40,0.95), rgba(10,10,36,0.92));
+ border-bottom: 1px solid rgba(0,229,255,0.18);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+}
+.header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
+.header-left .player-name {
+ font-family: var(--f-display);
+ font-size: 13px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 110px;
+ color: var(--text);
}
-.header-left, .header-right { flex: 1; }
-.header-left { display: flex; align-items: center; gap: 8px; }
.color-dot {
display: inline-block;
- width: 16px;
- height: 16px;
+ width: 14px;
+ height: 14px;
border-radius: 50%;
- border: 2px solid rgba(255,255,255,0.4);
+ border: 1px solid rgba(255,255,255,0.6);
flex-shrink: 0;
+ box-shadow: 0 0 8px currentColor;
+}
+.header-center { display: flex; flex-direction: column; align-items: center; gap: 2px; }
+#round-label {
+ font-family: var(--f-display);
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+}
+#timer-label {
+ font-family: var(--f-mono);
+ font-size: 22px;
+ font-weight: 700;
+ color: var(--success);
+ text-shadow: var(--glow-success);
+ line-height: 1;
+ font-variant-numeric: tabular-nums;
+ transition: color 200ms ease, text-shadow 200ms ease;
+}
+#timer-label.warning { color: var(--warning); text-shadow: var(--glow-warning); }
+#timer-label.danger { color: var(--danger); text-shadow: var(--glow-danger); animation: timerPulse 0.6s ease-in-out infinite; }
+@keyframes timerPulse {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.08); }
+}
+.header-right { display: flex; align-items: center; justify-content: flex-end; gap: 6px; }
+#ap-label {
+ font-family: var(--f-display);
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--success);
+ text-shadow: var(--glow-success);
+ letter-spacing: 0.08em;
+ font-variant-numeric: tabular-nums;
}
-.header-center { text-align: center; }
-#round-label { font-size: 14px; font-weight: bold; color: #00ccff; text-transform: uppercase; }
-#timer-label { font-size: 22px; color: #ffcc00; font-weight: bold; }
-#ap-label { font-size: 14px; font-weight: bold; color: #00ff88; text-align: right; }
/* AP bar */
.ap-bar {
height: 4px;
- background: #112233;
+ background: rgba(0, 229, 255, 0.08);
+ position: relative;
+ overflow: hidden;
}
.ap-fill {
height: 100%;
- background: #00ff88;
- transition: width 0.2s;
+ background: linear-gradient(90deg, var(--success), var(--primary));
+ transition: width 240ms ease, background 200ms ease;
+ box-shadow: 0 0 12px currentColor;
+ width: 100%;
}
+.ap-fill.warning { background: linear-gradient(90deg, var(--warning), #ff9500); }
+.ap-fill.danger { background: linear-gradient(90deg, var(--danger), #ff2244); }
-/* Action grid */
+/* ββ Action grid (D-pad style) βββββββββββββββββββββββββββββ */
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
- gap: 8px;
- padding: 12px;
+ grid-template-areas:
+ "forward forward"
+ "left right";
+ gap: 10px;
+ padding: 14px;
flex: 1;
+ min-height: 0;
}
-
.act-btn {
+ grid-area: forward;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
- padding: 16px 8px;
- background: #111144;
- border: 2px solid #223366;
- border-radius: 16px;
+ padding: 12px 6px;
+ background: linear-gradient(160deg, rgba(17,17,51,0.95), rgba(8,8,32,0.95));
+ border: 1px solid rgba(0,229,255,0.22);
+ border-radius: var(--r-lg);
cursor: pointer;
touch-action: manipulation;
- -webkit-tap-highlight-color: transparent;
+ font-family: inherit;
+ color: var(--text);
+ text-transform: none;
+ letter-spacing: 0;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
+ transition: transform 120ms ease, border-color 180ms ease, background 200ms ease, box-shadow 200ms ease;
+ overflow: hidden;
}
+.act-btn[data-action="MOVE_FORWARD"] { grid-area: forward; }
+.act-btn[data-action="TURN_LEFT"] { grid-area: left; }
+.act-btn[data-action="TURN_RIGHT"] { grid-area: right; }
.act-btn:active {
- background: #1a1a66;
- border-color: #00ccff;
- transform: scale(0.95);
-}
-.act-btn.primary {
- background: #112244;
- border-color: #3366aa;
-}
-.act-btn.primary:active {
- background: #1a3366;
- border-color: #00ccff;
- transform: scale(0.95);
-}
-.act-btn.accent {
- border-color: #886600;
- background: #221a08;
-}
-.act-btn.accent:active {
- border-color: #ffaa00;
- background: #332a10;
transform: scale(0.95);
+ border-color: var(--primary);
+ background: linear-gradient(160deg, rgba(0,80,110,0.55), rgba(0,30,50,0.85));
+ box-shadow: var(--glow-primary);
}
.act-icon {
- font-size: 36px;
+ font-size: 30px;
line-height: 1;
+ color: var(--primary);
+ text-shadow: 0 0 10px rgba(0,229,255,0.55);
}
.act-label {
- font-size: 14px;
- font-weight: bold;
- color: #ccccff;
+ font-family: var(--f-display);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--text-dim);
}
.act-cost {
- font-size: 11px;
- color: #6666aa;
+ font-family: var(--f-mono);
+ font-size: 10px;
+ font-weight: 500;
+ color: var(--text-muted);
+ letter-spacing: 0.06em;
}
-/* Action queue */
+/* ββ Action queue ββββββββββββββββββββββββββββββββββββββββββ */
.action-queue {
padding: 8px 12px;
- border-top: 1px solid #1a1a44;
- background: #080820;
+ border-top: 1px solid rgba(0, 229, 255, 0.12);
+ background: rgba(5, 5, 20, 0.7);
+}
+.action-queue-label {
+ font-family: var(--f-display);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 6px;
}
.queue-list {
display: flex;
gap: 6px;
overflow-x: auto;
- min-height: 44px;
+ min-height: 50px;
align-items: center;
+ padding-bottom: 4px;
}
+.queue-list::-webkit-scrollbar { height: 4px; }
+.queue-list::-webkit-scrollbar-thumb { background: rgba(0,229,255,0.3); border-radius: 2px; }
.queue-item {
flex-shrink: 0;
- width: 44px;
- height: 44px;
+ width: 48px;
+ height: 48px;
display: flex;
align-items: center;
justify-content: center;
- background: #1a3355;
- border: 2px solid #3366aa;
- border-radius: 10px;
- font-size: 13px;
+ background: linear-gradient(160deg, rgba(0,40,60,0.85), rgba(0,15,30,0.85));
+ border: 1px solid rgba(0,229,255,0.4);
+ border-radius: var(--r-md);
+ font-size: 18px;
font-weight: bold;
position: relative;
cursor: pointer;
+ color: var(--primary);
+ text-shadow: 0 0 8px rgba(0,229,255,0.6);
+ box-shadow: 0 0 8px rgba(0,229,255,0.2);
+ font-family: var(--f-mono);
}
-.queue-item:active {
- background: #442222;
- border-color: #ff4444;
-}
-.queue-item .remove-hint {
+.queue-item .step {
position: absolute;
- top: -4px;
- right: -4px;
- width: 18px;
- height: 18px;
- background: #ff4444;
- color: #fff;
+ top: -6px;
+ left: -6px;
+ width: 18px; height: 18px;
border-radius: 50%;
+ background: var(--primary);
+ color: #00121a;
+ font-family: var(--f-display);
font-size: 10px;
+ font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
+ box-shadow: 0 0 8px rgba(0,229,255,0.7);
+}
+.queue-item .remove-hint {
+ position: absolute;
+ top: -5px;
+ right: -5px;
+ width: 18px;
+ height: 18px;
+ background: var(--danger);
+ color: #fff;
+ border-radius: 50%;
+ font-size: 11px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 0 6px rgba(255,59,107,0.7);
+ font-family: var(--f-body);
+}
+.queue-empty {
+ color: var(--text-muted);
+ font-size: 12px;
+ font-style: italic;
+ padding: 8px 0;
}
-/* Card hand */
+/* ββ Card hand βββββββββββββββββββββββββββββββββββββββββββββ */
.card-hand {
- padding: 8px 12px;
- border-top: 1px solid #1a1a44;
- background: #0a0a28;
+ padding: 10px 12px 12px;
+ border-top: 1px solid rgba(0, 229, 255, 0.12);
+ background: rgba(10, 10, 36, 0.8);
+}
+.card-hand-label {
+ font-family: var(--f-display);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 6px;
}
.card-list {
display: flex;
gap: 8px;
overflow-x: auto;
+ padding-bottom: 4px;
}
+.card-list::-webkit-scrollbar { height: 4px; }
+.card-list::-webkit-scrollbar-thumb { background: rgba(0,229,255,0.3); border-radius: 2px; }
.card-item {
flex-shrink: 0;
- width: 110px;
- min-height: 60px;
- padding: 8px 10px;
- background: #151535;
- border: 2px solid #334466;
- border-radius: 12px;
+ width: 104px;
+ min-height: 90px;
+ padding: 8px 8px 10px;
+ background: linear-gradient(160deg, rgba(25,25,60,0.95), rgba(10,10,30,0.95));
+ border: 1px solid rgba(0,229,255,0.2);
+ border-radius: var(--r-md);
cursor: pointer;
touch-action: manipulation;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: space-between;
+ gap: 2px;
+ transition: transform 160ms ease, border-color 200ms ease, box-shadow 200ms ease, background 200ms ease;
+ position: relative;
+ font-family: inherit;
+ color: var(--text);
+ text-transform: none;
+ letter-spacing: 0;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
+ padding-bottom: 8px;
}
+.card-item::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: var(--r-md);
+ background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent 30%);
+ pointer-events: none;
+}
+.card-item:active { transform: translateY(2px) scale(0.97); }
.card-item.selected {
- border-color: #ffaa00;
- background: #221a10;
- box-shadow: 0 0 12px #ffaa0044;
+ border-color: var(--warning);
+ background: linear-gradient(160deg, rgba(70,50,0,0.7), rgba(30,20,0,0.85));
+ box-shadow: 0 0 0 1px var(--warning), 0 0 18px rgba(255,204,0,0.45);
+ transform: translateY(-3px);
}
.card-item .card-icon {
- font-size: 22px;
- text-align: center;
+ font-size: 28px;
+ line-height: 1;
+ filter: drop-shadow(0 0 6px rgba(0,229,255,0.4));
}
.card-item .card-name {
- font-size: 11px;
- font-weight: bold;
- color: #ffaa00;
+ font-family: var(--f-display);
+ font-size: 10px;
+ font-weight: 700;
+ color: var(--primary);
text-align: center;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
}
.card-item .card-cost {
+ font-family: var(--f-mono);
font-size: 10px;
- color: #8888aa;
- text-align: center;
+ font-weight: 500;
+ color: var(--text-muted);
+ letter-spacing: 0.06em;
+}
+.card-empty {
+ color: var(--text-muted);
+ font-size: 12px;
+ font-style: italic;
+ padding: 12px 0;
}
-/* Bottom row */
+/* ββ Bottom row ββββββββββββββββββββββββββββββββββββββββββββ */
.planning-bottom {
display: flex;
- gap: 8px;
- padding: 8px 12px 16px;
- border-top: 1px solid #1a1a44;
- background: #0d0d35;
+ gap: 10px;
+ padding: 10px 12px 18px;
+ border-top: 1px solid rgba(0, 229, 255, 0.12);
+ background: linear-gradient(180deg, rgba(13,13,40,0.92), rgba(5,5,20,0.98));
}
.btn-clear {
flex: 1;
- padding: 14px;
- font-size: 16px;
- font-family: monospace;
- font-weight: bold;
- border: 2px solid #553333;
- border-radius: 12px;
- background: #221111;
- color: #ff6666;
+ font-family: var(--f-display);
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ padding: 16px;
+ border: 1px solid rgba(255,59,107,0.35);
+ border-radius: var(--r-md);
+ background: rgba(40,10,20,0.6);
+ color: var(--danger);
cursor: pointer;
+ text-shadow: 0 0 8px rgba(255,59,107,0.5);
+ transition: transform 120ms ease, background 200ms ease;
}
+.btn-clear:active { transform: scale(0.97); background: rgba(70,15,30,0.85); }
.btn-submit {
flex: 2;
- padding: 14px;
- font-size: 20px;
- font-family: monospace;
- font-weight: bold;
+ font-family: var(--f-display);
+ font-size: 22px;
+ font-weight: 800;
+ letter-spacing: 0.2em;
+ text-transform: uppercase;
+ padding: 16px;
border: none;
- border-radius: 12px;
+ border-radius: var(--r-md);
cursor: pointer;
- background: #00cc44;
- color: #000;
+ background: linear-gradient(135deg, var(--accent) 0%, #b81fa0 100%);
+ color: #fff;
+ box-shadow: var(--glow-accent);
+ position: relative;
+ overflow: hidden;
+ animation: goPulse 2.4s ease-in-out infinite;
}
.btn-submit:disabled {
- background: #223344;
- color: #556677;
+ background: linear-gradient(135deg, #1a1a3a, #232345);
+ color: var(--text-muted);
+ box-shadow: none;
cursor: not-allowed;
+ animation: none;
+}
+@keyframes goPulse {
+ 0%, 100% { box-shadow: var(--glow-accent); }
+ 50% { box-shadow: 0 0 24px rgba(255,43,214,0.85), 0 0 48px rgba(255,43,214,0.45); }
}
-/* Waiting Screen */
-#screen-waiting { align-items: center; justify-content: center; }
-#waiting-message { font-size: 16px; color: #8888aa; margin-top: 12px; }
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ Waiting Screen
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+#screen-waiting { align-items: center; justify-content: center; gap: 16px; padding: 40px; }
+.tv-icon {
+ width: 80px; height: 56px;
+ border: 2px solid var(--primary);
+ border-radius: var(--r-md);
+ position: relative;
+ box-shadow: var(--glow-primary);
+ animation: tvFlicker 1.4s ease-in-out infinite;
+}
+.tv-icon::before {
+ content: "";
+ position: absolute;
+ left: 6px; right: 6px; top: 6px; bottom: 14px;
+ background: radial-gradient(circle at 30% 30%, rgba(0,229,255,0.6), transparent 60%);
+ border-radius: 3px;
+}
+.tv-icon::after {
+ content: "";
+ position: absolute;
+ left: 35%; right: 35%; bottom: -10px;
+ height: 6px;
+ background: var(--primary);
+ border-radius: 0 0 4px 4px;
+ box-shadow: 0 0 8px rgba(0,229,255,0.6);
+}
+@keyframes tvFlicker {
+ 0%, 100% { box-shadow: var(--glow-primary); }
+ 50% { box-shadow: 0 0 24px rgba(0,229,255,0.85); }
+}
+#screen-waiting h2 {
+ margin: 0;
+ text-shadow: var(--glow-primary);
+}
+#waiting-message {
+ text-align: center;
+ font-size: 14px;
+ color: var(--text-dim);
+ max-width: 320px;
+ line-height: 1.5;
+ font-family: var(--f-mono);
+ letter-spacing: 0.04em;
+}
-/* Spectator Screen */
-#screen-spectator { align-items: center; justify-content: center; }
-#screen-spectator p { color: #8888aa; margin-top: 8px; }
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ Spectator Screen
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+#screen-spectator { align-items: center; justify-content: center; gap: 14px; padding: 40px; text-align: center; }
+#screen-spectator h2 {
+ font-size: 32px;
+ margin: 0;
+ text-shadow: 0 0 18px rgba(255,204,0,0.6);
+}
+#screen-spectator h2.dead {
+ color: var(--text-muted);
+ text-shadow: 0 0 12px rgba(160,160,200,0.4);
+}
+#screen-spectator p { color: var(--text-dim); max-width: 320px; line-height: 1.5; font-size: 14px; }
-/* Lobby Screen */
-#screen-lobby { align-items: center; padding-top: 40px; }
-#lobby-room { font-size: 32px; font-weight: bold; color: #00ccff; margin: 8px 0; letter-spacing: 8px; }
-#lobby-players { width: 100%; max-width: 320px; margin: 16px 0; }
-#lobby-player-list { list-style: none; }
-#lobby-player-list li { padding: 8px; border-bottom: 1px solid #223355; font-size: 16px; }
-#lobby-waiting { color: #8888aa; margin-top: 16px; font-size: 14px; }
+/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ Utility β used by LobbyScreen.ts injected element
+ βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
+.audio-toggle {
+ position: fixed;
+ top: 12px;
+ right: 12px;
+ z-index: 50;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--bg-glass-strong);
+ border: 1px solid rgba(0, 229, 255, 0.3);
+ box-shadow: 0 0 12px rgba(0, 229, 255, 0.25);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ padding: 0;
+ color: inherit;
+ text-transform: none;
+ letter-spacing: 0;
+ cursor: pointer;
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ transition: transform 120ms ease, border-color 200ms ease, box-shadow 200ms ease;
+}
+.audio-toggle:active { transform: scale(0.9); }
+.audio-toggle .audio-icon-off { display: none; }
+body.audio-muted .audio-toggle .audio-icon-on { display: none; }
+body.audio-muted .audio-toggle .audio-icon-off { display: inline; }
+body.audio-muted .audio-toggle {
+ border-color: rgba(255, 59, 107, 0.4);
+ box-shadow: 0 0 8px rgba(255, 59, 107, 0.25);
+}
+
+.start-btn-neon {
+ font-family: var(--f-display);
+ font-size: 18px;
+ font-weight: 800;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ padding: 16px 32px;
+ border: none;
+ border-radius: var(--r-md);
+ cursor: pointer;
+ background: linear-gradient(135deg, var(--accent) 0%, #b81fa0 100%);
+ color: #fff;
+ box-shadow: var(--glow-accent);
+}
+.start-btn-neon:disabled {
+ background: linear-gradient(135deg, #1a1a3a, #232345);
+ color: var(--text-muted);
+ box-shadow: none;
+ cursor: not-allowed;
+}
diff --git a/controller/vite.config.ts b/controller/vite.config.ts
index 7f77c23..56899ed 100644
--- a/controller/vite.config.ts
+++ b/controller/vite.config.ts
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
+ base: '/controller/',
server: {
port: 3001,
proxy: {
diff --git a/package-lock.json b/package-lock.json
index e9055f6..93669d4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2911,6 +2911,7 @@
"dependencies": {
"@spacerace/shared": "*",
"phaser": "^3.80.1",
+ "qrcode-generator": "^1.5.2",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
diff --git a/server/Dockerfile b/server/Dockerfile
index 3313995..04b607f 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -1,10 +1,20 @@
-FROM node:22-alpine AS server-build
+FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/ ./shared/
COPY server/ ./server/
-RUN npm ci --workspace=server --workspace=shared
-RUN npm -w shared run build 2>/dev/null || true
-WORKDIR /app/server
+COPY tv/ ./tv/
+COPY controller/ ./controller/
+RUN npm ci
+RUN npm run build
+
+FROM node:22-alpine
+WORKDIR /app
+COPY --from=build /app/package.json /app/package-lock.json ./
+COPY --from=build /app/node_modules ./node_modules
+COPY --from=build /app/shared ./shared
+COPY --from=build /app/server ./server
+COPY --from=build /app/tv/dist ./tv/dist
+COPY --from=build /app/controller/dist ./controller/dist
EXPOSE 8080
-CMD ["npx", "tsx", "src/index.ts"]
+CMD ["npx", "tsx", "server/src/index.ts"]
diff --git a/server/src/game/CardHandler.ts b/server/src/game/CardHandler.ts
index 47f1074..a28d489 100644
--- a/server/src/game/CardHandler.ts
+++ b/server/src/game/CardHandler.ts
@@ -4,88 +4,139 @@ import { Ship } from './Ship.js';
export interface CardContext {
ship: Ship;
- target?: Position;
grid: Grid;
allShips: Ship[];
shipStates: Map;
}
-export function executeCard(cardType: CardType, ctx: CardContext): { success: boolean; message: string; gridUpdates: { position: Position; tile: string }[] } {
- const def = CARD_DEFS[cardType];
- if (!def) return { success: false, message: 'Unknown card', gridUpdates: [] };
+export type CardEvent =
+ | { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string }
+ | { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' };
- switch (cardType) {
- case 'METEOR_STRIKE': {
- if (!ctx.target) return { success: false, message: 'No target for Meteor Strike', gridUpdates: [] };
- // Check range: within 3 tiles of ship
- const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y);
- if (dist > 3) return { success: false, message: 'Target out of range', gridUpdates: [] };
- ctx.grid.setTile(ctx.target, 'meteor');
- return { success: true, message: `${ctx.ship.playerId} summoned a meteor at (${ctx.target.x},${ctx.target.y})`, gridUpdates: [{ position: ctx.target, tile: 'meteor' }] };
- }
-
- case 'SHIELD': {
- ctx.ship.shielded = true;
- return { success: true, message: `${ctx.ship.playerId} activated shield`, gridUpdates: [] };
- }
-
- case 'EMP': {
- ctx.ship.empActive = true;
- return { success: true, message: `${ctx.ship.playerId} activated EMP`, gridUpdates: [] };
- }
-
- case 'JUMP': {
- const dir = ctx.ship.direction;
- const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
- const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0;
-
- const jumpPos: Position = {
- x: ctx.ship.position.x + dx * 2,
- y: ctx.ship.position.y + dy * 2,
- };
-
- if (!ctx.grid.isInBounds(jumpPos)) return { success: false, message: 'Jump out of bounds', gridUpdates: [] };
-
- ctx.ship.position = jumpPos;
- return { success: true, message: `${ctx.ship.playerId} jumped 2 tiles forward`, gridUpdates: [] };
- }
-
- case 'MINE': {
- ctx.grid.setTile(ctx.ship.position, 'mine');
- return { success: true, message: `${ctx.ship.playerId} dropped a mine`, gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }] };
- }
-
- case 'TELEPORT': {
- if (!ctx.target) return { success: false, message: 'No target for Teleport', gridUpdates: [] };
- const targetX = ctx.target!.x;
- const targetY = ctx.target!.y;
- const targetShip = ctx.allShips.find(
- (s) => s.alive && s.id !== ctx.ship.id &&
- s.position.x === targetX && s.position.y === targetY
- );
- if (!targetShip) return { success: false, message: 'No ship at target location', gridUpdates: [] };
- const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y);
- if (dist > 5) return { success: false, message: 'Target out of range for Teleport', gridUpdates: [] };
-
- // Check EMP on target ship
- const targetState = ctx.shipStates.get(targetShip.id);
- if (targetState?.empActive) {
- targetState.empActive = false;
- return { success: false, message: `Teleport blocked by ${targetShip.playerId}'s EMP`, gridUpdates: [] };
- }
-
- const myPos = { ...ctx.ship.position };
- ctx.ship.position = { ...targetShip.position };
- targetShip.position = myPos;
- return { success: true, message: `${ctx.ship.playerId} teleported with ${targetShip.playerId}`, gridUpdates: [] };
- }
-
- case 'PHASE_SHIFT': {
- ctx.ship.phaseShifting = true;
- return { success: true, message: `${ctx.ship.playerId} activated Phase Shift`, gridUpdates: [] };
- }
-
- default:
- return { success: false, message: 'Unknown card', gridUpdates: [] };
- }
+export interface CardResult {
+ success: boolean;
+ message: string;
+ gridUpdates: { position: Position; tile: string }[];
+ events: CardEvent[];
}
+
+function cardEvent(cardType: CardType, ctx: CardContext, position: Position, targetId?: string): CardEvent {
+ return { type: 'card_played', shipId: ctx.ship.id, card: cardType, position, targetId };
+}
+
+function moveEvent(ctx: CardContext, from: Position, to: Position, source: 'walk' | 'boost' | 'jump'): CardEvent {
+ return { type: 'move', shipId: ctx.ship.id, from, to, source };
+}
+
+type CardHandler = (ctx: CardContext) => CardResult;
+
+const SHIELD: CardHandler = (ctx) => {
+ ctx.ship.shielded = true;
+ return {
+ success: true,
+ message: `${ctx.ship.playerId} activated Shield`,
+ gridUpdates: [],
+ events: [cardEvent('SHIELD', ctx, { ...ctx.ship.position })],
+ };
+};
+
+const EMP: CardHandler = (ctx) => {
+ ctx.ship.empActive = true;
+ return {
+ success: true,
+ message: `${ctx.ship.playerId} activated EMP`,
+ gridUpdates: [],
+ events: [cardEvent('EMP', ctx, { ...ctx.ship.position })],
+ };
+};
+
+const JUMP: CardHandler = (ctx) => {
+ const dir = ctx.ship.direction;
+ const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
+ const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0;
+
+ const jumpPos: Position = {
+ x: ctx.ship.position.x + dx * 2,
+ y: ctx.ship.position.y + dy * 2,
+ };
+
+ if (!ctx.grid.isInBounds(jumpPos)) {
+ return { success: false, message: 'Jump out of bounds', gridUpdates: [], events: [] };
+ }
+
+ const fromPos = { ...ctx.ship.position };
+ ctx.ship.position = jumpPos;
+ return {
+ success: true,
+ message: `${ctx.ship.playerId} jumped 2 tiles forward`,
+ gridUpdates: [],
+ events: [
+ cardEvent('JUMP', ctx, { ...jumpPos }, `${fromPos.x},${fromPos.y}`),
+ moveEvent(ctx, fromPos, jumpPos, 'jump'),
+ ],
+ };
+};
+
+const MINE: CardHandler = (ctx) => {
+ ctx.grid.setTile(ctx.ship.position, 'mine');
+ return {
+ success: true,
+ message: `${ctx.ship.playerId} dropped a mine`,
+ gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }],
+ events: [cardEvent('MINE', ctx, { ...ctx.ship.position })],
+ };
+};
+
+const BOOST: CardHandler = (ctx) => {
+ const dir = ctx.ship.direction;
+ const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
+ const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0;
+
+ const boostPos: Position = {
+ x: ctx.ship.position.x + dx * 3,
+ y: ctx.ship.position.y + dy * 3,
+ };
+
+ if (!ctx.grid.isInBounds(boostPos)) {
+ return { success: false, message: 'Boost out of bounds', gridUpdates: [], events: [] };
+ }
+
+ const fromPos = { ...ctx.ship.position };
+ ctx.ship.position = boostPos;
+ return {
+ success: true,
+ message: `${ctx.ship.playerId} boosted forward`,
+ gridUpdates: [],
+ events: [
+ cardEvent('BOOST', ctx, { ...boostPos }, `${fromPos.x},${fromPos.y}`),
+ moveEvent(ctx, fromPos, boostPos, 'boost'),
+ ],
+ };
+};
+
+const PHASE_SHIFT: CardHandler = (ctx) => {
+ ctx.ship.phaseShifting = true;
+ return {
+ success: true,
+ message: `${ctx.ship.playerId} activated Phase Shift`,
+ gridUpdates: [],
+ events: [cardEvent('PHASE_SHIFT', ctx, { ...ctx.ship.position })],
+ };
+};
+
+const HANDLERS: Record = {
+ SHIELD,
+ EMP,
+ JUMP,
+ MINE,
+ BOOST,
+ PHASE_SHIFT,
+};
+
+export function executeCard(cardType: CardType, ctx: CardContext): CardResult {
+ const handler = HANDLERS[cardType];
+ if (!handler) {
+ return { success: false, message: 'Unknown card', gridUpdates: [], events: [] };
+ }
+ return handler(ctx);
+}
\ No newline at end of file
diff --git a/server/src/game/Executor.ts b/server/src/game/Executor.ts
index 4ddf371..702cad0 100644
--- a/server/src/game/Executor.ts
+++ b/server/src/game/Executor.ts
@@ -76,9 +76,11 @@ export class Executor {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
+ // Stash action on the ship so CardHandler can read .target for METEOR_STRIKE
+ (ship as any).lastAction = action;
+
const ctx = {
ship,
- target: action.target,
grid: this.grid,
allShips: Array.from(this.ships.values()),
shipStates: this.ships,
@@ -90,6 +92,9 @@ export class Executor {
for (const gu of cardResult.gridUpdates) {
result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any });
}
+ for (const ev of cardResult.events) {
+ result.shipUpdates.push(ev);
+ }
} else {
result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`);
}
@@ -118,15 +123,6 @@ export class Executor {
intents.push({ shipId, from: { ...ship.position }, to });
break;
}
- case 'BOOST': {
- const delta = DIRECTION_DELTA[ship.direction];
- const to: Position = {
- x: ship.position.x + delta.x * 3,
- y: ship.position.y + delta.y * 3,
- };
- intents.push({ shipId, from: { ...ship.position }, to });
- break;
- }
case 'TURN_LEFT': {
const dirs: Direction[] = ['N', 'W', 'S', 'E'];
const idx = dirs.indexOf(ship.direction);
diff --git a/server/src/index.ts b/server/src/index.ts
index 4abae3b..13fb1aa 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -1,5 +1,6 @@
import express from 'express';
import cors from 'cors';
+import path from 'path';
import { createServer } from 'http';
import { WsServer } from './ws/WsServer.js';
import { GAME_CONFIG } from './config.js';
@@ -8,6 +9,13 @@ const app = express();
app.use(cors());
app.use(express.json());
+// Serve controller frontend
+app.use('/controller', express.static(path.join(__dirname, '../../controller/dist')));
+
+// Serve TV frontend at root
+app.use('/', express.static(path.join(__dirname, '../../tv/dist')));
+app.get('/', (_req, res) => res.sendFile(path.join(__dirname, '../../tv/dist/index.html')));
+
const httpServer = createServer(app);
const wsServer = new WsServer(httpServer);
diff --git a/shared/src/audio.ts b/shared/src/audio.ts
new file mode 100644
index 0000000..e4d7f31
--- /dev/null
+++ b/shared/src/audio.ts
@@ -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> = {
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/shared/src/index.ts b/shared/src/index.ts
index d470296..e0ace6c 100644
--- a/shared/src/index.ts
+++ b/shared/src/index.ts
@@ -1 +1,3 @@
export * from './types.js';
+export * from './theme.js';
+export * from './audio.js';
diff --git a/shared/src/theme.ts b/shared/src/theme.ts
new file mode 100644
index 0000000..e1605e5
--- /dev/null
+++ b/shared/src/theme.ts
@@ -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;
diff --git a/shared/src/types.ts b/shared/src/types.ts
index 9106ada..a85c30c 100644
--- a/shared/src/types.ts
+++ b/shared/src/types.ts
@@ -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 = {
- 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 = {
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 = {
};
// ββ 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 }
diff --git a/tv/index.html b/tv/index.html
index 468f314..e03dede 100644
--- a/tv/index.html
+++ b/tv/index.html
@@ -4,14 +4,40 @@
SpaceRace - TV
+
+
+
+
-
+