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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||||
<title>SpaceRace - Controller</title>
|
<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" />
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<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">
|
<div id="screen-join" class="screen active">
|
||||||
<h1>🚀 SpaceRace</h1>
|
<h1>🚀 SpaceRace</h1>
|
||||||
|
<p class="join-sub">Pilot your ship to victory</p>
|
||||||
<div class="join-form">
|
<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" />
|
<input type="text" id="name-input" placeholder="Your Name" maxlength="12" autocomplete="off" />
|
||||||
<button id="join-btn">Join Race</button>
|
<button id="join-btn">Join Race</button>
|
||||||
<p id="join-error" class="error"></p>
|
<p id="join-error" class="error"></p>
|
||||||
@@ -23,9 +33,10 @@
|
|||||||
<div class="planning-header">
|
<div class="planning-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<span id="color-dot" class="color-dot"></span>
|
<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>
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
|
<span id="round-label">Round 1</span>
|
||||||
<span id="timer-label">45s</span>
|
<span id="timer-label">45s</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -55,20 +66,17 @@
|
|||||||
<span class="act-label">Right</span>
|
<span class="act-label">Right</span>
|
||||||
<span class="act-cost">1 AP</span>
|
<span class="act-cost">1 AP</span>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Action queue -->
|
<!-- Action queue -->
|
||||||
<div id="action-queue" class="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 id="queue-list" class="queue-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card hand -->
|
<!-- Card hand -->
|
||||||
<div id="card-hand" class="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 id="card-list" class="card-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,20 +88,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="screen-waiting" class="screen">
|
<div id="screen-waiting" class="screen">
|
||||||
<h2>Executing...</h2>
|
<div class="tv-icon"></div>
|
||||||
<p id="waiting-message">Watch the TV!</p>
|
<h2>Executing…</h2>
|
||||||
|
<p id="waiting-message">Watch the TV screen</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="screen-spectator" class="screen">
|
<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>
|
<p>You're out! Watch the TV to see who wins.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="screen-lobby" class="screen">
|
<div id="screen-lobby" class="screen">
|
||||||
<h2>Lobby</h2>
|
<h2>Mission Lobby</h2>
|
||||||
<p id="lobby-room">Room: ----</p>
|
<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">
|
<div id="lobby-players">
|
||||||
<h3>Players</h3>
|
<h3>Crew Manifest</h3>
|
||||||
<ul id="lobby-player-list"></ul>
|
<ul id="lobby-player-list"></ul>
|
||||||
</div>
|
</div>
|
||||||
<p id="lobby-waiting">Waiting for host to start...</p>
|
<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 { WaitingScreen } from './screens/WaitingScreen.js';
|
||||||
import { SpectatorScreen } from './screens/SpectatorScreen.js';
|
import { SpectatorScreen } from './screens/SpectatorScreen.js';
|
||||||
import { LobbyScreen } from './screens/LobbyScreen.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;
|
socket: ControllerSocket;
|
||||||
|
audio: AudioEngine;
|
||||||
private currentScreen: string = 'join';
|
private currentScreen: string = 'join';
|
||||||
isHost: boolean = false;
|
isHost: boolean = false;
|
||||||
roomCode: string = '';
|
roomCode: string = '';
|
||||||
@@ -15,10 +16,37 @@ class App {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.socket = connectControllerSocket();
|
this.socket = connectControllerSocket();
|
||||||
|
this.audio = new AudioEngine();
|
||||||
|
this.initMuteToggle();
|
||||||
this.initScreens();
|
this.initScreens();
|
||||||
this.initSocketEvents();
|
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 {
|
private initScreens(): void {
|
||||||
new JoinScreen(this);
|
new JoinScreen(this);
|
||||||
new LobbyScreen(this);
|
new LobbyScreen(this);
|
||||||
@@ -36,36 +64,41 @@ class App {
|
|||||||
this.isHost = data.isHost;
|
this.isHost = data.isHost;
|
||||||
this.roomCode = data.roomCode;
|
this.roomCode = data.roomCode;
|
||||||
this.playerId = data.playerId;
|
this.playerId = data.playerId;
|
||||||
|
this.audio.feedback('round');
|
||||||
this.showScreen('lobby');
|
this.showScreen('lobby');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('gameStarting', () => {
|
this.socket.on('gameStarting', () => {
|
||||||
|
this.audio.feedback('phaseChange');
|
||||||
this.showScreen('planning');
|
this.showScreen('planning');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('planningRequest', (data) => {
|
this.socket.on('planningRequest', (data) => {
|
||||||
|
this.audio.feedback('round');
|
||||||
this.showScreen('planning');
|
this.showScreen('planning');
|
||||||
// Pass data to planning screen
|
|
||||||
document.dispatchEvent(new CustomEvent('planningRequest', { detail: data }));
|
document.dispatchEvent(new CustomEvent('planningRequest', { detail: data }));
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('executionTick', (data) => {
|
this.socket.on('executionTick', () => {
|
||||||
|
this.audio.feedback('phaseChange');
|
||||||
this.showScreen('waiting');
|
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) => {
|
this.socket.on('executionComplete', (data) => {
|
||||||
if (data.playerView.alive) {
|
if (data.playerView.alive) {
|
||||||
this.showScreen('planning');
|
this.showScreen('planning');
|
||||||
} else {
|
} else {
|
||||||
|
this.audio.feedback('eliminated');
|
||||||
this.showScreen('spectator');
|
this.showScreen('spectator');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('gameOver', (data) => {
|
this.socket.on('gameOver', (data) => {
|
||||||
|
if (data.winnerId === this.playerId) {
|
||||||
|
this.audio.feedback('win');
|
||||||
|
} else {
|
||||||
|
this.audio.feedback('eliminated');
|
||||||
|
}
|
||||||
this.showScreen('spectator');
|
this.showScreen('spectator');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,21 +21,31 @@ export class JoinScreen {
|
|||||||
roomInput.value = roomParam.toUpperCase();
|
roomInput.value = roomParam.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Small feedback on input focus
|
||||||
|
[roomInput, nameInput].forEach((input) => {
|
||||||
|
input.addEventListener('focus', () => this.app.audio.feedback('cardSelect'));
|
||||||
|
});
|
||||||
|
|
||||||
joinBtn.addEventListener('click', () => {
|
joinBtn.addEventListener('click', () => {
|
||||||
|
this.app.audio.unlock();
|
||||||
|
this.app.audio.feedback('go');
|
||||||
|
|
||||||
const roomCode = roomInput.value.trim().toUpperCase();
|
const roomCode = roomInput.value.trim().toUpperCase();
|
||||||
const playerName = nameInput.value.trim();
|
const playerName = nameInput.value.trim();
|
||||||
|
|
||||||
if (!roomCode || roomCode.length !== 4) {
|
if (!roomCode || roomCode.length !== 4) {
|
||||||
errorEl.textContent = 'Enter a 4-character room code';
|
errorEl.textContent = 'Enter a 4-character room code';
|
||||||
|
this.app.audio.feedback('warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!playerName) {
|
if (!playerName) {
|
||||||
errorEl.textContent = 'Enter your name';
|
errorEl.textContent = 'Enter your name';
|
||||||
|
this.app.audio.feedback('warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
errorEl.textContent = '';
|
errorEl.textContent = '';
|
||||||
joinBtn.textContent = 'Joining...';
|
joinBtn.textContent = 'Joining…';
|
||||||
joinBtn.disabled = true;
|
joinBtn.disabled = true;
|
||||||
|
|
||||||
this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => {
|
this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => {
|
||||||
@@ -43,8 +53,10 @@ export class JoinScreen {
|
|||||||
joinBtn.disabled = false;
|
joinBtn.disabled = false;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
errorEl.textContent = res.error || 'Failed to join';
|
errorEl.textContent = res.error || 'Failed to join';
|
||||||
|
this.app.audio.feedback('warning');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { App } from '../main.js';
|
import { App } from '../main.js';
|
||||||
|
import { PLAYER_HEX } from '@spacerace/shared';
|
||||||
|
|
||||||
|
const AVATAR_LETTERS = ['◆', '▲', '●', '■', '★', '⬢'];
|
||||||
|
|
||||||
export class LobbyScreen {
|
export class LobbyScreen {
|
||||||
private app: App;
|
private app: App;
|
||||||
@@ -10,12 +13,14 @@ export class LobbyScreen {
|
|||||||
this.createStartButton();
|
this.createStartButton();
|
||||||
|
|
||||||
app.socket.on('roomJoined', (data) => {
|
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.updatePlayerList(data.players);
|
||||||
this.updateHostUI();
|
this.updateHostUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.socket.on('playerJoined', (data) => {
|
app.socket.on('playerJoined', (data) => {
|
||||||
|
this.app.audio.feedback('cardSelect');
|
||||||
this.appendPlayer(data.playerId, data.name);
|
this.appendPlayer(data.playerId, data.name);
|
||||||
this.updateHostUI();
|
this.updateHostUI();
|
||||||
});
|
});
|
||||||
@@ -35,20 +40,22 @@ export class LobbyScreen {
|
|||||||
private createStartButton(): void {
|
private createStartButton(): void {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.id = 'host-controls';
|
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 = document.createElement('button');
|
||||||
this.startBtn.textContent = '🚀 Start Race';
|
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', () => {
|
this.startBtn.addEventListener('click', () => {
|
||||||
if (!this.startBtn) return;
|
if (!this.startBtn) return;
|
||||||
this.startBtn.textContent = 'Starting...';
|
this.app.audio.feedback('go');
|
||||||
|
this.startBtn.textContent = 'Launching…';
|
||||||
this.startBtn.disabled = true;
|
this.startBtn.disabled = true;
|
||||||
|
|
||||||
this.app.socket.emit('mobile:startGame', { roomCode: this.app.roomCode }, (res) => {
|
this.app.socket.emit('mobile:startGame', { roomCode: this.app.roomCode }, (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
this.startBtn!.textContent = '🚀 Start Race';
|
this.startBtn!.textContent = '🚀 Start Race';
|
||||||
this.startBtn!.disabled = false;
|
this.startBtn!.disabled = false;
|
||||||
|
this.app.audio.feedback('warning');
|
||||||
alert(res.error || 'Cannot start');
|
alert(res.error || 'Cannot start');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -56,7 +63,6 @@ export class LobbyScreen {
|
|||||||
|
|
||||||
div.appendChild(this.startBtn);
|
div.appendChild(this.startBtn);
|
||||||
|
|
||||||
// Insert before the waiting text
|
|
||||||
const lobbyScreen = document.getElementById('screen-lobby')!;
|
const lobbyScreen = document.getElementById('screen-lobby')!;
|
||||||
const waitingEl = document.getElementById('lobby-waiting')!;
|
const waitingEl = document.getElementById('lobby-waiting')!;
|
||||||
lobbyScreen.insertBefore(div, waitingEl);
|
lobbyScreen.insertBefore(div, waitingEl);
|
||||||
@@ -72,16 +78,11 @@ export class LobbyScreen {
|
|||||||
const playerCount = document.getElementById('lobby-player-list')!.children.length;
|
const playerCount = document.getElementById('lobby-player-list')!.children.length;
|
||||||
if (this.startBtn) {
|
if (this.startBtn) {
|
||||||
this.startBtn.disabled = playerCount < 2;
|
this.startBtn.disabled = playerCount < 2;
|
||||||
if (playerCount < 2) {
|
|
||||||
this.startBtn.style.opacity = '0.5';
|
|
||||||
} else {
|
|
||||||
this.startBtn.style.opacity = '1';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
div.style.display = 'none';
|
div.style.display = 'none';
|
||||||
waitingEl.style.display = 'block';
|
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 {
|
private appendPlayer(id: string, name: string): void {
|
||||||
const list = document.getElementById('lobby-player-list')!;
|
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 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}`;
|
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);
|
list.appendChild(li);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
import { App } from '../main.js';
|
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> = {
|
const CARD_ICONS: Record<CardType, string> = {
|
||||||
METEOR_STRIKE: '☄️',
|
|
||||||
SHIELD: '🛡️',
|
SHIELD: '🛡️',
|
||||||
BOOST: '🚀',
|
BOOST: '🚀',
|
||||||
EMP: '⚡',
|
EMP: '⚡',
|
||||||
JUMP: '🦘',
|
JUMP: '🦘',
|
||||||
MINE: '💣',
|
MINE: '💣',
|
||||||
TELEPORT: '🌀',
|
|
||||||
PHASE_SHIFT: '👻',
|
PHASE_SHIFT: '👻',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ACTION_ICONS: Record<string, string> = {
|
||||||
|
MOVE_FORWARD: '↑',
|
||||||
|
TURN_LEFT: '↰',
|
||||||
|
TURN_RIGHT: '↱',
|
||||||
|
TURN_180: '↻',
|
||||||
|
};
|
||||||
|
|
||||||
export class PlanningScreen {
|
export class PlanningScreen {
|
||||||
private app: App;
|
private app: App;
|
||||||
private plannedActions: Action[] = [];
|
private plannedActions: Action[] = [];
|
||||||
@@ -42,6 +47,7 @@ export class PlanningScreen {
|
|||||||
this.timerSeconds = data.timer;
|
this.timerSeconds = data.timer;
|
||||||
|
|
||||||
this.setPlayerColor(data.playerView.colorIndex);
|
this.setPlayerColor(data.playerView.colorIndex);
|
||||||
|
this.setPlayerName(data.playerView.name);
|
||||||
|
|
||||||
document.getElementById('round-label')!.textContent = `Round ${data.round}`;
|
document.getElementById('round-label')!.textContent = `Round ${data.round}`;
|
||||||
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
|
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
|
||||||
@@ -63,15 +69,29 @@ export class PlanningScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setPlayerColor(colorIndex: number): void {
|
private setPlayerColor(colorIndex: number): void {
|
||||||
const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44'];
|
|
||||||
const dot = document.getElementById('color-dot');
|
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 {
|
private updateTimer(): void {
|
||||||
const el = document.getElementById('timer-label')!;
|
const el = document.getElementById('timer-label')!;
|
||||||
el.textContent = `${this.timerSeconds}s`;
|
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 {
|
private initActionButtons(): void {
|
||||||
@@ -79,6 +99,7 @@ export class PlanningScreen {
|
|||||||
grid.querySelectorAll('.act-btn').forEach((btn) => {
|
grid.querySelectorAll('.act-btn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const actionType = (btn as HTMLElement).dataset.action!;
|
const actionType = (btn as HTMLElement).dataset.action!;
|
||||||
|
this.app.audio.feedback(actionType === 'BOOST' ? 'cardUse' : 'tap');
|
||||||
this.addAction({ type: actionType as Action['type'] });
|
this.addAction({ type: actionType as Action['type'] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -86,6 +107,7 @@ export class PlanningScreen {
|
|||||||
|
|
||||||
private initBottomButtons(): void {
|
private initBottomButtons(): void {
|
||||||
document.getElementById('clear-plan-btn')!.addEventListener('click', () => {
|
document.getElementById('clear-plan-btn')!.addEventListener('click', () => {
|
||||||
|
this.app.audio.feedback('queueRemove');
|
||||||
this.plannedActions = [];
|
this.plannedActions = [];
|
||||||
this.selectedCard = null;
|
this.selectedCard = null;
|
||||||
this.renderQueue();
|
this.renderQueue();
|
||||||
@@ -95,14 +117,16 @@ export class PlanningScreen {
|
|||||||
|
|
||||||
document.getElementById('submit-plan-btn')!.addEventListener('click', () => {
|
document.getElementById('submit-plan-btn')!.addEventListener('click', () => {
|
||||||
if (this.plannedActions.length === 0) return;
|
if (this.plannedActions.length === 0) return;
|
||||||
|
this.app.audio.feedback('go');
|
||||||
const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
|
const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = '...';
|
btn.textContent = '…';
|
||||||
|
|
||||||
this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => {
|
this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'GO!';
|
btn.textContent = 'GO!';
|
||||||
|
this.app.audio.feedback('warning');
|
||||||
alert(res.error || 'Invalid plan');
|
alert(res.error || 'Invalid plan');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -112,12 +136,15 @@ export class PlanningScreen {
|
|||||||
private addAction(action: Action): void {
|
private addAction(action: Action): void {
|
||||||
if (!this.playerView || !this.playerView.alive) return;
|
if (!this.playerView || !this.playerView.alive) return;
|
||||||
|
|
||||||
// If we have a selected card, add it as a CARD action first
|
|
||||||
if (this.selectedCard) {
|
if (this.selectedCard) {
|
||||||
const cardCost = CARD_DEFS[this.selectedCard].apCost;
|
const cardCost = CARD_DEFS[this.selectedCard].apCost;
|
||||||
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
|
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.plannedActions.push({ type: 'CARD', card: this.selectedCard });
|
||||||
this.selectedCard = null;
|
this.selectedCard = null;
|
||||||
this.renderCards(this.playerView.hand);
|
this.renderCards(this.playerView.hand);
|
||||||
@@ -128,7 +155,10 @@ export class PlanningScreen {
|
|||||||
|
|
||||||
const cost = actionApCost(action);
|
const cost = actionApCost(action);
|
||||||
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
|
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.plannedActions.push(action);
|
||||||
this.renderQueue();
|
this.renderQueue();
|
||||||
@@ -142,30 +172,37 @@ export class PlanningScreen {
|
|||||||
const list = document.getElementById('queue-list')!;
|
const list = document.getElementById('queue-list')!;
|
||||||
list.innerHTML = '';
|
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++) {
|
for (let i = 0; i < this.plannedActions.length; i++) {
|
||||||
const action = this.plannedActions[i];
|
const action = this.plannedActions[i];
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'queue-item';
|
div.className = 'queue-item';
|
||||||
|
|
||||||
let icon = '';
|
const icon = action.type === 'CARD'
|
||||||
switch (action.type) {
|
? (action.card ? CARD_ICONS[action.card] : '?')
|
||||||
case 'MOVE_FORWARD': icon = '↑'; break;
|
: ACTION_ICONS[action.type] || '?';
|
||||||
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;
|
|
||||||
}
|
|
||||||
div.textContent = icon;
|
div.textContent = icon;
|
||||||
div.title = action.type + (action.card ? ` ${action.card}` : '');
|
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');
|
const remove = document.createElement('span');
|
||||||
remove.className = 'remove-hint';
|
remove.className = 'remove-hint';
|
||||||
remove.textContent = '×';
|
remove.textContent = '×';
|
||||||
div.appendChild(remove);
|
div.appendChild(remove);
|
||||||
|
|
||||||
div.addEventListener('click', () => {
|
div.addEventListener('click', () => {
|
||||||
|
this.app.audio.feedback('queueRemove');
|
||||||
this.plannedActions.splice(i, 1);
|
this.plannedActions.splice(i, 1);
|
||||||
this.renderQueue();
|
this.renderQueue();
|
||||||
this.updateApDisplay();
|
this.updateApDisplay();
|
||||||
@@ -180,7 +217,6 @@ export class PlanningScreen {
|
|||||||
const list = document.getElementById('card-list')!;
|
const list = document.getElementById('card-list')!;
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
|
|
||||||
// If a card was already used in the plan, dim it
|
|
||||||
const usedCards = new Set<CardType>();
|
const usedCards = new Set<CardType>();
|
||||||
for (const a of this.plannedActions) {
|
for (const a of this.plannedActions) {
|
||||||
if (a.type === 'CARD' && a.card) usedCards.add(a.card);
|
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));
|
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) {
|
for (const card of availableCards) {
|
||||||
const def = CARD_DEFS[card];
|
const def = CARD_DEFS[card];
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'card-item';
|
div.className = 'card-item';
|
||||||
if (this.selectedCard === card) div.classList.add('selected');
|
if (this.selectedCard === card) div.classList.add('selected');
|
||||||
|
|
||||||
div.innerHTML = `
|
const icon = document.createElement('div');
|
||||||
<div class="card-icon">${CARD_ICONS[card]}</div>
|
icon.className = 'card-icon';
|
||||||
<div class="card-name">${def.name}</div>
|
icon.textContent = CARD_ICONS[card];
|
||||||
<div class="card-cost">${def.apCost} AP</div>
|
|
||||||
`;
|
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', () => {
|
div.addEventListener('click', () => {
|
||||||
const wasSelected = this.selectedCard === card;
|
const wasSelected = this.selectedCard === card;
|
||||||
this.selectedCard = wasSelected ? null : card;
|
this.selectedCard = wasSelected ? null : card;
|
||||||
|
this.app.audio.feedback(wasSelected ? 'queueRemove' : 'cardSelect');
|
||||||
this.renderCards(hand);
|
this.renderCards(hand);
|
||||||
});
|
});
|
||||||
|
|
||||||
list.appendChild(div);
|
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 {
|
private updateApDisplay(): void {
|
||||||
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
|
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
|
||||||
const remaining = this.apTotal - used;
|
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')!;
|
const fill = document.getElementById('ap-fill')!;
|
||||||
fill.style.width = `${(remaining / this.apTotal) * 100}%`;
|
fill.style.width = `${(remaining / this.apTotal) * 100}%`;
|
||||||
|
fill.classList.remove('warning', 'danger');
|
||||||
|
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
fill.style.background = '#ff4444';
|
apLabel.style.color = '';
|
||||||
document.getElementById('ap-label')!.style.color = '#ff4444';
|
fill.classList.add('danger');
|
||||||
} else if (remaining === this.apTotal) {
|
} else if (remaining === this.apTotal) {
|
||||||
fill.style.background = '#00ff88';
|
apLabel.style.color = '';
|
||||||
document.getElementById('ap-label')!.style.color = '#00ff88';
|
fill.classList.remove('warning');
|
||||||
} else {
|
} else {
|
||||||
fill.style.background = '#ffcc00';
|
apLabel.style.color = '';
|
||||||
document.getElementById('ap-label')!.style.color = '#ffcc00';
|
fill.classList.add('warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,12 +3,23 @@ import { App } from '../main.js';
|
|||||||
export class SpectatorScreen {
|
export class SpectatorScreen {
|
||||||
constructor(app: App) {
|
constructor(app: App) {
|
||||||
app.socket.on('gameOver', (data) => {
|
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) {
|
if (data.winnerId === app.socket.data?.playerId) {
|
||||||
h2.textContent = '🏆 You Win!';
|
h2.textContent = '🏆 You Win!';
|
||||||
|
h2.classList.remove('dead');
|
||||||
} else {
|
} else {
|
||||||
h2.textContent = `💀 ${data.winnerName} Wins!`;
|
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';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/controller/',
|
||||||
server: {
|
server: {
|
||||||
port: 3001,
|
port: 3001,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Generated
+1
@@ -2911,6 +2911,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@spacerace/shared": "*",
|
"@spacerace/shared": "*",
|
||||||
"phaser": "^3.80.1",
|
"phaser": "^3.80.1",
|
||||||
|
"qrcode-generator": "^1.5.2",
|
||||||
"socket.io-client": "^4.7.5"
|
"socket.io-client": "^4.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+15
-5
@@ -1,10 +1,20 @@
|
|||||||
FROM node:22-alpine AS server-build
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY shared/ ./shared/
|
COPY shared/ ./shared/
|
||||||
COPY server/ ./server/
|
COPY server/ ./server/
|
||||||
RUN npm ci --workspace=server --workspace=shared
|
COPY tv/ ./tv/
|
||||||
RUN npm -w shared run build 2>/dev/null || true
|
COPY controller/ ./controller/
|
||||||
WORKDIR /app/server
|
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
|
EXPOSE 8080
|
||||||
CMD ["npx", "tsx", "src/index.ts"]
|
CMD ["npx", "tsx", "server/src/index.ts"]
|
||||||
|
|||||||
+104
-53
@@ -4,37 +4,53 @@ import { Ship } from './Ship.js';
|
|||||||
|
|
||||||
export interface CardContext {
|
export interface CardContext {
|
||||||
ship: Ship;
|
ship: Ship;
|
||||||
target?: Position;
|
|
||||||
grid: Grid;
|
grid: Grid;
|
||||||
allShips: Ship[];
|
allShips: Ship[];
|
||||||
shipStates: Map<string, Ship>;
|
shipStates: Map<string, Ship>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function executeCard(cardType: CardType, ctx: CardContext): { success: boolean; message: string; gridUpdates: { position: Position; tile: string }[] } {
|
export type CardEvent =
|
||||||
const def = CARD_DEFS[cardType];
|
| { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string }
|
||||||
if (!def) return { success: false, message: 'Unknown card', gridUpdates: [] };
|
| { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' };
|
||||||
|
|
||||||
switch (cardType) {
|
export interface CardResult {
|
||||||
case 'METEOR_STRIKE': {
|
success: boolean;
|
||||||
if (!ctx.target) return { success: false, message: 'No target for Meteor Strike', gridUpdates: [] };
|
message: string;
|
||||||
// Check range: within 3 tiles of ship
|
gridUpdates: { position: Position; tile: string }[];
|
||||||
const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y);
|
events: CardEvent[];
|
||||||
if (dist > 3) return { success: false, message: 'Target out of range', gridUpdates: [] };
|
|
||||||
ctx.grid.setTile(ctx.target, 'meteor');
|
|
||||||
return { success: true, message: `${ctx.ship.playerId} summoned a meteor at (${ctx.target.x},${ctx.target.y})`, gridUpdates: [{ position: ctx.target, tile: 'meteor' }] };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'SHIELD': {
|
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;
|
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;
|
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 dir = ctx.ship.direction;
|
||||||
const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
|
const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0;
|
||||||
const dy = dir === 'E' ? 1 : dir === 'W' ? -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,
|
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;
|
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');
|
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': {
|
const fromPos = { ...ctx.ship.position };
|
||||||
if (!ctx.target) return { success: false, message: 'No target for Teleport', gridUpdates: [] };
|
ctx.ship.position = boostPos;
|
||||||
const targetX = ctx.target!.x;
|
return {
|
||||||
const targetY = ctx.target!.y;
|
success: true,
|
||||||
const targetShip = ctx.allShips.find(
|
message: `${ctx.ship.playerId} boosted forward`,
|
||||||
(s) => s.alive && s.id !== ctx.ship.id &&
|
gridUpdates: [],
|
||||||
s.position.x === targetX && s.position.y === targetY
|
events: [
|
||||||
);
|
cardEvent('BOOST', ctx, { ...boostPos }, `${fromPos.x},${fromPos.y}`),
|
||||||
if (!targetShip) return { success: false, message: 'No ship at target location', gridUpdates: [] };
|
moveEvent(ctx, fromPos, boostPos, 'boost'),
|
||||||
const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y);
|
],
|
||||||
if (dist > 5) return { success: false, message: 'Target out of range for Teleport', gridUpdates: [] };
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Check EMP on target ship
|
const PHASE_SHIFT: CardHandler = (ctx) => {
|
||||||
const targetState = ctx.shipStates.get(targetShip.id);
|
|
||||||
if (targetState?.empActive) {
|
|
||||||
targetState.empActive = false;
|
|
||||||
return { success: false, message: `Teleport blocked by ${targetShip.playerId}'s EMP`, gridUpdates: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const myPos = { ...ctx.ship.position };
|
|
||||||
ctx.ship.position = { ...targetShip.position };
|
|
||||||
targetShip.position = myPos;
|
|
||||||
return { success: true, message: `${ctx.ship.playerId} teleported with ${targetShip.playerId}`, gridUpdates: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'PHASE_SHIFT': {
|
|
||||||
ctx.ship.phaseShifting = true;
|
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:
|
const HANDLERS: Record<CardType, CardHandler> = {
|
||||||
return { success: false, message: 'Unknown card', gridUpdates: [] };
|
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);
|
const ship = this.ships.get(shipId);
|
||||||
if (!ship || !ship.alive) continue;
|
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 = {
|
const ctx = {
|
||||||
ship,
|
ship,
|
||||||
target: action.target,
|
|
||||||
grid: this.grid,
|
grid: this.grid,
|
||||||
allShips: Array.from(this.ships.values()),
|
allShips: Array.from(this.ships.values()),
|
||||||
shipStates: this.ships,
|
shipStates: this.ships,
|
||||||
@@ -90,6 +92,9 @@ export class Executor {
|
|||||||
for (const gu of cardResult.gridUpdates) {
|
for (const gu of cardResult.gridUpdates) {
|
||||||
result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any });
|
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 {
|
} else {
|
||||||
result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`);
|
result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`);
|
||||||
}
|
}
|
||||||
@@ -118,15 +123,6 @@ export class Executor {
|
|||||||
intents.push({ shipId, from: { ...ship.position }, to });
|
intents.push({ shipId, from: { ...ship.position }, to });
|
||||||
break;
|
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': {
|
case 'TURN_LEFT': {
|
||||||
const dirs: Direction[] = ['N', 'W', 'S', 'E'];
|
const dirs: Direction[] = ['N', 'W', 'S', 'E'];
|
||||||
const idx = dirs.indexOf(ship.direction);
|
const idx = dirs.indexOf(ship.direction);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { WsServer } from './ws/WsServer.js';
|
import { WsServer } from './ws/WsServer.js';
|
||||||
import { GAME_CONFIG } from './config.js';
|
import { GAME_CONFIG } from './config.js';
|
||||||
@@ -8,6 +9,13 @@ const app = express();
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
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 httpServer = createServer(app);
|
||||||
|
|
||||||
const wsServer = new WsServer(httpServer);
|
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 './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 ──
|
// ── Cards ──
|
||||||
export type CardType =
|
export type CardType =
|
||||||
| 'METEOR_STRIKE'
|
|
||||||
| 'SHIELD'
|
| 'SHIELD'
|
||||||
| 'BOOST'
|
| 'BOOST'
|
||||||
| 'EMP'
|
| 'EMP'
|
||||||
| 'JUMP'
|
| 'JUMP'
|
||||||
| 'MINE'
|
| 'MINE'
|
||||||
| 'TELEPORT'
|
|
||||||
| 'PHASE_SHIFT';
|
| 'PHASE_SHIFT';
|
||||||
|
|
||||||
export interface CardDef {
|
export interface CardDef {
|
||||||
@@ -75,12 +73,6 @@ export interface CardDef {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const CARD_DEFS: Record<CardType, 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: {
|
SHIELD: {
|
||||||
type: 'SHIELD',
|
type: 'SHIELD',
|
||||||
name: '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.',
|
description: 'Drop a mine on your current tile. Explodes next round.',
|
||||||
apCost: 1,
|
apCost: 1,
|
||||||
},
|
},
|
||||||
TELEPORT: {
|
|
||||||
type: 'TELEPORT',
|
|
||||||
name: 'Teleport',
|
|
||||||
description: 'Swap positions with any player within 5 tiles',
|
|
||||||
apCost: 3,
|
|
||||||
},
|
|
||||||
PHASE_SHIFT: {
|
PHASE_SHIFT: {
|
||||||
type: 'PHASE_SHIFT',
|
type: 'PHASE_SHIFT',
|
||||||
name: 'Phase Shift',
|
name: 'Phase Shift',
|
||||||
@@ -126,12 +112,11 @@ export const CARD_DEFS: Record<CardType, CardDef> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Actions ──
|
// ── 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 {
|
export interface Action {
|
||||||
type: ActionType;
|
type: ActionType;
|
||||||
card?: CardType; // when type === 'CARD'
|
card?: CardType; // when type === 'CARD'
|
||||||
target?: Position; // for targeted cards (METEOR_STRIKE, TELEPORT)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function actionApCost(action: Action): number {
|
export function actionApCost(action: Action): number {
|
||||||
@@ -140,7 +125,6 @@ export function actionApCost(action: Action): number {
|
|||||||
case 'TURN_LEFT': return 1;
|
case 'TURN_LEFT': return 1;
|
||||||
case 'TURN_RIGHT': return 1;
|
case 'TURN_RIGHT': return 1;
|
||||||
case 'TURN_180': return 2;
|
case 'TURN_180': return 2;
|
||||||
case 'BOOST': return 2;
|
|
||||||
case 'CARD':
|
case 'CARD':
|
||||||
return action.card ? CARD_DEFS[action.card].apCost : 0;
|
return action.card ? CARD_DEFS[action.card].apCost : 0;
|
||||||
default: return 0;
|
default: return 0;
|
||||||
@@ -227,11 +211,12 @@ export interface MobileToServerEvents {
|
|||||||
|
|
||||||
// Execution updates
|
// Execution updates
|
||||||
export type ShipUpdate =
|
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: 'turn'; shipId: string; direction: Direction }
|
||||||
| { type: 'eliminated'; shipId: string; position: Position; reason: string }
|
| { type: 'eliminated'; shipId: string; position: Position; reason: string }
|
||||||
| { type: 'collision'; shipId: string; position: Position }
|
| { 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 =
|
export type GridUpdate =
|
||||||
| { type: 'tile_change'; position: Position; tile: TileType }
|
| { type: 'tile_change'; position: Position; tile: TileType }
|
||||||
|
|||||||
+29
-3
@@ -4,14 +4,40 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=1280, initial-scale=1.0" />
|
<meta name="viewport" content="width=1280, initial-scale=1.0" />
|
||||||
<title>SpaceRace - TV</title>
|
<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>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #050514; }
|
||||||
#game-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
#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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@spacerace/shared": "*",
|
"@spacerace/shared": "*",
|
||||||
"phaser": "^3.80.1",
|
"phaser": "^3.80.1",
|
||||||
|
"qrcode-generator": "^1.5.2",
|
||||||
"socket.io-client": "^4.7.5"
|
"socket.io-client": "^4.7.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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,
|
TileType,
|
||||||
Position,
|
Position,
|
||||||
GRID_WIDTH,
|
GRID_WIDTH,
|
||||||
|
COLORS,
|
||||||
} from '@spacerace/shared';
|
} from '@spacerace/shared';
|
||||||
|
|
||||||
const TILE_SIZE = 64;
|
const TILE_SIZE = 64;
|
||||||
@@ -18,7 +19,9 @@ export class GridRenderer {
|
|||||||
private fieldContainer: Phaser.GameObjects.Container;
|
private fieldContainer: Phaser.GameObjects.Container;
|
||||||
private shipLayer: Phaser.GameObjects.Container;
|
private shipLayer: Phaser.GameObjects.Container;
|
||||||
private deathLineRect: Phaser.GameObjects.Rectangle | null = null;
|
private deathLineRect: Phaser.GameObjects.Rectangle | null = null;
|
||||||
|
private deathLineGlow: Phaser.GameObjects.Rectangle | null = null;
|
||||||
private deathLineY: number = 0;
|
private deathLineY: number = 0;
|
||||||
|
private gridOverlay: Phaser.GameObjects.Graphics | null = null;
|
||||||
|
|
||||||
constructor(scene: Phaser.Scene, offsetY: number, tileSize: number) {
|
constructor(scene: Phaser.Scene, offsetY: number, tileSize: number) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
@@ -70,6 +73,9 @@ export class GridRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw grid lines overlay once
|
||||||
|
this.renderGridOverlay();
|
||||||
|
|
||||||
if (scrollInPx > 0) {
|
if (scrollInPx > 0) {
|
||||||
this.fieldContainer.x = -scrollInPx;
|
this.fieldContainer.x = -scrollInPx;
|
||||||
this.shipLayer.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 {
|
private renderCell(col: number, lane: number, tile: TileType, screenX: number, screenY: number): void {
|
||||||
const bgAlpha = tile === 'space' ? 0.12 : 0.35;
|
if (tile === 'space') {
|
||||||
const bgColor =
|
// Subtle dark base
|
||||||
tile === 'asteroid' ? 0x555555 :
|
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, 0x0a0a2e, 0.5);
|
||||||
tile === 'meteor' ? 0x884400 :
|
bg.setStrokeStyle(1, 0x1a1a4e, 0.5);
|
||||||
tile === 'mine' ? 0x885500 :
|
|
||||||
0x1a1a4e;
|
|
||||||
|
|
||||||
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, bgColor, bgAlpha);
|
|
||||||
bg.setStrokeStyle(1, 0x223366, 0.2);
|
|
||||||
this.fieldContainer.add(bg);
|
this.fieldContainer.add(bg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tile !== 'space') {
|
|
||||||
const textureName =
|
const textureName =
|
||||||
tile === 'asteroid' ? 'asteroid' :
|
tile === 'asteroid' ? 'asteroid' :
|
||||||
tile === 'meteor' ? 'meteor' :
|
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);
|
const sprite = this.scene.add.sprite(screenX, screenY, textureName);
|
||||||
sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12);
|
sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12);
|
||||||
|
const angle = ((col * 13 + lane * 37) % 60) - 30; // ±30°
|
||||||
|
sprite.setAngle(angle);
|
||||||
this.fieldContainer.add(sprite);
|
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 {
|
updateTile(pos: Position, tile: TileType): void {
|
||||||
const screenX = this.gridXToScreen(pos.y);
|
const screenX = this.gridXToScreen(pos.y);
|
||||||
const screenY = this.gridYToScreen(pos.x);
|
const screenY = this.gridYToScreen(pos.x);
|
||||||
@@ -120,20 +185,25 @@ export class GridRenderer {
|
|||||||
|
|
||||||
private renderDeathLine(): void {
|
private renderDeathLine(): void {
|
||||||
if (this.deathLineRect) this.deathLineRect.destroy();
|
if (this.deathLineRect) this.deathLineRect.destroy();
|
||||||
|
if (this.deathLineGlow) this.deathLineGlow.destroy();
|
||||||
|
|
||||||
this.deathLineRect = this.scene.add.rectangle(
|
const x = DEATH_LINE_SCREEN_X;
|
||||||
DEATH_LINE_SCREEN_X,
|
const yCenter = this.offsetY + (GRID_HEIGHT * this.tileSize) / 2;
|
||||||
this.offsetY + (GRID_HEIGHT * this.tileSize) / 2,
|
const h = GRID_HEIGHT * this.tileSize;
|
||||||
6,
|
|
||||||
GRID_HEIGHT * this.tileSize,
|
// Outer glow
|
||||||
0xff0000,
|
this.deathLineGlow = this.scene.add.rectangle(x, yCenter, 16, h, 0xff3b6b, 0.25);
|
||||||
0.9
|
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);
|
this.deathLineRect.setDepth(20);
|
||||||
|
|
||||||
|
// Pulse animation
|
||||||
this.scene.tweens.add({
|
this.scene.tweens.add({
|
||||||
targets: this.deathLineRect,
|
targets: this.deathLineGlow,
|
||||||
alpha: 0.3,
|
alpha: { from: 0.2, to: 0.55 },
|
||||||
duration: 600,
|
duration: 600,
|
||||||
yoyo: true,
|
yoyo: true,
|
||||||
repeat: -1,
|
repeat: -1,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
import { Ship as ShipType } from '@spacerace/shared';
|
import { Ship as ShipType } from '@spacerace/shared';
|
||||||
|
import { FONTS } from '@spacerace/shared';
|
||||||
|
|
||||||
export class ShipSprite extends Phaser.GameObjects.Container {
|
export class ShipSprite extends Phaser.GameObjects.Container {
|
||||||
private shipData: ShipType;
|
private shipData: ShipType;
|
||||||
private label: Phaser.GameObjects.Text;
|
private label: Phaser.GameObjects.Text;
|
||||||
|
private glow: Phaser.GameObjects.Arc;
|
||||||
|
private body: Phaser.GameObjects.Sprite;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
scene: Phaser.Scene,
|
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 angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
|
||||||
const baseAngle = angles[ship.direction] || 0;
|
const baseAngle = angles[ship.direction] || 0;
|
||||||
|
|
||||||
// Ship body
|
// Glow halo behind the ship (uses player's color)
|
||||||
const body = scene.add.rectangle(0, 0, 40, 36, color);
|
this.glow = scene.add.circle(0, 0, 28, color, 0.35);
|
||||||
body.setStrokeStyle(2, 0xffffff);
|
this.glow.setBlendMode(Phaser.BlendModes.ADD);
|
||||||
|
this.glow.setDepth(-1);
|
||||||
|
|
||||||
// Direction indicator (small triangle)
|
// Ship body — uses the procedurally generated 'ship' texture
|
||||||
const indicator = scene.add.triangle(0, -22, 0, 8, 5, 0, 10, 8, 0xffffff);
|
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);
|
this.setAngle(baseAngle);
|
||||||
|
|
||||||
// Player name label
|
// Gentle pulse on the halo
|
||||||
this.label = scene.add.text(0, 28, ship.playerName || ship.playerId.substring(0, 6), {
|
scene.tweens.add({
|
||||||
fontSize: '11px',
|
targets: this.glow,
|
||||||
color: '#ffffff',
|
scaleX: { from: 0.85, to: 1.15 },
|
||||||
fontFamily: 'monospace',
|
scaleY: { from: 0.85, to: 1.15 },
|
||||||
backgroundColor: '#00000088',
|
alpha: { from: 0.25, to: 0.5 },
|
||||||
padding: { x: 2, y: 1 },
|
duration: 1400,
|
||||||
}).setOrigin(0.5);
|
yoyo: true,
|
||||||
this.add(this.label);
|
repeat: -1,
|
||||||
|
});
|
||||||
|
|
||||||
scene.add.existing(this);
|
scene.add.existing(this);
|
||||||
this.setDepth(10);
|
this.setDepth(10);
|
||||||
|
|||||||
+122
-29
@@ -1,68 +1,161 @@
|
|||||||
import Phaser from 'phaser';
|
import Phaser from 'phaser';
|
||||||
|
import { COLORS, AudioEngine } from '@spacerace/shared';
|
||||||
import { connectTvSocket, TvSocket } from '../network/TvSocket.js';
|
import { connectTvSocket, TvSocket } from '../network/TvSocket.js';
|
||||||
|
import { createStarfield } from './Starfield.js';
|
||||||
|
|
||||||
export class BootScene extends Phaser.Scene {
|
export class BootScene extends Phaser.Scene {
|
||||||
socket!: TvSocket;
|
socket!: TvSocket;
|
||||||
|
audio!: AudioEngine;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: 'BootScene' });
|
super({ key: 'BootScene' });
|
||||||
}
|
}
|
||||||
|
|
||||||
preload(): void {
|
preload(): void {
|
||||||
// Generate placeholder assets as textures
|
|
||||||
this.createPlaceholderTextures();
|
this.createPlaceholderTextures();
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.socket = connectTvSocket();
|
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 {
|
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 });
|
const shipGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
shipGfx.fillStyle(0x00ccff);
|
// Outer hull (white outline) — drawn first
|
||||||
shipGfx.fillTriangle(20, 0, 0, 36, 40, 36);
|
shipGfx.fillStyle(0xffffff, 1);
|
||||||
shipGfx.generateTexture('ship', 40, 36);
|
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();
|
shipGfx.destroy();
|
||||||
|
|
||||||
// Asteroid placeholder (rough circle)
|
// ── Asteroid — irregular rocky shape ──
|
||||||
const asteroidGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
const asteroidGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
asteroidGfx.fillStyle(0x888888);
|
asteroidGfx.fillStyle(0xffffff, 1);
|
||||||
asteroidGfx.fillCircle(20, 20, 18);
|
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.generateTexture('asteroid', 40, 40);
|
||||||
asteroidGfx.destroy();
|
asteroidGfx.destroy();
|
||||||
|
|
||||||
// Meteor placeholder (red circle)
|
// ── Meteor — burning rock with flame tail ──
|
||||||
const meteorGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
const meteorGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
meteorGfx.fillStyle(0xff4400);
|
// Flame tail (fading yellow → red)
|
||||||
meteorGfx.fillCircle(20, 20, 18);
|
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.generateTexture('meteor', 40, 40);
|
||||||
meteorGfx.destroy();
|
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 });
|
const mineGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
||||||
mineGfx.fillStyle(0xff8800);
|
// Spikes (8-pointed star)
|
||||||
mineGfx.fillCircle(20, 20, 16);
|
const cx = 20, cy = 20;
|
||||||
mineGfx.lineStyle(3, 0x000000);
|
const spikes = 8;
|
||||||
mineGfx.lineBetween(10, 10, 30, 30);
|
const outer = 18, inner = 7;
|
||||||
mineGfx.lineBetween(30, 10, 10, 30);
|
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.generateTexture('mine', 40, 40);
|
||||||
mineGfx.destroy();
|
mineGfx.destroy();
|
||||||
|
|
||||||
// Background tile
|
// ── Death line texture — animated stripe ──
|
||||||
const bgGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
|
||||||
bgGfx.fillStyle(0x0a0a2e);
|
|
||||||
bgGfx.fillRect(0, 0, 64, 64);
|
|
||||||
bgGfx.lineStyle(1, 0x1a1a4e);
|
|
||||||
bgGfx.strokeRect(0, 0, 64, 64);
|
|
||||||
bgGfx.generateTexture('bg_tile', 64, 64);
|
|
||||||
bgGfx.destroy();
|
|
||||||
|
|
||||||
// Death line texture
|
|
||||||
const dlGfx = this.make.graphics({ x: 0, y: 0, add: false });
|
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);
|
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.generateTexture('death_line', 512, 4);
|
||||||
dlGfx.destroy();
|
dlGfx.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
+249
-41
@@ -7,61 +7,112 @@ import {
|
|||||||
GRID_WIDTH,
|
GRID_WIDTH,
|
||||||
ShipUpdate,
|
ShipUpdate,
|
||||||
GridUpdate,
|
GridUpdate,
|
||||||
|
COLORS,
|
||||||
|
FONTS,
|
||||||
|
AudioEngine,
|
||||||
|
CardType,
|
||||||
} from '@spacerace/shared';
|
} from '@spacerace/shared';
|
||||||
import { GridRenderer } from '../objects/GridRenderer.js';
|
import { GridRenderer } from '../objects/GridRenderer.js';
|
||||||
import { ShipSprite } from '../objects/ShipSprite.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 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 ANIM_DURATION = 900;
|
||||||
const DEATH_LINE_ANIM = 1800;
|
const DEATH_LINE_ANIM = 1800;
|
||||||
|
|
||||||
export class GameScene extends Phaser.Scene {
|
export class GameScene extends Phaser.Scene {
|
||||||
private socket!: TvSocket;
|
private socket!: TvSocket;
|
||||||
|
private audio!: AudioEngine;
|
||||||
private gridRenderer!: GridRenderer;
|
private gridRenderer!: GridRenderer;
|
||||||
|
private effects!: EffectRenderer;
|
||||||
private shipSprites: Map<string, ShipSprite> = new Map();
|
private shipSprites: Map<string, ShipSprite> = new Map();
|
||||||
private gridState!: GameGridState;
|
private gridState!: GameGridState;
|
||||||
private roundText!: Phaser.GameObjects.Text;
|
private roundText!: Phaser.GameObjects.Text;
|
||||||
private statusText!: Phaser.GameObjects.Text;
|
private phaseText!: Phaser.GameObjects.Text;
|
||||||
private messageText!: Phaser.GameObjects.Text;
|
private phaseTextGlow!: Phaser.GameObjects.Text;
|
||||||
|
private messageLog: string[] = [];
|
||||||
|
private messageTexts: Phaser.GameObjects.Text[] = [];
|
||||||
private initialGrid: GameGridState | null = null;
|
private initialGrid: GameGridState | null = null;
|
||||||
private initialShips: ShipType[] | null = null;
|
private initialShips: ShipType[] | null = null;
|
||||||
private execQueue: { updates: ShipUpdate[]; gridUpdates: GridUpdate[]; msgs: string[] }[] = [];
|
private execQueue: { updates: ShipUpdate[]; gridUpdates: GridUpdate[]; msgs: string[] }[] = [];
|
||||||
private animating = false;
|
private animating = false;
|
||||||
private pendingDeathLineY: number | null = null;
|
private pendingDeathLineY: number | null = null;
|
||||||
private pendingPlanning: { round: number; grid: GameGridState; ships: ShipType[] } | null = null;
|
private pendingPlanning: { round: number; grid: GameGridState; ships: ShipType[] } | null = null;
|
||||||
|
private playerColors = new Map<string, number>();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: 'GameScene' });
|
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.socket = data.socket;
|
||||||
|
this.audio = data.audio;
|
||||||
this.initialGrid = data.grid ?? null;
|
this.initialGrid = data.grid ?? null;
|
||||||
this.initialShips = data.ships ?? null;
|
this.initialShips = data.ships ?? null;
|
||||||
|
|
||||||
|
(data.players || []).forEach((p, i) => {
|
||||||
|
this.playerColors.set(p.id, COLORS.player[i % COLORS.player.length]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
this.cameras.main.setBackgroundColor('#050515');
|
this.cameras.main.setBackgroundColor('#050514');
|
||||||
this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE);
|
createStarfield(this);
|
||||||
|
|
||||||
this.roundText = this.add.text(16, 16, 'Round 0', {
|
this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE);
|
||||||
fontSize: '24px', color: '#ffffff', fontFamily: 'monospace',
|
this.effects = new EffectRenderer(
|
||||||
});
|
this,
|
||||||
this.statusText = this.add.text(1280 / 2, 16, 'PLANNING PHASE', {
|
this.gridRenderer.field,
|
||||||
fontSize: '24px', color: '#ffcc00', fontFamily: 'monospace', fontStyle: 'bold',
|
(gy) => this.gridRenderer.gridXToScreen(gy),
|
||||||
}).setOrigin(0.5, 0);
|
(gx) => this.gridRenderer.gridYToScreen(gx),
|
||||||
this.messageText = this.add.text(16, 700, '', {
|
);
|
||||||
fontSize: '17px', color: '#cccccc', fontFamily: 'monospace', wordWrap: { width: 1248 },
|
|
||||||
|
// ── 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) {
|
if (this.initialGrid && this.initialShips) {
|
||||||
this.applyPlanning(1, this.initialGrid, this.initialShips);
|
this.applyPlanning(1, this.initialGrid, this.initialShips);
|
||||||
|
this.renderPlayerLegend(this.initialShips);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.on('planningStarted', (data) => {
|
this.socket.on('planningStarted', (data) => {
|
||||||
if (data.round === 1 && this.initialGrid) {
|
if (data.round === 1 && this.initialGrid) {
|
||||||
this.roundText.setText(`Round ${data.round}`);
|
this.roundText.setText(`${data.round}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.onPlanningStarted(data.round, data.grid, data.ships);
|
this.onPlanningStarted(data.round, data.grid, data.ships);
|
||||||
@@ -74,6 +125,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.socket.on('gameOver', (data) => {
|
this.socket.on('gameOver', (data) => {
|
||||||
this.scene.start('ResultScene', {
|
this.scene.start('ResultScene', {
|
||||||
socket: this.socket,
|
socket: this.socket,
|
||||||
|
audio: this.audio,
|
||||||
winnerId: data.winnerId,
|
winnerId: data.winnerId,
|
||||||
winnerName: data.winnerName,
|
winnerName: data.winnerName,
|
||||||
ships: data.ships,
|
ships: data.ships,
|
||||||
@@ -91,9 +143,9 @@ export class GameScene extends Phaser.Scene {
|
|||||||
|
|
||||||
private applyPlanning(round: number, grid: GameGridState, ships: ShipType[]): void {
|
private applyPlanning(round: number, grid: GameGridState, ships: ShipType[]): void {
|
||||||
this.gridState = grid;
|
this.gridState = grid;
|
||||||
this.roundText.setText(`Round ${round}`);
|
this.roundText.setText(`${round}`);
|
||||||
this.statusText.setText('PLANNING PHASE').setColor('#ffcc00');
|
this.setPhase('PLANNING PHASE', '#ffcc00', '#ffcc00');
|
||||||
this.messageText.setText('Players are planning their moves...');
|
this.audio.feedback('round', { haptic: false });
|
||||||
|
|
||||||
const oldDeathLineY = this.gridRenderer.viewOffset;
|
const oldDeathLineY = this.gridRenderer.viewOffset;
|
||||||
const newDeathLineY = grid.deathLineY;
|
const newDeathLineY = grid.deathLineY;
|
||||||
@@ -101,12 +153,15 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.gridRenderer.renderGrid(grid, ships, scrollInPx);
|
this.gridRenderer.renderGrid(grid, ships, scrollInPx);
|
||||||
|
|
||||||
this.updateShipSprites(ships);
|
this.updateShipSprites(ships);
|
||||||
|
this.renderPlayerLegend(ships);
|
||||||
|
this.pushMessage('— Planning round —', '#a0a0c8');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Execution animation: queue-based, sequential ──
|
// ── Execution animation ──
|
||||||
|
|
||||||
private onExecutionTick(data: ExecutionResult): void {
|
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({
|
this.execQueue.push({
|
||||||
updates: data.shipUpdates,
|
updates: data.shipUpdates,
|
||||||
gridUpdates: data.gridUpdates,
|
gridUpdates: data.gridUpdates,
|
||||||
@@ -121,6 +176,7 @@ export class GameScene extends Phaser.Scene {
|
|||||||
if (this.pendingDeathLineY !== null) {
|
if (this.pendingDeathLineY !== null) {
|
||||||
const deathLineAnim = this.pendingDeathLineY;
|
const deathLineAnim = this.pendingDeathLineY;
|
||||||
this.pendingDeathLineY = null;
|
this.pendingDeathLineY = null;
|
||||||
|
this.audio.feedback('deathLine', { haptic: false });
|
||||||
this.gridRenderer.animateDeathLine(deathLineAnim, DEATH_LINE_ANIM, () => {
|
this.gridRenderer.animateDeathLine(deathLineAnim, DEATH_LINE_ANIM, () => {
|
||||||
this.finishExecution();
|
this.finishExecution();
|
||||||
});
|
});
|
||||||
@@ -132,71 +188,194 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.animating = true;
|
this.animating = true;
|
||||||
const batch = this.execQueue.shift()!;
|
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) {
|
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;
|
if (u.type === 'death_line') this.pendingDeathLineY = u.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const u of batch.updates) {
|
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);
|
const sprite = this.shipSprites.get(u.shipId);
|
||||||
if (!sprite) continue;
|
const color = this.playerColors.get(u.shipId) ?? 0x00e5ff;
|
||||||
|
|
||||||
switch (u.type) {
|
switch (u.type) {
|
||||||
case 'move': {
|
case 'move': {
|
||||||
const tx = this.gridRenderer.gridXToScreen(u.to.y);
|
const tx = this.gridRenderer.gridXToScreen(u.to.y);
|
||||||
const ty = this.gridRenderer.gridYToScreen(u.to.x);
|
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({
|
this.tweens.add({
|
||||||
targets: sprite, x: tx, y: ty,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'turn': {
|
case 'turn': {
|
||||||
|
if (sprite) {
|
||||||
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
|
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
|
||||||
this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'card_played': {
|
||||||
|
this.playCardEffect(u.card, u.shipId, u.position, u.targetId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'eliminated': {
|
case 'eliminated': {
|
||||||
this.shipSprites.delete(u.shipId);
|
this.shipSprites.delete(u.shipId);
|
||||||
const px = this.add.particles(sprite.x, sprite.y, 'asteroid', {
|
if (sprite) {
|
||||||
speed: { min: 40, max: 180 }, scale: { start: 0.3, end: 0 },
|
this.effects.explosion(sprite.x, sprite.y, color);
|
||||||
lifespan: 800, quantity: 20, emitting: false,
|
this.audio.feedback('explosion', { haptic: false });
|
||||||
});
|
this.cameras.main.flash(180, 255, 255, 255, false, undefined, 0.4);
|
||||||
px.explode();
|
this.cameras.main.shake(200, 0.012);
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1,
|
targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1,
|
||||||
duration: 700, delay: 200,
|
duration: 700, delay: 100,
|
||||||
onComplete: () => { sprite.destroy(); px.destroy(); },
|
onComplete: () => sprite.destroy(),
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'collision': {
|
case 'collision': {
|
||||||
|
if (sprite) {
|
||||||
|
this.effects.impact(sprite.x, sprite.y, 0xffcc00, 36);
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: sprite,
|
targets: sprite,
|
||||||
alpha: 0.2,
|
alpha: 0.2,
|
||||||
duration: 100,
|
duration: 100, yoyo: true, repeat: 3,
|
||||||
yoyo: true,
|
|
||||||
repeat: 3,
|
|
||||||
});
|
});
|
||||||
|
this.cameras.main.shake(140, 0.008);
|
||||||
|
}
|
||||||
|
this.audio.feedback('collision', { haptic: false });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'shield_used': {
|
case 'shield_used': {
|
||||||
|
if (sprite) this.effects.shieldBubble(sprite.x, sprite.y);
|
||||||
|
this.audio.feedback('shield', { haptic: false });
|
||||||
|
if (sprite) {
|
||||||
this.tweens.add({
|
this.tweens.add({
|
||||||
targets: sprite,
|
targets: sprite,
|
||||||
alpha: 0.2,
|
alpha: 0.4,
|
||||||
duration: 150,
|
duration: 200, yoyo: true, repeat: 2,
|
||||||
yoyo: true,
|
|
||||||
repeat: 2,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
break;
|
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 {
|
private finishExecution(): void {
|
||||||
if (this.pendingPlanning) {
|
if (this.pendingPlanning) {
|
||||||
@@ -231,15 +410,18 @@ export class GameScene extends Phaser.Scene {
|
|||||||
this.shipSprites.delete(id);
|
this.shipSprites.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const colors = [0x00ccff, 0xff4444, 0x44ff44, 0xffaa00, 0xff44ff, 0xffff44];
|
|
||||||
for (let i = 0; i < ships.length; i++) {
|
for (let i = 0; i < ships.length; i++) {
|
||||||
const ship = ships[i];
|
const ship = ships[i];
|
||||||
if (!ship.alive) continue;
|
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 x = this.gridRenderer.gridXToScreen(ship.position.y);
|
||||||
const y = this.gridRenderer.gridYToScreen(ship.position.x);
|
const y = this.gridRenderer.gridYToScreen(ship.position.x);
|
||||||
let sprite = this.shipSprites.get(ship.id);
|
let sprite = this.shipSprites.get(ship.id);
|
||||||
if (!sprite) {
|
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.children.remove(sprite);
|
||||||
this.gridRenderer.ships.add(sprite);
|
this.gridRenderer.ships.add(sprite);
|
||||||
this.shipSprites.set(ship.id, 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 Phaser from 'phaser';
|
||||||
import { TvSocket } from '../network/TvSocket.js';
|
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 {
|
export class LobbyScene extends Phaser.Scene {
|
||||||
private socket!: TvSocket;
|
private socket!: TvSocket;
|
||||||
|
private audio!: AudioEngine;
|
||||||
private roomCodeText!: Phaser.GameObjects.Text;
|
private roomCodeText!: Phaser.GameObjects.Text;
|
||||||
private playerListText!: Phaser.GameObjects.Text;
|
private playerListContainer!: Phaser.GameObjects.Container;
|
||||||
private players: { id: string; name: string }[] = [];
|
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 = '';
|
private roomCode: string = '';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: 'LobbyScene' });
|
super({ key: 'LobbyScene' });
|
||||||
}
|
}
|
||||||
|
|
||||||
init(data: { socket: TvSocket }): void {
|
init(data: { socket: TvSocket; audio: AudioEngine }): void {
|
||||||
this.socket = data.socket;
|
this.socket = data.socket;
|
||||||
|
this.audio = data.audio;
|
||||||
this.players = [];
|
this.players = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
const { width, height } = this.scale;
|
this.cameras.main.setBackgroundColor('#050514');
|
||||||
|
createStarfield(this);
|
||||||
|
|
||||||
// Background
|
// ── Title with layered glow ──
|
||||||
this.cameras.main.setBackgroundColor('#0a0a2e');
|
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(W / 2, 80, 'SPACE RACE', {
|
||||||
this.add.text(width / 2, 60, '🚀 SPACE RACE 🚀', {
|
fontFamily: FONTS.display,
|
||||||
fontSize: '48px',
|
fontSize: '64px',
|
||||||
color: '#00ccff',
|
color: '#ffffff',
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontStyle: 'bold',
|
fontStyle: 'bold',
|
||||||
}).setOrigin(0.5);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Create room button
|
// Subtitle
|
||||||
this.roomCodeText = this.add.text(width / 2, 160, 'Creating room...', {
|
this.add.text(W / 2, 138, '◆ Multiplayer Tactical Race ◆', {
|
||||||
fontSize: '28px',
|
fontFamily: FONTS.display,
|
||||||
color: '#ffffff',
|
fontSize: '16px',
|
||||||
fontFamily: 'monospace',
|
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);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Player list
|
this.add.rectangle(W / 2, 268, 360, 72, 0x0a0a24, 0.85)
|
||||||
this.playerListText = this.add.text(width / 2, 240, 'Players: 0', {
|
.setStrokeStyle(2, 0x00e5ff, 0.55);
|
||||||
fontSize: '22px',
|
|
||||||
color: '#aaaaaa',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
align: 'center',
|
|
||||||
}).setOrigin(0.5, 0);
|
|
||||||
|
|
||||||
// QR Code hint
|
this.roomCodeText = this.add.text(W / 2, 268, '----', {
|
||||||
this.add.text(width / 2, height - 100, 'Scan QR code or enter room code on your phone', {
|
fontFamily: FONTS.mono,
|
||||||
fontSize: '18px',
|
fontSize: '44px',
|
||||||
color: '#666688',
|
color: '#00e5ff',
|
||||||
fontFamily: 'monospace',
|
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);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
// Start button (hidden until players join)
|
this.startBtn = this.createNeonButton(W / 2, H - 60, 'START RACE', '#ff2bd6', '#ff2bd6');
|
||||||
const startBtn = this.add.text(width / 2, height - 160, '[ START GAME ]', {
|
this.startBtn.setVisible(false);
|
||||||
fontSize: '32px',
|
|
||||||
color: '#00ff88',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
backgroundColor: '#115533',
|
|
||||||
padding: { x: 20, y: 10 },
|
|
||||||
}).setOrigin(0.5).setInteractive({ useHandCursor: true }).setVisible(false);
|
|
||||||
|
|
||||||
startBtn.on('pointerover', () => startBtn.setStyle({ backgroundColor: '#227744' }));
|
// ── Socket events ──
|
||||||
startBtn.on('pointerout', () => startBtn.setStyle({ backgroundColor: '#115533' }));
|
|
||||||
startBtn.on('pointerdown', () => {
|
|
||||||
this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => {
|
|
||||||
if (res.ok) {
|
|
||||||
console.log('Game starting...');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Socket events
|
|
||||||
this.socket.emit('tv:createRoom', {}, (res) => {
|
this.socket.emit('tv:createRoom', {}, (res) => {
|
||||||
this.roomCode = res.roomCode;
|
this.roomCode = res.roomCode;
|
||||||
this.roomCodeText.setText(`Room: ${res.roomCode}`);
|
this.roomCodeText.setText(res.roomCode);
|
||||||
this.updateQRHint();
|
this.generateQR(res.roomCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('playerJoined', (data) => {
|
this.socket.on('playerJoined', (data) => {
|
||||||
this.players.push({ id: data.playerId, name: data.name });
|
this.players.push({ id: data.playerId, name: data.name, colorIndex: this.players.length });
|
||||||
this.updatePlayerList();
|
this.audio.feedback('cardSelect', { haptic: false });
|
||||||
startBtn.setVisible(this.players.length >= 2);
|
this.refreshPlayerList();
|
||||||
|
this.startBtn.setVisible(this.players.length >= 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('playerLeft', (data) => {
|
this.socket.on('playerLeft', (data) => {
|
||||||
this.players = this.players.filter((p) => p.id !== data.playerId);
|
this.players = this.players.filter((p) => p.id !== data.playerId);
|
||||||
this.updatePlayerList();
|
// Reassign color indices in join order
|
||||||
startBtn.setVisible(this.players.length >= 2);
|
this.players.forEach((p, i) => { p.colorIndex = i; });
|
||||||
|
this.refreshPlayerList();
|
||||||
|
this.startBtn.setVisible(this.players.length >= 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.socket.on('gameStarting', (data) => {
|
this.socket.on('gameStarting', (data) => {
|
||||||
|
this.audio.feedback('phaseChange', { haptic: false });
|
||||||
this.scene.start('GameScene', {
|
this.scene.start('GameScene', {
|
||||||
socket: this.socket,
|
socket: this.socket,
|
||||||
|
audio: this.audio,
|
||||||
roomCode: this.roomCode,
|
roomCode: this.roomCode,
|
||||||
players: data.players,
|
players: data.players,
|
||||||
grid: data.grid,
|
grid: data.grid,
|
||||||
@@ -102,14 +141,161 @@ export class LobbyScene extends Phaser.Scene {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updatePlayerList(): void {
|
private createNeonButton(x: number, y: number, label: string, fillColor: number, glowColor: number): Phaser.GameObjects.Container {
|
||||||
const names = this.players.map((p, i) => ` ${i + 1}. ${p.name}`).join('\n');
|
const container = this.add.container(x, y);
|
||||||
this.playerListText.setText(`Players (${this.players.length}/6):\n${names}`);
|
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 {
|
private generateQR(roomCode: string): void {
|
||||||
// We'll use a canvas-based QR in the controller app
|
const url = `${BASE_URL}/controller?room=${roomCode}`;
|
||||||
const url = `${window.location.origin}?room=${this.roomCode}`;
|
const qr = QRCode(0, 'M');
|
||||||
console.log('Join URL:', url);
|
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 Phaser from 'phaser';
|
||||||
import { TvSocket } from '../network/TvSocket.js';
|
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 {
|
export class ResultScene extends Phaser.Scene {
|
||||||
private socket!: TvSocket;
|
private socket!: TvSocket;
|
||||||
|
private audio!: AudioEngine;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super({ key: 'ResultScene' });
|
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;
|
this.socket = data.socket;
|
||||||
|
this.audio = data.audio;
|
||||||
// We'll use data directly in create
|
|
||||||
this.registry.set('resultData', data);
|
this.registry.set('resultData', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
create(): void {
|
create(): void {
|
||||||
const data = this.registry.get('resultData') as {
|
const data = this.registry.get('resultData') as {
|
||||||
winnerId: string;
|
winnerId: string; winnerName: string; ships: Ship[];
|
||||||
winnerName: string;
|
|
||||||
ships: Ship[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { width, height } = this.scale;
|
this.cameras.main.setBackgroundColor('#050514');
|
||||||
this.cameras.main.setBackgroundColor('#0a0a2e');
|
createStarfield(this);
|
||||||
|
|
||||||
this.add.text(width / 2, 100, '🏆 RACE OVER 🏆', {
|
this.audio.feedback('fanfare', { haptic: false });
|
||||||
fontSize: '52px',
|
|
||||||
color: '#ffcc00',
|
// ── Title with glow ──
|
||||||
fontFamily: 'monospace',
|
const titleGlow = this.add.text(W / 2, 90, '🏆 RACE OVER 🏆', {
|
||||||
fontStyle: 'bold',
|
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);
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
this.add.text(width / 2, 220, `${data.winnerName} wins!`, {
|
// ── Winner banner ──
|
||||||
fontSize: '40px',
|
this.add.rectangle(W / 2, 170, 700, 70, 0x0a0a24, 0.85)
|
||||||
color: '#00ff88',
|
.setStrokeStyle(2, 0xff2bd6, 0.8);
|
||||||
fontFamily: 'monospace',
|
this.add.text(W / 2, 170, `${data.winnerName} WINS!`, {
|
||||||
}).setOrigin(0.5);
|
fontFamily: FONTS.display, fontSize: '36px', color: '#ffffff', fontStyle: 'bold',
|
||||||
|
}).setOrigin(0.5).setShadow(0, 0, '#ff2bd6', 16, true, true);
|
||||||
|
|
||||||
// Show final positions
|
// ── Build podium ──
|
||||||
const aliveShips = data.ships.filter((s) => s.alive);
|
const alive = data.ships.filter((s) => s.alive);
|
||||||
const deadShips = data.ships.filter((s) => !s.alive);
|
const dead = data.ships.filter((s) => !s.alive);
|
||||||
let yPos = 320;
|
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:', {
|
// ── Confetti burst ──
|
||||||
fontSize: '24px',
|
this.spawnConfetti();
|
||||||
color: '#aaaaaa',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}).setOrigin(0.5);
|
|
||||||
yPos += 40;
|
|
||||||
|
|
||||||
for (let i = 0; i < aliveShips.length; i++) {
|
// ── Play again button ──
|
||||||
const ship = aliveShips[i];
|
this.createNeonButton(W / 2, H - 70, 'BACK TO LOBBY', 0x00e5ff, 0x00e5ff);
|
||||||
this.add.text(width / 2, yPos, `${i + 1}. ${ship.playerName}`, {
|
|
||||||
fontSize: '20px',
|
|
||||||
color: '#ffffff',
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
}).setOrigin(0.5);
|
|
||||||
yPos += 30;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const ship of deadShips) {
|
private renderPodiumEntry(entry: PodiumEntry, total: number, index: number): void {
|
||||||
this.add.text(width / 2, yPos, ` ${ship.playerName} (eliminated)`, {
|
const isWinner = entry.rank === 1;
|
||||||
fontSize: '20px',
|
const colW = 180;
|
||||||
color: '#666666',
|
const cols = Math.min(total, 6);
|
||||||
fontFamily: 'monospace',
|
const startX = (W - cols * colW) / 2 + colW / 2;
|
||||||
}).setOrigin(0.5);
|
const x = startX + index * colW;
|
||||||
yPos += 30;
|
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
|
// Rank number
|
||||||
const playAgainBtn = this.add.text(width / 2, height - 100, '[ BACK TO LOBBY ]', {
|
this.add.text(x, baseY - heightByRank + 28, `${entry.rank}`, {
|
||||||
fontSize: '28px',
|
fontFamily: FONTS.display, fontSize: '32px', color: '#ffffff', fontStyle: 'bold',
|
||||||
color: '#00ccff',
|
}).setOrigin(0.5).setShadow(0, 0, '#ffffff', 8, true, true);
|
||||||
fontFamily: 'monospace',
|
|
||||||
backgroundColor: '#112244',
|
|
||||||
padding: { x: 20, y: 10 },
|
|
||||||
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
|
|
||||||
|
|
||||||
playAgainBtn.on('pointerover', () => playAgainBtn.setStyle({ backgroundColor: '#223366' }));
|
// Player name (on top of podium)
|
||||||
playAgainBtn.on('pointerout', () => playAgainBtn.setStyle({ backgroundColor: '#112244' }));
|
this.add.text(x, baseY - heightByRank - 16, entry.ship.playerName, {
|
||||||
playAgainBtn.on('pointerdown', () => {
|
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();
|
this.socket.disconnect();
|
||||||
window.location.reload();
|
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';
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: '/',
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
Reference in New Issue
Block a user