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
+17
View File
@@ -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>
+19
View File
@@ -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"
}
}
+20
View File
@@ -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);
+11
View File
@@ -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;
}
+160
View File
@@ -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?.(),
});
}
}
+52
View File
@@ -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);
}
}
}
+69
View File
@@ -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();
}
}
+252
View File
@@ -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);
}
}
}
}
+115
View File
@@ -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);
}
}
+89
View File
@@ -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();
});
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+16
View File
@@ -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',
},
});