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

This commit is contained in:
2026-06-24 20:32:34 +02:00
parent 5e2ab43ca3
commit 8531864b25
30 changed files with 3036 additions and 629 deletions
+25 -13
View File
@@ -4,14 +4,24 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>SpaceRace - Controller</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<button id="audio-toggle" class="audio-toggle" aria-label="Toggle sound" title="Toggle sound">
<span class="audio-icon-on">🔊</span>
<span class="audio-icon-off">🔇</span>
</button>
<div id="screen-join" class="screen active">
<h1>🚀 SpaceRace</h1>
<p class="join-sub">Pilot your ship to victory</p>
<div class="join-form">
<input type="text" id="room-input" placeholder="Room Code" maxlength="4" autocomplete="off" />
<label for="room-input">Room Code</label>
<input type="text" id="room-input" placeholder="ABCD" maxlength="4" autocomplete="off" />
<label for="name-input">Callsign</label>
<input type="text" id="name-input" placeholder="Your Name" maxlength="12" autocomplete="off" />
<button id="join-btn">Join Race</button>
<p id="join-error" class="error"></p>
@@ -23,9 +33,10 @@
<div class="planning-header">
<div class="header-left">
<span id="color-dot" class="color-dot"></span>
<span id="round-label">Round 1</span>
<span id="player-name" class="player-name">Pilot</span>
</div>
<div class="header-center">
<span id="round-label">Round 1</span>
<span id="timer-label">45s</span>
</div>
<div class="header-right">
@@ -55,20 +66,17 @@
<span class="act-label">Right</span>
<span class="act-cost">1 AP</span>
</button>
<button class="act-btn accent" data-action="BOOST">
<span class="act-icon"></span>
<span class="act-label">Boost</span>
<span class="act-cost">2 AP</span>
</button>
</div>
<!-- Action queue -->
<div id="action-queue" class="action-queue">
<div class="action-queue-label">Action Sequence</div>
<div id="queue-list" class="queue-list"></div>
</div>
<!-- Card hand -->
<div id="card-hand" class="card-hand">
<div class="card-hand-label">Tactical Cards</div>
<div id="card-list" class="card-list"></div>
</div>
@@ -80,20 +88,24 @@
</div>
<div id="screen-waiting" class="screen">
<h2>Executing...</h2>
<p id="waiting-message">Watch the TV!</p>
<div class="tv-icon"></div>
<h2>Executing…</h2>
<p id="waiting-message">Watch the TV screen</p>
</div>
<div id="screen-spectator" class="screen">
<h2>💀 Eliminated</h2>
<h2 id="spectator-title">💀 Eliminated</h2>
<p>You're out! Watch the TV to see who wins.</p>
</div>
<div id="screen-lobby" class="screen">
<h2>Lobby</h2>
<p id="lobby-room">Room: ----</p>
<h2>Mission Lobby</h2>
<div class="lobby-room-badge">
<span class="lobby-room-label">Room Code</span>
<span id="lobby-room" class="lobby-room-code">----</span>
</div>
<div id="lobby-players">
<h3>Players</h3>
<h3>Crew Manifest</h3>
<ul id="lobby-player-list"></ul>
</div>
<p id="lobby-waiting">Waiting for host to start...</p>
+41 -8
View File
@@ -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');
});
}
+13 -1
View File
@@ -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');
}
});
});
}
}
+43 -15
View File
@@ -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);
}
}
}
+91 -38
View File
@@ -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<CardType, string> = {
METEOR_STRIKE: '☄️',
SHIELD: '🛡️',
BOOST: '🚀',
EMP: '⚡',
JUMP: '🦘',
MINE: '💣',
TELEPORT: '🌀',
PHASE_SHIFT: '👻',
};
const ACTION_ICONS: Record<string, string> = {
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<CardType>();
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 = `
<div class="card-icon">${CARD_ICONS[card]}</div>
<div class="card-name">${def.name}</div>
<div class="card-cost">${def.apCost} AP</div>
`;
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 = '<div style="color:#445566;padding:8px;font-size:12px">No cards available</div>';
}
}
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');
}
}
}
}
+13 -2
View File
@@ -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');
});
}
}
}
+724 -175
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: '/controller/',
server: {
port: 3001,
proxy: {