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,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"]
|
||||
}
|
||||
Reference in New Issue
Block a user