QR code & clickable URL on TV lobby, BOOST card-only, remove METEOR_STRIKE/TELEPORT, fix vite base paths for production, Dockerfile with frontend builds, Makefile with podman

This commit is contained in:
2026-06-24 20:32:34 +02:00
parent 5e2ab43ca3
commit 8531864b25
30 changed files with 3036 additions and 629 deletions
+13
View File
@@ -0,0 +1,13 @@
REGISTRY := gitea.korbel.network
IMAGE := nico/spacerace
TAG := latest
ENGINE := podman
.PHONY: build push
build:
$(ENGINE) build -f server/Dockerfile -t $(IMAGE):$(TAG) .
push: build
$(ENGINE) tag $(IMAGE):$(TAG) $(REGISTRY)/$(IMAGE):$(TAG)
$(ENGINE) push $(REGISTRY)/$(IMAGE):$(TAG)
+25 -13
View File
@@ -4,14 +4,24 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>SpaceRace - Controller</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<button id="audio-toggle" class="audio-toggle" aria-label="Toggle sound" title="Toggle sound">
<span class="audio-icon-on">🔊</span>
<span class="audio-icon-off">🔇</span>
</button>
<div id="screen-join" class="screen active">
<h1>🚀 SpaceRace</h1>
<p class="join-sub">Pilot your ship to victory</p>
<div class="join-form">
<input type="text" id="room-input" placeholder="Room Code" maxlength="4" autocomplete="off" />
<label for="room-input">Room Code</label>
<input type="text" id="room-input" placeholder="ABCD" maxlength="4" autocomplete="off" />
<label for="name-input">Callsign</label>
<input type="text" id="name-input" placeholder="Your Name" maxlength="12" autocomplete="off" />
<button id="join-btn">Join Race</button>
<p id="join-error" class="error"></p>
@@ -23,9 +33,10 @@
<div class="planning-header">
<div class="header-left">
<span id="color-dot" class="color-dot"></span>
<span id="round-label">Round 1</span>
<span id="player-name" class="player-name">Pilot</span>
</div>
<div class="header-center">
<span id="round-label">Round 1</span>
<span id="timer-label">45s</span>
</div>
<div class="header-right">
@@ -55,20 +66,17 @@
<span class="act-label">Right</span>
<span class="act-cost">1 AP</span>
</button>
<button class="act-btn accent" data-action="BOOST">
<span class="act-icon"></span>
<span class="act-label">Boost</span>
<span class="act-cost">2 AP</span>
</button>
</div>
<!-- Action queue -->
<div id="action-queue" class="action-queue">
<div class="action-queue-label">Action Sequence</div>
<div id="queue-list" class="queue-list"></div>
</div>
<!-- Card hand -->
<div id="card-hand" class="card-hand">
<div class="card-hand-label">Tactical Cards</div>
<div id="card-list" class="card-list"></div>
</div>
@@ -80,20 +88,24 @@
</div>
<div id="screen-waiting" class="screen">
<h2>Executing...</h2>
<p id="waiting-message">Watch the TV!</p>
<div class="tv-icon"></div>
<h2>Executing…</h2>
<p id="waiting-message">Watch the TV screen</p>
</div>
<div id="screen-spectator" class="screen">
<h2>💀 Eliminated</h2>
<h2 id="spectator-title">💀 Eliminated</h2>
<p>You're out! Watch the TV to see who wins.</p>
</div>
<div id="screen-lobby" class="screen">
<h2>Lobby</h2>
<p id="lobby-room">Room: ----</p>
<h2>Mission Lobby</h2>
<div class="lobby-room-badge">
<span class="lobby-room-label">Room Code</span>
<span id="lobby-room" class="lobby-room-code">----</span>
</div>
<div id="lobby-players">
<h3>Players</h3>
<h3>Crew Manifest</h3>
<ul id="lobby-player-list"></ul>
</div>
<p id="lobby-waiting">Waiting for host to start...</p>
+41 -8
View File
@@ -4,10 +4,11 @@ import { PlanningScreen } from './screens/PlanningScreen.js';
import { WaitingScreen } from './screens/WaitingScreen.js';
import { SpectatorScreen } from './screens/SpectatorScreen.js';
import { LobbyScreen } from './screens/LobbyScreen.js';
import { Action } from '@spacerace/shared';
import { Action, AudioEngine } from '@spacerace/shared';
class App {
export class App {
socket: ControllerSocket;
audio: AudioEngine;
private currentScreen: string = 'join';
isHost: boolean = false;
roomCode: string = '';
@@ -15,10 +16,37 @@ class App {
constructor() {
this.socket = connectControllerSocket();
this.audio = new AudioEngine();
this.initMuteToggle();
this.initScreens();
this.initSocketEvents();
}
private initMuteToggle(): void {
const btn = document.getElementById('audio-toggle');
if (!btn) return;
if (this.audio.isMuted()) document.body.classList.add('audio-muted');
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.audio.unlock();
const muted = !this.audio.isMuted();
this.audio.setMuted(muted);
document.body.classList.toggle('audio-muted', muted);
this.audio.feedback('tap', { sound: false });
this.audio.vibrate(muted ? 'warning' : 'go');
});
// Unlock audio on the first user gesture (anywhere on the page)
const unlock = (): void => {
this.audio.unlock();
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('touchstart', unlock);
};
window.addEventListener('pointerdown', unlock, { once: true });
window.addEventListener('touchstart', unlock, { once: true });
}
private initScreens(): void {
new JoinScreen(this);
new LobbyScreen(this);
@@ -36,36 +64,41 @@ class App {
this.isHost = data.isHost;
this.roomCode = data.roomCode;
this.playerId = data.playerId;
this.audio.feedback('round');
this.showScreen('lobby');
});
this.socket.on('gameStarting', () => {
this.audio.feedback('phaseChange');
this.showScreen('planning');
});
this.socket.on('planningRequest', (data) => {
this.audio.feedback('round');
this.showScreen('planning');
// Pass data to planning screen
document.dispatchEvent(new CustomEvent('planningRequest', { detail: data }));
});
this.socket.on('executionTick', (data) => {
this.socket.on('executionTick', () => {
this.audio.feedback('phaseChange');
this.showScreen('waiting');
const msgEl = document.getElementById('waiting-message');
if (msgEl && data.messages.length > 0) {
msgEl.textContent = data.messages.join(' | ');
}
});
this.socket.on('executionComplete', (data) => {
if (data.playerView.alive) {
this.showScreen('planning');
} else {
this.audio.feedback('eliminated');
this.showScreen('spectator');
}
});
this.socket.on('gameOver', (data) => {
if (data.winnerId === this.playerId) {
this.audio.feedback('win');
} else {
this.audio.feedback('eliminated');
}
this.showScreen('spectator');
});
}
+13 -1
View File
@@ -21,21 +21,31 @@ export class JoinScreen {
roomInput.value = roomParam.toUpperCase();
}
// Small feedback on input focus
[roomInput, nameInput].forEach((input) => {
input.addEventListener('focus', () => this.app.audio.feedback('cardSelect'));
});
joinBtn.addEventListener('click', () => {
this.app.audio.unlock();
this.app.audio.feedback('go');
const roomCode = roomInput.value.trim().toUpperCase();
const playerName = nameInput.value.trim();
if (!roomCode || roomCode.length !== 4) {
errorEl.textContent = 'Enter a 4-character room code';
this.app.audio.feedback('warning');
return;
}
if (!playerName) {
errorEl.textContent = 'Enter your name';
this.app.audio.feedback('warning');
return;
}
errorEl.textContent = '';
joinBtn.textContent = 'Joining...';
joinBtn.textContent = 'Joining';
joinBtn.disabled = true;
this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => {
@@ -43,8 +53,10 @@ export class JoinScreen {
joinBtn.disabled = false;
if (!res.ok) {
errorEl.textContent = res.error || 'Failed to join';
this.app.audio.feedback('warning');
}
});
});
}
}
+42 -14
View File
@@ -1,4 +1,7 @@
import { App } from '../main.js';
import { PLAYER_HEX } from '@spacerace/shared';
const AVATAR_LETTERS = ['◆', '▲', '●', '■', '★', '⬢'];
export class LobbyScreen {
private app: App;
@@ -10,12 +13,14 @@ export class LobbyScreen {
this.createStartButton();
app.socket.on('roomJoined', (data) => {
document.getElementById('lobby-room')!.textContent = `Room: ${data.roomCode}`;
const roomEl = document.getElementById('lobby-room');
if (roomEl) roomEl.textContent = data.roomCode;
this.updatePlayerList(data.players);
this.updateHostUI();
});
app.socket.on('playerJoined', (data) => {
this.app.audio.feedback('cardSelect');
this.appendPlayer(data.playerId, data.name);
this.updateHostUI();
});
@@ -35,20 +40,22 @@ export class LobbyScreen {
private createStartButton(): void {
const div = document.createElement('div');
div.id = 'host-controls';
div.style.cssText = 'margin-top: 16px; text-align: center; display: none;';
div.style.cssText = 'margin-top: 16px; text-align: center; display: none; width: 100%; max-width: 360px;';
this.startBtn = document.createElement('button');
this.startBtn.textContent = '🚀 Start Race';
this.startBtn.style.cssText = 'padding: 14px 32px; font-size: 20px; background: #00ff88; color: #000; border: none; border-radius: 8px; font-weight: bold; cursor: pointer;';
this.startBtn.className = 'start-btn-neon';
this.startBtn.addEventListener('click', () => {
if (!this.startBtn) return;
this.startBtn.textContent = 'Starting...';
this.app.audio.feedback('go');
this.startBtn.textContent = 'Launching…';
this.startBtn.disabled = true;
this.app.socket.emit('mobile:startGame', { roomCode: this.app.roomCode }, (res) => {
if (!res.ok) {
this.startBtn!.textContent = '🚀 Start Race';
this.startBtn!.disabled = false;
this.app.audio.feedback('warning');
alert(res.error || 'Cannot start');
}
});
@@ -56,7 +63,6 @@ export class LobbyScreen {
div.appendChild(this.startBtn);
// Insert before the waiting text
const lobbyScreen = document.getElementById('screen-lobby')!;
const waitingEl = document.getElementById('lobby-waiting')!;
lobbyScreen.insertBefore(div, waitingEl);
@@ -72,16 +78,11 @@ export class LobbyScreen {
const playerCount = document.getElementById('lobby-player-list')!.children.length;
if (this.startBtn) {
this.startBtn.disabled = playerCount < 2;
if (playerCount < 2) {
this.startBtn.style.opacity = '0.5';
} else {
this.startBtn.style.opacity = '1';
}
}
} else {
div.style.display = 'none';
waitingEl.style.display = 'block';
waitingEl.textContent = 'Waiting for host to start...';
waitingEl.textContent = 'Waiting for host to start';
}
}
@@ -95,11 +96,38 @@ export class LobbyScreen {
private appendPlayer(id: string, name: string): void {
const list = document.getElementById('lobby-player-list')!;
const colorIndex = list.children.length;
const color = PLAYER_HEX[colorIndex % PLAYER_HEX.length];
const li = document.createElement('li');
const hostId = this.app.socket.data?.hostPlayerId;
const crown = (this.app.isHost && id === this.app.playerId) ? ' 👑' : '';
li.textContent = name + crown;
li.id = `player-${id}`;
const avatar = document.createElement('span');
avatar.className = 'player-avatar';
avatar.style.backgroundColor = color;
avatar.style.color = color;
avatar.textContent = AVATAR_LETTERS[colorIndex % AVATAR_LETTERS.length];
avatar.style.display = 'flex';
avatar.style.alignItems = 'center';
avatar.style.justifyContent = 'center';
avatar.style.fontSize = '12px';
avatar.style.color = '#00121a';
avatar.style.boxShadow = `0 0 12px ${color}`;
const nameSpan = document.createElement('span');
nameSpan.textContent = name;
const isMe = id === this.app.playerId;
li.appendChild(avatar);
li.appendChild(nameSpan);
if (isMe) {
const tag = document.createElement('span');
tag.style.cssText = 'margin-left: 8px; font-size: 10px; letter-spacing: 0.15em; padding: 2px 6px; border-radius: 999px; background: rgba(0,229,255,0.15); border: 1px solid rgba(0,229,255,0.4); color: var(--primary); font-family: var(--f-display); text-transform: uppercase;';
tag.textContent = 'You';
li.appendChild(tag);
}
list.appendChild(li);
}
}
+90 -37
View File
@@ -1,17 +1,22 @@
import { App } from '../main.js';
import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost } from '@spacerace/shared';
import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost, PLAYER_HEX } from '@spacerace/shared';
const CARD_ICONS: Record<CardType, string> = {
METEOR_STRIKE: '☄️',
SHIELD: '🛡️',
BOOST: '🚀',
EMP: '⚡',
JUMP: '🦘',
MINE: '💣',
TELEPORT: '🌀',
PHASE_SHIFT: '👻',
};
const ACTION_ICONS: Record<string, string> = {
MOVE_FORWARD: '↑',
TURN_LEFT: '↰',
TURN_RIGHT: '↱',
TURN_180: '↻',
};
export class PlanningScreen {
private app: App;
private plannedActions: Action[] = [];
@@ -42,6 +47,7 @@ export class PlanningScreen {
this.timerSeconds = data.timer;
this.setPlayerColor(data.playerView.colorIndex);
this.setPlayerName(data.playerView.name);
document.getElementById('round-label')!.textContent = `Round ${data.round}`;
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
@@ -63,15 +69,29 @@ export class PlanningScreen {
}
private setPlayerColor(colorIndex: number): void {
const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44'];
const dot = document.getElementById('color-dot');
if (dot) dot.style.backgroundColor = colors[colorIndex % colors.length];
if (dot) {
const color = PLAYER_HEX[colorIndex % PLAYER_HEX.length];
dot.style.backgroundColor = color;
dot.style.color = color;
}
}
private setPlayerName(name: string): void {
const el = document.getElementById('player-name');
if (el) el.textContent = name;
}
private updateTimer(): void {
const el = document.getElementById('timer-label')!;
el.textContent = `${this.timerSeconds}s`;
el.style.color = this.timerSeconds <= 10 ? '#ff4444' : this.timerSeconds <= 20 ? '#ffcc00' : '#00ff88';
el.classList.remove('warning', 'danger');
if (this.timerSeconds <= 10) {
el.classList.add('danger');
if (this.timerSeconds > 0) this.app.audio.feedback('warning');
} else if (this.timerSeconds <= 20) {
el.classList.add('warning');
}
}
private initActionButtons(): void {
@@ -79,6 +99,7 @@ export class PlanningScreen {
grid.querySelectorAll('.act-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const actionType = (btn as HTMLElement).dataset.action!;
this.app.audio.feedback(actionType === 'BOOST' ? 'cardUse' : 'tap');
this.addAction({ type: actionType as Action['type'] });
});
});
@@ -86,6 +107,7 @@ export class PlanningScreen {
private initBottomButtons(): void {
document.getElementById('clear-plan-btn')!.addEventListener('click', () => {
this.app.audio.feedback('queueRemove');
this.plannedActions = [];
this.selectedCard = null;
this.renderQueue();
@@ -95,14 +117,16 @@ export class PlanningScreen {
document.getElementById('submit-plan-btn')!.addEventListener('click', () => {
if (this.plannedActions.length === 0) return;
this.app.audio.feedback('go');
const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
btn.disabled = true;
btn.textContent = '...';
btn.textContent = '';
this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => {
if (!res.ok) {
btn.disabled = false;
btn.textContent = 'GO!';
this.app.audio.feedback('warning');
alert(res.error || 'Invalid plan');
}
});
@@ -112,12 +136,15 @@ export class PlanningScreen {
private addAction(action: Action): void {
if (!this.playerView || !this.playerView.alive) return;
// If we have a selected card, add it as a CARD action first
if (this.selectedCard) {
const cardCost = CARD_DEFS[this.selectedCard].apCost;
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
if (used + cardCost > this.apTotal) return;
if (used + cardCost > this.apTotal) {
this.app.audio.feedback('warning');
return;
}
this.app.audio.feedback('cardUse');
this.plannedActions.push({ type: 'CARD', card: this.selectedCard });
this.selectedCard = null;
this.renderCards(this.playerView.hand);
@@ -128,7 +155,10 @@ export class PlanningScreen {
const cost = actionApCost(action);
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
if (used + cost > this.apTotal) return;
if (used + cost > this.apTotal) {
this.app.audio.feedback('warning');
return;
}
this.plannedActions.push(action);
this.renderQueue();
@@ -142,30 +172,37 @@ export class PlanningScreen {
const list = document.getElementById('queue-list')!;
list.innerHTML = '';
if (this.plannedActions.length === 0) {
const empty = document.createElement('div');
empty.className = 'queue-empty';
empty.textContent = 'Pick a move to begin…';
list.appendChild(empty);
return;
}
for (let i = 0; i < this.plannedActions.length; i++) {
const action = this.plannedActions[i];
const div = document.createElement('div');
div.className = 'queue-item';
let icon = '';
switch (action.type) {
case 'MOVE_FORWARD': icon = ''; break;
case 'TURN_LEFT': icon = '↰'; break;
case 'TURN_RIGHT': icon = '↱'; break;
case 'TURN_180': icon = '↻'; break;
case 'BOOST': icon = '⚡'; break;
case 'CARD': icon = action.card ? CARD_ICONS[action.card] : '?'; break;
}
const icon = action.type === 'CARD'
? (action.card ? CARD_ICONS[action.card] : '?')
: ACTION_ICONS[action.type] || '?';
div.textContent = icon;
div.title = action.type + (action.card ? ` ${action.card}` : '');
// X button to remove
const step = document.createElement('span');
step.className = 'step';
step.textContent = String(i + 1);
div.appendChild(step);
const remove = document.createElement('span');
remove.className = 'remove-hint';
remove.textContent = '×';
div.appendChild(remove);
div.addEventListener('click', () => {
this.app.audio.feedback('queueRemove');
this.plannedActions.splice(i, 1);
this.renderQueue();
this.updateApDisplay();
@@ -180,7 +217,6 @@ export class PlanningScreen {
const list = document.getElementById('card-list')!;
list.innerHTML = '';
// If a card was already used in the plan, dim it
const usedCards = new Set<CardType>();
for (const a of this.plannedActions) {
if (a.type === 'CARD' && a.card) usedCards.add(a.card);
@@ -188,50 +224,67 @@ export class PlanningScreen {
const availableCards = hand.filter((c) => !usedCards.has(c));
if (availableCards.length === 0) {
const empty = document.createElement('div');
empty.className = 'card-empty';
empty.textContent = 'No cards available';
list.appendChild(empty);
return;
}
for (const card of availableCards) {
const def = CARD_DEFS[card];
const div = document.createElement('div');
div.className = 'card-item';
if (this.selectedCard === card) div.classList.add('selected');
div.innerHTML = `
<div class="card-icon">${CARD_ICONS[card]}</div>
<div class="card-name">${def.name}</div>
<div class="card-cost">${def.apCost} AP</div>
`;
const icon = document.createElement('div');
icon.className = 'card-icon';
icon.textContent = CARD_ICONS[card];
const name = document.createElement('div');
name.className = 'card-name';
name.textContent = def.name;
const cost = document.createElement('div');
cost.className = 'card-cost';
cost.textContent = `${def.apCost} AP`;
div.appendChild(icon);
div.appendChild(name);
div.appendChild(cost);
div.addEventListener('click', () => {
const wasSelected = this.selectedCard === card;
this.selectedCard = wasSelected ? null : card;
this.app.audio.feedback(wasSelected ? 'queueRemove' : 'cardSelect');
this.renderCards(hand);
});
list.appendChild(div);
}
if (availableCards.length === 0) {
list.innerHTML = '<div style="color:#445566;padding:8px;font-size:12px">No cards available</div>';
}
}
private updateApDisplay(): void {
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
const remaining = this.apTotal - used;
document.getElementById('ap-label')!.textContent = `AP: ${remaining}/${this.apTotal}`;
const apLabel = document.getElementById('ap-label')!;
apLabel.textContent = `AP: ${remaining}/${this.apTotal}`;
const fill = document.getElementById('ap-fill')!;
fill.style.width = `${(remaining / this.apTotal) * 100}%`;
fill.classList.remove('warning', 'danger');
if (remaining === 0) {
fill.style.background = '#ff4444';
document.getElementById('ap-label')!.style.color = '#ff4444';
apLabel.style.color = '';
fill.classList.add('danger');
} else if (remaining === this.apTotal) {
fill.style.background = '#00ff88';
document.getElementById('ap-label')!.style.color = '#00ff88';
apLabel.style.color = '';
fill.classList.remove('warning');
} else {
fill.style.background = '#ffcc00';
document.getElementById('ap-label')!.style.color = '#ffcc00';
apLabel.style.color = '';
fill.classList.add('warning');
}
}
}
+12 -1
View File
@@ -3,12 +3,23 @@ import { App } from '../main.js';
export class SpectatorScreen {
constructor(app: App) {
app.socket.on('gameOver', (data) => {
const h2 = document.querySelector('#screen-spectator h2')!;
const h2 = document.getElementById('spectator-title');
if (!h2) return;
if (data.winnerId === app.socket.data?.playerId) {
h2.textContent = '🏆 You Win!';
h2.classList.remove('dead');
} else {
h2.textContent = `💀 ${data.winnerName} Wins!`;
h2.classList.remove('dead');
}
});
app.socket.on('executionComplete', (data) => {
const h2 = document.getElementById('spectator-title');
if (!h2) return;
if (data.playerView.alive) return;
h2.textContent = '💀 Eliminated';
h2.classList.add('dead');
});
}
}
+724 -175
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: '/controller/',
server: {
port: 3001,
proxy: {
+1
View File
@@ -2911,6 +2911,7 @@
"dependencies": {
"@spacerace/shared": "*",
"phaser": "^3.80.1",
"qrcode-generator": "^1.5.2",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
+15 -5
View File
@@ -1,10 +1,20 @@
FROM node:22-alpine AS server-build
FROM node:22-alpine AS 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
COPY tv/ ./tv/
COPY controller/ ./controller/
RUN npm ci
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/package.json /app/package-lock.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/shared ./shared
COPY --from=build /app/server ./server
COPY --from=build /app/tv/dist ./tv/dist
COPY --from=build /app/controller/dist ./controller/dist
EXPOSE 8080
CMD ["npx", "tsx", "src/index.ts"]
CMD ["npx", "tsx", "server/src/index.ts"]
+128 -77
View File
@@ -4,88 +4,139 @@ 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: [] };
export type CardEvent =
| { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string }
| { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' };
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: [] };
export interface CardResult {
success: boolean;
message: string;
gridUpdates: { position: Position; tile: string }[];
events: CardEvent[];
}
function cardEvent(cardType: CardType, ctx: CardContext, position: Position, targetId?: string): CardEvent {
return { type: 'card_played', shipId: ctx.ship.id, card: cardType, position, targetId };
}
function moveEvent(ctx: CardContext, from: Position, to: Position, source: 'walk' | 'boost' | 'jump'): CardEvent {
return { type: 'move', shipId: ctx.ship.id, from, to, source };
}
type CardHandler = (ctx: CardContext) => CardResult;
const SHIELD: CardHandler = (ctx) => {
ctx.ship.shielded = true;
return {
success: true,
message: `${ctx.ship.playerId} activated Shield`,
gridUpdates: [],
events: [cardEvent('SHIELD', ctx, { ...ctx.ship.position })],
};
};
const EMP: CardHandler = (ctx) => {
ctx.ship.empActive = true;
return {
success: true,
message: `${ctx.ship.playerId} activated EMP`,
gridUpdates: [],
events: [cardEvent('EMP', ctx, { ...ctx.ship.position })],
};
};
const JUMP: CardHandler = (ctx) => {
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: [], events: [] };
}
const fromPos = { ...ctx.ship.position };
ctx.ship.position = jumpPos;
return {
success: true,
message: `${ctx.ship.playerId} jumped 2 tiles forward`,
gridUpdates: [],
events: [
cardEvent('JUMP', ctx, { ...jumpPos }, `${fromPos.x},${fromPos.y}`),
moveEvent(ctx, fromPos, jumpPos, 'jump'),
],
};
};
const MINE: CardHandler = (ctx) => {
ctx.grid.setTile(ctx.ship.position, 'mine');
return {
success: true,
message: `${ctx.ship.playerId} dropped a mine`,
gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }],
events: [cardEvent('MINE', ctx, { ...ctx.ship.position })],
};
};
const BOOST: CardHandler = (ctx) => {
const dir = ctx.ship.direction;
const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0;
const boostPos: Position = {
x: ctx.ship.position.x + dx * 3,
y: ctx.ship.position.y + dy * 3,
};
if (!ctx.grid.isInBounds(boostPos)) {
return { success: false, message: 'Boost out of bounds', gridUpdates: [], events: [] };
}
const fromPos = { ...ctx.ship.position };
ctx.ship.position = boostPos;
return {
success: true,
message: `${ctx.ship.playerId} boosted forward`,
gridUpdates: [],
events: [
cardEvent('BOOST', ctx, { ...boostPos }, `${fromPos.x},${fromPos.y}`),
moveEvent(ctx, fromPos, boostPos, 'boost'),
],
};
};
const PHASE_SHIFT: CardHandler = (ctx) => {
ctx.ship.phaseShifting = true;
return {
success: true,
message: `${ctx.ship.playerId} activated Phase Shift`,
gridUpdates: [],
events: [cardEvent('PHASE_SHIFT', ctx, { ...ctx.ship.position })],
};
};
const HANDLERS: Record<CardType, CardHandler> = {
SHIELD,
EMP,
JUMP,
MINE,
BOOST,
PHASE_SHIFT,
};
export function executeCard(cardType: CardType, ctx: CardContext): CardResult {
const handler = HANDLERS[cardType];
if (!handler) {
return { success: false, message: 'Unknown card', gridUpdates: [], events: [] };
}
return handler(ctx);
}
+6 -10
View File
@@ -76,9 +76,11 @@ export class Executor {
const ship = this.ships.get(shipId);
if (!ship || !ship.alive) continue;
// Stash action on the ship so CardHandler can read .target for METEOR_STRIKE
(ship as any).lastAction = action;
const ctx = {
ship,
target: action.target,
grid: this.grid,
allShips: Array.from(this.ships.values()),
shipStates: this.ships,
@@ -90,6 +92,9 @@ export class Executor {
for (const gu of cardResult.gridUpdates) {
result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any });
}
for (const ev of cardResult.events) {
result.shipUpdates.push(ev);
}
} else {
result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`);
}
@@ -118,15 +123,6 @@ export class Executor {
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);
+8
View File
@@ -1,5 +1,6 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { createServer } from 'http';
import { WsServer } from './ws/WsServer.js';
import { GAME_CONFIG } from './config.js';
@@ -8,6 +9,13 @@ const app = express();
app.use(cors());
app.use(express.json());
// Serve controller frontend
app.use('/controller', express.static(path.join(__dirname, '../../controller/dist')));
// Serve TV frontend at root
app.use('/', express.static(path.join(__dirname, '../../tv/dist')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, '../../tv/dist/index.html')));
const httpServer = createServer(app);
const wsServer = new WsServer(httpServer);
+436
View File
@@ -0,0 +1,436 @@
// Shared audio engine — Web Audio API based, no asset files.
// Generates short procedural sounds for UI feedback and game events.
// `navigator.vibrate` is used opportunistically (Android only; iOS Safari no-op).
export type SoundName =
| 'tap' // soft button tap
| 'cardSelect' // card hover
| 'cardUse' // card activated
| 'queueRemove' // action removed from queue
| 'go' // submit / launch
| 'countdown' // 3-2-1 beep
| 'warning' // low-timer ping
| 'eliminated' // you were killed
| 'win' // you won
| 'round' // new round / planning start
| 'phaseChange' // planning → executing
| 'move' // ship moves
| 'turn' // ship turns
| 'collision' // obstacle hit
| 'explosion' // ship destroyed
| 'deathLine' // wall advances
| 'fanfare' // winner reveal
| 'shield' // shield bubble
| 'meteor' // meteor strikes
| 'boost' // boost forward
| 'emp' // EMP shock
| 'jump' // jump / teleport
| 'mine' // mine drop
| 'phaseShift' // phase shift activate
| 'turn_sweep' // smoother 180° rotation
| 'move_punch'; // extra low-end for MOVE_FORWARD hit
const HAPTIC_PATTERNS: Partial<Record<SoundName, number | number[]>> = {
tap: 10,
cardSelect: 5,
cardUse: 15,
queueRemove: 8,
go: [25, 10, 40],
countdown: 15,
warning: [10, 30, 10],
eliminated: [200, 80, 200],
win: [60, 30, 60, 30, 120],
round: 20,
phaseChange: 25,
move: 5,
turn: 5,
collision: [30, 20, 30],
explosion: [120, 60, 80],
deathLine: 25,
fanfare: [40, 30, 40, 30, 40, 30, 160],
shield: 12,
meteor: [40, 20, 60],
boost: 18,
emp: [25, 15, 35],
jump: 12,
mine: 18,
phaseShift: 14,
turn_sweep: 8,
move_punch: 6,
};
const STORAGE_KEY = 'spacerace:audio-muted';
export class AudioEngine {
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private muted: boolean;
constructor() {
this.muted = false;
try {
this.muted = localStorage.getItem(STORAGE_KEY) === '1';
} catch { /* localStorage might be unavailable */ }
}
/**
* Must be called on a user gesture (click/touch) to satisfy browser
* autoplay policy. Safe to call multiple times.
*/
unlock(): void {
if (this.ctx) {
if (this.ctx.state === 'suspended') this.ctx.resume();
return;
}
const w = window as unknown as { AudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext };
const Ctor = w.AudioContext || w.webkitAudioContext;
if (!Ctor) return;
this.ctx = new Ctor();
this.master = this.ctx.createGain();
this.master.gain.value = this.muted ? 0 : 0.5;
this.master.connect(this.ctx.destination);
}
setMuted(muted: boolean): void {
this.muted = muted;
if (this.master) this.master.gain.value = muted ? 0 : 0.5;
try { localStorage.setItem(STORAGE_KEY, muted ? '1' : '0'); } catch { /* ignore */ }
}
isMuted(): boolean { return this.muted; }
/**
* Play sound. No-op if the audio context hasn't been unlocked yet.
*/
play(name: SoundName): void {
if (!this.ctx || !this.master) return;
const now = this.ctx.currentTime;
switch (name) {
case 'tap': this.synthTap(now, 660, 0.04); break;
case 'cardSelect': this.synthTap(now, 880, 0.05, 'sine'); break;
case 'cardUse': this.synthSweep(now, 520, 880, 0.1, 'sine'); break;
case 'queueRemove': this.synthSweep(now, 300, 180, 0.08, 'square'); break;
case 'go': this.synthGo(now); break;
case 'countdown': this.synthTap(now, 1200, 0.06, 'square'); break;
case 'warning': this.synthWarning(now); break;
case 'eliminated': this.synthSweep(now, 220, 60, 0.5, 'sawtooth'); break;
case 'win': this.synthFanfare(now, [523.25, 659.25, 783.99], 0.9); break;
case 'round': this.synthTap(now, 660, 0.18, 'triangle'); break;
case 'phaseChange': this.synthTap(now, 330, 0.22, 'triangle'); break;
case 'move': this.synthMove(now); break;
case 'move_punch': this.synthNoise(now, 0.08, 120); this.synthTap(now, 140, 0.06, 'triangle'); break;
case 'turn': this.synthTap(now, 880, 0.04, 'triangle'); break;
case 'turn_sweep': this.synthSweep(now, 700, 1100, 0.18, 'sine'); break;
case 'collision': this.synthNoise(now, 0.22, 200); break;
case 'explosion': this.synthExplosion(now); break;
case 'deathLine': this.synthSweep(now, 180, 90, 0.35, 'sawtooth'); break;
case 'fanfare': this.synthFanfare(now, [523.25, 659.25, 783.99, 1046.5], 1.2); break;
case 'shield': this.synthShield(now); break;
case 'meteor': this.synthMeteor(now); break;
case 'boost': this.synthBoost(now); break;
case 'emp': this.synthEmp(now); break;
case 'jump': this.synthJump(now); break;
case 'mine': this.synthMine(now); break;
case 'phaseShift': this.synthPhaseShift(now); break;
}
}
/**
* Sustained engine sound for a ship move. Plays for the full duration so
* the move feels powerful. `pitch` raises the engine tone (1 = normal,
* 1.4 = BOOST). Schedules a matching `settle` thump at the end.
*/
playEngine(durationSec: number, opts: { pitch?: number; peak?: number; settle?: boolean } = {}): void {
if (!this.ctx || !this.master) return;
const pitch = opts.pitch ?? 1;
const peak = opts.peak ?? 0.5;
this.synthEngine(this.ctx.currentTime, durationSec, pitch, peak);
if (opts.settle !== false) {
// Schedule settle slightly before the engine fades out
const settleAt = this.ctx.currentTime + Math.max(0, durationSec - 0.12);
this.synthSettle(settleAt, pitch);
}
}
vibrate(name: SoundName): void {
const pattern = HAPTIC_PATTERNS[name];
if (!pattern) return;
if (typeof navigator === 'undefined') return;
const nav = navigator as Navigator & { vibrate?: (p: number | number[]) => boolean };
if (typeof nav.vibrate !== 'function') return;
try { nav.vibrate(pattern); } catch { /* ignore */ }
}
/** Combined play + vibrate, single call for most feedback. */
feedback(name: SoundName, opts: { sound?: boolean; haptic?: boolean } = {}): void {
if (opts.sound !== false) this.play(name);
if (opts.haptic !== false) this.vibrate(name);
}
// ── Synth primitives ─────────────────────────────────────────────
private envGain(t: number, attack: number, hold: number, release: number, peak = 0.6): GainNode {
if (!this.ctx || !this.master) throw new Error('AudioContext not initialized');
const g = this.ctx.createGain();
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(peak, t + attack);
g.gain.setValueAtTime(peak, t + attack + hold);
g.gain.exponentialRampToValueAtTime(0.0001, t + attack + hold + release);
g.connect(this.master);
return g;
}
private synthTap(t: number, freq: number, duration: number, type: OscillatorType = 'square'): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(freq, t);
const g = this.envGain(t, 0.005, duration * 0.5, duration * 0.5, 0.45);
osc.connect(g);
osc.start(t);
osc.stop(t + duration + 0.02);
}
private synthSweep(t: number, fromHz: number, toHz: number, duration: number, type: OscillatorType): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(fromHz, t);
osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 20), t + duration);
const g = this.envGain(t, 0.01, duration * 0.4, duration * 0.6, 0.45);
osc.connect(g);
osc.start(t);
osc.stop(t + duration + 0.02);
}
private synthNoise(t: number, duration: number, freqHint: number): void {
if (!this.ctx) return;
const sampleRate = this.ctx.sampleRate;
const buffer = this.ctx.createBuffer(1, Math.max(1, Math.floor(sampleRate * duration)), sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = freqHint;
filter.Q.value = 1;
const g = this.envGain(t, 0.005, duration * 0.3, duration * 0.7, 0.55);
src.connect(filter);
filter.connect(g);
src.start(t);
src.stop(t + duration + 0.02);
}
private synthExplosion(t: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(160, t);
osc.frequency.exponentialRampToValueAtTime(30, t + 0.4);
const og = this.envGain(t, 0.005, 0.05, 0.4, 0.5);
osc.connect(og);
osc.start(t);
osc.stop(t + 0.5);
this.synthNoise(t, 0.45, 300);
}
private synthWarning(t: number): void {
if (!this.ctx) return;
for (let i = 0; i < 2; i++) {
const start = t + i * 0.16;
const osc = this.ctx.createOscillator();
osc.type = 'square';
osc.frequency.setValueAtTime(880, start);
const g = this.envGain(start, 0.003, 0.04, 0.06, 0.4);
osc.connect(g);
osc.start(start);
osc.stop(start + 0.12);
}
}
private synthGo(t: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(180, t);
osc.frequency.exponentialRampToValueAtTime(900, t + 0.22);
const g = this.envGain(t, 0.005, 0.05, 0.18, 0.5);
osc.connect(g);
osc.start(t);
osc.stop(t + 0.3);
this.synthNoise(t, 0.25, 1500);
}
private synthFanfare(t: number, freqs: number[], duration: number): void {
if (!this.ctx) return;
for (let i = 0; i < freqs.length; i++) {
const start = t + i * 0.09;
const osc = this.ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freqs[i], start);
const g = this.envGain(start, 0.01, 0.05, duration * 0.7, 0.45);
osc.connect(g);
osc.start(start);
osc.stop(start + duration + 0.1);
}
}
// Card-specific synths ───────────────────────────────────────────
private synthMove(t: number): void {
this.synthNoise(t, 0.14, 500);
this.runOsc(t, 'sawtooth', 90, 90, 0.25, 0.08, 0.35);
}
private synthShield(t: number): void {
this.runOsc(t, 'sine', 660, 1320, 0.02, 0.1, 0.35);
this.runOsc(t + 0.04, 'triangle', 1320, 1320, 0.005, 0.18, 0.18);
}
private synthMeteor(t: number): void {
this.runOsc(t, 'sawtooth', 880, 100, 0.5, 0, 0.45);
// Impact noise at t + 0.45
this.synthNoise(t + 0.45, 0.4, 250);
}
private synthBoost(t: number): void {
this.runOsc(t, 'sawtooth', 200, 1100, 0.005, 0.18, 0.45);
this.synthNoise(t, 0.25, 1500);
}
private synthEmp(t: number): void {
for (let i = 0; i < 3; i++) {
const start = t + i * 0.08;
this.runOsc(start, 'square', 1400, 200, 0.005, 0.04, 0.35);
}
this.runOsc(t + 0.24, 'sawtooth', 90, 40, 0.01, 0.2, 0.3);
}
private synthJump(t: number): void {
this.runOsc(t, 'sine', 440, 1320, 0.01, 0.05, 0.4);
this.runOsc(t + 0.08, 'triangle', 660, 220, 0.04, 0.1, 0.18);
}
private synthMine(t: number): void {
this.runOsc(t, 'square', 1500, 1500, 0.002, 0.02, 0.3);
this.runOsc(t + 0.04, 'sine', 220, 60, 0.01, 0.18, 0.4);
}
private synthPhaseShift(t: number): void {
if (!this.ctx) return;
const osc1 = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
osc1.type = 'sine'; osc2.type = 'sine';
osc1.frequency.setValueAtTime(330, t);
osc2.frequency.setValueAtTime(335, t);
osc1.frequency.exponentialRampToValueAtTime(880, t + 0.3);
osc2.frequency.exponentialRampToValueAtTime(890, t + 0.3);
const g = this.envGain(t, 0.02, 0.1, 0.25, 0.3);
osc1.connect(g); osc2.connect(g);
osc1.start(t); osc2.start(t);
osc1.stop(t + 0.45); osc2.stop(t + 0.45);
}
/**
* One-shot oscillator with frequency sweep + envelope. Auto-stops after
* attack + hold + release + 50ms.
*/
private runOsc(t: number, type: OscillatorType, fromHz: number, toHz: number, attack: number, hold: number, peak: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = type;
osc.frequency.setValueAtTime(fromHz, t);
if (toHz !== fromHz) {
osc.frequency.exponentialRampToValueAtTime(Math.max(toHz, 20), t + attack + hold);
}
const g = this.envGain(t, attack, hold, 0.2, peak);
osc.connect(g);
osc.start(t);
const total = attack + hold + 0.25;
osc.stop(t + total);
}
/**
* Sustained engine drone that lasts the full `durationSec`. Layered:
* - low sawtooth (throat)
* - filtered noise (rumble)
* - mid triangle (whine, frequency follows `pitch`)
* Envelope: short attack, sustain, soft release.
*/
private synthEngine(t: number, durationSec: number, pitch: number, peak: number): void {
if (!this.ctx) return;
const dur = Math.max(0.2, durationSec);
const release = 0.18;
const sustain = Math.max(0, dur - release - 0.02);
// Throat: low sawtooth (60-90Hz)
const osc1 = this.ctx.createOscillator();
osc1.type = 'sawtooth';
osc1.frequency.setValueAtTime(60 * pitch, t);
osc1.frequency.exponentialRampToValueAtTime(90 * pitch, t + 0.08);
osc1.frequency.exponentialRampToValueAtTime(70 * pitch, t + dur);
const g1 = this.envGain(t, 0.015, sustain, release, peak * 0.55);
osc1.connect(g1);
osc1.start(t);
osc1.stop(t + dur + 0.05);
// Whine: mid triangle, slightly detuned
const osc2 = this.ctx.createOscillator();
osc2.type = 'triangle';
osc2.frequency.setValueAtTime(180 * pitch, t);
osc2.frequency.exponentialRampToValueAtTime(280 * pitch, t + 0.06);
osc2.frequency.exponentialRampToValueAtTime(220 * pitch, t + dur);
const g2 = this.envGain(t, 0.02, sustain, release, peak * 0.4);
osc2.connect(g2);
osc2.start(t);
osc2.stop(t + dur + 0.05);
// Rumble: filtered noise
const sampleRate = this.ctx.sampleRate;
const buffer = this.ctx.createBuffer(1, Math.max(1, Math.floor(sampleRate * dur)), sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / data.length);
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const filter = this.ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 350 * pitch;
filter.Q.value = 1.2;
const g3 = this.envGain(t, 0.03, sustain, release, peak * 0.5);
src.connect(filter);
filter.connect(g3);
src.start(t);
src.stop(t + dur + 0.05);
}
/**
* Settling thump at the end of an engine sound — like a ship coming to
* rest on its struts. Soft, low, brief.
*/
private synthSettle(t: number, pitch: number): void {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
osc.type = 'sine';
const startHz = 220 * pitch;
osc.frequency.setValueAtTime(startHz, t);
osc.frequency.exponentialRampToValueAtTime(Math.max(80, startHz / 3), t + 0.12);
const g = this.envGain(t, 0.003, 0.04, 0.1, 0.4);
osc.connect(g);
osc.start(t);
osc.stop(t + 0.25);
// Tiny click on top
const click = this.ctx.createOscillator();
click.type = 'square';
click.frequency.setValueAtTime(900 * pitch, t);
const g2 = this.envGain(t, 0.001, 0.008, 0.04, 0.18);
click.connect(g2);
click.start(t);
click.stop(t + 0.08);
}
}
+2
View File
@@ -1 +1,3 @@
export * from './types.js';
export * from './theme.js';
export * from './audio.js';
+67
View File
@@ -0,0 +1,67 @@
// Centralized design tokens shared between TV and Controller.
// TV (Phaser) reads the numeric values directly;
// Controller mirrors them into CSS custom properties in style.css.
export const COLORS = {
// Backgrounds
bgDeep: 0x050514, // canvas / page base
bgPanel: 0x0a0a24, // raised surfaces
bgPanelAlt: 0x111133, // input fields
bgGlass: 0x0d0d28, // translucent overlays
// Accent / brand
primary: 0x00e5ff, // cyan — main brand
primaryDim: 0x007a99,
accent: 0xff2bd6, // magenta — CTAs, "GO!"
accentDim: 0x7a1465,
// Semantic
success: 0x00ff9c, // AP full, alive
warning: 0xffcc00, // mid-timer
danger: 0xff3b6b, // low timer, death line
neutral: 0x6a6a8a,
// Text
text: 0xffffff,
textDim: 0xa0a0c8,
textMuted: 0x6a6a8a,
// Obstacles
asteroid: 0x7a7a8c,
meteor: 0xff6a1a,
mine: 0xffaa00,
// Player colors (used by ship sprites and controller accents)
player: [0x00e5ff, 0xff3b6b, 0x00ff9c, 0xffaa00, 0xff2bd6, 0xfff200],
} as const;
export const PLAYER_HEX = COLORS.player.map((c) => `#${c.toString(16).padStart(6, '0')}`);
export const FONTS = {
display: '"Orbitron", "Rajdhani", system-ui, sans-serif',
body: '"Inter", system-ui, -apple-system, "Segoe UI", sans-serif',
mono: '"JetBrains Mono", "Fira Code", monospace',
} as const;
export const SPACING = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24,
xxl: 32,
} as const;
export const RADII = {
sm: 6,
md: 12,
lg: 20,
pill: 999,
} as const;
// Used by TV HUD/scene tween helpers
export const TIMING = {
fast: 150,
normal: 250,
slow: 600,
} as const;
+4 -19
View File
@@ -58,13 +58,11 @@ export interface Ship {
// ── Cards ──
export type CardType =
| 'METEOR_STRIKE'
| 'SHIELD'
| 'BOOST'
| 'EMP'
| 'JUMP'
| 'MINE'
| 'TELEPORT'
| 'PHASE_SHIFT';
export interface CardDef {
@@ -75,12 +73,6 @@ export interface CardDef {
}
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',
@@ -111,12 +103,6 @@ export const CARD_DEFS: Record<CardType, CardDef> = {
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',
@@ -126,12 +112,11 @@ export const CARD_DEFS: Record<CardType, CardDef> = {
};
// ── Actions ──
export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'BOOST' | 'CARD';
export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | '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 {
@@ -140,7 +125,6 @@ export function actionApCost(action: Action): number {
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;
@@ -227,11 +211,12 @@ export interface MobileToServerEvents {
// Execution updates
export type ShipUpdate =
| { type: 'move'; shipId: string; from: Position; to: Position }
| { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' }
| { 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 };
| { type: 'shield_used'; shipId: string }
| { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string };
export type GridUpdate =
| { type: 'tile_change'; position: Position; tile: TileType }
+29 -3
View File
@@ -4,14 +4,40 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=1280, initial-scale=1.0" />
<title>SpaceRace - TV</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/style.css" />
<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; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #050514; }
#game-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; }
.qr-overlay {
position: absolute;
bottom: 8%;
left: 50%;
transform: translateX(-50%);
color: #a0a0c8;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
text-align: center;
padding: 6px 16px;
background: rgba(10,10,36,0.85);
border: 1px solid rgba(0,229,255,0.25);
border-radius: 6px;
text-decoration: none;
user-select: all;
pointer-events: auto;
z-index: 100;
display: none;
}
.qr-overlay:hover { border-color: #00e5ff; color: #00e5ff; }
</style>
</head>
<body>
<div id="game-container"></div>
<div id="game-container">
<a id="qr-url" class="qr-overlay" href="#" target="_blank"></a>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@spacerace/shared": "*",
"phaser": "^3.80.1",
"qrcode-generator": "^1.5.2",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
+329
View File
@@ -0,0 +1,329 @@
import Phaser from 'phaser';
import { Position, GRID_WIDTH, COLORS } from '@spacerace/shared';
const TILE_SIZE = 64;
const GRID_HEIGHT = GRID_WIDTH;
/**
* Centralized visual effects for the TV gameplay scene. Each method spawns
* particles / sprites / tweens for a specific game event. All effects are
* parented to the `fieldLayer` container so they scroll together with the
* game field when the death line advances.
*/
export class EffectRenderer {
private scene: Phaser.Scene;
private layer: Phaser.GameObjects.Container;
private gridXToScreen: (gy: number) => number;
private gridYToScreen: (gx: number) => number;
constructor(
scene: Phaser.Scene,
layer: Phaser.GameObjects.Container,
gridXToScreen: (gy: number) => number,
gridYToScreen: (gx: number) => number,
) {
this.scene = scene;
this.layer = layer;
this.gridXToScreen = gridXToScreen;
this.gridYToScreen = gridYToScreen;
}
posToScreen(p: Position): { x: number; y: number } {
return { x: this.gridXToScreen(p.y), y: this.gridYToScreen(p.x) };
}
// ── Movement & turn ─────────────────────────────────────────────
/** Engine trail at the ship's current screen position. */
engineTrail(x: number, y: number, color: number, scale = 1): void {
const p = this.scene.add.circle(x, y, 4 * scale, color, 0.6);
p.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(p);
this.scene.tweens.add({
targets: p,
alpha: 0,
scaleX: 2.2,
scaleY: 2.2,
duration: 420,
onComplete: () => p.destroy(),
});
}
/** Speed lines for BOOST — fast streaks behind the ship. */
speedLines(x: number, y: number, color: number, direction: 'N' | 'E' | 'S' | 'W'): void {
const dirVec = { N: { x: 0, y: -1 }, E: { x: 1, y: 0 }, S: { x: 0, y: 1 }, W: { x: -1, y: 0 } }[direction];
for (let i = 0; i < 10; i++) {
const line = this.scene.add.rectangle(
x - dirVec.x * (i * 8) + (Math.random() - 0.5) * 12,
y - dirVec.y * (i * 8) + (Math.random() - 0.5) * 12,
18, 2, color, 0.85
);
line.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(line);
this.scene.tweens.add({
targets: line,
alpha: 0,
scaleX: 2.5,
scaleY: 2.5,
x: x - dirVec.x * 60,
y: y - dirVec.y * 60,
duration: 380,
delay: i * 12,
onComplete: () => line.destroy(),
});
}
}
/** Burst of small particles around the ship on a TURN event. */
turnBurst(x: number, y: number, color: number): void {
const px = this.scene.add.particles(x, y, 'asteroid', {
speed: { min: 60, max: 160 },
angle: { min: 0, max: 360 },
scale: { start: 0.15, end: 0 },
lifespan: 320,
quantity: 14,
emitting: false,
tint: color,
alpha: { start: 0.9, end: 0 },
blendMode: Phaser.BlendModes.ADD,
});
this.layer.add(px);
px.explode();
this.scene.time.delayedCall(400, () => px.destroy());
}
// ── Cards ───────────────────────────────────────────────────────
/** Cyan hex bubble around the ship, expanding and fading. */
shieldBubble(x: number, y: number): void {
const ring = this.scene.add.circle(x, y, 22, 0x00e5ff, 0);
ring.setStrokeStyle(3, 0x00e5ff, 0.9);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring,
radius: 60,
alpha: 0,
duration: 700,
ease: 'Cubic.easeOut',
onComplete: () => ring.destroy(),
});
// Inner glow flash
const inner = this.scene.add.circle(x, y, 24, 0x00e5ff, 0.4);
inner.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(inner);
this.scene.tweens.add({
targets: inner,
scaleX: 1.6, scaleY: 1.6, alpha: 0,
duration: 500, ease: 'Cubic.easeOut',
onComplete: () => inner.destroy(),
});
}
/** Red ring pulse at the target tile — mine placed. */
minePlaced(x: number, y: number): void {
const ring = this.scene.add.circle(x, y, 8, 0xff3b6b, 0);
ring.setStrokeStyle(3, 0xffaa00, 1);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring,
radius: 36, alpha: 0, duration: 600, ease: 'Cubic.easeOut',
onComplete: () => ring.destroy(),
});
}
/** White flash + horizontal afterimage — JUMP. */
jumpFlash(x: number, y: number): void {
const flash = this.scene.add.circle(x, y, 30, 0xffffff, 1);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 2, scaleY: 2, alpha: 0, duration: 320,
onComplete: () => flash.destroy(),
});
// Spark ring
const spark = this.scene.add.circle(x, y, 18, 0x00e5ff, 0);
spark.setStrokeStyle(2, 0x00e5ff, 1);
spark.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(spark);
this.scene.tweens.add({
targets: spark, radius: 50, alpha: 0, duration: 400,
onComplete: () => spark.destroy(),
});
}
/** EMP — three electric arcs radiating outward + screen flash. */
empPulse(x: number, y: number, color = 0x00e5ff): void {
// Lightning bolts
for (let i = 0; i < 6; i++) {
const angle = (i / 6) * Math.PI * 2;
const bolt = this.scene.add.line(0, 0, x, y,
x + Math.cos(angle) * 80, y + Math.sin(angle) * 80,
color, 1);
bolt.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(bolt);
this.scene.tweens.add({
targets: bolt,
alpha: 0,
duration: 220,
delay: i * 30,
onComplete: () => bolt.destroy(),
});
}
// Expanding ring
const ring = this.scene.add.circle(x, y, 20, color, 0);
ring.setStrokeStyle(3, color, 1);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring, radius: 100, alpha: 0, duration: 500,
onComplete: () => ring.destroy(),
});
// Soft flash
const flash = this.scene.add.circle(x, y, 50, color, 0.3);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 1.8, scaleY: 1.8, alpha: 0, duration: 300,
onComplete: () => flash.destroy(),
});
}
/** Phase shift — ship leaves a cyan ghost that fades. */
phaseGhost(x: number, y: number, tint: number): void {
const ghost = this.scene.add.sprite(x, y, 'ship');
ghost.setTint(tint);
ghost.setAlpha(0.7);
this.layer.add(ghost);
this.scene.tweens.add({
targets: ghost,
alpha: 0,
scaleX: 1.3, scaleY: 1.3,
duration: 600,
onComplete: () => ghost.destroy(),
});
// Two faint rings
for (let i = 0; i < 2; i++) {
const r = this.scene.add.circle(x, y, 12, 0x00e5ff, 0);
r.setStrokeStyle(2, 0x00e5ff, 0.6);
r.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(r);
this.scene.tweens.add({
targets: r,
radius: 40 + i * 20,
alpha: 0,
duration: 700,
delay: i * 100,
onComplete: () => r.destroy(),
});
}
}
/** Meteor strike — falling meteor with impact. */
meteorStrike(x: number, y: number, color: number): void {
// Falling meteor from above
const meteor = this.scene.add.sprite(x, y - 320, 'meteor');
meteor.setDisplaySize(TILE_SIZE - 4, TILE_SIZE - 4);
this.layer.add(meteor);
this.scene.tweens.add({
targets: meteor,
y: y,
duration: 500,
ease: 'Cubic.easeIn',
onComplete: () => {
meteor.destroy();
this.impact(x, y, color, 60);
},
});
}
// ── Combat & physics ────────────────────────────────────────────
/** Big impact: expanding ring + sparks + brief flash. */
impact(x: number, y: number, color: number, radius = 40): void {
const ring = this.scene.add.circle(x, y, 8, color, 0);
ring.setStrokeStyle(3, color, 1);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring, radius, alpha: 0, duration: 400,
onComplete: () => ring.destroy(),
});
// Sparks
const sparks = this.scene.add.particles(x, y, 'asteroid', {
speed: { min: 80, max: 220 },
angle: { min: 0, max: 360 },
scale: { start: 0.2, end: 0 },
lifespan: 600,
quantity: 24,
emitting: false,
tint: color,
alpha: { start: 1, end: 0 },
blendMode: Phaser.BlendModes.ADD,
});
this.layer.add(sparks);
sparks.explode();
this.scene.time.delayedCall(700, () => sparks.destroy());
// Brief flash
const flash = this.scene.add.circle(x, y, 16, 0xffffff, 0.85);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 1.6, scaleY: 1.6, alpha: 0, duration: 180,
onComplete: () => flash.destroy(),
});
}
/** Big explosion (used on eliminated). */
explosion(x: number, y: number, color: number): void {
// Inner flash
const flash = this.scene.add.circle(x, y, 18, 0xffffff, 1);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 2.4, scaleY: 2.4, alpha: 0, duration: 220,
onComplete: () => flash.destroy(),
});
// Outer blast
this.impact(x, y, color, 80);
// Long-lived sparks
const sparks = this.scene.add.particles(x, y, 'asteroid', {
speed: { min: 60, max: 240 },
scale: { start: 0.4, end: 0 },
lifespan: 1100,
quantity: 40,
emitting: false,
tint: color,
alpha: { start: 1, end: 0 },
blendMode: Phaser.BlendModes.ADD,
});
this.layer.add(sparks);
sparks.explode();
this.scene.time.delayedCall(1300, () => sparks.destroy());
// Smoke ring
const ring = this.scene.add.circle(x, y, 20, 0x000000, 0);
ring.setStrokeStyle(4, 0x555555, 0.5);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring, radius: 100, alpha: 0, duration: 900,
onComplete: () => ring.destroy(),
});
}
/** Death-line advance — flashing overlay pulse on the death line. */
deathLinePulse(x: number, y: number, height: number): void {
const flash = this.scene.add.rectangle(x, y, 80, height, 0xff3b6b, 0.4);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, alpha: 0, duration: 600,
onComplete: () => flash.destroy(),
});
}
}
+95 -25
View File
@@ -4,6 +4,7 @@ import {
TileType,
Position,
GRID_WIDTH,
COLORS,
} from '@spacerace/shared';
const TILE_SIZE = 64;
@@ -18,7 +19,9 @@ export class GridRenderer {
private fieldContainer: Phaser.GameObjects.Container;
private shipLayer: Phaser.GameObjects.Container;
private deathLineRect: Phaser.GameObjects.Rectangle | null = null;
private deathLineGlow: Phaser.GameObjects.Rectangle | null = null;
private deathLineY: number = 0;
private gridOverlay: Phaser.GameObjects.Graphics | null = null;
constructor(scene: Phaser.Scene, offsetY: number, tileSize: number) {
this.scene = scene;
@@ -70,6 +73,9 @@ export class GridRenderer {
}
}
// Draw grid lines overlay once
this.renderGridOverlay();
if (scrollInPx > 0) {
this.fieldContainer.x = -scrollInPx;
this.shipLayer.x = -scrollInPx;
@@ -88,29 +94,88 @@ export class GridRenderer {
}
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;
if (tile === 'space') {
// Subtle dark base
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, 0x0a0a2e, 0.5);
bg.setStrokeStyle(1, 0x1a1a4e, 0.5);
this.fieldContainer.add(bg);
return;
}
const textureName =
tile === 'asteroid' ? 'asteroid' :
tile === 'meteor' ? 'meteor' :
tile === 'mine' ? 'mine' :
'asteroid';
// Tile background — gives the obstacle a contained feel
let bgColor: number;
let bgAlpha: number;
if (tile === 'asteroid') { bgColor = 0x2a2a3a; bgAlpha = 0.7; }
else if (tile === 'meteor') { bgColor = 0x3a1a0a; bgAlpha = 0.7; }
else if (tile === 'mine') { bgColor = 0x3a2a00; bgAlpha = 0.7; }
else { bgColor = 0x2a2a3a; bgAlpha = 0.7; }
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, bgColor, bgAlpha);
bg.setStrokeStyle(1, 0x223366, 0.2);
bg.setStrokeStyle(1, 0x223366, 0.6);
this.fieldContainer.add(bg);
if (tile !== 'space') {
const textureName =
tile === 'asteroid' ? 'asteroid' :
tile === 'meteor' ? 'meteor' :
'mine';
// Slight random rotation/scale for visual variety (deterministic by lane/col)
const sprite = this.scene.add.sprite(screenX, screenY, textureName);
sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12);
const angle = ((col * 13 + lane * 37) % 60) - 30; // ±30°
sprite.setAngle(angle);
this.fieldContainer.add(sprite);
const sprite = this.scene.add.sprite(screenX, screenY, textureName);
sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12);
this.fieldContainer.add(sprite);
// Glow for dangerous tiles
if (tile === 'meteor' || tile === 'mine') {
const glow = this.scene.add.circle(screenX, screenY, this.tileSize * 0.5,
tile === 'meteor' ? 0xff6a1a : 0xffaa00, 0.18);
glow.setBlendMode(Phaser.BlendModes.ADD);
this.fieldContainer.add(glow);
}
}
private renderGridOverlay(): void {
if (this.gridOverlay) this.gridOverlay.destroy();
const g = this.scene.add.graphics();
g.setDepth(0.5);
const w = this.scene.scale.width;
const h = this.scene.scale.height;
// Outer border around the play area
const playLeft = DEATH_LINE_SCREEN_X;
const playTop = this.offsetY - 4;
const playRight = DEATH_LINE_SCREEN_X + VISIBLE_COLS * this.tileSize;
const playBottom = this.offsetY + GRID_HEIGHT * this.tileSize + 4;
g.lineStyle(1, 0x00e5ff, 0.2);
g.strokeRect(playLeft, playTop, playRight - playLeft, playBottom - playTop);
// Lane separators (horizontal)
for (let i = 1; i < GRID_HEIGHT; i++) {
const y = this.offsetY + i * this.tileSize;
g.lineStyle(1, 0x223366, 0.35);
g.beginPath();
g.moveTo(playLeft, y);
g.lineTo(playRight, y);
g.strokePath();
}
// Column separators (vertical) — light, only for visible area
g.lineStyle(1, 0x1a1a4e, 0.4);
for (let i = 0; i <= VISIBLE_COLS; i++) {
const x = playLeft + i * this.tileSize;
g.beginPath();
g.moveTo(x, playTop);
g.lineTo(x, playBottom);
g.strokePath();
}
this.gridOverlay = g;
}
updateTile(pos: Position, tile: TileType): void {
const screenX = this.gridXToScreen(pos.y);
const screenY = this.gridYToScreen(pos.x);
@@ -120,20 +185,25 @@ export class GridRenderer {
private renderDeathLine(): void {
if (this.deathLineRect) this.deathLineRect.destroy();
if (this.deathLineGlow) this.deathLineGlow.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
);
const x = DEATH_LINE_SCREEN_X;
const yCenter = this.offsetY + (GRID_HEIGHT * this.tileSize) / 2;
const h = GRID_HEIGHT * this.tileSize;
// Outer glow
this.deathLineGlow = this.scene.add.rectangle(x, yCenter, 16, h, 0xff3b6b, 0.25);
this.deathLineGlow.setBlendMode(Phaser.BlendModes.ADD);
this.deathLineGlow.setDepth(19);
// Main line
this.deathLineRect = this.scene.add.rectangle(x, yCenter, 4, h, 0xff3b6b, 0.95);
this.deathLineRect.setDepth(20);
// Pulse animation
this.scene.tweens.add({
targets: this.deathLineRect,
alpha: 0.3,
targets: this.deathLineGlow,
alpha: { from: 0.2, to: 0.55 },
duration: 600,
yoyo: true,
repeat: -1,
+35 -15
View File
@@ -1,9 +1,12 @@
import Phaser from 'phaser';
import { Ship as ShipType } from '@spacerace/shared';
import { FONTS } from '@spacerace/shared';
export class ShipSprite extends Phaser.GameObjects.Container {
private shipData: ShipType;
private label: Phaser.GameObjects.Text;
private glow: Phaser.GameObjects.Arc;
private body: Phaser.GameObjects.Sprite;
constructor(
scene: Phaser.Scene,
@@ -18,25 +21,42 @@ export class ShipSprite extends Phaser.GameObjects.Container {
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);
// Glow halo behind the ship (uses player's color)
this.glow = scene.add.circle(0, 0, 28, color, 0.35);
this.glow.setBlendMode(Phaser.BlendModes.ADD);
this.glow.setDepth(-1);
// Direction indicator (small triangle)
const indicator = scene.add.triangle(0, -22, 0, 8, 5, 0, 10, 8, 0xffffff);
// Ship body — uses the procedurally generated 'ship' texture
this.body = scene.add.sprite(0, 0, 'ship');
// Recolor the cyan body to the player's color via tint (preserves the white outline)
this.body.setTint(color);
this.add([body, indicator]);
this.add([this.glow, this.body]);
// Player name label in a pill below
const name = ship.playerName || ship.playerId.substring(0, 6);
const labelBg = scene.add.rectangle(0, 30, Math.max(48, name.length * 7 + 14), 18, 0x000000, 0.7);
labelBg.setStrokeStyle(1, color, 0.9);
this.label = scene.add.text(0, 30, name, {
fontFamily: FONTS.body,
fontSize: '12px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add([labelBg, this.label]);
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);
// Gentle pulse on the halo
scene.tweens.add({
targets: this.glow,
scaleX: { from: 0.85, to: 1.15 },
scaleY: { from: 0.85, to: 1.15 },
alpha: { from: 0.25, to: 0.5 },
duration: 1400,
yoyo: true,
repeat: -1,
});
scene.add.existing(this);
this.setDepth(10);
+122 -29
View File
@@ -1,68 +1,161 @@
import Phaser from 'phaser';
import { COLORS, AudioEngine } from '@spacerace/shared';
import { connectTvSocket, TvSocket } from '../network/TvSocket.js';
import { createStarfield } from './Starfield.js';
export class BootScene extends Phaser.Scene {
socket!: TvSocket;
audio!: AudioEngine;
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 });
this.audio = new AudioEngine();
// Browsers require a user gesture before audio context creation.
// Wire any pointer/touch on the canvas to unlock it.
const unlock = (): void => {
this.audio.unlock();
this.input.off('pointerdown', unlock);
this.input.off('touchstart', unlock);
};
this.input.on('pointerdown', unlock);
this.input.on('touchstart', unlock);
createStarfield(this);
this.scene.start('LobbyScene', { socket: this.socket, audio: this.audio });
}
private createPlaceholderTextures(): void {
// Ship placeholder (triangle pointing up)
// ── Ship — angular hull pointing up, with cockpit and engines ──
// Anchor at center. Heading "up" = +Y in texture (ship sprite is rotated by direction).
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);
// Outer hull (white outline) — drawn first
shipGfx.fillStyle(0xffffff, 1);
shipGfx.beginPath();
shipGfx.moveTo(20, 0);
shipGfx.lineTo(38, 14);
shipGfx.lineTo(34, 32);
shipGfx.lineTo(30, 36);
shipGfx.lineTo(10, 36);
shipGfx.lineTo(6, 32);
shipGfx.lineTo(2, 14);
shipGfx.closePath();
shipGfx.fillPath();
// Fill cutout by drawing a slightly smaller version of the hull on top with a transparent rect
shipGfx.fillStyle(0x00e5ff, 1);
shipGfx.beginPath();
shipGfx.moveTo(20, 4);
shipGfx.lineTo(34, 16);
shipGfx.lineTo(31, 30);
shipGfx.lineTo(9, 30);
shipGfx.lineTo(6, 16);
shipGfx.closePath();
shipGfx.fillPath();
// Cockpit window
shipGfx.fillStyle(0x00121a, 1);
shipGfx.fillCircle(20, 14, 4);
shipGfx.fillStyle(0x00e5ff, 0.7);
shipGfx.fillCircle(20, 14, 2.5);
// Engine glow
shipGfx.fillStyle(0xff2bd6, 0.9);
shipGfx.fillCircle(13, 33, 3);
shipGfx.fillCircle(27, 33, 3);
shipGfx.fillStyle(0xffcc00, 1);
shipGfx.fillCircle(13, 33, 1.5);
shipGfx.fillCircle(27, 33, 1.5);
shipGfx.generateTexture('ship', 40, 40);
shipGfx.destroy();
// Asteroid placeholder (rough circle)
// ── Asteroid — irregular rocky shape ──
const asteroidGfx = this.make.graphics({ x: 0, y: 0, add: false });
asteroidGfx.fillStyle(0x888888);
asteroidGfx.fillCircle(20, 20, 18);
asteroidGfx.fillStyle(0xffffff, 1);
asteroidGfx.beginPath();
const aShape = [
[20, 3], [30, 8], [36, 18], [34, 28], [28, 35], [18, 36],
[8, 32], [3, 22], [5, 12], [12, 6]
];
asteroidGfx.moveTo(aShape[0][0], aShape[0][1]);
for (let i = 1; i < aShape.length; i++) asteroidGfx.lineTo(aShape[i][0], aShape[i][1]);
asteroidGfx.closePath();
asteroidGfx.fillPath();
asteroidGfx.fillStyle(COLORS.asteroid, 1);
const aShapeIn = [
[20, 7], [28, 11], [32, 19], [30, 27], [26, 31], [18, 32],
[10, 28], [7, 21], [9, 13], [14, 9]
];
asteroidGfx.moveTo(aShapeIn[0][0], aShapeIn[0][1]);
for (let i = 1; i < aShapeIn.length; i++) asteroidGfx.lineTo(aShapeIn[i][0], aShapeIn[i][1]);
asteroidGfx.closePath();
asteroidGfx.fillPath();
// Crater detail
asteroidGfx.fillStyle(0x4a4a5a, 0.8);
asteroidGfx.fillCircle(14, 18, 2);
asteroidGfx.fillCircle(24, 22, 1.5);
asteroidGfx.fillCircle(20, 12, 1);
asteroidGfx.generateTexture('asteroid', 40, 40);
asteroidGfx.destroy();
// Meteor placeholder (red circle)
// ── Meteor — burning rock with flame tail ──
const meteorGfx = this.make.graphics({ x: 0, y: 0, add: false });
meteorGfx.fillStyle(0xff4400);
meteorGfx.fillCircle(20, 20, 18);
// Flame tail (fading yellow → red)
meteorGfx.fillStyle(0xffcc00, 0.4);
meteorGfx.fillTriangle(20, 6, 4, 36, 16, 36);
meteorGfx.fillStyle(0xff6600, 0.7);
meteorGfx.fillTriangle(20, 8, 8, 36, 18, 36);
meteorGfx.fillStyle(0xff3b00, 0.95);
meteorGfx.fillTriangle(20, 12, 14, 36, 22, 36);
// Core
meteorGfx.fillStyle(0xffffff, 1);
meteorGfx.fillCircle(20, 18, 11);
meteorGfx.fillStyle(0xff8c1a, 1);
meteorGfx.fillCircle(20, 18, 8);
meteorGfx.fillStyle(0xffcc00, 0.8);
meteorGfx.fillCircle(20, 18, 5);
meteorGfx.generateTexture('meteor', 40, 40);
meteorGfx.destroy();
// Mine placeholder (orange circle with X)
// ── Mine — spiky orange orb with warning glow ──
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);
// Spikes (8-pointed star)
const cx = 20, cy = 20;
const spikes = 8;
const outer = 18, inner = 7;
mineGfx.fillStyle(0xffaa00, 1);
mineGfx.beginPath();
for (let i = 0; i < spikes * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
if (i === 0) mineGfx.moveTo(x, y);
else mineGfx.lineTo(x, y);
}
mineGfx.closePath();
mineGfx.fillPath();
// Core
mineGfx.fillStyle(0x2a1500, 1);
mineGfx.fillCircle(cx, cy, 6);
// Warning dot
mineGfx.fillStyle(0xff3b6b, 1);
mineGfx.fillCircle(cx, cy, 2.5);
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
// ── Death line texture — animated stripe ──
const dlGfx = this.make.graphics({ x: 0, y: 0, add: false });
dlGfx.fillStyle(0xff0000, 0.6);
dlGfx.fillStyle(0xff3b6b, 0.95);
dlGfx.fillRect(0, 0, 512, 4);
// Inner highlight
dlGfx.fillStyle(0xffffff, 0.9);
dlGfx.fillRect(0, 1, 512, 1);
dlGfx.generateTexture('death_line', 512, 4);
dlGfx.destroy();
}
+286 -78
View File
@@ -7,61 +7,112 @@ import {
GRID_WIDTH,
ShipUpdate,
GridUpdate,
COLORS,
FONTS,
AudioEngine,
CardType,
} from '@spacerace/shared';
import { GridRenderer } from '../objects/GridRenderer.js';
import { ShipSprite } from '../objects/ShipSprite.js';
import { EffectRenderer } from '../effects/EffectRenderer.js';
import { createStarfield } from './Starfield.js';
const W = 1280;
const H = 720;
const TILE_SIZE = 64;
const GRID_OFFSET_Y = (720 - GRID_WIDTH * TILE_SIZE) / 2;
const GRID_OFFSET_Y = (H - GRID_WIDTH * TILE_SIZE) / 2;
const ANIM_DURATION = 900;
const DEATH_LINE_ANIM = 1800;
export class GameScene extends Phaser.Scene {
private socket!: TvSocket;
private audio!: AudioEngine;
private gridRenderer!: GridRenderer;
private effects!: EffectRenderer;
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 phaseText!: Phaser.GameObjects.Text;
private phaseTextGlow!: Phaser.GameObjects.Text;
private messageLog: string[] = [];
private messageTexts: 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;
private playerColors = new Map<string, number>();
constructor() {
super({ key: 'GameScene' });
}
init(data: { socket: TvSocket; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void {
init(data: { socket: TvSocket; audio: AudioEngine; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void {
this.socket = data.socket;
this.audio = data.audio;
this.initialGrid = data.grid ?? null;
this.initialShips = data.ships ?? null;
(data.players || []).forEach((p, i) => {
this.playerColors.set(p.id, COLORS.player[i % COLORS.player.length]);
});
}
create(): void {
this.cameras.main.setBackgroundColor('#050515');
this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE);
this.cameras.main.setBackgroundColor('#050514');
createStarfield(this);
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 },
});
this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE);
this.effects = new EffectRenderer(
this,
this.gridRenderer.field,
(gy) => this.gridRenderer.gridXToScreen(gy),
(gx) => this.gridRenderer.gridYToScreen(gx),
);
// ── HUD ──
this.add.rectangle(20, 20, 220, 56, 0x0a0a24, 0.85)
.setOrigin(0, 0)
.setStrokeStyle(1, 0x00e5ff, 0.5);
this.add.text(36, 36, 'ROUND', {
fontFamily: FONTS.display, fontSize: '12px', color: '#6a6a8a',
}).setOrigin(0, 0);
this.roundText = this.add.text(36, 52, '1', {
fontFamily: FONTS.display, fontSize: '26px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0, 0);
const phaseX = W / 2;
const phaseY = 36;
this.phaseTextGlow = this.add.text(phaseX, phaseY, 'PLANNING PHASE', {
fontFamily: FONTS.display, fontSize: '28px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
this.phaseTextGlow.setShadow(0, 0, '#ffcc00', 18, true, true);
this.phaseTextGlow.setAlpha(0.6);
this.phaseText = this.add.text(phaseX, phaseY, 'PLANNING PHASE', {
fontFamily: FONTS.display, fontSize: '28px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(W - 20, 28, 'PILOTS', {
fontFamily: FONTS.display, fontSize: '11px', color: '#6a6a8a',
}).setOrigin(1, 0);
for (let i = 0; i < 3; i++) {
const t = this.add.text(20, H - 100 + i * 22, '', {
fontFamily: FONTS.body, fontSize: '15px', color: '#cccccc',
});
t.setOrigin(0, 0);
this.messageTexts.push(t);
}
if (this.initialGrid && this.initialShips) {
this.applyPlanning(1, this.initialGrid, this.initialShips);
this.renderPlayerLegend(this.initialShips);
}
this.socket.on('planningStarted', (data) => {
if (data.round === 1 && this.initialGrid) {
this.roundText.setText(`Round ${data.round}`);
this.roundText.setText(`${data.round}`);
return;
}
this.onPlanningStarted(data.round, data.grid, data.ships);
@@ -74,6 +125,7 @@ export class GameScene extends Phaser.Scene {
this.socket.on('gameOver', (data) => {
this.scene.start('ResultScene', {
socket: this.socket,
audio: this.audio,
winnerId: data.winnerId,
winnerName: data.winnerName,
ships: data.ships,
@@ -91,9 +143,9 @@ export class GameScene extends Phaser.Scene {
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...');
this.roundText.setText(`${round}`);
this.setPhase('PLANNING PHASE', '#ffcc00', '#ffcc00');
this.audio.feedback('round', { haptic: false });
const oldDeathLineY = this.gridRenderer.viewOffset;
const newDeathLineY = grid.deathLineY;
@@ -101,12 +153,15 @@ export class GameScene extends Phaser.Scene {
this.gridRenderer.renderGrid(grid, ships, scrollInPx);
this.updateShipSprites(ships);
this.renderPlayerLegend(ships);
this.pushMessage('— Planning round —', '#a0a0c8');
}
// ── Execution animation: queue-based, sequential ──
// ── Execution animation ──
private onExecutionTick(data: ExecutionResult): void {
this.statusText.setText('EXECUTING').setColor('#00ff88');
this.setPhase('EXECUTING', '#00ff9c', '#00ff9c');
this.audio.feedback('phaseChange', { haptic: false });
this.execQueue.push({
updates: data.shipUpdates,
gridUpdates: data.gridUpdates,
@@ -121,6 +176,7 @@ export class GameScene extends Phaser.Scene {
if (this.pendingDeathLineY !== null) {
const deathLineAnim = this.pendingDeathLineY;
this.pendingDeathLineY = null;
this.audio.feedback('deathLine', { haptic: false });
this.gridRenderer.animateDeathLine(deathLineAnim, DEATH_LINE_ANIM, () => {
this.finishExecution();
});
@@ -132,72 +188,195 @@ export class GameScene extends Phaser.Scene {
this.animating = true;
const batch = this.execQueue.shift()!;
this.messageText.setText(batch.msgs.slice(0, 3).join(' | '));
for (const msg of batch.msgs.slice(0, 3)) {
this.pushMessage(msg, '#ffffff');
}
// Tile changes (from mine drop, etc.) — small sound + spark on the new tile
for (const u of batch.gridUpdates) {
if (u.type === 'tile_change') this.gridRenderer.updateTile(u.position, u.tile);
if (u.type === 'tile_change') {
this.gridRenderer.updateTile(u.position, u.tile);
if (u.tile === 'mine') {
const p = this.effects.posToScreen(u.position);
this.effects.minePlaced(p.x, p.y);
this.audio.feedback('mine', { haptic: false });
} else if (u.tile === 'meteor') {
const p = this.effects.posToScreen(u.position);
this.effects.meteorStrike(p.x, p.y, 0xff6a1a);
this.audio.feedback('meteor', { haptic: false });
}
}
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.handleShipUpdate(u);
}
this.time.delayedCall(ANIM_DURATION + 120, () => this.playNext());
}
private handleShipUpdate(u: ShipUpdate): void {
const sprite = this.shipSprites.get(u.shipId);
const color = this.playerColors.get(u.shipId) ?? 0x00e5ff;
switch (u.type) {
case 'move': {
const tx = this.gridRenderer.gridXToScreen(u.to.y);
const ty = this.gridRenderer.gridYToScreen(u.to.x);
const source = u.source ?? 'walk';
if (source === 'jump') {
// JUMP: instant snap, no engine drone (the jump sound + flash already happened)
if (sprite) {
sprite.setPosition(tx, ty);
this.effects.engineTrail(tx, ty, color, 0.7);
}
break;
}
const isBoost = source === 'boost';
const animDuration = isBoost ? ANIM_DURATION * 0.6 : ANIM_DURATION;
const pitch = isBoost ? 1.5 : 1;
const peak = isBoost ? 0.65 : 0.55;
// Initial engine burst
if (sprite) this.effects.engineTrail(sprite.x, sprite.y, color, isBoost ? 1.4 : 1);
// Sustained engine drone for the duration of the move
this.audio.playEngine(animDuration / 1000, { pitch, peak });
if (sprite) {
const trailState = { last: 0 };
this.tweens.add({
targets: sprite, x: tx, y: ty,
duration: animDuration, ease: isBoost ? 'Cubic.easeIn' : 'Sine.easeInOut',
onUpdate: () => {
const now = this.time.now;
if (now - trailState.last < (isBoost ? 70 : 110)) return;
trailState.last = now;
this.effects.engineTrail(sprite.x, sprite.y, color, isBoost ? 0.7 : 0.5);
},
});
}
break;
}
case 'turn': {
if (sprite) {
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION);
// Burst at the ship's position
this.effects.turnBurst(sprite.x, sprite.y, color);
}
this.audio.feedback('turn', { haptic: false });
break;
}
case 'card_played': {
this.playCardEffect(u.card, u.shipId, u.position, u.targetId);
break;
}
case 'eliminated': {
this.shipSprites.delete(u.shipId);
if (sprite) {
this.effects.explosion(sprite.x, sprite.y, color);
this.audio.feedback('explosion', { haptic: false });
this.cameras.main.flash(180, 255, 255, 255, false, undefined, 0.4);
this.cameras.main.shake(200, 0.012);
this.tweens.add({
targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1,
duration: 700, delay: 100,
onComplete: () => sprite.destroy(),
});
}
break;
}
case 'collision': {
if (sprite) {
this.effects.impact(sprite.x, sprite.y, 0xffcc00, 36);
this.tweens.add({
targets: sprite,
alpha: 0.2,
duration: 100, yoyo: true, repeat: 3,
});
this.cameras.main.shake(140, 0.008);
}
this.audio.feedback('collision', { haptic: false });
break;
}
case 'shield_used': {
if (sprite) this.effects.shieldBubble(sprite.x, sprite.y);
this.audio.feedback('shield', { haptic: false });
if (sprite) {
this.tweens.add({
targets: sprite,
alpha: 0.4,
duration: 200, yoyo: true, repeat: 2,
});
}
break;
}
}
}
// ── Card effect dispatch ───────────────────────────────────────
private playCardEffect(card: CardType, shipId: string, position: Position, targetId?: string): void {
const shipColor = this.playerColors.get(shipId) ?? 0x00e5ff;
const pos = this.effects.posToScreen(position);
const sprite = this.shipSprites.get(shipId);
switch (card) {
case 'SHIELD': {
if (sprite) this.effects.shieldBubble(sprite.x, sprite.y);
else this.effects.shieldBubble(pos.x, pos.y);
this.audio.feedback('shield', { haptic: false });
break;
}
case 'EMP': {
if (sprite) this.effects.empPulse(sprite.x, sprite.y, 0x00e5ff);
else this.effects.empPulse(pos.x, pos.y, 0x00e5ff);
this.cameras.main.flash(120, 0, 229, 255, false, undefined, 0.25);
this.audio.feedback('emp', { haptic: false });
break;
}
case 'JUMP': {
// Where the ship left from
if (targetId) {
const [x, y] = targetId.split(',').map(Number);
const from = this.effects.posToScreen({ x, y });
this.effects.jumpFlash(from.x, from.y);
}
// Where the ship appeared
this.effects.jumpFlash(pos.x, pos.y);
this.audio.feedback('jump', { haptic: false });
break;
}
case 'MINE': {
this.effects.minePlaced(pos.x, pos.y);
this.audio.feedback('mine', { haptic: false });
break;
}
case 'BOOST': {
if (sprite) this.effects.speedLines(sprite.x, sprite.y, shipColor, sprite.shipData.direction);
else this.effects.speedLines(pos.x, pos.y, shipColor, 'E');
this.audio.feedback('boost', { haptic: false });
break;
}
case 'PHASE_SHIFT': {
if (sprite) this.effects.phaseGhost(sprite.x, sprite.y, shipColor);
else this.effects.phaseGhost(pos.x, pos.y, shipColor);
this.audio.feedback('phaseShift', { haptic: false });
break;
}
}
}
// ── Phase helpers ─────────────────────────────────────────────
private finishExecution(): void {
if (this.pendingPlanning) {
const p = this.pendingPlanning;
@@ -231,15 +410,18 @@ export class GameScene extends Phaser.Scene {
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 color = this.playerColors.get(ship.playerId) ?? COLORS.player[i % COLORS.player.length];
this.playerColors.set(ship.id, color);
this.playerColors.set(ship.playerId, color);
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]);
sprite = new ShipSprite(this, x, y, ship, color);
this.children.remove(sprite);
this.gridRenderer.ships.add(sprite);
this.shipSprites.set(ship.id, sprite);
@@ -249,4 +431,30 @@ export class GameScene extends Phaser.Scene {
}
}
}
// ── HUD helpers ──
private setPhase(text: string, color: string, glow: string): void {
this.phaseText.setText(text).setColor(color);
this.phaseTextGlow.setText(text).setColor(glow);
this.phaseTextGlow.setShadow(0, 0, glow, 18, true, true);
}
private pushMessage(text: string, color: string): void {
this.messageLog.push(text);
if (this.messageLog.length > 3) this.messageLog.shift();
for (let i = 0; i < 3; i++) {
const msg = this.messageLog[i];
if (msg) {
this.messageTexts[i].setText(msg).setColor(color).setAlpha(1);
} else {
this.messageTexts[i].setText('').setAlpha(0);
}
}
}
private renderPlayerLegend(ships: ShipType[]): void {
// Reserved for future detailed legend.
}
}
+246 -60
View File
@@ -1,99 +1,138 @@
import Phaser from 'phaser';
import { TvSocket } from '../network/TvSocket.js';
import { COLORS, FONTS, AudioEngine } from '@spacerace/shared';
import { createStarfield } from './Starfield.js';
import QRCode from 'qrcode-generator';
const W = 1280;
const H = 720;
const BASE_URL = window.location.origin;
export class LobbyScene extends Phaser.Scene {
private socket!: TvSocket;
private audio!: AudioEngine;
private roomCodeText!: Phaser.GameObjects.Text;
private playerListText!: Phaser.GameObjects.Text;
private players: { id: string; name: string }[] = [];
private playerListContainer!: Phaser.GameObjects.Container;
private titleGlow!: Phaser.GameObjects.Text;
private startBtn!: Phaser.GameObjects.Container;
private startBtnLabel!: Phaser.GameObjects.Text;
private playerCountText!: Phaser.GameObjects.Text;
private qrImage!: Phaser.GameObjects.Image;
private players: { id: string; name: string; colorIndex: number }[] = [];
private roomCode: string = '';
constructor() {
super({ key: 'LobbyScene' });
}
init(data: { socket: TvSocket }): void {
init(data: { socket: TvSocket; audio: AudioEngine }): void {
this.socket = data.socket;
this.audio = data.audio;
this.players = [];
}
create(): void {
const { width, height } = this.scale;
this.cameras.main.setBackgroundColor('#050514');
createStarfield(this);
// Background
this.cameras.main.setBackgroundColor('#0a0a2e');
// ── Title with layered glow ──
this.titleGlow = this.add.text(W / 2, 80, 'SPACE RACE', {
fontFamily: FONTS.display,
fontSize: '64px',
color: '#00e5ff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.titleGlow.setShadow(0, 0, '#00e5ff', 24, true, true);
this.titleGlow.setAlpha(0.6);
this.titleGlow.setDepth(0.5);
// Title
this.add.text(width / 2, 60, '🚀 SPACE RACE 🚀', {
fontSize: '48px',
color: '#00ccff',
fontFamily: 'monospace',
this.add.text(W / 2, 80, 'SPACE RACE', {
fontFamily: FONTS.display,
fontSize: '64px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
// Create room button
this.roomCodeText = this.add.text(width / 2, 160, 'Creating room...', {
fontSize: '28px',
color: '#ffffff',
fontFamily: 'monospace',
// Subtitle
this.add.text(W / 2, 138, '◆ Multiplayer Tactical Race ◆', {
fontFamily: FONTS.display,
fontSize: '16px',
color: '#ff2bd6',
}).setOrigin(0.5).setShadow(0, 0, '#ff2bd6', 12, true, true);
// ── Room code badge ──
this.add.text(W / 2, 220, 'ROOM CODE', {
fontFamily: FONTS.display,
fontSize: '13px',
color: '#6a6a8a',
}).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);
this.add.rectangle(W / 2, 268, 360, 72, 0x0a0a24, 0.85)
.setStrokeStyle(2, 0x00e5ff, 0.55);
// 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);
this.roomCodeText = this.add.text(W / 2, 268, '----', {
fontFamily: FONTS.mono,
fontSize: '44px',
color: '#00e5ff',
fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, '#00e5ff', 16, true, true);
// 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);
// ── QR code ──
this.qrImage = this.add.image(W / 2, 400, '__DEFAULT').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...');
}
});
// ── Players section ──
this.add.text(160, 500, 'CREW MANIFEST', {
fontFamily: FONTS.display,
fontSize: '14px',
color: '#a0a0c8',
});
// Socket events
this.playerCountText = this.add.text(W - 160, 500, '0 / 6 PILOTS', {
fontFamily: FONTS.display,
fontSize: '14px',
color: '#a0a0c8',
}).setOrigin(1, 0);
this.playerListContainer = this.add.container(0, 0);
this.renderPlayerList();
// ── Bottom hint + start button ──
this.add.text(W / 2, H - 110, 'Scan the QR code on the TV, or enter the room code on your phone', {
fontFamily: FONTS.body,
fontSize: '15px',
color: '#6a6a8a',
}).setOrigin(0.5);
this.startBtn = this.createNeonButton(W / 2, H - 60, 'START RACE', '#ff2bd6', '#ff2bd6');
this.startBtn.setVisible(false);
// ── Socket events ──
this.socket.emit('tv:createRoom', {}, (res) => {
this.roomCode = res.roomCode;
this.roomCodeText.setText(`Room: ${res.roomCode}`);
this.updateQRHint();
this.roomCodeText.setText(res.roomCode);
this.generateQR(res.roomCode);
});
this.socket.on('playerJoined', (data) => {
this.players.push({ id: data.playerId, name: data.name });
this.updatePlayerList();
startBtn.setVisible(this.players.length >= 2);
this.players.push({ id: data.playerId, name: data.name, colorIndex: this.players.length });
this.audio.feedback('cardSelect', { haptic: false });
this.refreshPlayerList();
this.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);
// Reassign color indices in join order
this.players.forEach((p, i) => { p.colorIndex = i; });
this.refreshPlayerList();
this.startBtn.setVisible(this.players.length >= 2);
});
this.socket.on('gameStarting', (data) => {
this.audio.feedback('phaseChange', { haptic: false });
this.scene.start('GameScene', {
socket: this.socket,
audio: this.audio,
roomCode: this.roomCode,
players: data.players,
grid: data.grid,
@@ -102,14 +141,161 @@ export class LobbyScene extends Phaser.Scene {
});
}
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 createNeonButton(x: number, y: number, label: string, fillColor: number, glowColor: number): Phaser.GameObjects.Container {
const container = this.add.container(x, y);
const w = 320, h = 64;
// Outer glow
const glow = this.add.rectangle(0, 0, w + 14, h + 14, glowColor, 0.18);
glow.setBlendMode(Phaser.BlendModes.ADD);
// Main button
const bg = this.add.rectangle(0, 0, w, h, fillColor, 1);
bg.setStrokeStyle(2, 0xffffff, 0.6);
// Top highlight gradient
const highlight = this.add.rectangle(0, -h / 4, w - 4, h / 2, 0xffffff, 0.18);
highlight.setBlendMode(Phaser.BlendModes.ADD);
const text = this.add.text(0, 0, label, {
fontFamily: FONTS.display,
fontSize: '22px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
text.setShadow(0, 0, glowColor, 12, true, true);
container.add([glow, bg, highlight, text]);
container.setSize(w, h);
container.setInteractive(new Phaser.Geom.Rectangle(-w/2, -h/2, w, h), Phaser.Geom.Rectangle.Contains);
container.on('pointerover', () => {
bg.setFillStyle(0xffffff, 0.18);
bg.setFillStyle(fillColor, 1);
this.tweens.add({ targets: container, scaleX: 1.04, scaleY: 1.04, duration: 120 });
this.audio.feedback('cardSelect', { haptic: false });
});
container.on('pointerout', () => {
this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 120 });
});
container.on('pointerdown', () => {
this.audio.feedback('go', { haptic: false });
this.tweens.add({ targets: container, scaleX: 0.96, scaleY: 0.96, duration: 80, yoyo: true });
this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => {
if (!res.ok) console.warn('Start failed');
});
});
// Pulsing glow
this.tweens.add({
targets: glow,
alpha: { from: 0.18, to: 0.42 },
duration: 1200,
yoyo: true,
repeat: -1,
});
return container;
}
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);
private generateQR(roomCode: string): void {
const url = `${BASE_URL}/controller?room=${roomCode}`;
const qr = QRCode(0, 'M');
qr.addData(url);
qr.make();
const size = 160;
const moduleCount = qr.getModuleCount();
const moduleSize = Math.floor(size / (moduleCount + 2));
const canvasSize = (moduleCount + 2) * moduleSize;
const canvas = document.createElement('canvas');
canvas.width = canvasSize;
canvas.height = canvasSize;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#050514';
ctx.fillRect(0, 0, canvasSize, canvasSize);
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qr.isDark(row, col)) {
ctx.fillStyle = '#00e5ff';
ctx.fillRect((col + 1) * moduleSize, (row + 1) * moduleSize, moduleSize, moduleSize);
}
}
}
const key = `qr-${roomCode}`;
if (this.textures.exists(key)) this.textures.remove(key);
this.textures.addImage(key, canvas as unknown as HTMLImageElement);
this.qrImage.setTexture(key).setVisible(true).setDisplaySize(size, size);
const qrLink = document.getElementById('qr-url') as HTMLAnchorElement;
qrLink.href = url;
qrLink.textContent = url;
qrLink.style.display = 'block';
}
private renderPlayerList(): void {
this.playerListContainer.removeAll(true);
const startY = 530;
const rowH = 48;
const colW = 300;
const cols = 4;
const leftMargin = (W - cols * colW) / 2;
for (let i = 0; i < this.players.length; i++) {
const p = this.players[i];
const col = i % cols;
const row = Math.floor(i / cols);
const x = leftMargin + col * colW + colW / 2;
const y = startY + row * rowH;
const color = COLORS.player[p.colorIndex % COLORS.player.length];
// Card bg
const card = this.add.rectangle(x, y, colW - 16, rowH - 8, 0x0a0a24, 0.9);
card.setStrokeStyle(1, color, 0.7);
// Color avatar (left)
const avatar = this.add.circle(x - colW/2 + 28, y, 12, color);
avatar.setStrokeStyle(1, 0xffffff, 0.6);
// Player name
const name = this.add.text(x - colW/2 + 52, y, p.name, {
fontFamily: FONTS.body,
fontSize: '16px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0, 0.5);
// Pilot tag (right)
this.add.text(x + colW/2 - 18, y, `P0${p.colorIndex + 1}`, {
fontFamily: FONTS.mono,
fontSize: '11px',
color: '#6a6a8a',
}).setOrigin(1, 0.5);
this.playerListContainer.add([card, avatar, name]);
}
// Empty slot
if (this.players.length === 0) {
const x = W / 2;
const y = startY + 12;
this.add.text(x, y, 'Waiting for pilots to join…', {
fontFamily: FONTS.body,
fontSize: '16px',
color: '#6a6a8a',
fontStyle: 'italic',
}).setOrigin(0.5);
}
this.playerCountText.setText(`${this.players.length} / 6 PILOTS`);
}
private refreshPlayerList(): void {
this.renderPlayerList();
}
}
+160 -54
View File
@@ -1,89 +1,195 @@
import Phaser from 'phaser';
import { TvSocket } from '../network/TvSocket.js';
import { Ship } from '@spacerace/shared';
import { Ship, COLORS, FONTS, AudioEngine } from '@spacerace/shared';
import { createStarfield } from './Starfield.js';
const W = 1280;
const H = 720;
interface PodiumEntry {
ship: Ship;
color: number;
rank: number;
}
export class ResultScene extends Phaser.Scene {
private socket!: TvSocket;
private audio!: AudioEngine;
constructor() {
super({ key: 'ResultScene' });
}
init(data: { socket: TvSocket; winnerId: string; winnerName: string; ships: Ship[] }): void {
init(data: { socket: TvSocket; audio: AudioEngine; winnerId: string; winnerName: string; ships: Ship[] }): void {
this.socket = data.socket;
// We'll use data directly in create
this.audio = data.audio;
this.registry.set('resultData', data);
}
create(): void {
const data = this.registry.get('resultData') as {
winnerId: string;
winnerName: string;
ships: Ship[];
winnerId: string; winnerName: string; ships: Ship[];
};
const { width, height } = this.scale;
this.cameras.main.setBackgroundColor('#0a0a2e');
this.cameras.main.setBackgroundColor('#050514');
createStarfield(this);
this.add.text(width / 2, 100, '🏆 RACE OVER 🏆', {
fontSize: '52px',
color: '#ffcc00',
fontFamily: 'monospace',
fontStyle: 'bold',
this.audio.feedback('fanfare', { haptic: false });
// ── Title with glow ──
const titleGlow = this.add.text(W / 2, 90, '🏆 RACE OVER 🏆', {
fontFamily: FONTS.display, fontSize: '56px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
titleGlow.setShadow(0, 0, '#ffcc00', 24, true, true);
titleGlow.setAlpha(0.6);
this.add.text(W / 2, 90, '🏆 RACE OVER 🏆', {
fontFamily: FONTS.display, fontSize: '56px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(width / 2, 220, `${data.winnerName} wins!`, {
fontSize: '40px',
color: '#00ff88',
fontFamily: 'monospace',
}).setOrigin(0.5);
// ── Winner banner ──
this.add.rectangle(W / 2, 170, 700, 70, 0x0a0a24, 0.85)
.setStrokeStyle(2, 0xff2bd6, 0.8);
this.add.text(W / 2, 170, `${data.winnerName} WINS!`, {
fontFamily: FONTS.display, fontSize: '36px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, '#ff2bd6', 16, true, true);
// Show final positions
const aliveShips = data.ships.filter((s) => s.alive);
const deadShips = data.ships.filter((s) => !s.alive);
let yPos = 320;
// ── Build podium ──
const alive = data.ships.filter((s) => s.alive);
const dead = data.ships.filter((s) => !s.alive);
const sorted = [...alive, ...dead].slice(0, 6);
sorted.forEach((ship, i) => {
const color = COLORS.player[i % COLORS.player.length];
const podiumEntry: PodiumEntry = { ship, color, rank: i + 1 };
this.renderPodiumEntry(podiumEntry, sorted.length, i);
});
this.add.text(width / 2, yPos, 'Final Standings:', {
fontSize: '24px',
color: '#aaaaaa',
fontFamily: 'monospace',
}).setOrigin(0.5);
yPos += 40;
// ── Confetti burst ──
this.spawnConfetti();
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;
// ── Play again button ──
this.createNeonButton(W / 2, H - 70, 'BACK TO LOBBY', 0x00e5ff, 0x00e5ff);
}
private renderPodiumEntry(entry: PodiumEntry, total: number, index: number): void {
const isWinner = entry.rank === 1;
const colW = 180;
const cols = Math.min(total, 6);
const startX = (W - cols * colW) / 2 + colW / 2;
const x = startX + index * colW;
const baseY = 480;
const heightByRank = isWinner ? 180 : entry.rank === 2 ? 130 : entry.rank === 3 ? 90 : 60;
// Podium block
const block = this.add.rectangle(x, baseY, colW - 14, heightByRank, entry.color, 0.85);
block.setStrokeStyle(2, 0xffffff, 0.4);
block.setOrigin(0.5, 1);
// Glow halo for top 3
if (entry.rank <= 3) {
const glow = this.add.rectangle(x, baseY, colW - 8, heightByRank + 10, entry.color, 0.3);
glow.setOrigin(0.5, 1);
glow.setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({
targets: glow,
alpha: { from: 0.2, to: 0.5 },
duration: 1100 + index * 100,
yoyo: true,
repeat: -1,
});
}
for (const ship of deadShips) {
this.add.text(width / 2, yPos, ` ${ship.playerName} (eliminated)`, {
fontSize: '20px',
color: '#666666',
fontFamily: 'monospace',
// Rank number
this.add.text(x, baseY - heightByRank + 28, `${entry.rank}`, {
fontFamily: FONTS.display, fontSize: '32px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, '#ffffff', 8, true, true);
// Player name (on top of podium)
this.add.text(x, baseY - heightByRank - 16, entry.ship.playerName, {
fontFamily: FONTS.body, fontSize: '16px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
// Status (eliminated) below name
if (!entry.ship.alive) {
this.add.text(x, baseY - heightByRank - 38, '✗ ELIMINATED', {
fontFamily: FONTS.display, fontSize: '11px', color: '#ff3b6b',
}).setOrigin(0.5);
yPos += 30;
} else if (isWinner) {
this.add.text(x, baseY - heightByRank - 38, '★ CHAMPION ★', {
fontFamily: FONTS.display, fontSize: '11px', color: '#ffcc00',
}).setOrigin(0.5).setShadow(0, 0, '#ffcc00', 8, true, true);
}
// 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 });
// Winner bouncy scale-in
if (isWinner) {
block.setScale(0.7, 0.7);
this.tweens.add({
targets: block,
scaleX: 1,
scaleY: 1,
duration: 800,
ease: 'Back.easeOut',
});
}
}
playAgainBtn.on('pointerover', () => playAgainBtn.setStyle({ backgroundColor: '#223366' }));
playAgainBtn.on('pointerout', () => playAgainBtn.setStyle({ backgroundColor: '#112244' }));
playAgainBtn.on('pointerdown', () => {
private spawnConfetti(): void {
const colors = [0x00e5ff, 0xff2bd6, 0xffcc00, 0x00ff9c, 0xff3b6b];
const confetti = this.add.particles(0, 0, 'ship', {
x: { min: 0, max: W },
y: -20,
lifespan: 4000,
speedY: { min: 60, max: 140 },
speedX: { min: -60, max: 60 },
gravityY: 80,
scale: { min: 0.08, max: 0.2 },
rotate: { min: 0, max: 360 },
alpha: { start: 1, end: 0.4 },
tint: colors,
quantity: 2,
frequency: 30,
blendMode: Phaser.BlendModes.ADD,
});
confetti.setDepth(5);
}
private createNeonButton(x: number, y: number, label: string, fillColor: number, glowColor: number): void {
const w = 320, h = 56;
const container = this.add.container(x, y);
const glow = this.add.rectangle(0, 0, w + 12, h + 12, glowColor, 0.22);
glow.setBlendMode(Phaser.BlendModes.ADD);
const bg = this.add.rectangle(0, 0, w, h, fillColor, 1);
bg.setStrokeStyle(2, 0xffffff, 0.6);
const text = this.add.text(0, 0, label, {
fontFamily: FONTS.display, fontSize: '20px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, glowColor, 12, true, true);
container.add([glow, bg, text]);
container.setSize(w, h);
container.setInteractive(new Phaser.Geom.Rectangle(-w/2, -h/2, w, h), Phaser.Geom.Rectangle.Contains);
container.on('pointerover', () => {
this.tweens.add({ targets: container, scaleX: 1.05, scaleY: 1.05, duration: 120 });
this.audio.feedback('cardSelect', { haptic: false });
});
container.on('pointerout', () => {
this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 120 });
});
container.on('pointerdown', () => {
this.audio.feedback('go', { haptic: false });
this.tweens.add({ targets: container, scaleX: 0.96, scaleY: 0.96, duration: 80, yoyo: true });
this.socket.disconnect();
window.location.reload();
});
this.tweens.add({
targets: glow,
alpha: { from: 0.18, to: 0.42 },
duration: 1300,
yoyo: true,
repeat: -1,
});
}
}
+62
View File
@@ -0,0 +1,62 @@
import Phaser from 'phaser';
/**
* Creates a procedurally generated starfield texture and renders it as a
* slowly scrolling TileSprite in the back. Three depth layers give a parallax
* feel. Drawn from the deepest layer (setDepth = -10) so game content always
* renders on top.
*/
export function createStarfield(scene: Phaser.Scene): void {
const w = scene.scale.width;
const h = scene.scale.height;
const layers: { count: number; color: number; alpha: number; speed: number; size: [number, number] }[] = [
{ count: 90, color: 0x6a6a8a, alpha: 0.6, speed: 0.05, size: [1, 1] },
{ count: 50, color: 0xffffff, alpha: 0.85, speed: 0.12, size: [1, 2] },
{ count: 18, color: 0x00e5ff, alpha: 0.75, speed: 0.22, size: [2, 2] },
{ count: 8, color: 0xff2bd6, alpha: 0.7, speed: 0.32, size: [2, 3] },
];
const tex = scene.make.graphics({ x: 0, y: 0, add: false });
tex.fillStyle(0x050514, 1);
tex.fillRect(0, 0, w, h);
for (const layer of layers) {
tex.fillStyle(layer.color, layer.alpha);
for (let i = 0; i < layer.count; i++) {
const x = Math.floor(Math.random() * w);
const y = Math.floor(Math.random() * h);
tex.fillRect(x, y, layer.size[0], layer.size[1]);
}
}
tex.generateTexture('starfield', w, h);
tex.destroy();
// Single tile of stars is enough — we just translate it.
const tile = scene.add.tileSprite(0, 0, w, h, 'starfield').setOrigin(0, 0);
tile.setDepth(-100);
// Add a second dim layer for depth
const dimTex = scene.make.graphics({ x: 0, y: 0, add: false });
dimTex.fillStyle(0x050514, 1);
dimTex.fillRect(0, 0, w, h);
for (let i = 0; i < 40; i++) {
const x = Math.floor(Math.random() * w);
const y = Math.floor(Math.random() * h);
dimTex.fillStyle([0x4a4a6a, 0x5a3a7a, 0x3a5a7a][i % 3], 0.6);
dimTex.fillRect(x, y, 1, 1);
}
dimTex.generateTexture('starfield_dim', w, h);
dimTex.destroy();
const dim = scene.add.tileSprite(0, 0, w, h, 'starfield_dim').setOrigin(0, 0);
dim.setDepth(-99);
// Slow horizontal drift — Phaser is single-threaded so a single tween is fine
scene.tweens.add({
targets: [tile, dim],
tilePositionX: { from: 0, to: w },
duration: 90000,
repeat: -1,
});
}
+37
View File
@@ -0,0 +1,37 @@
/* TV surface mirrors shared/src/theme.ts as CSS variables for any HTML elements.
The Phaser canvas itself is rendered to the #game-container; we just give the
page a polished frame and host the starfield that shows through during scene
fades. */
:root {
--primary: #00e5ff;
--primary-dim: #007a99;
--accent: #ff2bd6;
--success: #00ff9c;
--warning: #ffcc00;
--danger: #ff3b6b;
--bg-deep: #050514;
--bg-panel: #0a0a24;
--text: #ffffff;
--text-dim: #a0a0c8;
--text-muted: #6a6a8a;
--f-display: "Orbitron", "Rajdhani", system-ui, sans-serif;
--f-body: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--f-mono: "JetBrains Mono", "Fira Code", monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: var(--bg-deep); font-family: var(--f-body); color: var(--text); }
#game-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; }
/* Vignette + scanline overlay drawn above the canvas edges */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
repeating-linear-gradient(0deg, rgba(0,229,255,0.02) 0px, rgba(0,229,255,0.02) 1px, transparent 1px, transparent 3px);
}
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: '/',
server: {
port: 3000,
proxy: {