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