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:
+25
-13
@@ -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
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/controller/',
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user