SpaceRace: Full-width TV field, death line scrolling, camera following leader, jump fix, path never fully blocked, player color on controller
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<title>SpaceRace - Controller</title>
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="screen-join" class="screen active">
|
||||
<h1>🚀 SpaceRace</h1>
|
||||
<div class="join-form">
|
||||
<input type="text" id="room-input" placeholder="Room Code" maxlength="4" autocomplete="off" />
|
||||
<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>
|
||||
</div>
|
||||
<div id="qr-container"></div>
|
||||
</div>
|
||||
|
||||
<div id="screen-planning" class="screen">
|
||||
<div class="planning-header">
|
||||
<div class="header-left">
|
||||
<span id="color-dot" class="color-dot"></span>
|
||||
<span id="round-label">Round 1</span>
|
||||
</div>
|
||||
<div class="header-center">
|
||||
<span id="timer-label">45s</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span id="ap-label">AP: 0/4</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AP bar -->
|
||||
<div class="ap-bar">
|
||||
<div id="ap-fill" class="ap-fill"></div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons grid -->
|
||||
<div id="action-grid" class="action-grid">
|
||||
<button class="act-btn" data-action="TURN_LEFT">
|
||||
<span class="act-icon">↰</span>
|
||||
<span class="act-label">Left</span>
|
||||
<span class="act-cost">1 AP</span>
|
||||
</button>
|
||||
<button class="act-btn primary" data-action="MOVE_FORWARD">
|
||||
<span class="act-icon">↑</span>
|
||||
<span class="act-label">Forward</span>
|
||||
<span class="act-cost">1 AP</span>
|
||||
</button>
|
||||
<button class="act-btn" data-action="TURN_RIGHT">
|
||||
<span class="act-icon">↱</span>
|
||||
<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 id="queue-list" class="queue-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Card hand -->
|
||||
<div id="card-hand" class="card-hand">
|
||||
<div id="card-list" class="card-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom buttons -->
|
||||
<div class="planning-bottom">
|
||||
<button id="clear-plan-btn" class="btn-clear">✕ Clear</button>
|
||||
<button id="submit-plan-btn" class="btn-submit" disabled>GO!</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="screen-waiting" class="screen">
|
||||
<h2>Executing...</h2>
|
||||
<p id="waiting-message">Watch the TV!</p>
|
||||
</div>
|
||||
|
||||
<div id="screen-spectator" class="screen">
|
||||
<h2>💀 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>
|
||||
<div id="lobby-players">
|
||||
<h3>Players</h3>
|
||||
<ul id="lobby-player-list"></ul>
|
||||
</div>
|
||||
<p id="lobby-waiting">Waiting for host to start...</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@spacerace/controller",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --port 3001",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@spacerace/shared": "*",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"qr-code-styling": "^1.6.0-rc.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { GameGridState, Ship, GRID_WIDTH, TileType } from '@spacerace/shared';
|
||||
|
||||
export class MiniMap {
|
||||
private container: HTMLElement;
|
||||
private ships: Ship[];
|
||||
|
||||
constructor(container: HTMLElement, ships: Ship[]) {
|
||||
this.container = container;
|
||||
this.ships = ships;
|
||||
}
|
||||
|
||||
update(grid: GameGridState, ships: Ship[]): void {
|
||||
this.ships = ships;
|
||||
this.container.innerHTML = '';
|
||||
|
||||
const deathLineY = grid.deathLineY;
|
||||
const visibleRows = 8;
|
||||
const cellSize = this.container.clientHeight / visibleRows;
|
||||
const cellW = this.container.clientWidth / GRID_WIDTH;
|
||||
|
||||
// Render grid cells
|
||||
for (let dy = 0; dy < visibleRows; dy++) {
|
||||
const gridY = deathLineY - dy;
|
||||
const row = grid.rows[gridY] || Array(GRID_WIDTH).fill('space');
|
||||
|
||||
for (let x = 0; x < GRID_WIDTH; x++) {
|
||||
const tile = (row[x] as TileType) || 'space';
|
||||
const cell = document.createElement('div');
|
||||
cell.className = 'minimap-cell';
|
||||
if (tile !== 'space') cell.classList.add(tile);
|
||||
cell.style.left = `${x * cellW}px`;
|
||||
cell.style.top = `${dy * cellSize}px`;
|
||||
cell.style.width = `${cellW}px`;
|
||||
cell.style.height = `${cellSize}px`;
|
||||
this.container.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// Render ships
|
||||
const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44'];
|
||||
for (let i = 0; i < ships.length; i++) {
|
||||
const ship = ships[i];
|
||||
if (!ship.alive) continue;
|
||||
|
||||
const relativeY = deathLineY - ship.position.y;
|
||||
if (relativeY < 0 || relativeY >= visibleRows) continue;
|
||||
|
||||
const shipEl = document.createElement('div');
|
||||
shipEl.className = 'minimap-ship';
|
||||
shipEl.style.left = `${ship.position.x * cellW}px`;
|
||||
shipEl.style.top = `${relativeY * cellSize}px`;
|
||||
shipEl.style.width = `${cellW}px`;
|
||||
shipEl.style.height = `${cellSize}px`;
|
||||
shipEl.style.backgroundColor = colors[i % colors.length];
|
||||
shipEl.style.borderRadius = '50%';
|
||||
shipEl.style.fontSize = '8px';
|
||||
shipEl.textContent = ship.playerId.substring(0, 3);
|
||||
this.container.appendChild(shipEl);
|
||||
}
|
||||
|
||||
// Death line
|
||||
const dl = document.createElement('div');
|
||||
dl.style.cssText = `
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: ${visibleRows * cellSize}px;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #ff0000;
|
||||
opacity: 0.6;
|
||||
`;
|
||||
this.container.appendChild(dl);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { connectControllerSocket, ControllerSocket } from './network/ControllerSocket.js';
|
||||
import { JoinScreen } from './screens/JoinScreen.js';
|
||||
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';
|
||||
|
||||
class App {
|
||||
socket: ControllerSocket;
|
||||
private currentScreen: string = 'join';
|
||||
isHost: boolean = false;
|
||||
roomCode: string = '';
|
||||
playerId: string = '';
|
||||
|
||||
constructor() {
|
||||
this.socket = connectControllerSocket();
|
||||
this.initScreens();
|
||||
this.initSocketEvents();
|
||||
}
|
||||
|
||||
private initScreens(): void {
|
||||
new JoinScreen(this);
|
||||
new LobbyScreen(this);
|
||||
new PlanningScreen(this);
|
||||
new WaitingScreen(this);
|
||||
new SpectatorScreen(this);
|
||||
}
|
||||
|
||||
private initSocketEvents(): void {
|
||||
this.socket.on('disconnect', () => {
|
||||
this.clearAllScreens();
|
||||
});
|
||||
|
||||
this.socket.on('roomJoined', (data) => {
|
||||
this.isHost = data.isHost;
|
||||
this.roomCode = data.roomCode;
|
||||
this.playerId = data.playerId;
|
||||
this.showScreen('lobby');
|
||||
});
|
||||
|
||||
this.socket.on('gameStarting', () => {
|
||||
this.showScreen('planning');
|
||||
});
|
||||
|
||||
this.socket.on('planningRequest', (data) => {
|
||||
this.showScreen('planning');
|
||||
// Pass data to planning screen
|
||||
document.dispatchEvent(new CustomEvent('planningRequest', { detail: data }));
|
||||
});
|
||||
|
||||
this.socket.on('executionTick', (data) => {
|
||||
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.showScreen('spectator');
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('gameOver', (data) => {
|
||||
this.showScreen('spectator');
|
||||
});
|
||||
}
|
||||
|
||||
showScreen(name: string): void {
|
||||
document.querySelectorAll('.screen').forEach((el) => el.classList.remove('active'));
|
||||
const el = document.getElementById(`screen-${name}`);
|
||||
if (el) el.classList.add('active');
|
||||
this.currentScreen = name;
|
||||
}
|
||||
|
||||
clearAllScreens(): void {
|
||||
document.querySelectorAll('.screen').forEach((el) => el.classList.remove('active'));
|
||||
document.getElementById('screen-join')?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Boot
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new App();
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { ServerToMobileEvents, MobileToServerEvents } from '@spacerace/shared';
|
||||
|
||||
export type ControllerSocket = Socket<ServerToMobileEvents, MobileToServerEvents>;
|
||||
|
||||
export function connectControllerSocket(): ControllerSocket {
|
||||
const socket: ControllerSocket = io('/', {
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { App } from '../main.js';
|
||||
|
||||
export class JoinScreen {
|
||||
private app: App;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
this.init();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
const joinBtn = document.getElementById('join-btn')!;
|
||||
const roomInput = document.getElementById('room-input') as HTMLInputElement;
|
||||
const nameInput = document.getElementById('name-input') as HTMLInputElement;
|
||||
const errorEl = document.getElementById('join-error')!;
|
||||
|
||||
// Pre-fill room code from URL if present
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const roomParam = params.get('room');
|
||||
if (roomParam) {
|
||||
roomInput.value = roomParam.toUpperCase();
|
||||
}
|
||||
|
||||
joinBtn.addEventListener('click', () => {
|
||||
const roomCode = roomInput.value.trim().toUpperCase();
|
||||
const playerName = nameInput.value.trim();
|
||||
|
||||
if (!roomCode || roomCode.length !== 4) {
|
||||
errorEl.textContent = 'Enter a 4-character room code';
|
||||
return;
|
||||
}
|
||||
if (!playerName) {
|
||||
errorEl.textContent = 'Enter your name';
|
||||
return;
|
||||
}
|
||||
|
||||
errorEl.textContent = '';
|
||||
joinBtn.textContent = 'Joining...';
|
||||
joinBtn.disabled = true;
|
||||
|
||||
this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => {
|
||||
joinBtn.textContent = 'Join Race';
|
||||
joinBtn.disabled = false;
|
||||
if (!res.ok) {
|
||||
errorEl.textContent = res.error || 'Failed to join';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { App } from '../main.js';
|
||||
|
||||
export class LobbyScreen {
|
||||
private app: App;
|
||||
private startBtn: HTMLButtonElement | null = null;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
|
||||
this.createStartButton();
|
||||
|
||||
app.socket.on('roomJoined', (data) => {
|
||||
document.getElementById('lobby-room')!.textContent = `Room: ${data.roomCode}`;
|
||||
this.updatePlayerList(data.players);
|
||||
this.updateHostUI();
|
||||
});
|
||||
|
||||
app.socket.on('playerJoined', (data) => {
|
||||
this.appendPlayer(data.playerId, data.name);
|
||||
this.updateHostUI();
|
||||
});
|
||||
|
||||
app.socket.on('playerLeft', (data) => {
|
||||
const li = document.getElementById(`player-${data.playerId}`);
|
||||
if (li) li.remove();
|
||||
this.updateHostUI();
|
||||
});
|
||||
|
||||
app.socket.on('hostChanged', (data) => {
|
||||
this.app.isHost = data.hostPlayerId === this.app.playerId;
|
||||
this.updateHostUI();
|
||||
});
|
||||
}
|
||||
|
||||
private createStartButton(): void {
|
||||
const div = document.createElement('div');
|
||||
div.id = 'host-controls';
|
||||
div.style.cssText = 'margin-top: 16px; text-align: center; display: none;';
|
||||
|
||||
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.addEventListener('click', () => {
|
||||
if (!this.startBtn) return;
|
||||
this.startBtn.textContent = 'Starting...';
|
||||
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;
|
||||
alert(res.error || 'Cannot start');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private updateHostUI(): void {
|
||||
const div = document.getElementById('host-controls')!;
|
||||
const waitingEl = document.getElementById('lobby-waiting')!;
|
||||
|
||||
if (this.app.isHost) {
|
||||
div.style.display = 'block';
|
||||
waitingEl.style.display = 'none';
|
||||
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...';
|
||||
}
|
||||
}
|
||||
|
||||
private updatePlayerList(players: { id: string; name: string }[]): void {
|
||||
const list = document.getElementById('lobby-player-list')!;
|
||||
list.innerHTML = '';
|
||||
for (const p of players) {
|
||||
this.appendPlayer(p.id, p.name);
|
||||
}
|
||||
}
|
||||
|
||||
private appendPlayer(id: string, name: string): void {
|
||||
const list = document.getElementById('lobby-player-list')!;
|
||||
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}`;
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { App } from '../main.js';
|
||||
import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost } from '@spacerace/shared';
|
||||
|
||||
const CARD_ICONS: Record<CardType, string> = {
|
||||
METEOR_STRIKE: '☄️',
|
||||
SHIELD: '🛡️',
|
||||
BOOST: '🚀',
|
||||
EMP: '⚡',
|
||||
JUMP: '🦘',
|
||||
MINE: '💣',
|
||||
TELEPORT: '🌀',
|
||||
PHASE_SHIFT: '👻',
|
||||
};
|
||||
|
||||
export class PlanningScreen {
|
||||
private app: App;
|
||||
private plannedActions: Action[] = [];
|
||||
private playerView: PlayerView | null = null;
|
||||
private apTotal: number = 4;
|
||||
private selectedCard: CardType | null = null;
|
||||
private timerInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private timerSeconds: number = 0;
|
||||
|
||||
constructor(app: App) {
|
||||
this.app = app;
|
||||
|
||||
document.addEventListener('planningRequest', ((e: CustomEvent) => {
|
||||
this.onPlanningRequest(e.detail);
|
||||
}) as EventListener);
|
||||
|
||||
this.initActionButtons();
|
||||
this.initBottomButtons();
|
||||
}
|
||||
|
||||
private onPlanningRequest(data: {
|
||||
round: number; playerView: PlayerView; grid: GameGridState; ships: Ship[]; timer: number;
|
||||
}): void {
|
||||
this.playerView = data.playerView;
|
||||
this.plannedActions = [];
|
||||
this.selectedCard = null;
|
||||
this.apTotal = data.playerView.ap;
|
||||
this.timerSeconds = data.timer;
|
||||
|
||||
this.setPlayerColor(data.playerView.colorIndex);
|
||||
|
||||
document.getElementById('round-label')!.textContent = `Round ${data.round}`;
|
||||
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'GO!';
|
||||
this.updateApDisplay();
|
||||
this.renderQueue();
|
||||
this.renderCards(data.playerView.hand);
|
||||
this.updateTimer();
|
||||
|
||||
if (this.timerInterval) clearInterval(this.timerInterval);
|
||||
this.timerInterval = setInterval(() => {
|
||||
this.timerSeconds--;
|
||||
this.updateTimer();
|
||||
if (this.timerSeconds <= 0 && this.timerInterval) {
|
||||
clearInterval(this.timerInterval);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
private initActionButtons(): void {
|
||||
const grid = document.getElementById('action-grid')!;
|
||||
grid.querySelectorAll('.act-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const actionType = (btn as HTMLElement).dataset.action!;
|
||||
this.addAction({ type: actionType as Action['type'] });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private initBottomButtons(): void {
|
||||
document.getElementById('clear-plan-btn')!.addEventListener('click', () => {
|
||||
this.plannedActions = [];
|
||||
this.selectedCard = null;
|
||||
this.renderQueue();
|
||||
this.updateApDisplay();
|
||||
this.renderCards(this.playerView?.hand || []);
|
||||
});
|
||||
|
||||
document.getElementById('submit-plan-btn')!.addEventListener('click', () => {
|
||||
if (this.plannedActions.length === 0) return;
|
||||
const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
|
||||
this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => {
|
||||
if (!res.ok) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'GO!';
|
||||
alert(res.error || 'Invalid plan');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
this.plannedActions.push({ type: 'CARD', card: this.selectedCard });
|
||||
this.selectedCard = null;
|
||||
this.renderCards(this.playerView.hand);
|
||||
this.renderQueue();
|
||||
this.updateApDisplay();
|
||||
return;
|
||||
}
|
||||
|
||||
const cost = actionApCost(action);
|
||||
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
|
||||
if (used + cost > this.apTotal) return;
|
||||
|
||||
this.plannedActions.push(action);
|
||||
this.renderQueue();
|
||||
this.updateApDisplay();
|
||||
|
||||
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
|
||||
submitBtn.disabled = this.plannedActions.length === 0;
|
||||
}
|
||||
|
||||
private renderQueue(): void {
|
||||
const list = document.getElementById('queue-list')!;
|
||||
list.innerHTML = '';
|
||||
|
||||
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;
|
||||
}
|
||||
div.textContent = icon;
|
||||
div.title = action.type + (action.card ? ` ${action.card}` : '');
|
||||
|
||||
// X button to remove
|
||||
const remove = document.createElement('span');
|
||||
remove.className = 'remove-hint';
|
||||
remove.textContent = '×';
|
||||
div.appendChild(remove);
|
||||
|
||||
div.addEventListener('click', () => {
|
||||
this.plannedActions.splice(i, 1);
|
||||
this.renderQueue();
|
||||
this.updateApDisplay();
|
||||
this.renderCards(this.playerView?.hand || []);
|
||||
});
|
||||
|
||||
list.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
private renderCards(hand: CardType[]): void {
|
||||
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);
|
||||
}
|
||||
|
||||
const availableCards = hand.filter((c) => !usedCards.has(c));
|
||||
|
||||
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>
|
||||
`;
|
||||
|
||||
div.addEventListener('click', () => {
|
||||
const wasSelected = this.selectedCard === card;
|
||||
this.selectedCard = wasSelected ? null : card;
|
||||
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 fill = document.getElementById('ap-fill')!;
|
||||
fill.style.width = `${(remaining / this.apTotal) * 100}%`;
|
||||
|
||||
if (remaining === 0) {
|
||||
fill.style.background = '#ff4444';
|
||||
document.getElementById('ap-label')!.style.color = '#ff4444';
|
||||
} else if (remaining === this.apTotal) {
|
||||
fill.style.background = '#00ff88';
|
||||
document.getElementById('ap-label')!.style.color = '#00ff88';
|
||||
} else {
|
||||
fill.style.background = '#ffcc00';
|
||||
document.getElementById('ap-label')!.style.color = '#ffcc00';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { App } from '../main.js';
|
||||
|
||||
export class SpectatorScreen {
|
||||
constructor(app: App) {
|
||||
app.socket.on('gameOver', (data) => {
|
||||
const h2 = document.querySelector('#screen-spectator h2')!;
|
||||
if (data.winnerId === app.socket.data?.playerId) {
|
||||
h2.textContent = '🏆 You Win!';
|
||||
} else {
|
||||
h2.textContent = `💀 ${data.winnerName} Wins!`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { App } from '../main.js';
|
||||
|
||||
export class WaitingScreen {
|
||||
constructor(app: App) {
|
||||
// Mostly handled by main.ts socket events
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: monospace;
|
||||
background: #0a0a2e;
|
||||
color: #ffffff;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screen {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.screen.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
/* Join Screen */
|
||||
#screen-join { align-items: center; justify-content: flex-start; padding-top: 40px; }
|
||||
|
||||
.join-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 14px;
|
||||
font-size: 20px;
|
||||
font-family: monospace;
|
||||
border: 2px solid #334466;
|
||||
background: #111133;
|
||||
color: #ffffff;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #00ccff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#name-input { text-transform: none; }
|
||||
|
||||
button {
|
||||
padding: 14px;
|
||||
font-size: 18px;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
background: #00ccff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
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-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #0d0d35;
|
||||
border-bottom: 2px solid #1a1a55;
|
||||
}
|
||||
.header-left, .header-right { flex: 1; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; }
|
||||
.color-dot {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.ap-fill {
|
||||
height: 100%;
|
||||
background: #00ff88;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
/* Action grid */
|
||||
.action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.act-btn {
|
||||
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;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
.act-icon {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
}
|
||||
.act-label {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #ccccff;
|
||||
}
|
||||
.act-cost {
|
||||
font-size: 11px;
|
||||
color: #6666aa;
|
||||
}
|
||||
|
||||
/* Action queue */
|
||||
.action-queue {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #1a1a44;
|
||||
background: #080820;
|
||||
}
|
||||
.queue-list {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
}
|
||||
.queue-item {
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #1a3355;
|
||||
border: 2px solid #3366aa;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
.queue-item:active {
|
||||
background: #442222;
|
||||
border-color: #ff4444;
|
||||
}
|
||||
.queue-item .remove-hint {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #ff4444;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
font-size: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Card hand */
|
||||
.card-hand {
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid #1a1a44;
|
||||
background: #0a0a28;
|
||||
}
|
||||
.card-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.card-item {
|
||||
flex-shrink: 0;
|
||||
width: 110px;
|
||||
min-height: 60px;
|
||||
padding: 8px 10px;
|
||||
background: #151535;
|
||||
border: 2px solid #334466;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.card-item.selected {
|
||||
border-color: #ffaa00;
|
||||
background: #221a10;
|
||||
box-shadow: 0 0 12px #ffaa0044;
|
||||
}
|
||||
.card-item .card-icon {
|
||||
font-size: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
.card-item .card-name {
|
||||
font-size: 11px;
|
||||
font-weight: bold;
|
||||
color: #ffaa00;
|
||||
text-align: center;
|
||||
}
|
||||
.card-item .card-cost {
|
||||
font-size: 10px;
|
||||
color: #8888aa;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Bottom row */
|
||||
.planning-bottom {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 16px;
|
||||
border-top: 1px solid #1a1a44;
|
||||
background: #0d0d35;
|
||||
}
|
||||
.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;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-submit {
|
||||
flex: 2;
|
||||
padding: 14px;
|
||||
font-size: 20px;
|
||||
font-family: monospace;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
background: #00cc44;
|
||||
color: #000;
|
||||
}
|
||||
.btn-submit:disabled {
|
||||
background: #223344;
|
||||
color: #556677;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Waiting Screen */
|
||||
#screen-waiting { align-items: center; justify-content: center; }
|
||||
#waiting-message { font-size: 16px; color: #8888aa; margin-top: 12px; }
|
||||
|
||||
/* Spectator Screen */
|
||||
#screen-spectator { align-items: center; justify-content: center; }
|
||||
#screen-spectator p { color: #8888aa; margin-top: 8px; }
|
||||
|
||||
/* 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; }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:8080',
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user