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,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