|
|
|
@@ -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[];
|
|
|
|
|
}
|