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:
2026-06-24 16:41:37 +02:00
commit 5e2ab43ca3
46 changed files with 6581 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+104
View File
@@ -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>
+19
View File
@@ -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"
}
}
+74
View File
@@ -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);
}
}
+89
View File
@@ -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;
}
+50
View File
@@ -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';
}
});
});
}
}
+105
View File
@@ -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);
}
}
+237
View File
@@ -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';
}
}
}
+14
View File
@@ -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!`;
}
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { App } from '../main.js';
export class WaitingScreen {
constructor(app: App) {
// Mostly handled by main.ts socket events
}
}
+335
View File
@@ -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; }
+15
View File
@@ -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"]
}
+16
View File
@@ -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',
},
});
+8
View File
@@ -0,0 +1,8 @@
services:
server:
build:
context: .
dockerfile: server/Dockerfile
ports:
- "8080:8080"
restart: unless-stopped
+2922
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "spacerace",
"private": true,
"workspaces": [
"shared",
"server",
"tv",
"controller"
],
"scripts": {
"dev:server": "npm -w server run dev",
"dev:tv": "npm -w tv run dev",
"dev:controller": "npm -w controller run dev",
"build": "npm -w shared run build && npm -w server run build && npm -w tv run build && npm -w controller run build"
}
}
+10
View File
@@ -0,0 +1,10 @@
FROM node:22-alpine AS server-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
EXPOSE 8080
CMD ["npx", "tsx", "src/index.ts"]
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@spacerace/server",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit",
"start": "tsx src/index.ts"
},
"dependencies": {
"@spacerace/shared": "*",
"socket.io": "^4.7.5",
"cors": "^2.8.5",
"express": "^4.19.2",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/cors": "^2.8.17",
"@types/node": "^20.14.0",
"tsx": "^4.15.0",
"typescript": "^5.5.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
export const GAME_CONFIG = {
GRID_WIDTH: 8,
VISIBLE_ROWS: 10,
AP_PER_ROUND: 4,
DEATH_LINE_ADVANCE: 1,
MAX_HAND_SIZE: 3,
PLANNING_TIME_SECONDS: 45,
EXECUTION_TICK_MS: 500,
MIN_PLAYERS: 2,
MAX_PLAYERS: 6,
TRACK_DENSITY: 0.2, // probability of asteroid per tile
SERVE_PORT: 8080,
} as const;
+48
View File
@@ -0,0 +1,48 @@
import { Action, actionApCost, CardType, CARD_DEFS } from '@spacerace/shared';
import { GAME_CONFIG } from '../config.js';
export function validatePlan(actions: Action[], hand: CardType[]): { valid: boolean; error?: string } {
if (actions.length === 0) {
return { valid: false, error: 'Plan cannot be empty' };
}
let totalAp = 0;
const usedCards = new Set<CardType>();
for (const action of actions) {
const cost = actionApCost(action);
totalAp += cost;
if (totalAp > GAME_CONFIG.AP_PER_ROUND) {
return { valid: false, error: `Exceeds ${GAME_CONFIG.AP_PER_ROUND} AP limit (used ${totalAp})` };
}
if (action.type === 'CARD') {
if (!action.card) {
return { valid: false, error: 'Card action requires a card type' };
}
if (!CARD_DEFS[action.card]) {
return { valid: false, error: `Unknown card: ${action.card}` };
}
if (!hand.includes(action.card)) {
return { valid: false, error: `Card ${action.card} not in hand` };
}
if (usedCards.has(action.card)) {
return { valid: false, error: `Card ${action.card} used twice` };
}
usedCards.add(action.card);
}
}
return { valid: true };
}
export function removeUsedCards(actions: Action[], hand: CardType[]): CardType[] {
const usedCards = new Set<CardType>();
for (const action of actions) {
if (action.type === 'CARD' && action.card) {
usedCards.add(action.card);
}
}
return hand.filter((c) => !usedCards.has(c));
}
+91
View File
@@ -0,0 +1,91 @@
import { CardType, Position, CARD_DEFS } from '@spacerace/shared';
import { Grid } from './Grid.js';
import { Ship } from './Ship.js';
export interface CardContext {
ship: Ship;
target?: Position;
grid: Grid;
allShips: Ship[];
shipStates: Map<string, Ship>;
}
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: [] };
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: [] };
}
}
+309
View File
@@ -0,0 +1,309 @@
import {
Action,
PlayerPlan,
Position,
Direction,
DIRECTION_DELTA,
isBlocked as isTileBlocked,
ExecutionResult,
ShipUpdate,
GridUpdate,
CardType,
} from '@spacerace/shared';
import { Grid } from './Grid.js';
import { Ship } from './Ship.js';
import { executeCard } from './CardHandler.js';
interface TickPlan {
shipId: string;
actionIndex: number;
action: Action;
}
export class Executor {
private ships: Map<string, Ship>;
private grid: Grid;
private deadPlayers: Set<string> = new Set();
constructor(ships: Map<string, Ship>, grid: Grid) {
this.ships = ships;
this.grid = grid;
}
execute(plans: PlayerPlan[]): ExecutionResult[] {
const results: ExecutionResult[] = [];
// Build tick-based action queue
const planMap = new Map<string, Action[]>();
for (const plan of plans) {
const ship = Array.from(this.ships.values()).find((s) => s.playerId === plan.playerId);
if (ship && ship.alive) {
planMap.set(ship.id, [...plan.actions]);
}
}
// Determine max ticks
let maxTicks = 0;
for (const actions of planMap.values()) {
maxTicks = Math.max(maxTicks, actions.length);
}
// Execute tick by tick
for (let tick = 0; tick < maxTicks; tick++) {
const result: ExecutionResult = {
tick,
shipUpdates: [],
gridUpdates: [],
messages: [],
};
// Phase 1: Resolve card actions first
const cardActions: { shipId: string; action: Action }[] = [];
const nonCardActions: { shipId: string; action: Action }[] = [];
for (const [shipId, actions] of planMap) {
if (tick >= actions.length) continue;
const action = actions[tick];
if (action.type === 'CARD') {
cardActions.push({ shipId, action });
} else {
nonCardActions.push({ shipId, action });
}
}
// Execute card actions
for (const { shipId, action } of cardActions) {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
const ctx = {
ship,
target: action.target,
grid: this.grid,
allShips: Array.from(this.ships.values()),
shipStates: this.ships,
};
const cardResult = executeCard(action.card as CardType, ctx);
if (cardResult.success) {
result.messages.push(cardResult.message);
for (const gu of cardResult.gridUpdates) {
result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any });
}
} else {
result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`);
}
}
// Phase 2: Determine movement intentions
interface MoveIntent {
shipId: string;
from: Position;
to: Position | null;
}
const intents: MoveIntent[] = [];
for (const { shipId, action } of nonCardActions) {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
switch (action.type) {
case 'MOVE_FORWARD': {
const delta = DIRECTION_DELTA[ship.direction];
const to: Position = {
x: ship.position.x + delta.x,
y: ship.position.y + delta.y,
};
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);
ship.direction = dirs[(idx + 1) % 4];
result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction });
result.messages.push(`[${ship.playerId}] turned left`);
intents.push({ shipId, from: { ...ship.position }, to: null });
break;
}
case 'TURN_RIGHT': {
const dirs: Direction[] = ['N', 'E', 'S', 'W'];
const idx = dirs.indexOf(ship.direction);
ship.direction = dirs[(idx + 1) % 4];
result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction });
result.messages.push(`[${ship.playerId}] turned right`);
intents.push({ shipId, from: { ...ship.position }, to: null });
break;
}
case 'TURN_180': {
const dirs: Direction[] = ['N', 'S', 'E', 'W'];
const map: Record<Direction, Direction> = { N: 'S', S: 'N', E: 'W', W: 'E' };
ship.direction = map[ship.direction];
result.shipUpdates.push({ type: 'turn', shipId, direction: ship.direction });
result.messages.push(`[${ship.playerId}] turned 180°`);
intents.push({ shipId, from: { ...ship.position }, to: null });
break;
}
}
}
// Phase 3: Resolve movement conflicts
// Count how many ships want to go to each tile
const destinationCount = new Map<string, string[]>();
for (const intent of intents) {
if (!intent.to) continue;
const key = `${intent.to.x},${intent.to.y}`;
if (!destinationCount.has(key)) {
destinationCount.set(key, []);
}
destinationCount.get(key)!.push(intent.shipId);
}
// Track which ships are leaving their current tile
const leavingPositions = new Set<string>();
const shipsMovedThisTick = new Set<string>();
for (const intent of intents) {
if (!intent.to) continue;
const ship = this.ships.get(intent.shipId);
if (!ship || !ship.alive) continue;
const destKey = `${intent.to.x},${intent.to.y}`;
const shipsToDest = destinationCount.get(destKey) || [];
// Conflict: multiple ships want same tile
if (shipsToDest.length > 1) {
result.messages.push(`[${ship.playerId}] collision conflict at (${intent.to.x},${intent.to.y})`);
result.shipUpdates.push({ type: 'collision', shipId: intent.shipId, position: { ...ship.position } });
continue;
}
// Check bounds
if (!this.grid.isInBounds(intent.to)) {
result.messages.push(`[${ship.playerId}] blocked by track boundary`);
continue;
}
// Check obstacles (phase shift ignores them)
if (!ship.phaseShifting && this.grid.isBlocked(intent.to)) {
if (ship.shielded) {
ship.shielded = false;
result.messages.push(`[${ship.playerId}] shield absorbed obstacle`);
result.shipUpdates.push({ type: 'shield_used', shipId: intent.shipId });
// Still move there with shield
} else {
result.messages.push(`[${ship.playerId}] blocked by obstacle at (${intent.to.x},${intent.to.y})`);
continue;
}
}
// Check if a ship is already at destination and not moving away
const to = intent.to!;
const occupyingShip = Array.from(this.ships.values()).find(
(s) => s.alive && s.id !== intent.shipId &&
s.position.x === to.x && s.position.y === to.y
);
if (occupyingShip) {
// Check if occupying ship is also leaving this tick
const occupierLeaving = intents.find(
(i) => i.shipId === occupyingShip.id && i.to !== null
);
if (!occupierLeaving) {
result.messages.push(`[${ship.playerId}] blocked by ${occupyingShip.playerId}'s ship`);
continue;
}
}
// Move is valid!
const fromPos = { ...ship.position };
ship.position = { ...to };
shipsMovedThisTick.add(intent.shipId);
leavingPositions.add(`${fromPos.x},${fromPos.y}`);
if (ship.phaseShifting) {
ship.phaseShifting = false;
}
result.shipUpdates.push({
type: 'move',
shipId: intent.shipId,
from: fromPos,
to: { ...to },
});
result.messages.push(`[${ship.playerId}] moved to (${to.x},${to.y})`);
}
// Check mine explosions on ships that just moved
for (const shipId of shipsMovedThisTick) {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
if (this.grid.getTile(ship.position) === 'mine') {
ship.alive = false;
this.deadPlayers.add(ship.playerId);
this.grid.setTile(ship.position, 'space'); // mine exploded
result.shipUpdates.push({ type: 'eliminated', shipId, position: { ...ship.position }, reason: 'mine' });
result.gridUpdates.push({ type: 'tile_change', position: { ...ship.position }, tile: 'space' });
result.messages.push(`[${ship.playerId}] stepped on a mine and was destroyed!`);
}
}
results.push(result);
}
// After all ticks: advance death line and check eliminations
const deathLineResult: ExecutionResult = {
tick: -1,
shipUpdates: [],
gridUpdates: [],
messages: [],
};
let leadingY = 0;
for (const ship of this.ships.values()) {
if (ship.alive && ship.position.y > leadingY) {
leadingY = ship.position.y;
}
}
const newDeathLine = this.grid.advanceDeathLine(leadingY);
deathLineResult.gridUpdates.push({ type: 'death_line', y: newDeathLine });
const deathLineX = this.grid.deathLineY;
for (const ship of this.ships.values()) {
if (!ship.alive) continue;
if (ship.position.y < deathLineX) {
ship.alive = false;
this.deadPlayers.add(ship.playerId);
deathLineResult.shipUpdates.push({
type: 'eliminated',
shipId: ship.id,
position: { ...ship.position },
reason: 'death_line',
});
deathLineResult.messages.push(`[${ship.playerId}] fell behind and was eliminated!`);
}
}
if (deathLineResult.shipUpdates.length > 0 || deathLineResult.gridUpdates.length > 0) {
results.push(deathLineResult);
}
return results;
}
getDeadPlayerIds(): Set<string> {
return this.deadPlayers;
}
}
+290
View File
@@ -0,0 +1,290 @@
import {
GamePhase,
GameGridState,
GameState as GameStateType,
PlayerPlan,
CardType,
CARD_DEFS,
ExecutionResult,
actionApCost,
} from '@spacerace/shared';
import { Grid } from './Grid.js';
import { Ship } from './Ship.js';
import { Executor } from './Executor.js';
import { validatePlan, removeUsedCards } from './ActionQueue.js';
import { GAME_CONFIG } from '../config.js';
const ALL_CARDS: CardType[] = Object.keys(CARD_DEFS) as CardType[];
function drawCard(): CardType {
return ALL_CARDS[Math.floor(Math.random() * ALL_CARDS.length)];
}
function drawHand(): CardType[] {
return Array.from({ length: GAME_CONFIG.MAX_HAND_SIZE }, () => drawCard());
}
export interface GamePlayer {
id: string;
name: string;
shipId: string;
hand: CardType[];
ap: number;
apUsed: number;
actions: import('@spacerace/shared').Action[];
planSubmitted: boolean;
ready: boolean;
alive: boolean;
}
export class Game {
roomCode: string;
phase: GamePhase = 'LOBBY';
round: number = 0;
hostPlayerId: string = '';
grid: Grid;
ships: Map<string, Ship> = new Map();
players: Map<string, GamePlayer> = new Map();
playerOrder: string[] = [];
// Callbacks
onPhaseChange?: (phase: GamePhase) => void;
onPlanningStart?: (players: GamePlayer[], grid: GameGridState) => void;
onExecutionTick?: (results: ExecutionResult) => void;
onGameOver?: (winner: GamePlayer) => void;
// Timer
private planningTimer: ReturnType<typeof setTimeout> | null = null;
private planningSecondsLeft: number = 0;
constructor(roomCode: string) {
this.roomCode = roomCode;
this.grid = new Grid(Date.now());
}
get alivePlayers(): number {
let count = 0;
for (const p of this.players.values()) {
if (p.alive) count++;
}
return count;
}
addPlayer(playerId: string, name: string): GamePlayer {
const usedPositions = Array.from(this.ships.values()).map((s) => s.position);
const startPos = this.grid.findSafeStart(usedPositions, 2, 0) || { x: 3 + this.ships.size, y: 2 };
const ship = new Ship(`ship_${playerId}`, playerId, name, startPos, 'E');
this.ships.set(ship.id, ship);
const player: GamePlayer = {
id: playerId,
name,
shipId: ship.id,
hand: drawHand(),
ap: GAME_CONFIG.AP_PER_ROUND,
apUsed: 0,
actions: [],
planSubmitted: false,
ready: false,
alive: true,
};
this.players.set(playerId, player);
this.playerOrder.push(playerId);
return player;
}
removePlayer(playerId: string): void {
const player = this.players.get(playerId);
if (!player) return;
const ship = this.ships.get(player.shipId);
if (ship) {
ship.alive = false;
}
player.alive = false;
this.playerOrder = this.playerOrder.filter((id) => id !== playerId);
}
startGame(): boolean {
if (this.phase !== 'LOBBY') return false;
const alive = Array.from(this.players.values()).filter((p) => p.alive);
if (alive.length < GAME_CONFIG.MIN_PLAYERS) return false;
if (alive.length > GAME_CONFIG.MAX_PLAYERS) return false;
this.phase = 'PLANNING';
this.round = 1;
this.startPlanningPhase();
this.onPhaseChange?.('PLANNING');
return true;
}
private startPlanningPhase(): void {
// Reset round state for all players
for (const player of this.players.values()) {
if (!player.alive) continue;
player.ap = GAME_CONFIG.AP_PER_ROUND;
player.apUsed = 0;
player.actions = [];
player.planSubmitted = false;
player.ready = false;
// Reset ship effects
const ship = this.ships.get(player.shipId);
if (ship) ship.resetRoundEffects();
}
// Ensure grid is generated ahead of leading ships
let leadingX = -Infinity;
for (const ship of this.ships.values()) {
if (ship.alive && ship.position.y > leadingX) {
leadingX = ship.position.y;
}
}
if (leadingX > -Infinity) {
this.grid.ensureAheadOf(leadingX);
}
// Start countdown
this.planningSecondsLeft = GAME_CONFIG.PLANNING_TIME_SECONDS;
this.planningTimer = setInterval(() => {
this.planningSecondsLeft--;
if (this.planningSecondsLeft <= 0) {
this.executeRound();
}
}, 1000);
}
submitPlan(playerId: string, actions: import('@spacerace/shared').Action[]): { ok: boolean; error?: string } {
const player = this.players.get(playerId);
if (!player || !player.alive) return { ok: false, error: 'Player not found' };
if (this.phase !== 'PLANNING') return { ok: false, error: 'Not in planning phase' };
if (player.planSubmitted) return { ok: false, error: 'Plan already submitted' };
const result = validatePlan(actions, player.hand);
if (!result.valid) return { ok: false, error: result.error };
player.actions = actions;
player.apUsed = actions.reduce((sum, a) => sum + actionApCost(a), 0);
player.planSubmitted = true;
// Check if all alive players have submitted
this.checkAllPlansSubmitted();
return { ok: true };
}
private checkAllPlansSubmitted(): void {
const alive = Array.from(this.players.values()).filter((p) => p.alive);
const allSubmitted = alive.every((p) => p.planSubmitted);
if (allSubmitted) {
this.executeRound();
}
}
private executeRound(): void {
if (this.planningTimer) {
clearInterval(this.planningTimer);
this.planningTimer = null;
}
this.phase = 'EXECUTING';
this.onPhaseChange?.('EXECUTING');
// Build plans from submitted players
// Players who didn't submit get an empty plan (they stand still)
const plans: PlayerPlan[] = [];
for (const player of this.players.values()) {
if (!player.alive) continue;
plans.push({
playerId: player.id,
actions: player.planSubmitted ? [...player.actions] : [],
});
// Remove used cards from hand
if (player.planSubmitted) {
player.hand = removeUsedCards(player.actions, player.hand);
// Draw new cards to maintain hand size
while (player.hand.length < GAME_CONFIG.MAX_HAND_SIZE) {
player.hand.push(drawCard());
}
}
}
// Execute!
const executor = new Executor(this.ships, this.grid);
const results = executor.execute(plans);
// Emit results one by one
for (const result of results) {
this.onExecutionTick?.(result);
}
// Mark dead players
const deadIds = executor.getDeadPlayerIds();
for (const [id, player] of this.players) {
if (deadIds.has(id)) {
player.alive = false;
}
}
// Check game over
const alivePlayers = Array.from(this.players.values()).filter((p) => p.alive);
if (alivePlayers.length <= 1) {
this.phase = 'FINISHED';
this.onPhaseChange?.('FINISHED');
const winner = alivePlayers[0] || Array.from(this.players.values()).find((p) => !p.alive);
if (winner) {
this.onGameOver?.(winner);
}
return;
}
// Next round
this.round++;
this.phase = 'PLANNING';
this.onPhaseChange?.('PLANNING');
this.startPlanningPhase();
}
getGridState(): GameGridState {
return {
rows: this.grid.toState(),
deathLineY: this.grid.deathLineY,
generatedUpToY: this.grid.generatedUpToY,
};
}
getShipsState(): import('@spacerace/shared').Ship[] {
return Array.from(this.ships.values()).map((s) => s.toState());
}
getPlayerState(playerId: string): import('@spacerace/shared').PlayerView | null {
const player = this.players.get(playerId);
if (!player) return null;
let colorIndex = 0;
for (const [sid, ship] of this.ships) {
if (ship.playerId === playerId) break;
colorIndex++;
}
return {
playerId: player.id,
name: player.name,
ap: player.ap,
apUsed: player.apUsed,
hand: [...player.hand],
alive: player.alive,
actions: [...player.actions],
colorIndex,
};
}
destroy(): void {
if (this.planningTimer) {
clearInterval(this.planningTimer);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
import {
Position,
TileType,
GRID_WIDTH,
} from '@spacerace/shared';
import { GAME_CONFIG } from '../config.js';
export class Grid {
private cols: Map<number, TileType[]> = new Map();
private _deathLineX: number;
private _generatedUpToX: number;
private seed: number;
constructor(seed: number = 42) {
this.seed = seed;
this._deathLineX = 0;
this._generatedUpToX = 12;
this.generateCols(0, 25);
this.clearRunway(0, 20);
}
private clearRunway(fromX: number, toX: number): void {
const start = Math.min(fromX, toX);
const end = Math.max(fromX, toX);
for (let x = start; x <= end; x++) {
this.cols.set(x, Array(GRID_WIDTH).fill('space'));
}
}
get deathLineY(): number {
return this._deathLineX;
}
get generatedUpToY(): number {
return this._generatedUpToX;
}
private randFor(seed: number): number {
let h = ((this.seed + seed) * 31 + seed * 7 + seed * seed * 13) % 2147483647;
return (h & 0x7fffffff) / 0x7fffffff;
}
getTile(pos: Position): TileType {
this.ensureCol(pos.y);
const col = this.cols.get(pos.y);
if (!col) return 'space';
return col[pos.x] ?? 'space';
}
private ensureCol(y: number): void {
if (this.cols.has(y)) return;
if (y > this._generatedUpToX) {
this.generateCols(this._generatedUpToX, y);
} else {
this.generateCols(0, y);
}
}
setTile(pos: Position, tile: TileType): void {
this.ensureCol(pos.y);
const col = this.cols.get(pos.y)!;
col[pos.x] = tile;
}
isBlocked(pos: Position): boolean {
const tile = this.getTile(pos);
return tile !== 'space';
}
isInBounds(pos: Position): boolean {
return pos.x >= 0 && pos.x < GRID_WIDTH;
}
advanceDeathLine(leadingY?: number): number {
let advance: number = GAME_CONFIG.DEATH_LINE_ADVANCE;
if (leadingY !== undefined) {
const maxBehind = 10;
const distance = leadingY - this._deathLineX;
if (distance > maxBehind) {
advance = Math.max(advance, distance - maxBehind);
}
}
this._deathLineX += advance;
return this._deathLineX;
}
generateCols(fromX: number, toX: number): void {
const start = Math.min(fromX, toX);
const end = Math.max(fromX, toX);
for (let x = start; x <= end; x++) {
if (this.cols.has(x)) continue;
const col: TileType[] = [];
const r = this.randFor(x * 7919);
const pattern = Math.abs((x * 13 + this.seed * 7) % 5);
for (let lane = 0; lane < GRID_WIDTH; lane++) {
const tileR = this.randFor(x * 1000 + lane);
switch (pattern) {
case 0: col.push(tileR < GAME_CONFIG.TRACK_DENSITY * 0.5 ? 'asteroid' : 'space'); break;
case 1: col.push(lane < 3 && tileR < 0.6 ? 'asteroid' : 'space'); break;
case 2: col.push(lane >= 5 && tileR < 0.6 ? 'asteroid' : 'space'); break;
case 3: col.push((lane < 2 || lane > 5) && tileR < 0.7 ? 'asteroid' : 'space'); break;
case 4: col.push(tileR < GAME_CONFIG.TRACK_DENSITY * 1.5 ? 'asteroid' : 'space'); break;
}
}
// Ensure at least 1 lane is open
const blocked = col.every((t) => t !== 'space');
if (blocked) {
const openLane = Math.floor(this.randFor(x * 31337) * GRID_WIDTH);
(col as any)[openLane] = 'space';
}
this.cols.set(x, col);
}
if (toX > this._generatedUpToX) {
this._generatedUpToX = Math.max(this._generatedUpToX, toX);
}
}
ensureAheadOf(leadingX: number): number {
const neededX = leadingX + 5;
if (neededX > this._generatedUpToX) {
this.generateCols(this._generatedUpToX + 1, neededX);
this._generatedUpToX = neededX;
}
return this._generatedUpToX;
}
toState(): Record<number, (TileType | null)[]> {
const state: Record<number, (TileType | null)[]> = {};
for (const [x, col] of this.cols) {
state[x] = [...col];
}
return state;
}
findSafeStart(usedPositions: Position[], startX: number, columnSpread: number = 2): Position | null {
const candidates: Position[] = [];
for (let lane = 0; lane < GRID_WIDTH; lane++) {
for (let colX = startX; colX <= startX + columnSpread; colX++) {
const pos = { x: lane, y: colX };
if (
!this.isBlocked(pos) &&
!usedPositions.some((p) => p.y === pos.y && p.x === pos.x)
) {
candidates.push(pos);
}
}
}
if (candidates.length === 0) return null;
return candidates[Math.floor(this.randFor(startX * 7) * candidates.length)];
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Ship as ShipType, Position, Direction, posEqual } from '@spacerace/shared';
export class Ship {
id: string;
playerId: string;
playerName: string;
position: Position;
direction: Direction;
alive: boolean;
shielded: boolean;
phaseShifting: boolean;
empActive: boolean;
boostSteps: number;
constructor(id: string, playerId: string, playerName: string, position: Position, direction: Direction) {
this.id = id;
this.playerId = playerId;
this.playerName = playerName;
this.position = { ...position };
this.direction = direction;
this.alive = true;
this.shielded = false;
this.phaseShifting = false;
this.empActive = false;
this.boostSteps = 0;
}
resetRoundEffects(): void {
this.shielded = false;
this.phaseShifting = false;
this.empActive = false;
this.boostSteps = 0;
}
toState(): ShipType {
return {
id: this.id,
playerId: this.playerId,
playerName: this.playerName,
position: { ...this.position },
direction: this.direction,
alive: this.alive,
};
}
}
+41
View File
@@ -0,0 +1,41 @@
import express from 'express';
import cors from 'cors';
import { createServer } from 'http';
import { WsServer } from './ws/WsServer.js';
import { GAME_CONFIG } from './config.js';
const app = express();
app.use(cors());
app.use(express.json());
const httpServer = createServer(app);
const wsServer = new WsServer(httpServer);
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
httpServer.listen(GAME_CONFIG.SERVE_PORT, () => {
console.log(`[Server] SpaceRace server running on port ${GAME_CONFIG.SERVE_PORT}`);
console.log(`[Server] TV: http://localhost:${GAME_CONFIG.SERVE_PORT}`);
console.log(`[Server] Mobile: http://localhost:${GAME_CONFIG.SERVE_PORT}`);
});
// ── Graceful shutdown ──
function shutdown(signal: string) {
console.log(`\n[Server] Received ${signal}, shutting down...`);
wsServer.destroy();
httpServer.close(() => {
console.log('[Server] HTTP server closed');
process.exit(0);
});
// Force exit after 3s
setTimeout(() => {
console.log('[Server] Force exiting');
process.exit(1);
}, 3000);
}
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
+127
View File
@@ -0,0 +1,127 @@
import { Game } from '../game/Game.js';
import { GAME_CONFIG } from '../config.js';
function generateRoomCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 4; i++) {
code += chars[Math.floor(Math.random() * chars.length)];
}
return code;
}
function generatePlayerId(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
export class LobbyManager {
private rooms: Map<string, Game> = new Map();
private playerRooms: Map<string, string> = new Map(); // playerId -> roomCode
private hostPlayers: Map<string, string> = new Map(); // roomCode -> hostPlayerId
createRoom(): { roomCode: string; game: Game } {
let code: string;
do {
code = generateRoomCode();
} while (this.rooms.has(code));
const game = new Game(code);
this.rooms.set(code, game);
return { roomCode: code, game };
}
joinRoom(roomCode: string, playerName: string): { ok: boolean; playerId?: string; error?: string; game?: Game; isHost?: boolean } {
const game = this.rooms.get(roomCode.toUpperCase());
if (!game) return { ok: false, error: 'Room not found' };
if (game.phase !== 'LOBBY') return { ok: false, error: 'Game already started' };
const aliveCount = Array.from(game.players.values()).filter((p) => p.alive).length;
if (aliveCount >= GAME_CONFIG.MAX_PLAYERS) return { ok: false, error: 'Room is full' };
const playerId = generatePlayerId();
game.addPlayer(playerId, playerName);
this.playerRooms.set(playerId, roomCode.toUpperCase());
// First player becomes host
const uc = roomCode.toUpperCase();
let isHost = false;
if (!this.hostPlayers.has(uc)) {
this.hostPlayers.set(uc, playerId);
game.hostPlayerId = playerId;
isHost = true;
}
return { ok: true, playerId, game, isHost };
}
getGame(roomCode: string): Game | undefined {
return this.rooms.get(roomCode.toUpperCase());
}
getGameForPlayer(playerId: string): Game | undefined {
const roomCode = this.playerRooms.get(playerId);
if (!roomCode) return undefined;
return this.rooms.get(roomCode);
}
getHostPlayerId(roomCode: string): string | undefined {
return this.hostPlayers.get(roomCode.toUpperCase());
}
private reassignHost(roomCode: string, game: Game): void {
const uc = roomCode.toUpperCase();
this.hostPlayers.delete(uc);
// First alive player becomes new host
for (const player of game.players.values()) {
if (player.alive) {
this.hostPlayers.set(uc, player.id);
game.hostPlayerId = player.id;
return;
}
}
}
removePlayer(playerId: string): Game | undefined {
const roomCode = this.playerRooms.get(playerId);
if (!roomCode) return undefined;
const game = this.rooms.get(roomCode);
if (!game) return undefined;
const wasHost = this.hostPlayers.get(roomCode) === playerId;
game.removePlayer(playerId);
this.playerRooms.delete(playerId);
if (game.alivePlayers === 0) {
game.destroy();
this.rooms.delete(roomCode);
this.hostPlayers.delete(roomCode);
} else if (wasHost) {
this.reassignHost(roomCode, game);
}
return game;
}
removeRoom(roomCode: string): void {
const game = this.rooms.get(roomCode.toUpperCase());
if (game) {
game.destroy();
this.rooms.delete(roomCode.toUpperCase());
}
}
destroyAll(): void {
for (const [code, game] of this.rooms) {
game.destroy();
}
this.rooms.clear();
this.playerRooms.clear();
this.hostPlayers.clear();
}
}
+276
View File
@@ -0,0 +1,276 @@
import { Server as HttpServer } from 'http';
import { Server, Socket } from 'socket.io';
import { LobbyManager } from '../lobby/LobbyManager.js';
import { Game } from '../game/Game.js';
import { GamePlayer } from '../game/Game.js';
import {
ServerToTvEvents,
TvToServerEvents,
ServerToMobileEvents,
MobileToServerEvents,
ExecutionResult,
GameGridState,
PlayerView,
Ship,
} from '@spacerace/shared';
export class WsServer {
private io: Server;
private lobby: LobbyManager;
constructor(httpServer: HttpServer) {
this.lobby = new LobbyManager();
this.io = new Server(httpServer, {
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
pingTimeout: 5000,
pingInterval: 10000,
});
this.io.on('connection', (socket: Socket) => {
this.handleConnection(socket);
});
console.log('[WS] Server initialized');
}
destroy(): void {
console.log('[WS] Shutting down...');
this.io.close();
// Clear all game timers by destroying rooms
this.lobby.destroyAll();
}
private handleConnection(socket: Socket): void {
console.log(`[WS] New connection: ${socket.id}`);
socket.on('disconnect', () => {
this.handleDisconnect(socket);
});
// ── TV Events ──
socket.on('tv:createRoom', (data: {}, callback: (res: { roomCode: string }) => void) => {
const { roomCode, game } = this.lobby.createRoom();
socket.join(`room:${roomCode}`);
socket.data.roomCode = roomCode;
socket.data.isTv = true;
this.bindGameEvents(game, roomCode);
callback({ roomCode });
console.log(`[WS] Room created: ${roomCode}`);
});
socket.on('tv:startGame', (data: { roomCode: string }, callback: (res: { ok: boolean }) => void) => {
const game = this.lobby.getGame(data.roomCode);
if (!game) {
callback({ ok: false });
return;
}
const ok = game.startGame();
callback({ ok });
if (ok) {
console.log(`[WS] Game started in room ${data.roomCode}`);
}
});
// ── Mobile Events ──
socket.on('mobile:joinRoom', (
data: { roomCode: string; playerName: string },
callback: (res: { ok: boolean; playerId?: string; error?: string }) => void
) => {
const result = this.lobby.joinRoom(data.roomCode, data.playerName);
if (!result.ok || !result.playerId || !result.game) {
callback({ ok: false, error: result.error });
return;
}
socket.join(`room:${result.game.roomCode}`);
socket.data.roomCode = result.game.roomCode;
socket.data.playerId = result.playerId;
socket.data.isTv = false;
// Notify TV
this.io.to(`room:${result.game.roomCode}`).emit('playerJoined', {
playerId: result.playerId,
name: data.playerName,
});
// Send back player info
callback({ ok: true, playerId: result.playerId });
// Send current player list
const players = Array.from(result.game.players.values())
.filter((p) => p.alive)
.map((p) => ({ id: p.id, name: p.name }));
socket.emit('roomJoined', {
playerId: result.playerId,
roomCode: result.game.roomCode,
players,
isHost: result.isHost ?? false,
});
console.log(`[WS] ${data.playerName} joined room ${result.game.roomCode}`);
});
socket.on('mobile:submitPlan', (
data: { actions: import('@spacerace/shared').Action[] },
callback: (res: { ok: boolean; error?: string }) => void
) => {
const playerId = socket.data.playerId;
const roomCode = socket.data.roomCode;
if (!playerId || !roomCode) {
callback({ ok: false, error: 'Not in a room' });
return;
}
const game = this.lobby.getGame(roomCode);
if (!game) {
callback({ ok: false, error: 'Game not found' });
return;
}
const result = game.submitPlan(playerId, data.actions);
callback(result);
});
socket.on('mobile:setReady', (
data: {},
callback: (res: { ok: boolean }) => void
) => {
callback({ ok: true });
});
// Mobile host can start game
socket.on('mobile:startGame', (
data: { roomCode: string },
callback: (res: { ok: boolean; error?: string }) => void
) => {
const playerId = socket.data.playerId;
const roomCode = socket.data.roomCode || data.roomCode;
if (!roomCode) {
callback({ ok: false, error: 'No room code' });
return;
}
const game = this.lobby.getGame(roomCode);
if (!game) {
callback({ ok: false, error: 'Game not found' });
return;
}
if (game.phase !== 'LOBBY') {
callback({ ok: false, error: 'Game already started' });
return;
}
// Only host can start
const hostId = this.lobby.getHostPlayerId(roomCode);
if (hostId && playerId && hostId !== playerId) {
callback({ ok: false, error: 'Only the host can start the game' });
return;
}
const ok = game.startGame();
callback({ ok: ok ? true : false, error: ok ? undefined : `Need at least ${2} players` });
if (ok) {
console.log(`[WS] Game started by mobile host in room ${roomCode}`);
}
});
}
private handleDisconnect(socket: Socket): void {
console.log(`[WS] Disconnected: ${socket.id}`);
if (socket.data.isTv && socket.data.roomCode) {
// TV disconnected - remove room
this.io.to(`room:${socket.data.roomCode}`).emit('roomClosed', {});
this.lobby.removeRoom(socket.data.roomCode);
} else if (socket.data.playerId) {
const game = this.lobby.removePlayer(socket.data.playerId);
if (game) {
this.io.to(`room:${game.roomCode}`).emit('playerLeft', {
playerId: socket.data.playerId,
});
// Notify about new host
const newHost = this.lobby.getHostPlayerId(game.roomCode);
if (newHost) {
this.io.to(`room:${game.roomCode}`).emit('hostChanged', {
hostPlayerId: newHost,
});
}
}
}
}
private bindGameEvents(game: Game, roomCode: string): void {
game.onPhaseChange = (phase) => {
if (phase === 'PLANNING') {
const gridState = game.getGridState();
const ships = game.getShipsState();
// First planning phase = game just started, notify TV to switch scenes
if (game.round === 1) {
const players = Array.from(game.players.values())
.filter((p) => p.alive)
.map((p) => ({ id: p.id, name: p.name }));
this.io.to(`room:${roomCode}`).emit('gameStarting', {
players,
round: game.round,
grid: gridState,
ships,
});
}
this.io.to(`room:${roomCode}`).emit('planningStarted', {
round: game.round,
timer: 45,
grid: gridState,
ships,
});
// Send individual planning requests to each mobile client
for (const [playerId, player] of game.players) {
if (!player.alive) continue;
const playerView = game.getPlayerState(playerId);
if (!playerView) continue;
// Find the mobile socket for this player
const sockets = this.io.sockets.adapter.rooms.get(`room:${roomCode}`);
if (!sockets) continue;
for (const socketId of sockets) {
const sock = this.io.sockets.sockets.get(socketId);
if (sock && sock.data.playerId === playerId) {
sock.emit('planningRequest', {
round: game.round,
playerView,
grid: gridState,
ships,
timer: 45,
});
break;
}
}
}
}
};
game.onExecutionTick = (result: ExecutionResult) => {
this.io.to(`room:${roomCode}`).emit('executionTick', result);
};
game.onGameOver = (winner) => {
const ships = game.getShipsState();
this.io.to(`room:${roomCode}`).emit('gameOver', {
winnerId: winner.id,
winnerName: winner.name,
ships,
});
};
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@spacerace/shared",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
+1
View File
@@ -0,0 +1 @@
export * from './types.js';
+247
View File
@@ -0,0 +1,247 @@
// ── Grid & Position ──
export const GRID_WIDTH = 8;
export const VISIBLE_ROWS = 10;
export const AP_PER_ROUND = 4;
export const DEATH_LINE_ADVANCE = 1;
export const MAX_HAND_SIZE = 3;
export interface Position {
x: number; // 0..GRID_WIDTH-1
y: number; // infinite positive direction (track goes up)
}
export type Direction = 'N' | 'E' | 'S' | 'W';
export const DIRECTION_DELTA: Record<Direction, Position> = {
N: { x: -1, y: 0 }, // up on screen = decrease lane
E: { x: 0, y: 1 }, // right on screen = increase track position
S: { x: 1, y: 0 }, // down on screen = increase lane
W: { x: 0, y: -1 }, // left on screen = decrease track position
};
export function turnLeft(dir: Direction): Direction {
const order: Direction[] = ['N', 'W', 'S', 'E'];
return order[(order.indexOf(dir) + 1) % 4];
}
export function turnRight(dir: Direction): Direction {
const order: Direction[] = ['N', 'E', 'S', 'W'];
return order[(order.indexOf(dir) + 1) % 4];
}
export function turn180(dir: Direction): Direction {
const order: Direction[] = ['N', 'S', 'E', 'W'];
const map: Record<Direction, Direction> = { N: 'S', S: 'N', E: 'W', W: 'E' };
return map[dir];
}
export function posEqual(a: Position, b: Position): boolean {
return a.x === b.x && a.y === b.y;
}
// ── Tiles ──
export type TileType = 'space' | 'asteroid' | 'meteor' | 'mine' | 'debris';
export function isBlocked(tile: TileType): boolean {
return tile !== 'space';
}
// ── Ships ──
export interface Ship {
id: string;
playerId: string;
playerName: string;
position: Position;
direction: Direction;
alive: boolean;
}
// ── Cards ──
export type CardType =
| 'METEOR_STRIKE'
| 'SHIELD'
| 'BOOST'
| 'EMP'
| 'JUMP'
| 'MINE'
| 'TELEPORT'
| 'PHASE_SHIFT';
export interface CardDef {
type: CardType;
name: string;
description: string;
apCost: number;
}
export const CARD_DEFS: Record<CardType, CardDef> = {
METEOR_STRIKE: {
type: 'METEOR_STRIKE',
name: 'Meteor Strike',
description: 'Summon a meteor on any tile within 3 tiles of your ship',
apCost: 2,
},
SHIELD: {
type: 'SHIELD',
name: 'Shield',
description: 'Ignore the next obstacle collision this round',
apCost: 1,
},
BOOST: {
type: 'BOOST',
name: 'Boost',
description: 'Move 3 tiles forward instead of 1',
apCost: 2,
},
EMP: {
type: 'EMP',
name: 'EMP',
description: 'Cancel the next enemy action that targets you',
apCost: 2,
},
JUMP: {
type: 'JUMP',
name: 'Jump',
description: 'Jump over 1 obstacle tile directly in front of you',
apCost: 2,
},
MINE: {
type: 'MINE',
name: 'Mine',
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',
description: 'Ignore all obstacles during your next move',
apCost: 2,
},
};
// ── Actions ──
export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'BOOST' | '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 {
switch (action.type) {
case 'MOVE_FORWARD': return 1;
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;
}
}
// ── Plan ──
export interface PlayerPlan {
playerId: string;
actions: Action[];
}
// ── Game Phases ──
export type GamePhase = 'LOBBY' | 'PLANNING' | 'EXECUTING' | 'FINISHED';
// ── Game State ──
export interface GameGridState {
rows: Record<number, (TileType | null)[]>; // y -> row array
deathLineY: number; // any ship with y > deathLineY is eliminated
generatedUpToY: number; // highest y generated so far (negative = upward)
}
export interface GameState {
phase: GamePhase;
round: number;
grid: GameGridState;
ships: Ship[];
playerPlans: PlayerPlan[];
planningTimer: number; // seconds left in planning
alivePlayers: number;
}
// ── Player (mobile view) ──
export interface PlayerView {
playerId: string;
name: string;
ap: number;
apUsed: number;
hand: CardType[];
alive: boolean;
actions: Action[];
colorIndex: number;
}
// ── WebSocket Events ──
// TV <-> Server
export interface ServerToTvEvents {
roomCreated: (data: { roomCode: string }) => void;
playerJoined: (data: { playerId: string; name: string }) => void;
playerLeft: (data: { playerId: string }) => void;
gameStarting: (data: { players: { id: string; name: string }[]; round: number; grid: GameGridState; ships: Ship[] }) => void;
planningStarted: (data: { round: number; timer: number; grid: GameGridState; ships: Ship[] }) => void;
executionTick: (data: { tick: number; shipUpdates: ShipUpdate[]; gridUpdates: GridUpdate[]; messages: string[] }) => void;
executionComplete: (data: { round: number; ships: Ship[]; grid: GameGridState }) => void;
gameOver: (data: { winnerId: string; winnerName: string; ships: Ship[] }) => void;
}
export interface TvToServerEvents {
createRoom: (data: {}, callback: (res: { roomCode: string }) => void) => void;
startGame: (data: { roomCode: string }, callback: (res: { ok: boolean }) => void) => void;
}
// Mobile <-> Server
export interface ServerToMobileEvents {
roomJoined: (data: { playerId: string; roomCode: string; players: { id: string; name: string }[]; isHost: boolean }) => void;
playerJoined: (data: { playerId: string; name: string }) => void;
playerLeft: (data: { playerId: string }) => void;
gameStarting: (data: {}) => void;
planningRequest: (data: { round: number; playerView: PlayerView; grid: GameGridState; ships: Ship[]; timer: number }) => void;
executionTick: (data: { tick: number; shipUpdates: ShipUpdate[]; gridUpdates: GridUpdate[]; messages: string[] }) => void;
executionComplete: (data: { round: number; playerView: PlayerView }) => void;
planRejected: (data: { reason: string }) => void;
gameOver: (data: { winnerId: string; winnerName: string }) => void;
hostChanged: (data: { hostPlayerId: string }) => void;
error: (data: { message: string }) => void;
}
export interface MobileToServerEvents {
joinRoom: (data: { roomCode: string; playerName: string }, callback: (res: { ok: boolean; playerId?: string; error?: string }) => void) => void;
submitPlan: (data: { actions: Action[] }, callback: (res: { ok: boolean; error?: string }) => void) => void;
setReady: (data: {}, callback: (res: { ok: boolean }) => void) => void;
startGame: (data: { roomCode: string }, callback: (res: { ok: boolean; error?: string }) => void) => void;
}
// Execution updates
export type ShipUpdate =
| { type: 'move'; shipId: string; from: Position; to: Position }
| { 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 };
export type GridUpdate =
| { type: 'tile_change'; position: Position; tile: TileType }
| { type: 'death_line'; y: number }
| { type: 'generation'; rows: Record<number, (TileType | null)[]> };
// ── Result ──
export interface ExecutionResult {
tick: number;
shipUpdates: ShipUpdate[];
gridUpdates: GridUpdate[];
messages: string[];
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=1280, initial-scale=1.0" />
<title>SpaceRace - TV</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#game-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body>
<div id="game-container"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@spacerace/tv",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite --port 3000",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@spacerace/shared": "*",
"phaser": "^3.80.1",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^5.3.0"
}
}
+20
View File
@@ -0,0 +1,20 @@
import Phaser from 'phaser';
import { BootScene } from './scenes/BootScene.js';
import { LobbyScene } from './scenes/LobbyScene.js';
import { GameScene } from './scenes/GameScene.js';
import { ResultScene } from './scenes/ResultScene.js';
const config: Phaser.Types.Core.GameConfig = {
type: Phaser.AUTO,
width: 1280,
height: 720,
parent: 'game-container',
backgroundColor: '#0a0a1a',
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [BootScene, LobbyScene, GameScene, ResultScene],
};
new Phaser.Game(config);
+11
View File
@@ -0,0 +1,11 @@
import { io, Socket } from 'socket.io-client';
import { ServerToTvEvents, TvToServerEvents, ExecutionResult, Ship, GameGridState } from '@spacerace/shared';
export type TvSocket = Socket<ServerToTvEvents, TvToServerEvents>;
export function connectTvSocket(): TvSocket {
const socket: TvSocket = io('/', {
transports: ['websocket', 'polling'],
});
return socket;
}
+160
View File
@@ -0,0 +1,160 @@
import Phaser from 'phaser';
import {
GameGridState,
TileType,
Position,
GRID_WIDTH,
} from '@spacerace/shared';
const TILE_SIZE = 64;
const GRID_HEIGHT = GRID_WIDTH;
const VISIBLE_COLS = 20;
const DEATH_LINE_SCREEN_X = TILE_SIZE / 2;
export class GridRenderer {
private scene: Phaser.Scene;
private offsetY: number;
private tileSize: number;
private fieldContainer: Phaser.GameObjects.Container;
private shipLayer: Phaser.GameObjects.Container;
private deathLineRect: Phaser.GameObjects.Rectangle | null = null;
private deathLineY: number = 0;
constructor(scene: Phaser.Scene, offsetY: number, tileSize: number) {
this.scene = scene;
this.offsetY = offsetY;
this.tileSize = tileSize;
this.fieldContainer = scene.add.container(0, 0);
this.fieldContainer.setDepth(0);
this.shipLayer = scene.add.container(0, 0);
this.shipLayer.setDepth(10);
}
get viewOffset(): number {
return this.deathLineY;
}
get field(): Phaser.GameObjects.Container {
return this.fieldContainer;
}
get ships(): Phaser.GameObjects.Container {
return this.shipLayer;
}
gridXToScreen(gridY: number): number {
const relativeX = gridY - this.deathLineY;
return relativeX * this.tileSize + this.tileSize / 2;
}
gridYToScreen(gridX: number): number {
return this.offsetY + gridX * this.tileSize + this.tileSize / 2;
}
renderGrid(grid: GameGridState, _ships: any[], scrollInPx: number = 0): void {
this.fieldContainer.removeAll(true);
this.deathLineY = grid.deathLineY;
const startCol = this.deathLineY;
const endCol = this.deathLineY + VISIBLE_COLS;
for (let col = startCol; col < endCol; col++) {
const column = grid.rows[col] || Array(GRID_HEIGHT).fill('space');
const screenX = this.gridXToScreen(col);
for (let lane = 0; lane < GRID_HEIGHT; lane++) {
const tile = (column[lane] as TileType) || 'space';
const screenY = this.gridYToScreen(lane);
this.renderCell(col, lane, tile, screenX, screenY);
}
}
if (scrollInPx > 0) {
this.fieldContainer.x = -scrollInPx;
this.shipLayer.x = -scrollInPx;
this.scene.tweens.add({
targets: [this.fieldContainer, this.shipLayer],
x: 0,
duration: 600,
ease: 'Sine.easeInOut',
});
} else {
this.fieldContainer.x = 0;
this.shipLayer.x = 0;
}
this.renderDeathLine();
}
private renderCell(col: number, lane: number, tile: TileType, screenX: number, screenY: number): void {
const bgAlpha = tile === 'space' ? 0.12 : 0.35;
const bgColor =
tile === 'asteroid' ? 0x555555 :
tile === 'meteor' ? 0x884400 :
tile === 'mine' ? 0x885500 :
0x1a1a4e;
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, bgColor, bgAlpha);
bg.setStrokeStyle(1, 0x223366, 0.2);
this.fieldContainer.add(bg);
if (tile !== 'space') {
const textureName =
tile === 'asteroid' ? 'asteroid' :
tile === 'meteor' ? 'meteor' :
'mine';
const sprite = this.scene.add.sprite(screenX, screenY, textureName);
sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12);
this.fieldContainer.add(sprite);
}
}
updateTile(pos: Position, tile: TileType): void {
const screenX = this.gridXToScreen(pos.y);
const screenY = this.gridYToScreen(pos.x);
this.renderCell(pos.y, pos.x, tile, screenX, screenY);
}
private renderDeathLine(): void {
if (this.deathLineRect) this.deathLineRect.destroy();
this.deathLineRect = this.scene.add.rectangle(
DEATH_LINE_SCREEN_X,
this.offsetY + (GRID_HEIGHT * this.tileSize) / 2,
6,
GRID_HEIGHT * this.tileSize,
0xff0000,
0.9
);
this.deathLineRect.setDepth(20);
this.scene.tweens.add({
targets: this.deathLineRect,
alpha: 0.3,
duration: 600,
yoyo: true,
repeat: -1,
});
}
animateDeathLine(deathLineY: number, duration: number, onComplete?: () => void): void {
const shiftPx = (deathLineY - this.deathLineY) * this.tileSize;
this.deathLineY = deathLineY;
if (shiftPx <= 0) {
onComplete?.();
return;
}
this.scene.tweens.add({
targets: [this.fieldContainer, this.shipLayer],
x: this.fieldContainer.x - shiftPx,
duration,
ease: 'Sine.easeInOut',
onComplete: () => onComplete?.(),
});
}
}
+52
View File
@@ -0,0 +1,52 @@
import Phaser from 'phaser';
import { Ship as ShipType } from '@spacerace/shared';
export class ShipSprite extends Phaser.GameObjects.Container {
private shipData: ShipType;
private label: Phaser.GameObjects.Text;
constructor(
scene: Phaser.Scene,
x: number,
y: number,
ship: ShipType,
color: number,
) {
super(scene, x, y);
this.shipData = ship;
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
const baseAngle = angles[ship.direction] || 0;
// Ship body
const body = scene.add.rectangle(0, 0, 40, 36, color);
body.setStrokeStyle(2, 0xffffff);
// Direction indicator (small triangle)
const indicator = scene.add.triangle(0, -22, 0, 8, 5, 0, 10, 8, 0xffffff);
this.add([body, indicator]);
this.setAngle(baseAngle);
// Player name label
this.label = scene.add.text(0, 28, ship.playerName || ship.playerId.substring(0, 6), {
fontSize: '11px',
color: '#ffffff',
fontFamily: 'monospace',
backgroundColor: '#00000088',
padding: { x: 2, y: 1 },
}).setOrigin(0.5);
this.add(this.label);
scene.add.existing(this);
this.setDepth(10);
}
updateState(ship: ShipType): void {
this.shipData = ship;
if (!ship.alive) {
this.setAlpha(0.3);
}
}
}
+69
View File
@@ -0,0 +1,69 @@
import Phaser from 'phaser';
import { connectTvSocket, TvSocket } from '../network/TvSocket.js';
export class BootScene extends Phaser.Scene {
socket!: TvSocket;
constructor() {
super({ key: 'BootScene' });
}
preload(): void {
// Generate placeholder assets as textures
this.createPlaceholderTextures();
}
create(): void {
this.socket = connectTvSocket();
this.scene.start('LobbyScene', { socket: this.socket });
}
private createPlaceholderTextures(): void {
// Ship placeholder (triangle pointing up)
const shipGfx = this.make.graphics({ x: 0, y: 0, add: false });
shipGfx.fillStyle(0x00ccff);
shipGfx.fillTriangle(20, 0, 0, 36, 40, 36);
shipGfx.generateTexture('ship', 40, 36);
shipGfx.destroy();
// Asteroid placeholder (rough circle)
const asteroidGfx = this.make.graphics({ x: 0, y: 0, add: false });
asteroidGfx.fillStyle(0x888888);
asteroidGfx.fillCircle(20, 20, 18);
asteroidGfx.generateTexture('asteroid', 40, 40);
asteroidGfx.destroy();
// Meteor placeholder (red circle)
const meteorGfx = this.make.graphics({ x: 0, y: 0, add: false });
meteorGfx.fillStyle(0xff4400);
meteorGfx.fillCircle(20, 20, 18);
meteorGfx.generateTexture('meteor', 40, 40);
meteorGfx.destroy();
// Mine placeholder (orange circle with X)
const mineGfx = this.make.graphics({ x: 0, y: 0, add: false });
mineGfx.fillStyle(0xff8800);
mineGfx.fillCircle(20, 20, 16);
mineGfx.lineStyle(3, 0x000000);
mineGfx.lineBetween(10, 10, 30, 30);
mineGfx.lineBetween(30, 10, 10, 30);
mineGfx.generateTexture('mine', 40, 40);
mineGfx.destroy();
// Background tile
const bgGfx = this.make.graphics({ x: 0, y: 0, add: false });
bgGfx.fillStyle(0x0a0a2e);
bgGfx.fillRect(0, 0, 64, 64);
bgGfx.lineStyle(1, 0x1a1a4e);
bgGfx.strokeRect(0, 0, 64, 64);
bgGfx.generateTexture('bg_tile', 64, 64);
bgGfx.destroy();
// Death line texture
const dlGfx = this.make.graphics({ x: 0, y: 0, add: false });
dlGfx.fillStyle(0xff0000, 0.6);
dlGfx.fillRect(0, 0, 512, 4);
dlGfx.generateTexture('death_line', 512, 4);
dlGfx.destroy();
}
}
+252
View File
@@ -0,0 +1,252 @@
import Phaser from 'phaser';
import { TvSocket } from '../network/TvSocket.js';
import {
Ship as ShipType,
GameGridState,
ExecutionResult,
GRID_WIDTH,
ShipUpdate,
GridUpdate,
} from '@spacerace/shared';
import { GridRenderer } from '../objects/GridRenderer.js';
import { ShipSprite } from '../objects/ShipSprite.js';
const TILE_SIZE = 64;
const GRID_OFFSET_Y = (720 - GRID_WIDTH * TILE_SIZE) / 2;
const ANIM_DURATION = 900;
const DEATH_LINE_ANIM = 1800;
export class GameScene extends Phaser.Scene {
private socket!: TvSocket;
private gridRenderer!: GridRenderer;
private shipSprites: Map<string, ShipSprite> = new Map();
private gridState!: GameGridState;
private roundText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text;
private messageText!: Phaser.GameObjects.Text;
private initialGrid: GameGridState | null = null;
private initialShips: ShipType[] | null = null;
private execQueue: { updates: ShipUpdate[]; gridUpdates: GridUpdate[]; msgs: string[] }[] = [];
private animating = false;
private pendingDeathLineY: number | null = null;
private pendingPlanning: { round: number; grid: GameGridState; ships: ShipType[] } | null = null;
constructor() {
super({ key: 'GameScene' });
}
init(data: { socket: TvSocket; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void {
this.socket = data.socket;
this.initialGrid = data.grid ?? null;
this.initialShips = data.ships ?? null;
}
create(): void {
this.cameras.main.setBackgroundColor('#050515');
this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE);
this.roundText = this.add.text(16, 16, 'Round 0', {
fontSize: '24px', color: '#ffffff', fontFamily: 'monospace',
});
this.statusText = this.add.text(1280 / 2, 16, 'PLANNING PHASE', {
fontSize: '24px', color: '#ffcc00', fontFamily: 'monospace', fontStyle: 'bold',
}).setOrigin(0.5, 0);
this.messageText = this.add.text(16, 700, '', {
fontSize: '17px', color: '#cccccc', fontFamily: 'monospace', wordWrap: { width: 1248 },
});
if (this.initialGrid && this.initialShips) {
this.applyPlanning(1, this.initialGrid, this.initialShips);
}
this.socket.on('planningStarted', (data) => {
if (data.round === 1 && this.initialGrid) {
this.roundText.setText(`Round ${data.round}`);
return;
}
this.onPlanningStarted(data.round, data.grid, data.ships);
});
this.socket.on('executionTick', (data: ExecutionResult) => {
this.onExecutionTick(data);
});
this.socket.on('gameOver', (data) => {
this.scene.start('ResultScene', {
socket: this.socket,
winnerId: data.winnerId,
winnerName: data.winnerName,
ships: data.ships,
});
});
}
private onPlanningStarted(round: number, grid: GameGridState, ships: ShipType[]): void {
if (this.animating || this.execQueue.length > 0) {
this.pendingPlanning = { round, grid, ships };
return;
}
this.applyPlanning(round, grid, ships);
}
private applyPlanning(round: number, grid: GameGridState, ships: ShipType[]): void {
this.gridState = grid;
this.roundText.setText(`Round ${round}`);
this.statusText.setText('PLANNING PHASE').setColor('#ffcc00');
this.messageText.setText('Players are planning their moves...');
const oldDeathLineY = this.gridRenderer.viewOffset;
const newDeathLineY = grid.deathLineY;
const scrollInPx = (newDeathLineY - oldDeathLineY) * TILE_SIZE;
this.gridRenderer.renderGrid(grid, ships, scrollInPx);
this.updateShipSprites(ships);
}
// ── Execution animation: queue-based, sequential ──
private onExecutionTick(data: ExecutionResult): void {
this.statusText.setText('EXECUTING').setColor('#00ff88');
this.execQueue.push({
updates: data.shipUpdates,
gridUpdates: data.gridUpdates,
msgs: data.messages,
});
if (!this.animating) this.playNext();
}
private playNext(): void {
if (this.execQueue.length === 0) {
this.animating = false;
if (this.pendingDeathLineY !== null) {
const deathLineAnim = this.pendingDeathLineY;
this.pendingDeathLineY = null;
this.gridRenderer.animateDeathLine(deathLineAnim, DEATH_LINE_ANIM, () => {
this.finishExecution();
});
} else {
this.finishExecution();
}
return;
}
this.animating = true;
const batch = this.execQueue.shift()!;
this.messageText.setText(batch.msgs.slice(0, 3).join(' | '));
for (const u of batch.gridUpdates) {
if (u.type === 'tile_change') this.gridRenderer.updateTile(u.position, u.tile);
if (u.type === 'death_line') this.pendingDeathLineY = u.y;
}
for (const u of batch.updates) {
const sprite = this.shipSprites.get(u.shipId);
if (!sprite) continue;
switch (u.type) {
case 'move': {
const tx = this.gridRenderer.gridXToScreen(u.to.y);
const ty = this.gridRenderer.gridYToScreen(u.to.x);
this.tweens.add({
targets: sprite, x: tx, y: ty,
duration: ANIM_DURATION, ease: 'Sine.easeInOut',
});
break;
}
case 'turn': {
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION);
break;
}
case 'eliminated': {
this.shipSprites.delete(u.shipId);
const px = this.add.particles(sprite.x, sprite.y, 'asteroid', {
speed: { min: 40, max: 180 }, scale: { start: 0.3, end: 0 },
lifespan: 800, quantity: 20, emitting: false,
});
px.explode();
this.tweens.add({
targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1,
duration: 700, delay: 200,
onComplete: () => { sprite.destroy(); px.destroy(); },
});
break;
}
case 'collision': {
this.tweens.add({
targets: sprite,
alpha: 0.2,
duration: 100,
yoyo: true,
repeat: 3,
});
break;
}
case 'shield_used': {
this.tweens.add({
targets: sprite,
alpha: 0.2,
duration: 150,
yoyo: true,
repeat: 2,
});
break;
}
}
}
this.time.delayedCall(ANIM_DURATION + 120, () => this.playNext());
}
private finishExecution(): void {
if (this.pendingPlanning) {
const p = this.pendingPlanning;
this.pendingPlanning = null;
this.applyPlanning(p.round, p.grid, p.ships);
}
}
private rotateSpriteSlow(sprite: ShipSprite, targetAngle: number, duration: number): void {
const startAngle = sprite.angle;
let delta = targetAngle - startAngle;
delta = ((delta + 180) % 360 + 360) % 360 - 180;
this.tweens.addCounter({
from: 0, to: 1,
duration,
ease: 'Sine.easeInOut',
onUpdate: (tween) => {
const progress = tween.getValue();
sprite.setAngle(startAngle + delta * progress);
},
});
}
// ── Ship sprite management ──
private updateShipSprites(ships: ShipType[]): void {
for (const [id, sprite] of this.shipSprites) {
if (!ships.find((s) => s.id === id && s.alive)) {
sprite.destroy();
this.shipSprites.delete(id);
}
}
const colors = [0x00ccff, 0xff4444, 0x44ff44, 0xffaa00, 0xff44ff, 0xffff44];
for (let i = 0; i < ships.length; i++) {
const ship = ships[i];
if (!ship.alive) continue;
const x = this.gridRenderer.gridXToScreen(ship.position.y);
const y = this.gridRenderer.gridYToScreen(ship.position.x);
let sprite = this.shipSprites.get(ship.id);
if (!sprite) {
sprite = new ShipSprite(this, x, y, ship, colors[i % colors.length]);
this.children.remove(sprite);
this.gridRenderer.ships.add(sprite);
this.shipSprites.set(ship.id, sprite);
} else {
sprite.setPosition(x, y);
sprite.updateState(ship);
}
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import Phaser from 'phaser';
import { TvSocket } from '../network/TvSocket.js';
export class LobbyScene extends Phaser.Scene {
private socket!: TvSocket;
private roomCodeText!: Phaser.GameObjects.Text;
private playerListText!: Phaser.GameObjects.Text;
private players: { id: string; name: string }[] = [];
private roomCode: string = '';
constructor() {
super({ key: 'LobbyScene' });
}
init(data: { socket: TvSocket }): void {
this.socket = data.socket;
this.players = [];
}
create(): void {
const { width, height } = this.scale;
// Background
this.cameras.main.setBackgroundColor('#0a0a2e');
// Title
this.add.text(width / 2, 60, '🚀 SPACE RACE 🚀', {
fontSize: '48px',
color: '#00ccff',
fontFamily: 'monospace',
fontStyle: 'bold',
}).setOrigin(0.5);
// Create room button
this.roomCodeText = this.add.text(width / 2, 160, 'Creating room...', {
fontSize: '28px',
color: '#ffffff',
fontFamily: 'monospace',
}).setOrigin(0.5);
// Player list
this.playerListText = this.add.text(width / 2, 240, 'Players: 0', {
fontSize: '22px',
color: '#aaaaaa',
fontFamily: 'monospace',
align: 'center',
}).setOrigin(0.5, 0);
// QR Code hint
this.add.text(width / 2, height - 100, 'Scan QR code or enter room code on your phone', {
fontSize: '18px',
color: '#666688',
fontFamily: 'monospace',
}).setOrigin(0.5);
// Start button (hidden until players join)
const startBtn = this.add.text(width / 2, height - 160, '[ START GAME ]', {
fontSize: '32px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#115533',
padding: { x: 20, y: 10 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true }).setVisible(false);
startBtn.on('pointerover', () => startBtn.setStyle({ backgroundColor: '#227744' }));
startBtn.on('pointerout', () => startBtn.setStyle({ backgroundColor: '#115533' }));
startBtn.on('pointerdown', () => {
this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => {
if (res.ok) {
console.log('Game starting...');
}
});
});
// Socket events
this.socket.emit('tv:createRoom', {}, (res) => {
this.roomCode = res.roomCode;
this.roomCodeText.setText(`Room: ${res.roomCode}`);
this.updateQRHint();
});
this.socket.on('playerJoined', (data) => {
this.players.push({ id: data.playerId, name: data.name });
this.updatePlayerList();
startBtn.setVisible(this.players.length >= 2);
});
this.socket.on('playerLeft', (data) => {
this.players = this.players.filter((p) => p.id !== data.playerId);
this.updatePlayerList();
startBtn.setVisible(this.players.length >= 2);
});
this.socket.on('gameStarting', (data) => {
this.scene.start('GameScene', {
socket: this.socket,
roomCode: this.roomCode,
players: data.players,
grid: data.grid,
ships: data.ships,
});
});
}
private updatePlayerList(): void {
const names = this.players.map((p, i) => ` ${i + 1}. ${p.name}`).join('\n');
this.playerListText.setText(`Players (${this.players.length}/6):\n${names}`);
}
private updateQRHint(): void {
// We'll use a canvas-based QR in the controller app
const url = `${window.location.origin}?room=${this.roomCode}`;
console.log('Join URL:', url);
}
}
+89
View File
@@ -0,0 +1,89 @@
import Phaser from 'phaser';
import { TvSocket } from '../network/TvSocket.js';
import { Ship } from '@spacerace/shared';
export class ResultScene extends Phaser.Scene {
private socket!: TvSocket;
constructor() {
super({ key: 'ResultScene' });
}
init(data: { socket: TvSocket; winnerId: string; winnerName: string; ships: Ship[] }): void {
this.socket = data.socket;
// We'll use data directly in create
this.registry.set('resultData', data);
}
create(): void {
const data = this.registry.get('resultData') as {
winnerId: string;
winnerName: string;
ships: Ship[];
};
const { width, height } = this.scale;
this.cameras.main.setBackgroundColor('#0a0a2e');
this.add.text(width / 2, 100, '🏆 RACE OVER 🏆', {
fontSize: '52px',
color: '#ffcc00',
fontFamily: 'monospace',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(width / 2, 220, `${data.winnerName} wins!`, {
fontSize: '40px',
color: '#00ff88',
fontFamily: 'monospace',
}).setOrigin(0.5);
// Show final positions
const aliveShips = data.ships.filter((s) => s.alive);
const deadShips = data.ships.filter((s) => !s.alive);
let yPos = 320;
this.add.text(width / 2, yPos, 'Final Standings:', {
fontSize: '24px',
color: '#aaaaaa',
fontFamily: 'monospace',
}).setOrigin(0.5);
yPos += 40;
for (let i = 0; i < aliveShips.length; i++) {
const ship = aliveShips[i];
this.add.text(width / 2, yPos, `${i + 1}. ${ship.playerName}`, {
fontSize: '20px',
color: '#ffffff',
fontFamily: 'monospace',
}).setOrigin(0.5);
yPos += 30;
}
for (const ship of deadShips) {
this.add.text(width / 2, yPos, ` ${ship.playerName} (eliminated)`, {
fontSize: '20px',
color: '#666666',
fontFamily: 'monospace',
}).setOrigin(0.5);
yPos += 30;
}
// Play again button
const playAgainBtn = this.add.text(width / 2, height - 100, '[ BACK TO LOBBY ]', {
fontSize: '28px',
color: '#00ccff',
fontFamily: 'monospace',
backgroundColor: '#112244',
padding: { x: 20, y: 10 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
playAgainBtn.on('pointerover', () => playAgainBtn.setStyle({ backgroundColor: '#223366' }));
playAgainBtn.on('pointerout', () => playAgainBtn.setStyle({ backgroundColor: '#112244' }));
playAgainBtn.on('pointerdown', () => {
this.socket.disconnect();
window.location.reload();
});
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3000,
proxy: {
'/socket.io': {
target: 'http://localhost:8080',
ws: true,
},
},
},
build: {
outDir: 'dist',
},
});