SpaceRace: Full-width TV field, death line scrolling, camera following leader, jump fix, path never fully blocked, player color on controller

This commit is contained in:
2026-06-24 16:41:37 +02:00
commit 5e2ab43ca3
46 changed files with 6581 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@spacerace/shared",
"version": "1.0.0",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
+1
View File
@@ -0,0 +1 @@
export * from './types.js';
+247
View File
@@ -0,0 +1,247 @@
// ── Grid & Position ──
export const GRID_WIDTH = 8;
export const VISIBLE_ROWS = 10;
export const AP_PER_ROUND = 4;
export const DEATH_LINE_ADVANCE = 1;
export const MAX_HAND_SIZE = 3;
export interface Position {
x: number; // 0..GRID_WIDTH-1
y: number; // infinite positive direction (track goes up)
}
export type Direction = 'N' | 'E' | 'S' | 'W';
export const DIRECTION_DELTA: Record<Direction, Position> = {
N: { x: -1, y: 0 }, // up on screen = decrease lane
E: { x: 0, y: 1 }, // right on screen = increase track position
S: { x: 1, y: 0 }, // down on screen = increase lane
W: { x: 0, y: -1 }, // left on screen = decrease track position
};
export function turnLeft(dir: Direction): Direction {
const order: Direction[] = ['N', 'W', 'S', 'E'];
return order[(order.indexOf(dir) + 1) % 4];
}
export function turnRight(dir: Direction): Direction {
const order: Direction[] = ['N', 'E', 'S', 'W'];
return order[(order.indexOf(dir) + 1) % 4];
}
export function turn180(dir: Direction): Direction {
const order: Direction[] = ['N', 'S', 'E', 'W'];
const map: Record<Direction, Direction> = { N: 'S', S: 'N', E: 'W', W: 'E' };
return map[dir];
}
export function posEqual(a: Position, b: Position): boolean {
return a.x === b.x && a.y === b.y;
}
// ── Tiles ──
export type TileType = 'space' | 'asteroid' | 'meteor' | 'mine' | 'debris';
export function isBlocked(tile: TileType): boolean {
return tile !== 'space';
}
// ── Ships ──
export interface Ship {
id: string;
playerId: string;
playerName: string;
position: Position;
direction: Direction;
alive: boolean;
}
// ── Cards ──
export type CardType =
| 'METEOR_STRIKE'
| 'SHIELD'
| 'BOOST'
| 'EMP'
| 'JUMP'
| 'MINE'
| 'TELEPORT'
| 'PHASE_SHIFT';
export interface CardDef {
type: CardType;
name: string;
description: string;
apCost: number;
}
export const CARD_DEFS: Record<CardType, CardDef> = {
METEOR_STRIKE: {
type: 'METEOR_STRIKE',
name: 'Meteor Strike',
description: 'Summon a meteor on any tile within 3 tiles of your ship',
apCost: 2,
},
SHIELD: {
type: 'SHIELD',
name: 'Shield',
description: 'Ignore the next obstacle collision this round',
apCost: 1,
},
BOOST: {
type: 'BOOST',
name: 'Boost',
description: 'Move 3 tiles forward instead of 1',
apCost: 2,
},
EMP: {
type: 'EMP',
name: 'EMP',
description: 'Cancel the next enemy action that targets you',
apCost: 2,
},
JUMP: {
type: 'JUMP',
name: 'Jump',
description: 'Jump over 1 obstacle tile directly in front of you',
apCost: 2,
},
MINE: {
type: 'MINE',
name: 'Mine',
description: 'Drop a mine on your current tile. Explodes next round.',
apCost: 1,
},
TELEPORT: {
type: 'TELEPORT',
name: 'Teleport',
description: 'Swap positions with any player within 5 tiles',
apCost: 3,
},
PHASE_SHIFT: {
type: 'PHASE_SHIFT',
name: 'Phase Shift',
description: 'Ignore all obstacles during your next move',
apCost: 2,
},
};
// ── Actions ──
export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'BOOST' | 'CARD';
export interface Action {
type: ActionType;
card?: CardType; // when type === 'CARD'
target?: Position; // for targeted cards (METEOR_STRIKE, TELEPORT)
}
export function actionApCost(action: Action): number {
switch (action.type) {
case 'MOVE_FORWARD': return 1;
case 'TURN_LEFT': return 1;
case 'TURN_RIGHT': return 1;
case 'TURN_180': return 2;
case 'BOOST': return 2;
case 'CARD':
return action.card ? CARD_DEFS[action.card].apCost : 0;
default: return 0;
}
}
// ── Plan ──
export interface PlayerPlan {
playerId: string;
actions: Action[];
}
// ── Game Phases ──
export type GamePhase = 'LOBBY' | 'PLANNING' | 'EXECUTING' | 'FINISHED';
// ── Game State ──
export interface GameGridState {
rows: Record<number, (TileType | null)[]>; // y -> row array
deathLineY: number; // any ship with y > deathLineY is eliminated
generatedUpToY: number; // highest y generated so far (negative = upward)
}
export interface GameState {
phase: GamePhase;
round: number;
grid: GameGridState;
ships: Ship[];
playerPlans: PlayerPlan[];
planningTimer: number; // seconds left in planning
alivePlayers: number;
}
// ── Player (mobile view) ──
export interface PlayerView {
playerId: string;
name: string;
ap: number;
apUsed: number;
hand: CardType[];
alive: boolean;
actions: Action[];
colorIndex: number;
}
// ── WebSocket Events ──
// TV <-> Server
export interface ServerToTvEvents {
roomCreated: (data: { roomCode: string }) => void;
playerJoined: (data: { playerId: string; name: string }) => void;
playerLeft: (data: { playerId: string }) => void;
gameStarting: (data: { players: { id: string; name: string }[]; round: number; grid: GameGridState; ships: Ship[] }) => void;
planningStarted: (data: { round: number; timer: number; grid: GameGridState; ships: Ship[] }) => void;
executionTick: (data: { tick: number; shipUpdates: ShipUpdate[]; gridUpdates: GridUpdate[]; messages: string[] }) => void;
executionComplete: (data: { round: number; ships: Ship[]; grid: GameGridState }) => void;
gameOver: (data: { winnerId: string; winnerName: string; ships: Ship[] }) => void;
}
export interface TvToServerEvents {
createRoom: (data: {}, callback: (res: { roomCode: string }) => void) => void;
startGame: (data: { roomCode: string }, callback: (res: { ok: boolean }) => void) => void;
}
// Mobile <-> Server
export interface ServerToMobileEvents {
roomJoined: (data: { playerId: string; roomCode: string; players: { id: string; name: string }[]; isHost: boolean }) => void;
playerJoined: (data: { playerId: string; name: string }) => void;
playerLeft: (data: { playerId: string }) => void;
gameStarting: (data: {}) => void;
planningRequest: (data: { round: number; playerView: PlayerView; grid: GameGridState; ships: Ship[]; timer: number }) => void;
executionTick: (data: { tick: number; shipUpdates: ShipUpdate[]; gridUpdates: GridUpdate[]; messages: string[] }) => void;
executionComplete: (data: { round: number; playerView: PlayerView }) => void;
planRejected: (data: { reason: string }) => void;
gameOver: (data: { winnerId: string; winnerName: string }) => void;
hostChanged: (data: { hostPlayerId: string }) => void;
error: (data: { message: string }) => void;
}
export interface MobileToServerEvents {
joinRoom: (data: { roomCode: string; playerName: string }, callback: (res: { ok: boolean; playerId?: string; error?: string }) => void) => void;
submitPlan: (data: { actions: Action[] }, callback: (res: { ok: boolean; error?: string }) => void) => void;
setReady: (data: {}, callback: (res: { ok: boolean }) => void) => void;
startGame: (data: { roomCode: string }, callback: (res: { ok: boolean; error?: string }) => void) => void;
}
// Execution updates
export type ShipUpdate =
| { type: 'move'; shipId: string; from: Position; to: Position }
| { type: 'turn'; shipId: string; direction: Direction }
| { type: 'eliminated'; shipId: string; position: Position; reason: string }
| { type: 'collision'; shipId: string; position: Position }
| { type: 'shield_used'; shipId: string };
export type GridUpdate =
| { type: 'tile_change'; position: Position; tile: TileType }
| { type: 'death_line'; y: number }
| { type: 'generation'; rows: Record<number, (TileType | null)[]> };
// ── Result ──
export interface ExecutionResult {
tick: number;
shipUpdates: ShipUpdate[];
gridUpdates: GridUpdate[];
messages: string[];
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}