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