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:
@@ -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
@@ -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
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/controller/',
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
|
||||
Generated
+1
@@ -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
@@ -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"]
|
||||
|
||||
+105
-54
@@ -4,37 +4,53 @@ 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' }] };
|
||||
}
|
||||
export interface CardResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
gridUpdates: { position: Position; tile: string }[];
|
||||
events: CardEvent[];
|
||||
}
|
||||
|
||||
case 'SHIELD': {
|
||||
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: [] };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `${ctx.ship.playerId} activated Shield`,
|
||||
gridUpdates: [],
|
||||
events: [cardEvent('SHIELD', ctx, { ...ctx.ship.position })],
|
||||
};
|
||||
};
|
||||
|
||||
case 'EMP': {
|
||||
const EMP: CardHandler = (ctx) => {
|
||||
ctx.ship.empActive = true;
|
||||
return { success: true, message: `${ctx.ship.playerId} activated EMP`, gridUpdates: [] };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `${ctx.ship.playerId} activated EMP`,
|
||||
gridUpdates: [],
|
||||
events: [cardEvent('EMP', ctx, { ...ctx.ship.position })],
|
||||
};
|
||||
};
|
||||
|
||||
case 'JUMP': {
|
||||
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;
|
||||
@@ -44,48 +60,83 @@ export function executeCard(cardType: CardType, ctx: CardContext): { success: bo
|
||||
y: ctx.ship.position.y + dy * 2,
|
||||
};
|
||||
|
||||
if (!ctx.grid.isInBounds(jumpPos)) return { success: false, message: 'Jump out of bounds', gridUpdates: [] };
|
||||
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: [] };
|
||||
}
|
||||
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'),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
case 'MINE': {
|
||||
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' }] };
|
||||
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: [] };
|
||||
}
|
||||
|
||||
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: [] };
|
||||
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'),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
// 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': {
|
||||
const PHASE_SHIFT: CardHandler = (ctx) => {
|
||||
ctx.ship.phaseShifting = true;
|
||||
return { success: true, message: `${ctx.ship.playerId} activated Phase Shift`, gridUpdates: [] };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `${ctx.ship.playerId} activated Phase Shift`,
|
||||
gridUpdates: [],
|
||||
events: [cardEvent('PHASE_SHIFT', ctx, { ...ctx.ship.position })],
|
||||
};
|
||||
};
|
||||
|
||||
default:
|
||||
return { success: false, message: 'Unknown card', gridUpdates: [] };
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export * from './types.js';
|
||||
export * from './theme.js';
|
||||
export * from './audio.js';
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@spacerace/shared": "*",
|
||||
"phaser": "^3.80.1",
|
||||
"qrcode-generator": "^1.5.2",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, bgColor, bgAlpha);
|
||||
bg.setStrokeStyle(1, 0x223366, 0.2);
|
||||
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;
|
||||
}
|
||||
|
||||
if (tile !== 'space') {
|
||||
const textureName =
|
||||
tile === 'asteroid' ? 'asteroid' :
|
||||
tile === 'meteor' ? 'meteor' :
|
||||
'mine';
|
||||
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.6);
|
||||
this.fieldContainer.add(bg);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
+249
-41
@@ -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,71 +188,194 @@ 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) {
|
||||
this.handleShipUpdate(u);
|
||||
}
|
||||
|
||||
this.time.delayedCall(ANIM_DURATION + 120, () => this.playNext());
|
||||
}
|
||||
|
||||
private handleShipUpdate(u: ShipUpdate): void {
|
||||
const sprite = this.shipSprites.get(u.shipId);
|
||||
if (!sprite) continue;
|
||||
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: ANIM_DURATION, ease: 'Sine.easeInOut',
|
||||
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);
|
||||
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();
|
||||
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: 200,
|
||||
onComplete: () => { sprite.destroy(); px.destroy(); },
|
||||
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,
|
||||
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.2,
|
||||
duration: 150,
|
||||
yoyo: true,
|
||||
repeat: 2,
|
||||
alpha: 0.4,
|
||||
duration: 200, yoyo: true, repeat: 2,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.time.delayedCall(ANIM_DURATION + 120, () => this.playNext());
|
||||
// ── 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) {
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
+247
-61
@@ -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',
|
||||
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);
|
||||
|
||||
// ── QR code ──
|
||||
this.qrImage = this.add.image(W / 2, 400, '__DEFAULT').setVisible(false);
|
||||
|
||||
// ── Players section ──
|
||||
this.add.text(160, 500, 'CREW MANIFEST', {
|
||||
fontFamily: FONTS.display,
|
||||
fontSize: '14px',
|
||||
color: '#a0a0c8',
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
this.startBtn = this.createNeonButton(W / 2, H - 60, 'START RACE', '#ff2bd6', '#ff2bd6');
|
||||
this.startBtn.setVisible(false);
|
||||
|
||||
startBtn.on('pointerover', () => startBtn.setStyle({ backgroundColor: '#227744' }));
|
||||
startBtn.on('pointerout', () => startBtn.setStyle({ backgroundColor: '#115533' }));
|
||||
startBtn.on('pointerdown', () => {
|
||||
this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => {
|
||||
if (res.ok) {
|
||||
console.log('Game starting...');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Socket events
|
||||
// ── 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();
|
||||
}
|
||||
}
|
||||
|
||||
+161
-55
@@ -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);
|
||||
}
|
||||
|
||||
for (const ship of deadShips) {
|
||||
this.add.text(width / 2, yPos, ` ${ship.playerName} (eliminated)`, {
|
||||
fontSize: '20px',
|
||||
color: '#666666',
|
||||
fontFamily: 'monospace',
|
||||
}).setOrigin(0.5);
|
||||
yPos += 30;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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 });
|
||||
// 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);
|
||||
|
||||
playAgainBtn.on('pointerover', () => playAgainBtn.setStyle({ backgroundColor: '#223366' }));
|
||||
playAgainBtn.on('pointerout', () => playAgainBtn.setStyle({ backgroundColor: '#112244' }));
|
||||
playAgainBtn.on('pointerdown', () => {
|
||||
// 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);
|
||||
} 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);
|
||||
}
|
||||
|
||||
// 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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user