From 8531864b25a05fda86a917256eff28b5a0d18796 Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 24 Jun 2026 20:32:34 +0200 Subject: [PATCH] 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 --- Makefile | 13 + controller/index.html | 38 +- controller/src/main.ts | 49 +- controller/src/screens/JoinScreen.ts | 14 +- controller/src/screens/LobbyScreen.ts | 58 +- controller/src/screens/PlanningScreen.ts | 129 +++- controller/src/screens/SpectatorScreen.ts | 15 +- controller/src/style.css | 899 +++++++++++++++++----- controller/vite.config.ts | 1 + package-lock.json | 1 + server/Dockerfile | 20 +- server/src/game/CardHandler.ts | 207 +++-- server/src/game/Executor.ts | 16 +- server/src/index.ts | 8 + shared/src/audio.ts | 436 +++++++++++ shared/src/index.ts | 2 + shared/src/theme.ts | 67 ++ shared/src/types.ts | 23 +- tv/index.html | 32 +- tv/package.json | 1 + tv/src/effects/EffectRenderer.ts | 329 ++++++++ tv/src/objects/GridRenderer.ts | 120 ++- tv/src/objects/ShipSprite.ts | 50 +- tv/src/scenes/BootScene.ts | 151 +++- tv/src/scenes/GameScene.ts | 366 +++++++-- tv/src/scenes/LobbyScene.ts | 306 ++++++-- tv/src/scenes/ResultScene.ts | 214 +++-- tv/src/scenes/Starfield.ts | 62 ++ tv/src/style.css | 37 + tv/vite.config.ts | 1 + 30 files changed, 3036 insertions(+), 629 deletions(-) create mode 100644 Makefile create mode 100644 shared/src/audio.ts create mode 100644 shared/src/theme.ts create mode 100644 tv/src/effects/EffectRenderer.ts create mode 100644 tv/src/scenes/Starfield.ts create mode 100644 tv/src/style.css diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a25dfcc --- /dev/null +++ b/Makefile @@ -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) diff --git a/controller/index.html b/controller/index.html index 08d5cb7..1073073 100644 --- a/controller/index.html +++ b/controller/index.html @@ -4,14 +4,24 @@ SpaceRace - Controller + + +
+

πŸš€ SpaceRace

+

Pilot your ship to victory

- + + +

@@ -23,9 +33,10 @@
- Round 1 + Pilot
+ Round 1 45s
@@ -55,20 +66,17 @@ Right 1 AP -
+
Action Sequence
+
Tactical Cards
@@ -80,20 +88,24 @@
-

Executing...

-

Watch the TV!

+
+

Executing…

+

Watch the TV screen

-

πŸ’€ Eliminated

+

πŸ’€ Eliminated

You're out! Watch the TV to see who wins.

-

Lobby

-

Room: ----

+

Mission Lobby

+
+ Room Code + ---- +
-

Players

+

Crew Manifest

    Waiting for host to start...

    diff --git a/controller/src/main.ts b/controller/src/main.ts index 984d9b4..a161e22 100644 --- a/controller/src/main.ts +++ b/controller/src/main.ts @@ -4,10 +4,11 @@ import { PlanningScreen } from './screens/PlanningScreen.js'; import { WaitingScreen } from './screens/WaitingScreen.js'; import { SpectatorScreen } from './screens/SpectatorScreen.js'; import { LobbyScreen } from './screens/LobbyScreen.js'; -import { Action } from '@spacerace/shared'; +import { Action, AudioEngine } from '@spacerace/shared'; -class App { +export class App { socket: ControllerSocket; + audio: AudioEngine; private currentScreen: string = 'join'; isHost: boolean = false; roomCode: string = ''; @@ -15,10 +16,37 @@ class App { constructor() { this.socket = connectControllerSocket(); + this.audio = new AudioEngine(); + this.initMuteToggle(); this.initScreens(); this.initSocketEvents(); } + private initMuteToggle(): void { + const btn = document.getElementById('audio-toggle'); + if (!btn) return; + if (this.audio.isMuted()) document.body.classList.add('audio-muted'); + + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.audio.unlock(); + const muted = !this.audio.isMuted(); + this.audio.setMuted(muted); + document.body.classList.toggle('audio-muted', muted); + this.audio.feedback('tap', { sound: false }); + this.audio.vibrate(muted ? 'warning' : 'go'); + }); + + // Unlock audio on the first user gesture (anywhere on the page) + const unlock = (): void => { + this.audio.unlock(); + window.removeEventListener('pointerdown', unlock); + window.removeEventListener('touchstart', unlock); + }; + window.addEventListener('pointerdown', unlock, { once: true }); + window.addEventListener('touchstart', unlock, { once: true }); + } + private initScreens(): void { new JoinScreen(this); new LobbyScreen(this); @@ -36,36 +64,41 @@ class App { this.isHost = data.isHost; this.roomCode = data.roomCode; this.playerId = data.playerId; + this.audio.feedback('round'); this.showScreen('lobby'); }); this.socket.on('gameStarting', () => { + this.audio.feedback('phaseChange'); this.showScreen('planning'); }); this.socket.on('planningRequest', (data) => { + this.audio.feedback('round'); this.showScreen('planning'); - // Pass data to planning screen document.dispatchEvent(new CustomEvent('planningRequest', { detail: data })); }); - this.socket.on('executionTick', (data) => { + this.socket.on('executionTick', () => { + this.audio.feedback('phaseChange'); this.showScreen('waiting'); - const msgEl = document.getElementById('waiting-message'); - if (msgEl && data.messages.length > 0) { - msgEl.textContent = data.messages.join(' | '); - } }); this.socket.on('executionComplete', (data) => { if (data.playerView.alive) { this.showScreen('planning'); } else { + this.audio.feedback('eliminated'); this.showScreen('spectator'); } }); this.socket.on('gameOver', (data) => { + if (data.winnerId === this.playerId) { + this.audio.feedback('win'); + } else { + this.audio.feedback('eliminated'); + } this.showScreen('spectator'); }); } diff --git a/controller/src/screens/JoinScreen.ts b/controller/src/screens/JoinScreen.ts index dc358ca..d4c9329 100644 --- a/controller/src/screens/JoinScreen.ts +++ b/controller/src/screens/JoinScreen.ts @@ -21,21 +21,31 @@ export class JoinScreen { roomInput.value = roomParam.toUpperCase(); } + // Small feedback on input focus + [roomInput, nameInput].forEach((input) => { + input.addEventListener('focus', () => this.app.audio.feedback('cardSelect')); + }); + joinBtn.addEventListener('click', () => { + this.app.audio.unlock(); + this.app.audio.feedback('go'); + const roomCode = roomInput.value.trim().toUpperCase(); const playerName = nameInput.value.trim(); if (!roomCode || roomCode.length !== 4) { errorEl.textContent = 'Enter a 4-character room code'; + this.app.audio.feedback('warning'); return; } if (!playerName) { errorEl.textContent = 'Enter your name'; + this.app.audio.feedback('warning'); return; } errorEl.textContent = ''; - joinBtn.textContent = 'Joining...'; + joinBtn.textContent = 'Joining…'; joinBtn.disabled = true; this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => { @@ -43,8 +53,10 @@ export class JoinScreen { joinBtn.disabled = false; if (!res.ok) { errorEl.textContent = res.error || 'Failed to join'; + this.app.audio.feedback('warning'); } }); }); } } + diff --git a/controller/src/screens/LobbyScreen.ts b/controller/src/screens/LobbyScreen.ts index f4b8252..679bbb6 100644 --- a/controller/src/screens/LobbyScreen.ts +++ b/controller/src/screens/LobbyScreen.ts @@ -1,4 +1,7 @@ import { App } from '../main.js'; +import { PLAYER_HEX } from '@spacerace/shared'; + +const AVATAR_LETTERS = ['β—†', 'β–²', '●', 'β– ', 'β˜…', 'β¬’']; export class LobbyScreen { private app: App; @@ -10,12 +13,14 @@ export class LobbyScreen { this.createStartButton(); app.socket.on('roomJoined', (data) => { - document.getElementById('lobby-room')!.textContent = `Room: ${data.roomCode}`; + const roomEl = document.getElementById('lobby-room'); + if (roomEl) roomEl.textContent = data.roomCode; this.updatePlayerList(data.players); this.updateHostUI(); }); app.socket.on('playerJoined', (data) => { + this.app.audio.feedback('cardSelect'); this.appendPlayer(data.playerId, data.name); this.updateHostUI(); }); @@ -35,20 +40,22 @@ export class LobbyScreen { private createStartButton(): void { const div = document.createElement('div'); div.id = 'host-controls'; - div.style.cssText = 'margin-top: 16px; text-align: center; display: none;'; + div.style.cssText = 'margin-top: 16px; text-align: center; display: none; width: 100%; max-width: 360px;'; this.startBtn = document.createElement('button'); this.startBtn.textContent = 'πŸš€ Start Race'; - this.startBtn.style.cssText = 'padding: 14px 32px; font-size: 20px; background: #00ff88; color: #000; border: none; border-radius: 8px; font-weight: bold; cursor: pointer;'; + this.startBtn.className = 'start-btn-neon'; this.startBtn.addEventListener('click', () => { if (!this.startBtn) return; - this.startBtn.textContent = 'Starting...'; + this.app.audio.feedback('go'); + this.startBtn.textContent = 'Launching…'; this.startBtn.disabled = true; this.app.socket.emit('mobile:startGame', { roomCode: this.app.roomCode }, (res) => { if (!res.ok) { this.startBtn!.textContent = 'πŸš€ Start Race'; this.startBtn!.disabled = false; + this.app.audio.feedback('warning'); alert(res.error || 'Cannot start'); } }); @@ -56,7 +63,6 @@ export class LobbyScreen { div.appendChild(this.startBtn); - // Insert before the waiting text const lobbyScreen = document.getElementById('screen-lobby')!; const waitingEl = document.getElementById('lobby-waiting')!; lobbyScreen.insertBefore(div, waitingEl); @@ -72,16 +78,11 @@ export class LobbyScreen { const playerCount = document.getElementById('lobby-player-list')!.children.length; if (this.startBtn) { this.startBtn.disabled = playerCount < 2; - if (playerCount < 2) { - this.startBtn.style.opacity = '0.5'; - } else { - this.startBtn.style.opacity = '1'; - } } } else { div.style.display = 'none'; waitingEl.style.display = 'block'; - waitingEl.textContent = 'Waiting for host to start...'; + waitingEl.textContent = 'Waiting for host to start…'; } } @@ -95,11 +96,38 @@ export class LobbyScreen { private appendPlayer(id: string, name: string): void { const list = document.getElementById('lobby-player-list')!; + const colorIndex = list.children.length; + const color = PLAYER_HEX[colorIndex % PLAYER_HEX.length]; + const li = document.createElement('li'); - const hostId = this.app.socket.data?.hostPlayerId; - const crown = (this.app.isHost && id === this.app.playerId) ? ' πŸ‘‘' : ''; - li.textContent = name + crown; li.id = `player-${id}`; + + const avatar = document.createElement('span'); + avatar.className = 'player-avatar'; + avatar.style.backgroundColor = color; + avatar.style.color = color; + avatar.textContent = AVATAR_LETTERS[colorIndex % AVATAR_LETTERS.length]; + avatar.style.display = 'flex'; + avatar.style.alignItems = 'center'; + avatar.style.justifyContent = 'center'; + avatar.style.fontSize = '12px'; + avatar.style.color = '#00121a'; + avatar.style.boxShadow = `0 0 12px ${color}`; + + const nameSpan = document.createElement('span'); + nameSpan.textContent = name; + + const isMe = id === this.app.playerId; + li.appendChild(avatar); + li.appendChild(nameSpan); + + if (isMe) { + const tag = document.createElement('span'); + tag.style.cssText = 'margin-left: 8px; font-size: 10px; letter-spacing: 0.15em; padding: 2px 6px; border-radius: 999px; background: rgba(0,229,255,0.15); border: 1px solid rgba(0,229,255,0.4); color: var(--primary); font-family: var(--f-display); text-transform: uppercase;'; + tag.textContent = 'You'; + li.appendChild(tag); + } + list.appendChild(li); } -} +} \ No newline at end of file diff --git a/controller/src/screens/PlanningScreen.ts b/controller/src/screens/PlanningScreen.ts index 33a8877..35c3b40 100644 --- a/controller/src/screens/PlanningScreen.ts +++ b/controller/src/screens/PlanningScreen.ts @@ -1,17 +1,22 @@ import { App } from '../main.js'; -import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost } from '@spacerace/shared'; +import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost, PLAYER_HEX } from '@spacerace/shared'; const CARD_ICONS: Record = { - METEOR_STRIKE: 'β˜„οΈ', SHIELD: 'πŸ›‘οΈ', BOOST: 'πŸš€', EMP: '⚑', JUMP: '🦘', MINE: 'πŸ’£', - TELEPORT: 'πŸŒ€', PHASE_SHIFT: 'πŸ‘»', }; +const ACTION_ICONS: Record = { + MOVE_FORWARD: '↑', + TURN_LEFT: 'β†°', + TURN_RIGHT: '↱', + TURN_180: '↻', +}; + export class PlanningScreen { private app: App; private plannedActions: Action[] = []; @@ -42,6 +47,7 @@ export class PlanningScreen { this.timerSeconds = data.timer; this.setPlayerColor(data.playerView.colorIndex); + this.setPlayerName(data.playerView.name); document.getElementById('round-label')!.textContent = `Round ${data.round}`; const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement; @@ -63,15 +69,29 @@ export class PlanningScreen { } private setPlayerColor(colorIndex: number): void { - const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44']; const dot = document.getElementById('color-dot'); - if (dot) dot.style.backgroundColor = colors[colorIndex % colors.length]; + if (dot) { + const color = PLAYER_HEX[colorIndex % PLAYER_HEX.length]; + dot.style.backgroundColor = color; + dot.style.color = color; + } + } + + private setPlayerName(name: string): void { + const el = document.getElementById('player-name'); + if (el) el.textContent = name; } private updateTimer(): void { const el = document.getElementById('timer-label')!; el.textContent = `${this.timerSeconds}s`; - el.style.color = this.timerSeconds <= 10 ? '#ff4444' : this.timerSeconds <= 20 ? '#ffcc00' : '#00ff88'; + el.classList.remove('warning', 'danger'); + if (this.timerSeconds <= 10) { + el.classList.add('danger'); + if (this.timerSeconds > 0) this.app.audio.feedback('warning'); + } else if (this.timerSeconds <= 20) { + el.classList.add('warning'); + } } private initActionButtons(): void { @@ -79,6 +99,7 @@ export class PlanningScreen { grid.querySelectorAll('.act-btn').forEach((btn) => { btn.addEventListener('click', () => { const actionType = (btn as HTMLElement).dataset.action!; + this.app.audio.feedback(actionType === 'BOOST' ? 'cardUse' : 'tap'); this.addAction({ type: actionType as Action['type'] }); }); }); @@ -86,6 +107,7 @@ export class PlanningScreen { private initBottomButtons(): void { document.getElementById('clear-plan-btn')!.addEventListener('click', () => { + this.app.audio.feedback('queueRemove'); this.plannedActions = []; this.selectedCard = null; this.renderQueue(); @@ -95,14 +117,16 @@ export class PlanningScreen { document.getElementById('submit-plan-btn')!.addEventListener('click', () => { if (this.plannedActions.length === 0) return; + this.app.audio.feedback('go'); const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement; btn.disabled = true; - btn.textContent = '...'; + btn.textContent = '…'; this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => { if (!res.ok) { btn.disabled = false; btn.textContent = 'GO!'; + this.app.audio.feedback('warning'); alert(res.error || 'Invalid plan'); } }); @@ -112,12 +136,15 @@ export class PlanningScreen { private addAction(action: Action): void { if (!this.playerView || !this.playerView.alive) return; - // If we have a selected card, add it as a CARD action first if (this.selectedCard) { const cardCost = CARD_DEFS[this.selectedCard].apCost; const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0); - if (used + cardCost > this.apTotal) return; + if (used + cardCost > this.apTotal) { + this.app.audio.feedback('warning'); + return; + } + this.app.audio.feedback('cardUse'); this.plannedActions.push({ type: 'CARD', card: this.selectedCard }); this.selectedCard = null; this.renderCards(this.playerView.hand); @@ -128,7 +155,10 @@ export class PlanningScreen { const cost = actionApCost(action); const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0); - if (used + cost > this.apTotal) return; + if (used + cost > this.apTotal) { + this.app.audio.feedback('warning'); + return; + } this.plannedActions.push(action); this.renderQueue(); @@ -142,30 +172,37 @@ export class PlanningScreen { const list = document.getElementById('queue-list')!; list.innerHTML = ''; + if (this.plannedActions.length === 0) { + const empty = document.createElement('div'); + empty.className = 'queue-empty'; + empty.textContent = 'Pick a move to begin…'; + list.appendChild(empty); + return; + } + for (let i = 0; i < this.plannedActions.length; i++) { const action = this.plannedActions[i]; const div = document.createElement('div'); div.className = 'queue-item'; - let icon = ''; - switch (action.type) { - case 'MOVE_FORWARD': icon = '↑'; break; - case 'TURN_LEFT': icon = 'β†°'; break; - case 'TURN_RIGHT': icon = '↱'; break; - case 'TURN_180': icon = '↻'; break; - case 'BOOST': icon = '⚑'; break; - case 'CARD': icon = action.card ? CARD_ICONS[action.card] : '?'; break; - } + const icon = action.type === 'CARD' + ? (action.card ? CARD_ICONS[action.card] : '?') + : ACTION_ICONS[action.type] || '?'; div.textContent = icon; div.title = action.type + (action.card ? ` ${action.card}` : ''); - // X button to remove + const step = document.createElement('span'); + step.className = 'step'; + step.textContent = String(i + 1); + div.appendChild(step); + const remove = document.createElement('span'); remove.className = 'remove-hint'; remove.textContent = 'Γ—'; div.appendChild(remove); div.addEventListener('click', () => { + this.app.audio.feedback('queueRemove'); this.plannedActions.splice(i, 1); this.renderQueue(); this.updateApDisplay(); @@ -180,7 +217,6 @@ export class PlanningScreen { const list = document.getElementById('card-list')!; list.innerHTML = ''; - // If a card was already used in the plan, dim it const usedCards = new Set(); for (const a of this.plannedActions) { if (a.type === 'CARD' && a.card) usedCards.add(a.card); @@ -188,50 +224,67 @@ export class PlanningScreen { const availableCards = hand.filter((c) => !usedCards.has(c)); + if (availableCards.length === 0) { + const empty = document.createElement('div'); + empty.className = 'card-empty'; + empty.textContent = 'No cards available'; + list.appendChild(empty); + return; + } + for (const card of availableCards) { const def = CARD_DEFS[card]; const div = document.createElement('div'); div.className = 'card-item'; if (this.selectedCard === card) div.classList.add('selected'); - div.innerHTML = ` -
    ${CARD_ICONS[card]}
    -
    ${def.name}
    -
    ${def.apCost} AP
    - `; + const icon = document.createElement('div'); + icon.className = 'card-icon'; + icon.textContent = CARD_ICONS[card]; + + const name = document.createElement('div'); + name.className = 'card-name'; + name.textContent = def.name; + + const cost = document.createElement('div'); + cost.className = 'card-cost'; + cost.textContent = `${def.apCost} AP`; + + div.appendChild(icon); + div.appendChild(name); + div.appendChild(cost); div.addEventListener('click', () => { const wasSelected = this.selectedCard === card; this.selectedCard = wasSelected ? null : card; + this.app.audio.feedback(wasSelected ? 'queueRemove' : 'cardSelect'); this.renderCards(hand); }); list.appendChild(div); } - - if (availableCards.length === 0) { - list.innerHTML = '
    No cards available
    '; - } } private updateApDisplay(): void { const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0); const remaining = this.apTotal - used; - document.getElementById('ap-label')!.textContent = `AP: ${remaining}/${this.apTotal}`; + const apLabel = document.getElementById('ap-label')!; + apLabel.textContent = `AP: ${remaining}/${this.apTotal}`; const fill = document.getElementById('ap-fill')!; fill.style.width = `${(remaining / this.apTotal) * 100}%`; + fill.classList.remove('warning', 'danger'); if (remaining === 0) { - fill.style.background = '#ff4444'; - document.getElementById('ap-label')!.style.color = '#ff4444'; + apLabel.style.color = ''; + fill.classList.add('danger'); } else if (remaining === this.apTotal) { - fill.style.background = '#00ff88'; - document.getElementById('ap-label')!.style.color = '#00ff88'; + apLabel.style.color = ''; + fill.classList.remove('warning'); } else { - fill.style.background = '#ffcc00'; - document.getElementById('ap-label')!.style.color = '#ffcc00'; + apLabel.style.color = ''; + fill.classList.add('warning'); } } -} +} \ No newline at end of file diff --git a/controller/src/screens/SpectatorScreen.ts b/controller/src/screens/SpectatorScreen.ts index 0b4020a..c311dea 100644 --- a/controller/src/screens/SpectatorScreen.ts +++ b/controller/src/screens/SpectatorScreen.ts @@ -3,12 +3,23 @@ import { App } from '../main.js'; export class SpectatorScreen { constructor(app: App) { app.socket.on('gameOver', (data) => { - const h2 = document.querySelector('#screen-spectator h2')!; + const h2 = document.getElementById('spectator-title'); + if (!h2) return; if (data.winnerId === app.socket.data?.playerId) { h2.textContent = 'πŸ† You Win!'; + h2.classList.remove('dead'); } else { h2.textContent = `πŸ’€ ${data.winnerName} Wins!`; + h2.classList.remove('dead'); } }); + + app.socket.on('executionComplete', (data) => { + const h2 = document.getElementById('spectator-title'); + if (!h2) return; + if (data.playerView.alive) return; + h2.textContent = 'πŸ’€ Eliminated'; + h2.classList.add('dead'); + }); } -} +} \ No newline at end of file diff --git a/controller/src/style.css b/controller/src/style.css index f6fa2f2..a26964b 100644 --- a/controller/src/style.css +++ b/controller/src/style.css @@ -1,19 +1,121 @@ +/* ═══════════════════════════════════════════════════════════ + SpaceRace β€” Controller + Sci-Fi Neon / Cyberpunk theme + ═══════════════════════════════════════════════════════════ */ + +:root { + /* Colors β€” mirror shared/src/theme.ts */ + --bg-deep: #050514; + --bg-panel: #0a0a24; + --bg-panel-alt: #111133; + --bg-glass: rgba(13, 13, 40, 0.72); + --bg-glass-strong: rgba(13, 13, 40, 0.92); + + --primary: #00e5ff; + --primary-dim: #007a99; + --accent: #ff2bd6; + --accent-dim: #7a1465; + + --success: #00ff9c; + --warning: #ffcc00; + --danger: #ff3b6b; + --neutral: #6a6a8a; + + --text: #ffffff; + --text-dim: #a0a0c8; + --text-muted: #6a6a8a; + + --player-0: #00e5ff; + --player-1: #ff3b6b; + --player-2: #00ff9c; + --player-3: #ffaa00; + --player-4: #ff2bd6; + --player-5: #fff200; + + /* Spacing & radii */ + --sp-xs: 4px; + --sp-sm: 8px; + --sp-md: 12px; + --sp-lg: 16px; + --sp-xl: 24px; + --sp-xxl: 32px; + + --r-sm: 8px; + --r-md: 12px; + --r-lg: 20px; + --r-pill: 999px; + + /* Fonts */ + --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; + + /* Glow */ + --glow-primary: 0 0 16px rgba(0, 229, 255, 0.55), 0 0 32px rgba(0, 229, 255, 0.25); + --glow-accent: 0 0 16px rgba(255, 43, 214, 0.55), 0 0 32px rgba(255, 43, 214, 0.25); + --glow-success: 0 0 14px rgba(0, 255, 156, 0.55); + --glow-warning: 0 0 14px rgba(255, 204, 0, 0.55); + --glow-danger: 0 0 16px rgba(255, 59, 107, 0.65); +} + * { margin: 0; padding: 0; box-sizing: border-box; + -webkit-tap-highlight-color: transparent; } html, body { width: 100%; height: 100%; overflow: hidden; - font-family: monospace; - background: #0a0a2e; - color: #ffffff; + font-family: var(--f-body); + color: var(--text); + background: var(--bg-deep); user-select: none; -webkit-user-select: none; touch-action: manipulation; + letter-spacing: 0.01em; +} + +/* ── Animated starfield background ──────────────────────── */ +body::before, +body::after { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + radial-gradient(1px 1px at 12% 18%, rgba(255,255,255,0.85) 50%, transparent 51%), + radial-gradient(1px 1px at 28% 72%, rgba(255,255,255,0.55) 50%, transparent 51%), + radial-gradient(1.5px 1.5px at 47% 35%, rgba(0,229,255,0.7) 50%, transparent 51%), + radial-gradient(1px 1px at 63% 84%, rgba(255,255,255,0.6) 50%, transparent 51%), + radial-gradient(1px 1px at 78% 22%, rgba(255,43,214,0.6) 50%, transparent 51%), + radial-gradient(1.5px 1.5px at 88% 60%, rgba(255,255,255,0.75) 50%, transparent 51%), + radial-gradient(1px 1px at 5% 58%, rgba(255,255,255,0.5) 50%, transparent 51%), + radial-gradient(1px 1px at 36% 12%, rgba(255,255,255,0.45) 50%, transparent 51%), + radial-gradient(1px 1px at 55% 90%, rgba(0,229,255,0.55) 50%, transparent 51%), + radial-gradient(1px 1px at 82% 45%, rgba(255,255,255,0.65) 50%, transparent 51%); + background-size: 320px 320px; + background-repeat: repeat; + opacity: 0.9; + animation: drift 60s linear infinite; +} +body::after { + background-size: 220px 220px; + opacity: 0.5; + animation: drift 90s linear infinite reverse; +} +@keyframes drift { + from { transform: translate3d(0, 0, 0); } + to { transform: translate3d(-320px, 320px, 0); } +} + +/* Subtle scanline overlay for the cyberpunk feel */ +body > * { + position: relative; + z-index: 1; } #app { @@ -22,314 +124,761 @@ html, body { position: relative; } +/* ── Screens ─────────────────────────────────────────────── */ .screen { display: none; width: 100%; height: 100%; - padding: 16px; + padding: var(--sp-lg); overflow-y: auto; + overflow-x: hidden; flex-direction: column; position: absolute; - top: 0; - left: 0; + top: 0; left: 0; + animation: fadeIn 320ms ease; +} +.screen.active { display: flex; } + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } } -.screen.active { - display: flex; +h1, h2, h3 { + font-family: var(--f-display); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } +h1 { font-size: 28px; text-align: center; margin: 20px 0 16px; color: var(--primary); text-shadow: var(--glow-primary); } +h2 { font-size: 22px; text-align: center; margin: 16px 0 12px; color: var(--primary); text-shadow: 0 0 12px rgba(0,229,255,0.4); } +h3 { font-size: 13px; color: var(--text-muted); margin: 8px 0 6px; letter-spacing: 0.12em; } -h1 { font-size: 24px; text-align: center; margin: 20px 0; color: #00ccff; } -h2 { font-size: 20px; text-align: center; margin: 16px 0; color: #00ccff; } -h3 { font-size: 14px; color: #8888aa; margin: 8px 0 4px; } +/* ── Buttons ─────────────────────────────────────────────── */ +button { + font-family: var(--f-display); + font-weight: 700; + font-size: 16px; + letter-spacing: 0.08em; + text-transform: uppercase; + padding: 14px 18px; + border: 1px solid transparent; + border-radius: var(--r-md); + cursor: pointer; + background: linear-gradient(135deg, var(--primary) 0%, #00b4cc 100%); + color: #00121a; + box-shadow: var(--glow-primary); + transition: transform 120ms ease, box-shadow 200ms ease, filter 200ms ease; + position: relative; + overflow: hidden; +} +button::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgba(255,255,255,0.25), transparent 50%); + pointer-events: none; + opacity: 0.6; +} +button:active { transform: scale(0.97); filter: brightness(0.95); } +button:disabled { + background: linear-gradient(135deg, #1a1a3a, #232345); + color: var(--text-muted); + box-shadow: none; + cursor: not-allowed; +} +button:disabled::after { display: none; } -/* Join Screen */ -#screen-join { align-items: center; justify-content: flex-start; padding-top: 40px; } +/* ── Inputs ──────────────────────────────────────────────── */ +input { + font-family: var(--f-mono); + font-weight: 500; + font-size: 18px; + padding: 14px 16px; + border: 1px solid rgba(0, 229, 255, 0.25); + background: rgba(17, 17, 51, 0.7); + color: var(--text); + border-radius: var(--r-md); + text-align: center; + letter-spacing: 0.18em; + transition: border-color 200ms ease, box-shadow 200ms ease, background 200ms ease; + width: 100%; +} +input::placeholder { color: var(--text-muted); letter-spacing: 0.06em; } +input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(0, 229, 255, 0.18), var(--glow-primary); + background: rgba(17, 17, 51, 0.95); +} +#name-input { letter-spacing: 0.04em; } + +/* ═══════════════════════════════════════════════════════════ + Join Screen + ═══════════════════════════════════════════════════════════ */ +#screen-join { + align-items: center; + justify-content: flex-start; + padding-top: 56px; + gap: 6px; +} +#screen-join h1 { + font-size: 34px; + margin: 0 0 4px; + background: linear-gradient(180deg, #ffffff 0%, var(--primary) 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-shadow: none; + filter: drop-shadow(0 0 12px rgba(0,229,255,0.4)); +} +#screen-join .join-sub { + text-align: center; + font-size: 12px; + color: var(--text-muted); + letter-spacing: 0.3em; + text-transform: uppercase; + margin-bottom: 24px; +} .join-form { display: flex; flex-direction: column; - gap: 12px; + gap: var(--sp-md); width: 100%; max-width: 320px; + padding: var(--sp-xl); + background: var(--bg-glass); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(0, 229, 255, 0.18); + border-radius: var(--r-lg); + box-shadow: + 0 0 0 1px rgba(0,229,255,0.05) inset, + 0 12px 40px rgba(0, 0, 0, 0.5); +} +.join-form label { + font-family: var(--f-display); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--text-muted); + margin-top: 4px; +} +#join-btn { margin-top: 8px; font-size: 18px; padding: 16px; } + +.error { + color: var(--danger); + font-size: 13px; + text-align: center; + min-height: 18px; + text-shadow: 0 0 8px rgba(255,59,107,0.5); } -input { - padding: 14px; - font-size: 20px; - font-family: monospace; - border: 2px solid #334466; - background: #111133; - color: #ffffff; - border-radius: 8px; - text-align: center; +#qr-container { + margin-top: 20px; + display: flex; + justify-content: center; +} +#qr-container canvas, #qr-container svg { + background: #fff; + padding: 8px; + border-radius: var(--r-md); + box-shadow: var(--glow-primary); +} + +/* ═══════════════════════════════════════════════════════════ + Lobby Screen + ═══════════════════════════════════════════════════════════ */ +#screen-lobby { align-items: center; padding-top: 32px; gap: 4px; } + +.lobby-room-badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin: 8px 0 16px; +} +.lobby-room-label { + font-family: var(--f-display); + font-size: 11px; + letter-spacing: 0.3em; + color: var(--text-muted); text-transform: uppercase; } - -input:focus { - border-color: #00ccff; - outline: none; +.lobby-room-code { + font-family: var(--f-mono); + font-weight: 700; + font-size: 40px; + letter-spacing: 0.4em; + color: var(--primary); + text-shadow: var(--glow-primary); + padding: 8px 18px; + border: 1px solid rgba(0,229,255,0.4); + border-radius: var(--r-md); + background: var(--bg-glass); } -#name-input { text-transform: none; } +#lobby-players { + width: 100%; + max-width: 360px; + margin: 8px 0 12px; +} +#lobby-player-list { list-style: none; display: flex; flex-direction: column; gap: 8px; } +#lobby-player-list li { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: var(--bg-glass); + border: 1px solid rgba(0,229,255,0.12); + border-radius: var(--r-md); + font-size: 15px; + font-weight: 600; + font-family: var(--f-body); + animation: fadeIn 240ms ease; +} +#lobby-player-list .player-avatar { + width: 28px; height: 28px; + border-radius: 50%; + flex-shrink: 0; + box-shadow: 0 0 10px currentColor; + position: relative; +} +#lobby-player-list .player-avatar::after { + content: ""; + position: absolute; + inset: -3px; + border-radius: 50%; + border: 1px solid currentColor; + opacity: 0.35; +} +#lobby-player-list .crown { + margin-left: auto; + font-size: 16px; + filter: drop-shadow(0 0 6px #ffcc00); +} -button { - padding: 14px; +#lobby-waiting { + color: var(--text-muted); + margin-top: 8px; + font-size: 13px; + text-align: center; + letter-spacing: 0.05em; + animation: pulseText 2.2s ease-in-out infinite; +} +@keyframes pulseText { + 0%, 100% { opacity: 0.55; } + 50% { opacity: 1; } +} + +#host-controls { margin-top: 16px; text-align: center; width: 100%; max-width: 360px; } +#host-controls button { + width: 100%; + background: linear-gradient(135deg, var(--accent) 0%, #b81fa0 100%); + color: #fff; font-size: 18px; - font-family: monospace; - font-weight: bold; - border: none; - border-radius: 8px; - cursor: pointer; - background: #00ccff; - color: #000; + padding: 16px; + box-shadow: var(--glow-accent); } -button:active { opacity: 0.8; } -button:disabled { background: #334466; color: #666688; cursor: not-allowed; } - -.error { color: #ff4444; font-size: 13px; text-align: center; min-height: 18px; } - -/* Planning Screen */ -#screen-planning { - gap: 0; - padding: 0; -} +/* ═══════════════════════════════════════════════════════════ + Planning Screen + ═══════════════════════════════════════════════════════════ */ +#screen-planning { gap: 0; padding: 0; } .planning-header { - display: flex; - justify-content: space-between; + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - padding: 12px 16px; - background: #0d0d35; - border-bottom: 2px solid #1a1a55; + padding: 10px 14px; + background: linear-gradient(180deg, rgba(13,13,40,0.95), rgba(10,10,36,0.92)); + border-bottom: 1px solid rgba(0,229,255,0.18); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} +.header-left { display: flex; align-items: center; gap: 8px; min-width: 0; } +.header-left .player-name { + font-family: var(--f-display); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 110px; + color: var(--text); } -.header-left, .header-right { flex: 1; } -.header-left { display: flex; align-items: center; gap: 8px; } .color-dot { display: inline-block; - width: 16px; - height: 16px; + width: 14px; + height: 14px; border-radius: 50%; - border: 2px solid rgba(255,255,255,0.4); + border: 1px solid rgba(255,255,255,0.6); flex-shrink: 0; + box-shadow: 0 0 8px currentColor; +} +.header-center { display: flex; flex-direction: column; align-items: center; gap: 2px; } +#round-label { + font-family: var(--f-display); + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.2em; +} +#timer-label { + font-family: var(--f-mono); + font-size: 22px; + font-weight: 700; + color: var(--success); + text-shadow: var(--glow-success); + line-height: 1; + font-variant-numeric: tabular-nums; + transition: color 200ms ease, text-shadow 200ms ease; +} +#timer-label.warning { color: var(--warning); text-shadow: var(--glow-warning); } +#timer-label.danger { color: var(--danger); text-shadow: var(--glow-danger); animation: timerPulse 0.6s ease-in-out infinite; } +@keyframes timerPulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.08); } +} +.header-right { display: flex; align-items: center; justify-content: flex-end; gap: 6px; } +#ap-label { + font-family: var(--f-display); + font-size: 14px; + font-weight: 700; + color: var(--success); + text-shadow: var(--glow-success); + letter-spacing: 0.08em; + font-variant-numeric: tabular-nums; } -.header-center { text-align: center; } -#round-label { font-size: 14px; font-weight: bold; color: #00ccff; text-transform: uppercase; } -#timer-label { font-size: 22px; color: #ffcc00; font-weight: bold; } -#ap-label { font-size: 14px; font-weight: bold; color: #00ff88; text-align: right; } /* AP bar */ .ap-bar { height: 4px; - background: #112233; + background: rgba(0, 229, 255, 0.08); + position: relative; + overflow: hidden; } .ap-fill { height: 100%; - background: #00ff88; - transition: width 0.2s; + background: linear-gradient(90deg, var(--success), var(--primary)); + transition: width 240ms ease, background 200ms ease; + box-shadow: 0 0 12px currentColor; + width: 100%; } +.ap-fill.warning { background: linear-gradient(90deg, var(--warning), #ff9500); } +.ap-fill.danger { background: linear-gradient(90deg, var(--danger), #ff2244); } -/* Action grid */ +/* ── Action grid (D-pad style) ───────────────────────────── */ .action-grid { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; - gap: 8px; - padding: 12px; + grid-template-areas: + "forward forward" + "left right"; + gap: 10px; + padding: 14px; flex: 1; + min-height: 0; } - .act-btn { + grid-area: forward; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; - padding: 16px 8px; - background: #111144; - border: 2px solid #223366; - border-radius: 16px; + padding: 12px 6px; + background: linear-gradient(160deg, rgba(17,17,51,0.95), rgba(8,8,32,0.95)); + border: 1px solid rgba(0,229,255,0.22); + border-radius: var(--r-lg); cursor: pointer; touch-action: manipulation; - -webkit-tap-highlight-color: transparent; + font-family: inherit; + color: var(--text); + text-transform: none; + letter-spacing: 0; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); + transition: transform 120ms ease, border-color 180ms ease, background 200ms ease, box-shadow 200ms ease; + overflow: hidden; } +.act-btn[data-action="MOVE_FORWARD"] { grid-area: forward; } +.act-btn[data-action="TURN_LEFT"] { grid-area: left; } +.act-btn[data-action="TURN_RIGHT"] { grid-area: right; } .act-btn:active { - background: #1a1a66; - border-color: #00ccff; - transform: scale(0.95); -} -.act-btn.primary { - background: #112244; - border-color: #3366aa; -} -.act-btn.primary:active { - background: #1a3366; - border-color: #00ccff; - transform: scale(0.95); -} -.act-btn.accent { - border-color: #886600; - background: #221a08; -} -.act-btn.accent:active { - border-color: #ffaa00; - background: #332a10; transform: scale(0.95); + border-color: var(--primary); + background: linear-gradient(160deg, rgba(0,80,110,0.55), rgba(0,30,50,0.85)); + box-shadow: var(--glow-primary); } .act-icon { - font-size: 36px; + font-size: 30px; line-height: 1; + color: var(--primary); + text-shadow: 0 0 10px rgba(0,229,255,0.55); } .act-label { - font-size: 14px; - font-weight: bold; - color: #ccccff; + font-family: var(--f-display); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-dim); } .act-cost { - font-size: 11px; - color: #6666aa; + font-family: var(--f-mono); + font-size: 10px; + font-weight: 500; + color: var(--text-muted); + letter-spacing: 0.06em; } -/* Action queue */ +/* ── Action queue ────────────────────────────────────────── */ .action-queue { padding: 8px 12px; - border-top: 1px solid #1a1a44; - background: #080820; + border-top: 1px solid rgba(0, 229, 255, 0.12); + background: rgba(5, 5, 20, 0.7); +} +.action-queue-label { + font-family: var(--f-display); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; } .queue-list { display: flex; gap: 6px; overflow-x: auto; - min-height: 44px; + min-height: 50px; align-items: center; + padding-bottom: 4px; } +.queue-list::-webkit-scrollbar { height: 4px; } +.queue-list::-webkit-scrollbar-thumb { background: rgba(0,229,255,0.3); border-radius: 2px; } .queue-item { flex-shrink: 0; - width: 44px; - height: 44px; + width: 48px; + height: 48px; display: flex; align-items: center; justify-content: center; - background: #1a3355; - border: 2px solid #3366aa; - border-radius: 10px; - font-size: 13px; + background: linear-gradient(160deg, rgba(0,40,60,0.85), rgba(0,15,30,0.85)); + border: 1px solid rgba(0,229,255,0.4); + border-radius: var(--r-md); + font-size: 18px; font-weight: bold; position: relative; cursor: pointer; + color: var(--primary); + text-shadow: 0 0 8px rgba(0,229,255,0.6); + box-shadow: 0 0 8px rgba(0,229,255,0.2); + font-family: var(--f-mono); } -.queue-item:active { - background: #442222; - border-color: #ff4444; -} -.queue-item .remove-hint { +.queue-item .step { position: absolute; - top: -4px; - right: -4px; - width: 18px; - height: 18px; - background: #ff4444; - color: #fff; + top: -6px; + left: -6px; + width: 18px; height: 18px; border-radius: 50%; + background: var(--primary); + color: #00121a; + font-family: var(--f-display); font-size: 10px; + font-weight: 800; display: flex; align-items: center; justify-content: center; + box-shadow: 0 0 8px rgba(0,229,255,0.7); +} +.queue-item .remove-hint { + position: absolute; + top: -5px; + right: -5px; + width: 18px; + height: 18px; + background: var(--danger); + color: #fff; + border-radius: 50%; + font-size: 11px; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 6px rgba(255,59,107,0.7); + font-family: var(--f-body); +} +.queue-empty { + color: var(--text-muted); + font-size: 12px; + font-style: italic; + padding: 8px 0; } -/* Card hand */ +/* ── Card hand ───────────────────────────────────────────── */ .card-hand { - padding: 8px 12px; - border-top: 1px solid #1a1a44; - background: #0a0a28; + padding: 10px 12px 12px; + border-top: 1px solid rgba(0, 229, 255, 0.12); + background: rgba(10, 10, 36, 0.8); +} +.card-hand-label { + font-family: var(--f-display); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 6px; } .card-list { display: flex; gap: 8px; overflow-x: auto; + padding-bottom: 4px; } +.card-list::-webkit-scrollbar { height: 4px; } +.card-list::-webkit-scrollbar-thumb { background: rgba(0,229,255,0.3); border-radius: 2px; } .card-item { flex-shrink: 0; - width: 110px; - min-height: 60px; - padding: 8px 10px; - background: #151535; - border: 2px solid #334466; - border-radius: 12px; + width: 104px; + min-height: 90px; + padding: 8px 8px 10px; + background: linear-gradient(160deg, rgba(25,25,60,0.95), rgba(10,10,30,0.95)); + border: 1px solid rgba(0,229,255,0.2); + border-radius: var(--r-md); cursor: pointer; touch-action: manipulation; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + gap: 2px; + transition: transform 160ms ease, border-color 200ms ease, box-shadow 200ms ease, background 200ms ease; + position: relative; + font-family: inherit; + color: var(--text); + text-transform: none; + letter-spacing: 0; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.05); + padding-bottom: 8px; } +.card-item::before { + content: ""; + position: absolute; + inset: 0; + border-radius: var(--r-md); + background: linear-gradient(180deg, rgba(255,255,255,0.04), transparent 30%); + pointer-events: none; +} +.card-item:active { transform: translateY(2px) scale(0.97); } .card-item.selected { - border-color: #ffaa00; - background: #221a10; - box-shadow: 0 0 12px #ffaa0044; + border-color: var(--warning); + background: linear-gradient(160deg, rgba(70,50,0,0.7), rgba(30,20,0,0.85)); + box-shadow: 0 0 0 1px var(--warning), 0 0 18px rgba(255,204,0,0.45); + transform: translateY(-3px); } .card-item .card-icon { - font-size: 22px; - text-align: center; + font-size: 28px; + line-height: 1; + filter: drop-shadow(0 0 6px rgba(0,229,255,0.4)); } .card-item .card-name { - font-size: 11px; - font-weight: bold; - color: #ffaa00; + font-family: var(--f-display); + font-size: 10px; + font-weight: 700; + color: var(--primary); text-align: center; + letter-spacing: 0.08em; + text-transform: uppercase; } .card-item .card-cost { + font-family: var(--f-mono); font-size: 10px; - color: #8888aa; - text-align: center; + font-weight: 500; + color: var(--text-muted); + letter-spacing: 0.06em; +} +.card-empty { + color: var(--text-muted); + font-size: 12px; + font-style: italic; + padding: 12px 0; } -/* Bottom row */ +/* ── Bottom row ──────────────────────────────────────────── */ .planning-bottom { display: flex; - gap: 8px; - padding: 8px 12px 16px; - border-top: 1px solid #1a1a44; - background: #0d0d35; + gap: 10px; + padding: 10px 12px 18px; + border-top: 1px solid rgba(0, 229, 255, 0.12); + background: linear-gradient(180deg, rgba(13,13,40,0.92), rgba(5,5,20,0.98)); } .btn-clear { flex: 1; - padding: 14px; - font-size: 16px; - font-family: monospace; - font-weight: bold; - border: 2px solid #553333; - border-radius: 12px; - background: #221111; - color: #ff6666; + font-family: var(--f-display); + font-size: 14px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 16px; + border: 1px solid rgba(255,59,107,0.35); + border-radius: var(--r-md); + background: rgba(40,10,20,0.6); + color: var(--danger); cursor: pointer; + text-shadow: 0 0 8px rgba(255,59,107,0.5); + transition: transform 120ms ease, background 200ms ease; } +.btn-clear:active { transform: scale(0.97); background: rgba(70,15,30,0.85); } .btn-submit { flex: 2; - padding: 14px; - font-size: 20px; - font-family: monospace; - font-weight: bold; + font-family: var(--f-display); + font-size: 22px; + font-weight: 800; + letter-spacing: 0.2em; + text-transform: uppercase; + padding: 16px; border: none; - border-radius: 12px; + border-radius: var(--r-md); cursor: pointer; - background: #00cc44; - color: #000; + background: linear-gradient(135deg, var(--accent) 0%, #b81fa0 100%); + color: #fff; + box-shadow: var(--glow-accent); + position: relative; + overflow: hidden; + animation: goPulse 2.4s ease-in-out infinite; } .btn-submit:disabled { - background: #223344; - color: #556677; + background: linear-gradient(135deg, #1a1a3a, #232345); + color: var(--text-muted); + box-shadow: none; cursor: not-allowed; + animation: none; +} +@keyframes goPulse { + 0%, 100% { box-shadow: var(--glow-accent); } + 50% { box-shadow: 0 0 24px rgba(255,43,214,0.85), 0 0 48px rgba(255,43,214,0.45); } } -/* Waiting Screen */ -#screen-waiting { align-items: center; justify-content: center; } -#waiting-message { font-size: 16px; color: #8888aa; margin-top: 12px; } +/* ═══════════════════════════════════════════════════════════ + Waiting Screen + ═══════════════════════════════════════════════════════════ */ +#screen-waiting { align-items: center; justify-content: center; gap: 16px; padding: 40px; } +.tv-icon { + width: 80px; height: 56px; + border: 2px solid var(--primary); + border-radius: var(--r-md); + position: relative; + box-shadow: var(--glow-primary); + animation: tvFlicker 1.4s ease-in-out infinite; +} +.tv-icon::before { + content: ""; + position: absolute; + left: 6px; right: 6px; top: 6px; bottom: 14px; + background: radial-gradient(circle at 30% 30%, rgba(0,229,255,0.6), transparent 60%); + border-radius: 3px; +} +.tv-icon::after { + content: ""; + position: absolute; + left: 35%; right: 35%; bottom: -10px; + height: 6px; + background: var(--primary); + border-radius: 0 0 4px 4px; + box-shadow: 0 0 8px rgba(0,229,255,0.6); +} +@keyframes tvFlicker { + 0%, 100% { box-shadow: var(--glow-primary); } + 50% { box-shadow: 0 0 24px rgba(0,229,255,0.85); } +} +#screen-waiting h2 { + margin: 0; + text-shadow: var(--glow-primary); +} +#waiting-message { + text-align: center; + font-size: 14px; + color: var(--text-dim); + max-width: 320px; + line-height: 1.5; + font-family: var(--f-mono); + letter-spacing: 0.04em; +} -/* Spectator Screen */ -#screen-spectator { align-items: center; justify-content: center; } -#screen-spectator p { color: #8888aa; margin-top: 8px; } +/* ═══════════════════════════════════════════════════════════ + Spectator Screen + ═══════════════════════════════════════════════════════════ */ +#screen-spectator { align-items: center; justify-content: center; gap: 14px; padding: 40px; text-align: center; } +#screen-spectator h2 { + font-size: 32px; + margin: 0; + text-shadow: 0 0 18px rgba(255,204,0,0.6); +} +#screen-spectator h2.dead { + color: var(--text-muted); + text-shadow: 0 0 12px rgba(160,160,200,0.4); +} +#screen-spectator p { color: var(--text-dim); max-width: 320px; line-height: 1.5; font-size: 14px; } -/* Lobby Screen */ -#screen-lobby { align-items: center; padding-top: 40px; } -#lobby-room { font-size: 32px; font-weight: bold; color: #00ccff; margin: 8px 0; letter-spacing: 8px; } -#lobby-players { width: 100%; max-width: 320px; margin: 16px 0; } -#lobby-player-list { list-style: none; } -#lobby-player-list li { padding: 8px; border-bottom: 1px solid #223355; font-size: 16px; } -#lobby-waiting { color: #8888aa; margin-top: 16px; font-size: 14px; } +/* ═══════════════════════════════════════════════════════════ + Utility β€” used by LobbyScreen.ts injected element + ═══════════════════════════════════════════════════════════ */ +.audio-toggle { + position: fixed; + top: 12px; + right: 12px; + z-index: 50; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bg-glass-strong); + border: 1px solid rgba(0, 229, 255, 0.3); + box-shadow: 0 0 12px rgba(0, 229, 255, 0.25); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + padding: 0; + color: inherit; + text-transform: none; + letter-spacing: 0; + cursor: pointer; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + transition: transform 120ms ease, border-color 200ms ease, box-shadow 200ms ease; +} +.audio-toggle:active { transform: scale(0.9); } +.audio-toggle .audio-icon-off { display: none; } +body.audio-muted .audio-toggle .audio-icon-on { display: none; } +body.audio-muted .audio-toggle .audio-icon-off { display: inline; } +body.audio-muted .audio-toggle { + border-color: rgba(255, 59, 107, 0.4); + box-shadow: 0 0 8px rgba(255, 59, 107, 0.25); +} + +.start-btn-neon { + font-family: var(--f-display); + font-size: 18px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 16px 32px; + border: none; + border-radius: var(--r-md); + cursor: pointer; + background: linear-gradient(135deg, var(--accent) 0%, #b81fa0 100%); + color: #fff; + box-shadow: var(--glow-accent); +} +.start-btn-neon:disabled { + background: linear-gradient(135deg, #1a1a3a, #232345); + color: var(--text-muted); + box-shadow: none; + cursor: not-allowed; +} diff --git a/controller/vite.config.ts b/controller/vite.config.ts index 7f77c23..56899ed 100644 --- a/controller/vite.config.ts +++ b/controller/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ + base: '/controller/', server: { port: 3001, proxy: { diff --git a/package-lock.json b/package-lock.json index e9055f6..93669d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2911,6 +2911,7 @@ "dependencies": { "@spacerace/shared": "*", "phaser": "^3.80.1", + "qrcode-generator": "^1.5.2", "socket.io-client": "^4.7.5" }, "devDependencies": { diff --git a/server/Dockerfile b/server/Dockerfile index 3313995..04b607f 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,10 +1,20 @@ -FROM node:22-alpine AS server-build +FROM node:22-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ COPY shared/ ./shared/ COPY server/ ./server/ -RUN npm ci --workspace=server --workspace=shared -RUN npm -w shared run build 2>/dev/null || true -WORKDIR /app/server +COPY tv/ ./tv/ +COPY controller/ ./controller/ +RUN npm ci +RUN npm run build + +FROM node:22-alpine +WORKDIR /app +COPY --from=build /app/package.json /app/package-lock.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/shared ./shared +COPY --from=build /app/server ./server +COPY --from=build /app/tv/dist ./tv/dist +COPY --from=build /app/controller/dist ./controller/dist EXPOSE 8080 -CMD ["npx", "tsx", "src/index.ts"] +CMD ["npx", "tsx", "server/src/index.ts"] diff --git a/server/src/game/CardHandler.ts b/server/src/game/CardHandler.ts index 47f1074..a28d489 100644 --- a/server/src/game/CardHandler.ts +++ b/server/src/game/CardHandler.ts @@ -4,88 +4,139 @@ import { Ship } from './Ship.js'; export interface CardContext { ship: Ship; - target?: Position; grid: Grid; allShips: Ship[]; shipStates: Map; } -export function executeCard(cardType: CardType, ctx: CardContext): { success: boolean; message: string; gridUpdates: { position: Position; tile: string }[] } { - const def = CARD_DEFS[cardType]; - if (!def) return { success: false, message: 'Unknown card', gridUpdates: [] }; +export type CardEvent = + | { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string } + | { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' }; - switch (cardType) { - case 'METEOR_STRIKE': { - if (!ctx.target) return { success: false, message: 'No target for Meteor Strike', gridUpdates: [] }; - // Check range: within 3 tiles of ship - const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y); - if (dist > 3) return { success: false, message: 'Target out of range', gridUpdates: [] }; - ctx.grid.setTile(ctx.target, 'meteor'); - return { success: true, message: `${ctx.ship.playerId} summoned a meteor at (${ctx.target.x},${ctx.target.y})`, gridUpdates: [{ position: ctx.target, tile: 'meteor' }] }; - } - - case 'SHIELD': { - ctx.ship.shielded = true; - return { success: true, message: `${ctx.ship.playerId} activated shield`, gridUpdates: [] }; - } - - case 'EMP': { - ctx.ship.empActive = true; - return { success: true, message: `${ctx.ship.playerId} activated EMP`, gridUpdates: [] }; - } - - case 'JUMP': { - const dir = ctx.ship.direction; - const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0; - const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0; - - const jumpPos: Position = { - x: ctx.ship.position.x + dx * 2, - y: ctx.ship.position.y + dy * 2, - }; - - if (!ctx.grid.isInBounds(jumpPos)) return { success: false, message: 'Jump out of bounds', gridUpdates: [] }; - - ctx.ship.position = jumpPos; - return { success: true, message: `${ctx.ship.playerId} jumped 2 tiles forward`, gridUpdates: [] }; - } - - case 'MINE': { - ctx.grid.setTile(ctx.ship.position, 'mine'); - return { success: true, message: `${ctx.ship.playerId} dropped a mine`, gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }] }; - } - - case 'TELEPORT': { - if (!ctx.target) return { success: false, message: 'No target for Teleport', gridUpdates: [] }; - const targetX = ctx.target!.x; - const targetY = ctx.target!.y; - const targetShip = ctx.allShips.find( - (s) => s.alive && s.id !== ctx.ship.id && - s.position.x === targetX && s.position.y === targetY - ); - if (!targetShip) return { success: false, message: 'No ship at target location', gridUpdates: [] }; - const dist = Math.abs(ctx.ship.position.x - ctx.target.x) + Math.abs(ctx.ship.position.y - ctx.target.y); - if (dist > 5) return { success: false, message: 'Target out of range for Teleport', gridUpdates: [] }; - - // Check EMP on target ship - const targetState = ctx.shipStates.get(targetShip.id); - if (targetState?.empActive) { - targetState.empActive = false; - return { success: false, message: `Teleport blocked by ${targetShip.playerId}'s EMP`, gridUpdates: [] }; - } - - const myPos = { ...ctx.ship.position }; - ctx.ship.position = { ...targetShip.position }; - targetShip.position = myPos; - return { success: true, message: `${ctx.ship.playerId} teleported with ${targetShip.playerId}`, gridUpdates: [] }; - } - - case 'PHASE_SHIFT': { - ctx.ship.phaseShifting = true; - return { success: true, message: `${ctx.ship.playerId} activated Phase Shift`, gridUpdates: [] }; - } - - default: - return { success: false, message: 'Unknown card', gridUpdates: [] }; - } +export interface CardResult { + success: boolean; + message: string; + gridUpdates: { position: Position; tile: string }[]; + events: CardEvent[]; } + +function cardEvent(cardType: CardType, ctx: CardContext, position: Position, targetId?: string): CardEvent { + return { type: 'card_played', shipId: ctx.ship.id, card: cardType, position, targetId }; +} + +function moveEvent(ctx: CardContext, from: Position, to: Position, source: 'walk' | 'boost' | 'jump'): CardEvent { + return { type: 'move', shipId: ctx.ship.id, from, to, source }; +} + +type CardHandler = (ctx: CardContext) => CardResult; + +const SHIELD: CardHandler = (ctx) => { + ctx.ship.shielded = true; + return { + success: true, + message: `${ctx.ship.playerId} activated Shield`, + gridUpdates: [], + events: [cardEvent('SHIELD', ctx, { ...ctx.ship.position })], + }; +}; + +const EMP: CardHandler = (ctx) => { + ctx.ship.empActive = true; + return { + success: true, + message: `${ctx.ship.playerId} activated EMP`, + gridUpdates: [], + events: [cardEvent('EMP', ctx, { ...ctx.ship.position })], + }; +}; + +const JUMP: CardHandler = (ctx) => { + const dir = ctx.ship.direction; + const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0; + const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0; + + const jumpPos: Position = { + x: ctx.ship.position.x + dx * 2, + y: ctx.ship.position.y + dy * 2, + }; + + if (!ctx.grid.isInBounds(jumpPos)) { + return { success: false, message: 'Jump out of bounds', gridUpdates: [], events: [] }; + } + + const fromPos = { ...ctx.ship.position }; + ctx.ship.position = jumpPos; + return { + success: true, + message: `${ctx.ship.playerId} jumped 2 tiles forward`, + gridUpdates: [], + events: [ + cardEvent('JUMP', ctx, { ...jumpPos }, `${fromPos.x},${fromPos.y}`), + moveEvent(ctx, fromPos, jumpPos, 'jump'), + ], + }; +}; + +const MINE: CardHandler = (ctx) => { + ctx.grid.setTile(ctx.ship.position, 'mine'); + return { + success: true, + message: `${ctx.ship.playerId} dropped a mine`, + gridUpdates: [{ position: ctx.ship.position, tile: 'mine' }], + events: [cardEvent('MINE', ctx, { ...ctx.ship.position })], + }; +}; + +const BOOST: CardHandler = (ctx) => { + const dir = ctx.ship.direction; + const dx = dir === 'N' ? -1 : dir === 'S' ? 1 : 0; + const dy = dir === 'E' ? 1 : dir === 'W' ? -1 : 0; + + const boostPos: Position = { + x: ctx.ship.position.x + dx * 3, + y: ctx.ship.position.y + dy * 3, + }; + + if (!ctx.grid.isInBounds(boostPos)) { + return { success: false, message: 'Boost out of bounds', gridUpdates: [], events: [] }; + } + + const fromPos = { ...ctx.ship.position }; + ctx.ship.position = boostPos; + return { + success: true, + message: `${ctx.ship.playerId} boosted forward`, + gridUpdates: [], + events: [ + cardEvent('BOOST', ctx, { ...boostPos }, `${fromPos.x},${fromPos.y}`), + moveEvent(ctx, fromPos, boostPos, 'boost'), + ], + }; +}; + +const PHASE_SHIFT: CardHandler = (ctx) => { + ctx.ship.phaseShifting = true; + return { + success: true, + message: `${ctx.ship.playerId} activated Phase Shift`, + gridUpdates: [], + events: [cardEvent('PHASE_SHIFT', ctx, { ...ctx.ship.position })], + }; +}; + +const HANDLERS: Record = { + 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); +} \ No newline at end of file diff --git a/server/src/game/Executor.ts b/server/src/game/Executor.ts index 4ddf371..702cad0 100644 --- a/server/src/game/Executor.ts +++ b/server/src/game/Executor.ts @@ -76,9 +76,11 @@ export class Executor { const ship = this.ships.get(shipId); if (!ship || !ship.alive) continue; + // Stash action on the ship so CardHandler can read .target for METEOR_STRIKE + (ship as any).lastAction = action; + const ctx = { ship, - target: action.target, grid: this.grid, allShips: Array.from(this.ships.values()), shipStates: this.ships, @@ -90,6 +92,9 @@ export class Executor { for (const gu of cardResult.gridUpdates) { result.gridUpdates.push({ type: 'tile_change', position: gu.position, tile: gu.tile as any }); } + for (const ev of cardResult.events) { + result.shipUpdates.push(ev); + } } else { result.messages.push(`[${ship.playerId}] Card failed: ${cardResult.message}`); } @@ -118,15 +123,6 @@ export class Executor { intents.push({ shipId, from: { ...ship.position }, to }); break; } - case 'BOOST': { - const delta = DIRECTION_DELTA[ship.direction]; - const to: Position = { - x: ship.position.x + delta.x * 3, - y: ship.position.y + delta.y * 3, - }; - intents.push({ shipId, from: { ...ship.position }, to }); - break; - } case 'TURN_LEFT': { const dirs: Direction[] = ['N', 'W', 'S', 'E']; const idx = dirs.indexOf(ship.direction); diff --git a/server/src/index.ts b/server/src/index.ts index 4abae3b..13fb1aa 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,5 +1,6 @@ import express from 'express'; import cors from 'cors'; +import path from 'path'; import { createServer } from 'http'; import { WsServer } from './ws/WsServer.js'; import { GAME_CONFIG } from './config.js'; @@ -8,6 +9,13 @@ const app = express(); app.use(cors()); app.use(express.json()); +// Serve controller frontend +app.use('/controller', express.static(path.join(__dirname, '../../controller/dist'))); + +// Serve TV frontend at root +app.use('/', express.static(path.join(__dirname, '../../tv/dist'))); +app.get('/', (_req, res) => res.sendFile(path.join(__dirname, '../../tv/dist/index.html'))); + const httpServer = createServer(app); const wsServer = new WsServer(httpServer); diff --git a/shared/src/audio.ts b/shared/src/audio.ts new file mode 100644 index 0000000..e4d7f31 --- /dev/null +++ b/shared/src/audio.ts @@ -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> = { + 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); + } +} \ No newline at end of file diff --git a/shared/src/index.ts b/shared/src/index.ts index d470296..e0ace6c 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -1 +1,3 @@ export * from './types.js'; +export * from './theme.js'; +export * from './audio.js'; diff --git a/shared/src/theme.ts b/shared/src/theme.ts new file mode 100644 index 0000000..e1605e5 --- /dev/null +++ b/shared/src/theme.ts @@ -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; diff --git a/shared/src/types.ts b/shared/src/types.ts index 9106ada..a85c30c 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -58,13 +58,11 @@ export interface Ship { // ── Cards ── export type CardType = - | 'METEOR_STRIKE' | 'SHIELD' | 'BOOST' | 'EMP' | 'JUMP' | 'MINE' - | 'TELEPORT' | 'PHASE_SHIFT'; export interface CardDef { @@ -75,12 +73,6 @@ export interface CardDef { } export const CARD_DEFS: Record = { - METEOR_STRIKE: { - type: 'METEOR_STRIKE', - name: 'Meteor Strike', - description: 'Summon a meteor on any tile within 3 tiles of your ship', - apCost: 2, - }, SHIELD: { type: 'SHIELD', name: 'Shield', @@ -111,12 +103,6 @@ export const CARD_DEFS: Record = { description: 'Drop a mine on your current tile. Explodes next round.', apCost: 1, }, - TELEPORT: { - type: 'TELEPORT', - name: 'Teleport', - description: 'Swap positions with any player within 5 tiles', - apCost: 3, - }, PHASE_SHIFT: { type: 'PHASE_SHIFT', name: 'Phase Shift', @@ -126,12 +112,11 @@ export const CARD_DEFS: Record = { }; // ── Actions ── -export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'BOOST' | 'CARD'; +export type ActionType = 'MOVE_FORWARD' | 'TURN_LEFT' | 'TURN_RIGHT' | 'TURN_180' | 'CARD'; export interface Action { type: ActionType; card?: CardType; // when type === 'CARD' - target?: Position; // for targeted cards (METEOR_STRIKE, TELEPORT) } export function actionApCost(action: Action): number { @@ -140,7 +125,6 @@ export function actionApCost(action: Action): number { case 'TURN_LEFT': return 1; case 'TURN_RIGHT': return 1; case 'TURN_180': return 2; - case 'BOOST': return 2; case 'CARD': return action.card ? CARD_DEFS[action.card].apCost : 0; default: return 0; @@ -227,11 +211,12 @@ export interface MobileToServerEvents { // Execution updates export type ShipUpdate = - | { type: 'move'; shipId: string; from: Position; to: Position } + | { type: 'move'; shipId: string; from: Position; to: Position; source?: 'walk' | 'boost' | 'jump' } | { type: 'turn'; shipId: string; direction: Direction } | { type: 'eliminated'; shipId: string; position: Position; reason: string } | { type: 'collision'; shipId: string; position: Position } - | { type: 'shield_used'; shipId: string }; + | { type: 'shield_used'; shipId: string } + | { type: 'card_played'; shipId: string; card: CardType; position: Position; targetId?: string }; export type GridUpdate = | { type: 'tile_change'; position: Position; tile: TileType } diff --git a/tv/index.html b/tv/index.html index 468f314..e03dede 100644 --- a/tv/index.html +++ b/tv/index.html @@ -4,14 +4,40 @@ SpaceRace - TV + + + + -
    +
    + +
    diff --git a/tv/package.json b/tv/package.json index 4c8574b..62d08bc 100644 --- a/tv/package.json +++ b/tv/package.json @@ -10,6 +10,7 @@ "dependencies": { "@spacerace/shared": "*", "phaser": "^3.80.1", + "qrcode-generator": "^1.5.2", "socket.io-client": "^4.7.5" }, "devDependencies": { diff --git a/tv/src/effects/EffectRenderer.ts b/tv/src/effects/EffectRenderer.ts new file mode 100644 index 0000000..b22fa56 --- /dev/null +++ b/tv/src/effects/EffectRenderer.ts @@ -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(), + }); + } +} \ No newline at end of file diff --git a/tv/src/objects/GridRenderer.ts b/tv/src/objects/GridRenderer.ts index 8773e8f..3dbaaff 100644 --- a/tv/src/objects/GridRenderer.ts +++ b/tv/src/objects/GridRenderer.ts @@ -4,6 +4,7 @@ import { TileType, Position, GRID_WIDTH, + COLORS, } from '@spacerace/shared'; const TILE_SIZE = 64; @@ -18,7 +19,9 @@ export class GridRenderer { private fieldContainer: Phaser.GameObjects.Container; private shipLayer: Phaser.GameObjects.Container; private deathLineRect: Phaser.GameObjects.Rectangle | null = null; + private deathLineGlow: Phaser.GameObjects.Rectangle | null = null; private deathLineY: number = 0; + private gridOverlay: Phaser.GameObjects.Graphics | null = null; constructor(scene: Phaser.Scene, offsetY: number, tileSize: number) { this.scene = scene; @@ -70,6 +73,9 @@ export class GridRenderer { } } + // Draw grid lines overlay once + this.renderGridOverlay(); + if (scrollInPx > 0) { this.fieldContainer.x = -scrollInPx; this.shipLayer.x = -scrollInPx; @@ -88,29 +94,88 @@ export class GridRenderer { } private renderCell(col: number, lane: number, tile: TileType, screenX: number, screenY: number): void { - const bgAlpha = tile === 'space' ? 0.12 : 0.35; - const bgColor = - tile === 'asteroid' ? 0x555555 : - tile === 'meteor' ? 0x884400 : - tile === 'mine' ? 0x885500 : - 0x1a1a4e; + if (tile === 'space') { + // Subtle dark base + const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, 0x0a0a2e, 0.5); + bg.setStrokeStyle(1, 0x1a1a4e, 0.5); + this.fieldContainer.add(bg); + return; + } + + const textureName = + tile === 'asteroid' ? 'asteroid' : + tile === 'meteor' ? 'meteor' : + tile === 'mine' ? 'mine' : + 'asteroid'; + + // Tile background β€” gives the obstacle a contained feel + let bgColor: number; + let bgAlpha: number; + if (tile === 'asteroid') { bgColor = 0x2a2a3a; bgAlpha = 0.7; } + else if (tile === 'meteor') { bgColor = 0x3a1a0a; bgAlpha = 0.7; } + else if (tile === 'mine') { bgColor = 0x3a2a00; bgAlpha = 0.7; } + else { bgColor = 0x2a2a3a; bgAlpha = 0.7; } const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, bgColor, bgAlpha); - bg.setStrokeStyle(1, 0x223366, 0.2); + bg.setStrokeStyle(1, 0x223366, 0.6); this.fieldContainer.add(bg); - if (tile !== 'space') { - const textureName = - tile === 'asteroid' ? 'asteroid' : - tile === 'meteor' ? 'meteor' : - 'mine'; + // Slight random rotation/scale for visual variety (deterministic by lane/col) + const sprite = this.scene.add.sprite(screenX, screenY, textureName); + sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12); + const angle = ((col * 13 + lane * 37) % 60) - 30; // Β±30Β° + sprite.setAngle(angle); + this.fieldContainer.add(sprite); - const sprite = this.scene.add.sprite(screenX, screenY, textureName); - sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12); - this.fieldContainer.add(sprite); + // Glow for dangerous tiles + if (tile === 'meteor' || tile === 'mine') { + const glow = this.scene.add.circle(screenX, screenY, this.tileSize * 0.5, + tile === 'meteor' ? 0xff6a1a : 0xffaa00, 0.18); + glow.setBlendMode(Phaser.BlendModes.ADD); + this.fieldContainer.add(glow); } } + private renderGridOverlay(): void { + if (this.gridOverlay) this.gridOverlay.destroy(); + const g = this.scene.add.graphics(); + g.setDepth(0.5); + + const w = this.scene.scale.width; + const h = this.scene.scale.height; + + // Outer border around the play area + const playLeft = DEATH_LINE_SCREEN_X; + const playTop = this.offsetY - 4; + const playRight = DEATH_LINE_SCREEN_X + VISIBLE_COLS * this.tileSize; + const playBottom = this.offsetY + GRID_HEIGHT * this.tileSize + 4; + + g.lineStyle(1, 0x00e5ff, 0.2); + g.strokeRect(playLeft, playTop, playRight - playLeft, playBottom - playTop); + + // Lane separators (horizontal) + for (let i = 1; i < GRID_HEIGHT; i++) { + const y = this.offsetY + i * this.tileSize; + g.lineStyle(1, 0x223366, 0.35); + g.beginPath(); + g.moveTo(playLeft, y); + g.lineTo(playRight, y); + g.strokePath(); + } + + // Column separators (vertical) β€” light, only for visible area + g.lineStyle(1, 0x1a1a4e, 0.4); + for (let i = 0; i <= VISIBLE_COLS; i++) { + const x = playLeft + i * this.tileSize; + g.beginPath(); + g.moveTo(x, playTop); + g.lineTo(x, playBottom); + g.strokePath(); + } + + this.gridOverlay = g; + } + updateTile(pos: Position, tile: TileType): void { const screenX = this.gridXToScreen(pos.y); const screenY = this.gridYToScreen(pos.x); @@ -120,20 +185,25 @@ export class GridRenderer { private renderDeathLine(): void { if (this.deathLineRect) this.deathLineRect.destroy(); + if (this.deathLineGlow) this.deathLineGlow.destroy(); - this.deathLineRect = this.scene.add.rectangle( - DEATH_LINE_SCREEN_X, - this.offsetY + (GRID_HEIGHT * this.tileSize) / 2, - 6, - GRID_HEIGHT * this.tileSize, - 0xff0000, - 0.9 - ); + const x = DEATH_LINE_SCREEN_X; + const yCenter = this.offsetY + (GRID_HEIGHT * this.tileSize) / 2; + const h = GRID_HEIGHT * this.tileSize; + + // Outer glow + this.deathLineGlow = this.scene.add.rectangle(x, yCenter, 16, h, 0xff3b6b, 0.25); + this.deathLineGlow.setBlendMode(Phaser.BlendModes.ADD); + this.deathLineGlow.setDepth(19); + + // Main line + this.deathLineRect = this.scene.add.rectangle(x, yCenter, 4, h, 0xff3b6b, 0.95); this.deathLineRect.setDepth(20); + // Pulse animation this.scene.tweens.add({ - targets: this.deathLineRect, - alpha: 0.3, + targets: this.deathLineGlow, + alpha: { from: 0.2, to: 0.55 }, duration: 600, yoyo: true, repeat: -1, diff --git a/tv/src/objects/ShipSprite.ts b/tv/src/objects/ShipSprite.ts index 357a8f0..37b643a 100644 --- a/tv/src/objects/ShipSprite.ts +++ b/tv/src/objects/ShipSprite.ts @@ -1,9 +1,12 @@ import Phaser from 'phaser'; import { Ship as ShipType } from '@spacerace/shared'; +import { FONTS } from '@spacerace/shared'; export class ShipSprite extends Phaser.GameObjects.Container { private shipData: ShipType; private label: Phaser.GameObjects.Text; + private glow: Phaser.GameObjects.Arc; + private body: Phaser.GameObjects.Sprite; constructor( scene: Phaser.Scene, @@ -18,25 +21,42 @@ export class ShipSprite extends Phaser.GameObjects.Container { const angles: Record = { N: 0, E: 90, S: 180, W: 270 }; const baseAngle = angles[ship.direction] || 0; - // Ship body - const body = scene.add.rectangle(0, 0, 40, 36, color); - body.setStrokeStyle(2, 0xffffff); + // Glow halo behind the ship (uses player's color) + this.glow = scene.add.circle(0, 0, 28, color, 0.35); + this.glow.setBlendMode(Phaser.BlendModes.ADD); + this.glow.setDepth(-1); - // Direction indicator (small triangle) - const indicator = scene.add.triangle(0, -22, 0, 8, 5, 0, 10, 8, 0xffffff); + // Ship body β€” uses the procedurally generated 'ship' texture + this.body = scene.add.sprite(0, 0, 'ship'); + // Recolor the cyan body to the player's color via tint (preserves the white outline) + this.body.setTint(color); - this.add([body, indicator]); + this.add([this.glow, this.body]); + + // Player name label in a pill below + const name = ship.playerName || ship.playerId.substring(0, 6); + const labelBg = scene.add.rectangle(0, 30, Math.max(48, name.length * 7 + 14), 18, 0x000000, 0.7); + labelBg.setStrokeStyle(1, color, 0.9); + this.label = scene.add.text(0, 30, name, { + fontFamily: FONTS.body, + fontSize: '12px', + color: '#ffffff', + fontStyle: 'bold', + }).setOrigin(0.5); + + this.add([labelBg, this.label]); this.setAngle(baseAngle); - // Player name label - this.label = scene.add.text(0, 28, ship.playerName || ship.playerId.substring(0, 6), { - fontSize: '11px', - color: '#ffffff', - fontFamily: 'monospace', - backgroundColor: '#00000088', - padding: { x: 2, y: 1 }, - }).setOrigin(0.5); - this.add(this.label); + // Gentle pulse on the halo + scene.tweens.add({ + targets: this.glow, + scaleX: { from: 0.85, to: 1.15 }, + scaleY: { from: 0.85, to: 1.15 }, + alpha: { from: 0.25, to: 0.5 }, + duration: 1400, + yoyo: true, + repeat: -1, + }); scene.add.existing(this); this.setDepth(10); diff --git a/tv/src/scenes/BootScene.ts b/tv/src/scenes/BootScene.ts index dfc3d08..097b91b 100644 --- a/tv/src/scenes/BootScene.ts +++ b/tv/src/scenes/BootScene.ts @@ -1,68 +1,161 @@ import Phaser from 'phaser'; +import { COLORS, AudioEngine } from '@spacerace/shared'; import { connectTvSocket, TvSocket } from '../network/TvSocket.js'; +import { createStarfield } from './Starfield.js'; export class BootScene extends Phaser.Scene { socket!: TvSocket; + audio!: AudioEngine; constructor() { super({ key: 'BootScene' }); } preload(): void { - // Generate placeholder assets as textures this.createPlaceholderTextures(); } create(): void { this.socket = connectTvSocket(); - this.scene.start('LobbyScene', { socket: this.socket }); + this.audio = new AudioEngine(); + + // Browsers require a user gesture before audio context creation. + // Wire any pointer/touch on the canvas to unlock it. + const unlock = (): void => { + this.audio.unlock(); + this.input.off('pointerdown', unlock); + this.input.off('touchstart', unlock); + }; + this.input.on('pointerdown', unlock); + this.input.on('touchstart', unlock); + + createStarfield(this); + this.scene.start('LobbyScene', { socket: this.socket, audio: this.audio }); } private createPlaceholderTextures(): void { - // Ship placeholder (triangle pointing up) + // ── Ship β€” angular hull pointing up, with cockpit and engines ── + // Anchor at center. Heading "up" = +Y in texture (ship sprite is rotated by direction). const shipGfx = this.make.graphics({ x: 0, y: 0, add: false }); - shipGfx.fillStyle(0x00ccff); - shipGfx.fillTriangle(20, 0, 0, 36, 40, 36); - shipGfx.generateTexture('ship', 40, 36); + // Outer hull (white outline) β€” drawn first + shipGfx.fillStyle(0xffffff, 1); + shipGfx.beginPath(); + shipGfx.moveTo(20, 0); + shipGfx.lineTo(38, 14); + shipGfx.lineTo(34, 32); + shipGfx.lineTo(30, 36); + shipGfx.lineTo(10, 36); + shipGfx.lineTo(6, 32); + shipGfx.lineTo(2, 14); + shipGfx.closePath(); + shipGfx.fillPath(); + // Fill cutout by drawing a slightly smaller version of the hull on top with a transparent rect + shipGfx.fillStyle(0x00e5ff, 1); + shipGfx.beginPath(); + shipGfx.moveTo(20, 4); + shipGfx.lineTo(34, 16); + shipGfx.lineTo(31, 30); + shipGfx.lineTo(9, 30); + shipGfx.lineTo(6, 16); + shipGfx.closePath(); + shipGfx.fillPath(); + // Cockpit window + shipGfx.fillStyle(0x00121a, 1); + shipGfx.fillCircle(20, 14, 4); + shipGfx.fillStyle(0x00e5ff, 0.7); + shipGfx.fillCircle(20, 14, 2.5); + // Engine glow + shipGfx.fillStyle(0xff2bd6, 0.9); + shipGfx.fillCircle(13, 33, 3); + shipGfx.fillCircle(27, 33, 3); + shipGfx.fillStyle(0xffcc00, 1); + shipGfx.fillCircle(13, 33, 1.5); + shipGfx.fillCircle(27, 33, 1.5); + shipGfx.generateTexture('ship', 40, 40); shipGfx.destroy(); - // Asteroid placeholder (rough circle) + // ── Asteroid β€” irregular rocky shape ── const asteroidGfx = this.make.graphics({ x: 0, y: 0, add: false }); - asteroidGfx.fillStyle(0x888888); - asteroidGfx.fillCircle(20, 20, 18); + asteroidGfx.fillStyle(0xffffff, 1); + asteroidGfx.beginPath(); + const aShape = [ + [20, 3], [30, 8], [36, 18], [34, 28], [28, 35], [18, 36], + [8, 32], [3, 22], [5, 12], [12, 6] + ]; + asteroidGfx.moveTo(aShape[0][0], aShape[0][1]); + for (let i = 1; i < aShape.length; i++) asteroidGfx.lineTo(aShape[i][0], aShape[i][1]); + asteroidGfx.closePath(); + asteroidGfx.fillPath(); + asteroidGfx.fillStyle(COLORS.asteroid, 1); + const aShapeIn = [ + [20, 7], [28, 11], [32, 19], [30, 27], [26, 31], [18, 32], + [10, 28], [7, 21], [9, 13], [14, 9] + ]; + asteroidGfx.moveTo(aShapeIn[0][0], aShapeIn[0][1]); + for (let i = 1; i < aShapeIn.length; i++) asteroidGfx.lineTo(aShapeIn[i][0], aShapeIn[i][1]); + asteroidGfx.closePath(); + asteroidGfx.fillPath(); + // Crater detail + asteroidGfx.fillStyle(0x4a4a5a, 0.8); + asteroidGfx.fillCircle(14, 18, 2); + asteroidGfx.fillCircle(24, 22, 1.5); + asteroidGfx.fillCircle(20, 12, 1); asteroidGfx.generateTexture('asteroid', 40, 40); asteroidGfx.destroy(); - // Meteor placeholder (red circle) + // ── Meteor β€” burning rock with flame tail ── const meteorGfx = this.make.graphics({ x: 0, y: 0, add: false }); - meteorGfx.fillStyle(0xff4400); - meteorGfx.fillCircle(20, 20, 18); + // Flame tail (fading yellow β†’ red) + meteorGfx.fillStyle(0xffcc00, 0.4); + meteorGfx.fillTriangle(20, 6, 4, 36, 16, 36); + meteorGfx.fillStyle(0xff6600, 0.7); + meteorGfx.fillTriangle(20, 8, 8, 36, 18, 36); + meteorGfx.fillStyle(0xff3b00, 0.95); + meteorGfx.fillTriangle(20, 12, 14, 36, 22, 36); + // Core + meteorGfx.fillStyle(0xffffff, 1); + meteorGfx.fillCircle(20, 18, 11); + meteorGfx.fillStyle(0xff8c1a, 1); + meteorGfx.fillCircle(20, 18, 8); + meteorGfx.fillStyle(0xffcc00, 0.8); + meteorGfx.fillCircle(20, 18, 5); meteorGfx.generateTexture('meteor', 40, 40); meteorGfx.destroy(); - // Mine placeholder (orange circle with X) + // ── Mine β€” spiky orange orb with warning glow ── const mineGfx = this.make.graphics({ x: 0, y: 0, add: false }); - mineGfx.fillStyle(0xff8800); - mineGfx.fillCircle(20, 20, 16); - mineGfx.lineStyle(3, 0x000000); - mineGfx.lineBetween(10, 10, 30, 30); - mineGfx.lineBetween(30, 10, 10, 30); + // Spikes (8-pointed star) + const cx = 20, cy = 20; + const spikes = 8; + const outer = 18, inner = 7; + mineGfx.fillStyle(0xffaa00, 1); + mineGfx.beginPath(); + for (let i = 0; i < spikes * 2; i++) { + const r = i % 2 === 0 ? outer : inner; + const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2; + const x = cx + Math.cos(a) * r; + const y = cy + Math.sin(a) * r; + if (i === 0) mineGfx.moveTo(x, y); + else mineGfx.lineTo(x, y); + } + mineGfx.closePath(); + mineGfx.fillPath(); + // Core + mineGfx.fillStyle(0x2a1500, 1); + mineGfx.fillCircle(cx, cy, 6); + // Warning dot + mineGfx.fillStyle(0xff3b6b, 1); + mineGfx.fillCircle(cx, cy, 2.5); mineGfx.generateTexture('mine', 40, 40); mineGfx.destroy(); - // Background tile - const bgGfx = this.make.graphics({ x: 0, y: 0, add: false }); - bgGfx.fillStyle(0x0a0a2e); - bgGfx.fillRect(0, 0, 64, 64); - bgGfx.lineStyle(1, 0x1a1a4e); - bgGfx.strokeRect(0, 0, 64, 64); - bgGfx.generateTexture('bg_tile', 64, 64); - bgGfx.destroy(); - - // Death line texture + // ── Death line texture β€” animated stripe ── const dlGfx = this.make.graphics({ x: 0, y: 0, add: false }); - dlGfx.fillStyle(0xff0000, 0.6); + dlGfx.fillStyle(0xff3b6b, 0.95); dlGfx.fillRect(0, 0, 512, 4); + // Inner highlight + dlGfx.fillStyle(0xffffff, 0.9); + dlGfx.fillRect(0, 1, 512, 1); dlGfx.generateTexture('death_line', 512, 4); dlGfx.destroy(); } diff --git a/tv/src/scenes/GameScene.ts b/tv/src/scenes/GameScene.ts index 46130bd..af879a4 100644 --- a/tv/src/scenes/GameScene.ts +++ b/tv/src/scenes/GameScene.ts @@ -7,61 +7,112 @@ import { GRID_WIDTH, ShipUpdate, GridUpdate, + COLORS, + FONTS, + AudioEngine, + CardType, } from '@spacerace/shared'; import { GridRenderer } from '../objects/GridRenderer.js'; import { ShipSprite } from '../objects/ShipSprite.js'; +import { EffectRenderer } from '../effects/EffectRenderer.js'; +import { createStarfield } from './Starfield.js'; +const W = 1280; +const H = 720; const TILE_SIZE = 64; -const GRID_OFFSET_Y = (720 - GRID_WIDTH * TILE_SIZE) / 2; +const GRID_OFFSET_Y = (H - GRID_WIDTH * TILE_SIZE) / 2; const ANIM_DURATION = 900; const DEATH_LINE_ANIM = 1800; export class GameScene extends Phaser.Scene { private socket!: TvSocket; + private audio!: AudioEngine; private gridRenderer!: GridRenderer; + private effects!: EffectRenderer; private shipSprites: Map = new Map(); private gridState!: GameGridState; private roundText!: Phaser.GameObjects.Text; - private statusText!: Phaser.GameObjects.Text; - private messageText!: Phaser.GameObjects.Text; + private phaseText!: Phaser.GameObjects.Text; + private phaseTextGlow!: Phaser.GameObjects.Text; + private messageLog: string[] = []; + private messageTexts: Phaser.GameObjects.Text[] = []; private initialGrid: GameGridState | null = null; private initialShips: ShipType[] | null = null; private execQueue: { updates: ShipUpdate[]; gridUpdates: GridUpdate[]; msgs: string[] }[] = []; private animating = false; private pendingDeathLineY: number | null = null; private pendingPlanning: { round: number; grid: GameGridState; ships: ShipType[] } | null = null; + private playerColors = new Map(); constructor() { super({ key: 'GameScene' }); } - init(data: { socket: TvSocket; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void { + init(data: { socket: TvSocket; audio: AudioEngine; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void { this.socket = data.socket; + this.audio = data.audio; this.initialGrid = data.grid ?? null; this.initialShips = data.ships ?? null; + + (data.players || []).forEach((p, i) => { + this.playerColors.set(p.id, COLORS.player[i % COLORS.player.length]); + }); } create(): void { - this.cameras.main.setBackgroundColor('#050515'); - this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE); + this.cameras.main.setBackgroundColor('#050514'); + createStarfield(this); - this.roundText = this.add.text(16, 16, 'Round 0', { - fontSize: '24px', color: '#ffffff', fontFamily: 'monospace', - }); - this.statusText = this.add.text(1280 / 2, 16, 'PLANNING PHASE', { - fontSize: '24px', color: '#ffcc00', fontFamily: 'monospace', fontStyle: 'bold', - }).setOrigin(0.5, 0); - this.messageText = this.add.text(16, 700, '', { - fontSize: '17px', color: '#cccccc', fontFamily: 'monospace', wordWrap: { width: 1248 }, - }); + this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE); + this.effects = new EffectRenderer( + this, + this.gridRenderer.field, + (gy) => this.gridRenderer.gridXToScreen(gy), + (gx) => this.gridRenderer.gridYToScreen(gx), + ); + + // ── HUD ── + this.add.rectangle(20, 20, 220, 56, 0x0a0a24, 0.85) + .setOrigin(0, 0) + .setStrokeStyle(1, 0x00e5ff, 0.5); + this.add.text(36, 36, 'ROUND', { + fontFamily: FONTS.display, fontSize: '12px', color: '#6a6a8a', + }).setOrigin(0, 0); + this.roundText = this.add.text(36, 52, '1', { + fontFamily: FONTS.display, fontSize: '26px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0, 0); + + const phaseX = W / 2; + const phaseY = 36; + this.phaseTextGlow = this.add.text(phaseX, phaseY, 'PLANNING PHASE', { + fontFamily: FONTS.display, fontSize: '28px', color: '#ffcc00', fontStyle: 'bold', + }).setOrigin(0.5); + this.phaseTextGlow.setShadow(0, 0, '#ffcc00', 18, true, true); + this.phaseTextGlow.setAlpha(0.6); + this.phaseText = this.add.text(phaseX, phaseY, 'PLANNING PHASE', { + fontFamily: FONTS.display, fontSize: '28px', color: '#ffcc00', fontStyle: 'bold', + }).setOrigin(0.5); + + this.add.text(W - 20, 28, 'PILOTS', { + fontFamily: FONTS.display, fontSize: '11px', color: '#6a6a8a', + }).setOrigin(1, 0); + + for (let i = 0; i < 3; i++) { + const t = this.add.text(20, H - 100 + i * 22, '', { + fontFamily: FONTS.body, fontSize: '15px', color: '#cccccc', + }); + t.setOrigin(0, 0); + this.messageTexts.push(t); + } if (this.initialGrid && this.initialShips) { this.applyPlanning(1, this.initialGrid, this.initialShips); + this.renderPlayerLegend(this.initialShips); } this.socket.on('planningStarted', (data) => { if (data.round === 1 && this.initialGrid) { - this.roundText.setText(`Round ${data.round}`); + this.roundText.setText(`${data.round}`); return; } this.onPlanningStarted(data.round, data.grid, data.ships); @@ -74,6 +125,7 @@ export class GameScene extends Phaser.Scene { this.socket.on('gameOver', (data) => { this.scene.start('ResultScene', { socket: this.socket, + audio: this.audio, winnerId: data.winnerId, winnerName: data.winnerName, ships: data.ships, @@ -91,9 +143,9 @@ export class GameScene extends Phaser.Scene { private applyPlanning(round: number, grid: GameGridState, ships: ShipType[]): void { this.gridState = grid; - this.roundText.setText(`Round ${round}`); - this.statusText.setText('PLANNING PHASE').setColor('#ffcc00'); - this.messageText.setText('Players are planning their moves...'); + this.roundText.setText(`${round}`); + this.setPhase('PLANNING PHASE', '#ffcc00', '#ffcc00'); + this.audio.feedback('round', { haptic: false }); const oldDeathLineY = this.gridRenderer.viewOffset; const newDeathLineY = grid.deathLineY; @@ -101,12 +153,15 @@ export class GameScene extends Phaser.Scene { this.gridRenderer.renderGrid(grid, ships, scrollInPx); this.updateShipSprites(ships); + this.renderPlayerLegend(ships); + this.pushMessage('β€” Planning round β€”', '#a0a0c8'); } - // ── Execution animation: queue-based, sequential ── + // ── Execution animation ── private onExecutionTick(data: ExecutionResult): void { - this.statusText.setText('EXECUTING').setColor('#00ff88'); + this.setPhase('EXECUTING', '#00ff9c', '#00ff9c'); + this.audio.feedback('phaseChange', { haptic: false }); this.execQueue.push({ updates: data.shipUpdates, gridUpdates: data.gridUpdates, @@ -121,6 +176,7 @@ export class GameScene extends Phaser.Scene { if (this.pendingDeathLineY !== null) { const deathLineAnim = this.pendingDeathLineY; this.pendingDeathLineY = null; + this.audio.feedback('deathLine', { haptic: false }); this.gridRenderer.animateDeathLine(deathLineAnim, DEATH_LINE_ANIM, () => { this.finishExecution(); }); @@ -132,72 +188,195 @@ export class GameScene extends Phaser.Scene { this.animating = true; const batch = this.execQueue.shift()!; - this.messageText.setText(batch.msgs.slice(0, 3).join(' | ')); + for (const msg of batch.msgs.slice(0, 3)) { + this.pushMessage(msg, '#ffffff'); + } + // Tile changes (from mine drop, etc.) β€” small sound + spark on the new tile for (const u of batch.gridUpdates) { - if (u.type === 'tile_change') this.gridRenderer.updateTile(u.position, u.tile); + if (u.type === 'tile_change') { + this.gridRenderer.updateTile(u.position, u.tile); + if (u.tile === 'mine') { + const p = this.effects.posToScreen(u.position); + this.effects.minePlaced(p.x, p.y); + this.audio.feedback('mine', { haptic: false }); + } else if (u.tile === 'meteor') { + const p = this.effects.posToScreen(u.position); + this.effects.meteorStrike(p.x, p.y, 0xff6a1a); + this.audio.feedback('meteor', { haptic: false }); + } + } if (u.type === 'death_line') this.pendingDeathLineY = u.y; } for (const u of batch.updates) { - const sprite = this.shipSprites.get(u.shipId); - if (!sprite) continue; - - switch (u.type) { - case 'move': { - const tx = this.gridRenderer.gridXToScreen(u.to.y); - const ty = this.gridRenderer.gridYToScreen(u.to.x); - this.tweens.add({ - targets: sprite, x: tx, y: ty, - duration: ANIM_DURATION, ease: 'Sine.easeInOut', - }); - break; - } - case 'turn': { - const angles: Record = { N: 0, E: 90, S: 180, W: 270 }; - this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION); - break; - } - case 'eliminated': { - this.shipSprites.delete(u.shipId); - const px = this.add.particles(sprite.x, sprite.y, 'asteroid', { - speed: { min: 40, max: 180 }, scale: { start: 0.3, end: 0 }, - lifespan: 800, quantity: 20, emitting: false, - }); - px.explode(); - this.tweens.add({ - targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1, - duration: 700, delay: 200, - onComplete: () => { sprite.destroy(); px.destroy(); }, - }); - break; - } - case 'collision': { - this.tweens.add({ - targets: sprite, - alpha: 0.2, - duration: 100, - yoyo: true, - repeat: 3, - }); - break; - } - case 'shield_used': { - this.tweens.add({ - targets: sprite, - alpha: 0.2, - duration: 150, - yoyo: true, - repeat: 2, - }); - break; - } - } + this.handleShipUpdate(u); } this.time.delayedCall(ANIM_DURATION + 120, () => this.playNext()); } + private handleShipUpdate(u: ShipUpdate): void { + const sprite = this.shipSprites.get(u.shipId); + const color = this.playerColors.get(u.shipId) ?? 0x00e5ff; + + switch (u.type) { + case 'move': { + const tx = this.gridRenderer.gridXToScreen(u.to.y); + const ty = this.gridRenderer.gridYToScreen(u.to.x); + const source = u.source ?? 'walk'; + + if (source === 'jump') { + // JUMP: instant snap, no engine drone (the jump sound + flash already happened) + if (sprite) { + sprite.setPosition(tx, ty); + this.effects.engineTrail(tx, ty, color, 0.7); + } + break; + } + + const isBoost = source === 'boost'; + const animDuration = isBoost ? ANIM_DURATION * 0.6 : ANIM_DURATION; + const pitch = isBoost ? 1.5 : 1; + const peak = isBoost ? 0.65 : 0.55; + + // Initial engine burst + if (sprite) this.effects.engineTrail(sprite.x, sprite.y, color, isBoost ? 1.4 : 1); + // Sustained engine drone for the duration of the move + this.audio.playEngine(animDuration / 1000, { pitch, peak }); + + if (sprite) { + const trailState = { last: 0 }; + this.tweens.add({ + targets: sprite, x: tx, y: ty, + duration: animDuration, ease: isBoost ? 'Cubic.easeIn' : 'Sine.easeInOut', + onUpdate: () => { + const now = this.time.now; + if (now - trailState.last < (isBoost ? 70 : 110)) return; + trailState.last = now; + this.effects.engineTrail(sprite.x, sprite.y, color, isBoost ? 0.7 : 0.5); + }, + }); + } + break; + } + + case 'turn': { + if (sprite) { + const angles: Record = { N: 0, E: 90, S: 180, W: 270 }; + this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION); + // Burst at the ship's position + this.effects.turnBurst(sprite.x, sprite.y, color); + } + this.audio.feedback('turn', { haptic: false }); + break; + } + + case 'card_played': { + this.playCardEffect(u.card, u.shipId, u.position, u.targetId); + break; + } + + case 'eliminated': { + this.shipSprites.delete(u.shipId); + if (sprite) { + this.effects.explosion(sprite.x, sprite.y, color); + this.audio.feedback('explosion', { haptic: false }); + this.cameras.main.flash(180, 255, 255, 255, false, undefined, 0.4); + this.cameras.main.shake(200, 0.012); + this.tweens.add({ + targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1, + duration: 700, delay: 100, + onComplete: () => sprite.destroy(), + }); + } + break; + } + + case 'collision': { + if (sprite) { + this.effects.impact(sprite.x, sprite.y, 0xffcc00, 36); + this.tweens.add({ + targets: sprite, + alpha: 0.2, + duration: 100, yoyo: true, repeat: 3, + }); + this.cameras.main.shake(140, 0.008); + } + this.audio.feedback('collision', { haptic: false }); + break; + } + + case 'shield_used': { + if (sprite) this.effects.shieldBubble(sprite.x, sprite.y); + this.audio.feedback('shield', { haptic: false }); + if (sprite) { + this.tweens.add({ + targets: sprite, + alpha: 0.4, + duration: 200, yoyo: true, repeat: 2, + }); + } + break; + } + } + } + + // ── Card effect dispatch ─────────────────────────────────────── + + private playCardEffect(card: CardType, shipId: string, position: Position, targetId?: string): void { + const shipColor = this.playerColors.get(shipId) ?? 0x00e5ff; + const pos = this.effects.posToScreen(position); + const sprite = this.shipSprites.get(shipId); + + switch (card) { + case 'SHIELD': { + if (sprite) this.effects.shieldBubble(sprite.x, sprite.y); + else this.effects.shieldBubble(pos.x, pos.y); + this.audio.feedback('shield', { haptic: false }); + break; + } + case 'EMP': { + if (sprite) this.effects.empPulse(sprite.x, sprite.y, 0x00e5ff); + else this.effects.empPulse(pos.x, pos.y, 0x00e5ff); + this.cameras.main.flash(120, 0, 229, 255, false, undefined, 0.25); + this.audio.feedback('emp', { haptic: false }); + break; + } + case 'JUMP': { + // Where the ship left from + if (targetId) { + const [x, y] = targetId.split(',').map(Number); + const from = this.effects.posToScreen({ x, y }); + this.effects.jumpFlash(from.x, from.y); + } + // Where the ship appeared + this.effects.jumpFlash(pos.x, pos.y); + this.audio.feedback('jump', { haptic: false }); + break; + } + case 'MINE': { + this.effects.minePlaced(pos.x, pos.y); + this.audio.feedback('mine', { haptic: false }); + break; + } + case 'BOOST': { + if (sprite) this.effects.speedLines(sprite.x, sprite.y, shipColor, sprite.shipData.direction); + else this.effects.speedLines(pos.x, pos.y, shipColor, 'E'); + this.audio.feedback('boost', { haptic: false }); + break; + } + case 'PHASE_SHIFT': { + if (sprite) this.effects.phaseGhost(sprite.x, sprite.y, shipColor); + else this.effects.phaseGhost(pos.x, pos.y, shipColor); + this.audio.feedback('phaseShift', { haptic: false }); + break; + } + } + } + + // ── Phase helpers ───────────────────────────────────────────── + private finishExecution(): void { if (this.pendingPlanning) { const p = this.pendingPlanning; @@ -231,15 +410,18 @@ export class GameScene extends Phaser.Scene { this.shipSprites.delete(id); } } - const colors = [0x00ccff, 0xff4444, 0x44ff44, 0xffaa00, 0xff44ff, 0xffff44]; for (let i = 0; i < ships.length; i++) { const ship = ships[i]; if (!ship.alive) continue; + const color = this.playerColors.get(ship.playerId) ?? COLORS.player[i % COLORS.player.length]; + this.playerColors.set(ship.id, color); + this.playerColors.set(ship.playerId, color); + const x = this.gridRenderer.gridXToScreen(ship.position.y); const y = this.gridRenderer.gridYToScreen(ship.position.x); let sprite = this.shipSprites.get(ship.id); if (!sprite) { - sprite = new ShipSprite(this, x, y, ship, colors[i % colors.length]); + sprite = new ShipSprite(this, x, y, ship, color); this.children.remove(sprite); this.gridRenderer.ships.add(sprite); this.shipSprites.set(ship.id, sprite); @@ -249,4 +431,30 @@ export class GameScene extends Phaser.Scene { } } } -} + + // ── HUD helpers ── + + private setPhase(text: string, color: string, glow: string): void { + this.phaseText.setText(text).setColor(color); + this.phaseTextGlow.setText(text).setColor(glow); + this.phaseTextGlow.setShadow(0, 0, glow, 18, true, true); + } + + private pushMessage(text: string, color: string): void { + this.messageLog.push(text); + if (this.messageLog.length > 3) this.messageLog.shift(); + + for (let i = 0; i < 3; i++) { + const msg = this.messageLog[i]; + if (msg) { + this.messageTexts[i].setText(msg).setColor(color).setAlpha(1); + } else { + this.messageTexts[i].setText('').setAlpha(0); + } + } + } + + private renderPlayerLegend(ships: ShipType[]): void { + // Reserved for future detailed legend. + } +} \ No newline at end of file diff --git a/tv/src/scenes/LobbyScene.ts b/tv/src/scenes/LobbyScene.ts index 624e84b..fad0d1a 100644 --- a/tv/src/scenes/LobbyScene.ts +++ b/tv/src/scenes/LobbyScene.ts @@ -1,99 +1,138 @@ import Phaser from 'phaser'; import { TvSocket } from '../network/TvSocket.js'; +import { COLORS, FONTS, AudioEngine } from '@spacerace/shared'; +import { createStarfield } from './Starfield.js'; +import QRCode from 'qrcode-generator'; + +const W = 1280; +const H = 720; +const BASE_URL = window.location.origin; export class LobbyScene extends Phaser.Scene { private socket!: TvSocket; + private audio!: AudioEngine; private roomCodeText!: Phaser.GameObjects.Text; - private playerListText!: Phaser.GameObjects.Text; - private players: { id: string; name: string }[] = []; + private playerListContainer!: Phaser.GameObjects.Container; + private titleGlow!: Phaser.GameObjects.Text; + private startBtn!: Phaser.GameObjects.Container; + private startBtnLabel!: Phaser.GameObjects.Text; + private playerCountText!: Phaser.GameObjects.Text; + private qrImage!: Phaser.GameObjects.Image; + private players: { id: string; name: string; colorIndex: number }[] = []; private roomCode: string = ''; constructor() { super({ key: 'LobbyScene' }); } - init(data: { socket: TvSocket }): void { + init(data: { socket: TvSocket; audio: AudioEngine }): void { this.socket = data.socket; + this.audio = data.audio; this.players = []; } create(): void { - const { width, height } = this.scale; + this.cameras.main.setBackgroundColor('#050514'); + createStarfield(this); - // Background - this.cameras.main.setBackgroundColor('#0a0a2e'); + // ── Title with layered glow ── + this.titleGlow = this.add.text(W / 2, 80, 'SPACE RACE', { + fontFamily: FONTS.display, + fontSize: '64px', + color: '#00e5ff', + fontStyle: 'bold', + }).setOrigin(0.5); + this.titleGlow.setShadow(0, 0, '#00e5ff', 24, true, true); + this.titleGlow.setAlpha(0.6); + this.titleGlow.setDepth(0.5); - // Title - this.add.text(width / 2, 60, 'πŸš€ SPACE RACE πŸš€', { - fontSize: '48px', - color: '#00ccff', - fontFamily: 'monospace', + this.add.text(W / 2, 80, 'SPACE RACE', { + fontFamily: FONTS.display, + fontSize: '64px', + color: '#ffffff', fontStyle: 'bold', }).setOrigin(0.5); - // Create room button - this.roomCodeText = this.add.text(width / 2, 160, 'Creating room...', { - fontSize: '28px', - color: '#ffffff', - fontFamily: 'monospace', + // Subtitle + this.add.text(W / 2, 138, 'β—† Multiplayer Tactical Race β—†', { + fontFamily: FONTS.display, + fontSize: '16px', + color: '#ff2bd6', + }).setOrigin(0.5).setShadow(0, 0, '#ff2bd6', 12, true, true); + + // ── Room code badge ── + this.add.text(W / 2, 220, 'ROOM CODE', { + fontFamily: FONTS.display, + fontSize: '13px', + color: '#6a6a8a', }).setOrigin(0.5); - // Player list - this.playerListText = this.add.text(width / 2, 240, 'Players: 0', { - fontSize: '22px', - color: '#aaaaaa', - fontFamily: 'monospace', - align: 'center', - }).setOrigin(0.5, 0); + this.add.rectangle(W / 2, 268, 360, 72, 0x0a0a24, 0.85) + .setStrokeStyle(2, 0x00e5ff, 0.55); - // QR Code hint - this.add.text(width / 2, height - 100, 'Scan QR code or enter room code on your phone', { - fontSize: '18px', - color: '#666688', - fontFamily: 'monospace', - }).setOrigin(0.5); + this.roomCodeText = this.add.text(W / 2, 268, '----', { + fontFamily: FONTS.mono, + fontSize: '44px', + color: '#00e5ff', + fontStyle: 'bold', + }).setOrigin(0.5).setShadow(0, 0, '#00e5ff', 16, true, true); - // Start button (hidden until players join) - const startBtn = this.add.text(width / 2, height - 160, '[ START GAME ]', { - fontSize: '32px', - color: '#00ff88', - fontFamily: 'monospace', - backgroundColor: '#115533', - padding: { x: 20, y: 10 }, - }).setOrigin(0.5).setInteractive({ useHandCursor: true }).setVisible(false); + // ── QR code ── + this.qrImage = this.add.image(W / 2, 400, '__DEFAULT').setVisible(false); - startBtn.on('pointerover', () => startBtn.setStyle({ backgroundColor: '#227744' })); - startBtn.on('pointerout', () => startBtn.setStyle({ backgroundColor: '#115533' })); - startBtn.on('pointerdown', () => { - this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => { - if (res.ok) { - console.log('Game starting...'); - } - }); + // ── Players section ── + this.add.text(160, 500, 'CREW MANIFEST', { + fontFamily: FONTS.display, + fontSize: '14px', + color: '#a0a0c8', }); - // Socket events + this.playerCountText = this.add.text(W - 160, 500, '0 / 6 PILOTS', { + fontFamily: FONTS.display, + fontSize: '14px', + color: '#a0a0c8', + }).setOrigin(1, 0); + + this.playerListContainer = this.add.container(0, 0); + this.renderPlayerList(); + + // ── Bottom hint + start button ── + this.add.text(W / 2, H - 110, 'Scan the QR code on the TV, or enter the room code on your phone', { + fontFamily: FONTS.body, + fontSize: '15px', + color: '#6a6a8a', + }).setOrigin(0.5); + + this.startBtn = this.createNeonButton(W / 2, H - 60, 'START RACE', '#ff2bd6', '#ff2bd6'); + this.startBtn.setVisible(false); + + // ── Socket events ── this.socket.emit('tv:createRoom', {}, (res) => { this.roomCode = res.roomCode; - this.roomCodeText.setText(`Room: ${res.roomCode}`); - this.updateQRHint(); + this.roomCodeText.setText(res.roomCode); + this.generateQR(res.roomCode); }); this.socket.on('playerJoined', (data) => { - this.players.push({ id: data.playerId, name: data.name }); - this.updatePlayerList(); - startBtn.setVisible(this.players.length >= 2); + this.players.push({ id: data.playerId, name: data.name, colorIndex: this.players.length }); + this.audio.feedback('cardSelect', { haptic: false }); + this.refreshPlayerList(); + this.startBtn.setVisible(this.players.length >= 2); }); this.socket.on('playerLeft', (data) => { this.players = this.players.filter((p) => p.id !== data.playerId); - this.updatePlayerList(); - startBtn.setVisible(this.players.length >= 2); + // Reassign color indices in join order + this.players.forEach((p, i) => { p.colorIndex = i; }); + this.refreshPlayerList(); + this.startBtn.setVisible(this.players.length >= 2); }); this.socket.on('gameStarting', (data) => { + this.audio.feedback('phaseChange', { haptic: false }); this.scene.start('GameScene', { socket: this.socket, + audio: this.audio, roomCode: this.roomCode, players: data.players, grid: data.grid, @@ -102,14 +141,161 @@ export class LobbyScene extends Phaser.Scene { }); } - private updatePlayerList(): void { - const names = this.players.map((p, i) => ` ${i + 1}. ${p.name}`).join('\n'); - this.playerListText.setText(`Players (${this.players.length}/6):\n${names}`); + private createNeonButton(x: number, y: number, label: string, fillColor: number, glowColor: number): Phaser.GameObjects.Container { + const container = this.add.container(x, y); + const w = 320, h = 64; + + // Outer glow + const glow = this.add.rectangle(0, 0, w + 14, h + 14, glowColor, 0.18); + glow.setBlendMode(Phaser.BlendModes.ADD); + + // Main button + const bg = this.add.rectangle(0, 0, w, h, fillColor, 1); + bg.setStrokeStyle(2, 0xffffff, 0.6); + + // Top highlight gradient + const highlight = this.add.rectangle(0, -h / 4, w - 4, h / 2, 0xffffff, 0.18); + highlight.setBlendMode(Phaser.BlendModes.ADD); + + const text = this.add.text(0, 0, label, { + fontFamily: FONTS.display, + fontSize: '22px', + color: '#ffffff', + fontStyle: 'bold', + }).setOrigin(0.5); + text.setShadow(0, 0, glowColor, 12, true, true); + + container.add([glow, bg, highlight, text]); + container.setSize(w, h); + container.setInteractive(new Phaser.Geom.Rectangle(-w/2, -h/2, w, h), Phaser.Geom.Rectangle.Contains); + + container.on('pointerover', () => { + bg.setFillStyle(0xffffff, 0.18); + bg.setFillStyle(fillColor, 1); + this.tweens.add({ targets: container, scaleX: 1.04, scaleY: 1.04, duration: 120 }); + this.audio.feedback('cardSelect', { haptic: false }); + }); + container.on('pointerout', () => { + this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 120 }); + }); + container.on('pointerdown', () => { + this.audio.feedback('go', { haptic: false }); + this.tweens.add({ targets: container, scaleX: 0.96, scaleY: 0.96, duration: 80, yoyo: true }); + this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => { + if (!res.ok) console.warn('Start failed'); + }); + }); + + // Pulsing glow + this.tweens.add({ + targets: glow, + alpha: { from: 0.18, to: 0.42 }, + duration: 1200, + yoyo: true, + repeat: -1, + }); + + return container; } - private updateQRHint(): void { - // We'll use a canvas-based QR in the controller app - const url = `${window.location.origin}?room=${this.roomCode}`; - console.log('Join URL:', url); + private generateQR(roomCode: string): void { + const url = `${BASE_URL}/controller?room=${roomCode}`; + const qr = QRCode(0, 'M'); + qr.addData(url); + qr.make(); + + const size = 160; + const moduleCount = qr.getModuleCount(); + const moduleSize = Math.floor(size / (moduleCount + 2)); + const canvasSize = (moduleCount + 2) * moduleSize; + + const canvas = document.createElement('canvas'); + canvas.width = canvasSize; + canvas.height = canvasSize; + const ctx = canvas.getContext('2d')!; + ctx.fillStyle = '#050514'; + ctx.fillRect(0, 0, canvasSize, canvasSize); + + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (qr.isDark(row, col)) { + ctx.fillStyle = '#00e5ff'; + ctx.fillRect((col + 1) * moduleSize, (row + 1) * moduleSize, moduleSize, moduleSize); + } + } + } + + const key = `qr-${roomCode}`; + if (this.textures.exists(key)) this.textures.remove(key); + this.textures.addImage(key, canvas as unknown as HTMLImageElement); + this.qrImage.setTexture(key).setVisible(true).setDisplaySize(size, size); + + const qrLink = document.getElementById('qr-url') as HTMLAnchorElement; + qrLink.href = url; + qrLink.textContent = url; + qrLink.style.display = 'block'; + } + + private renderPlayerList(): void { + this.playerListContainer.removeAll(true); + + const startY = 530; + const rowH = 48; + const colW = 300; + const cols = 4; + const leftMargin = (W - cols * colW) / 2; + + for (let i = 0; i < this.players.length; i++) { + const p = this.players[i]; + const col = i % cols; + const row = Math.floor(i / cols); + const x = leftMargin + col * colW + colW / 2; + const y = startY + row * rowH; + + const color = COLORS.player[p.colorIndex % COLORS.player.length]; + + // Card bg + const card = this.add.rectangle(x, y, colW - 16, rowH - 8, 0x0a0a24, 0.9); + card.setStrokeStyle(1, color, 0.7); + + // Color avatar (left) + const avatar = this.add.circle(x - colW/2 + 28, y, 12, color); + avatar.setStrokeStyle(1, 0xffffff, 0.6); + + // Player name + const name = this.add.text(x - colW/2 + 52, y, p.name, { + fontFamily: FONTS.body, + fontSize: '16px', + color: '#ffffff', + fontStyle: 'bold', + }).setOrigin(0, 0.5); + + // Pilot tag (right) + this.add.text(x + colW/2 - 18, y, `P0${p.colorIndex + 1}`, { + fontFamily: FONTS.mono, + fontSize: '11px', + color: '#6a6a8a', + }).setOrigin(1, 0.5); + + this.playerListContainer.add([card, avatar, name]); + } + + // Empty slot + if (this.players.length === 0) { + const x = W / 2; + const y = startY + 12; + this.add.text(x, y, 'Waiting for pilots to join…', { + fontFamily: FONTS.body, + fontSize: '16px', + color: '#6a6a8a', + fontStyle: 'italic', + }).setOrigin(0.5); + } + + this.playerCountText.setText(`${this.players.length} / 6 PILOTS`); + } + + private refreshPlayerList(): void { + this.renderPlayerList(); } } diff --git a/tv/src/scenes/ResultScene.ts b/tv/src/scenes/ResultScene.ts index a6e1f01..1a2046e 100644 --- a/tv/src/scenes/ResultScene.ts +++ b/tv/src/scenes/ResultScene.ts @@ -1,89 +1,195 @@ import Phaser from 'phaser'; import { TvSocket } from '../network/TvSocket.js'; -import { Ship } from '@spacerace/shared'; +import { Ship, COLORS, FONTS, AudioEngine } from '@spacerace/shared'; +import { createStarfield } from './Starfield.js'; + +const W = 1280; +const H = 720; + +interface PodiumEntry { + ship: Ship; + color: number; + rank: number; +} export class ResultScene extends Phaser.Scene { private socket!: TvSocket; + private audio!: AudioEngine; constructor() { super({ key: 'ResultScene' }); } - init(data: { socket: TvSocket; winnerId: string; winnerName: string; ships: Ship[] }): void { + init(data: { socket: TvSocket; audio: AudioEngine; winnerId: string; winnerName: string; ships: Ship[] }): void { this.socket = data.socket; - - // We'll use data directly in create + this.audio = data.audio; this.registry.set('resultData', data); } create(): void { const data = this.registry.get('resultData') as { - winnerId: string; - winnerName: string; - ships: Ship[]; + winnerId: string; winnerName: string; ships: Ship[]; }; - const { width, height } = this.scale; - this.cameras.main.setBackgroundColor('#0a0a2e'); + this.cameras.main.setBackgroundColor('#050514'); + createStarfield(this); - this.add.text(width / 2, 100, 'πŸ† RACE OVER πŸ†', { - fontSize: '52px', - color: '#ffcc00', - fontFamily: 'monospace', - fontStyle: 'bold', + this.audio.feedback('fanfare', { haptic: false }); + + // ── Title with glow ── + const titleGlow = this.add.text(W / 2, 90, 'πŸ† RACE OVER πŸ†', { + fontFamily: FONTS.display, fontSize: '56px', color: '#ffcc00', fontStyle: 'bold', + }).setOrigin(0.5); + titleGlow.setShadow(0, 0, '#ffcc00', 24, true, true); + titleGlow.setAlpha(0.6); + this.add.text(W / 2, 90, 'πŸ† RACE OVER πŸ†', { + fontFamily: FONTS.display, fontSize: '56px', color: '#ffcc00', fontStyle: 'bold', }).setOrigin(0.5); - this.add.text(width / 2, 220, `${data.winnerName} wins!`, { - fontSize: '40px', - color: '#00ff88', - fontFamily: 'monospace', - }).setOrigin(0.5); + // ── Winner banner ── + this.add.rectangle(W / 2, 170, 700, 70, 0x0a0a24, 0.85) + .setStrokeStyle(2, 0xff2bd6, 0.8); + this.add.text(W / 2, 170, `${data.winnerName} WINS!`, { + fontFamily: FONTS.display, fontSize: '36px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5).setShadow(0, 0, '#ff2bd6', 16, true, true); - // Show final positions - const aliveShips = data.ships.filter((s) => s.alive); - const deadShips = data.ships.filter((s) => !s.alive); - let yPos = 320; + // ── Build podium ── + const alive = data.ships.filter((s) => s.alive); + const dead = data.ships.filter((s) => !s.alive); + const sorted = [...alive, ...dead].slice(0, 6); + sorted.forEach((ship, i) => { + const color = COLORS.player[i % COLORS.player.length]; + const podiumEntry: PodiumEntry = { ship, color, rank: i + 1 }; + this.renderPodiumEntry(podiumEntry, sorted.length, i); + }); - this.add.text(width / 2, yPos, 'Final Standings:', { - fontSize: '24px', - color: '#aaaaaa', - fontFamily: 'monospace', - }).setOrigin(0.5); - yPos += 40; + // ── Confetti burst ── + this.spawnConfetti(); - for (let i = 0; i < aliveShips.length; i++) { - const ship = aliveShips[i]; - this.add.text(width / 2, yPos, `${i + 1}. ${ship.playerName}`, { - fontSize: '20px', - color: '#ffffff', - fontFamily: 'monospace', - }).setOrigin(0.5); - yPos += 30; + // ── Play again button ── + this.createNeonButton(W / 2, H - 70, 'BACK TO LOBBY', 0x00e5ff, 0x00e5ff); + } + + private renderPodiumEntry(entry: PodiumEntry, total: number, index: number): void { + const isWinner = entry.rank === 1; + const colW = 180; + const cols = Math.min(total, 6); + const startX = (W - cols * colW) / 2 + colW / 2; + const x = startX + index * colW; + const baseY = 480; + const heightByRank = isWinner ? 180 : entry.rank === 2 ? 130 : entry.rank === 3 ? 90 : 60; + + // Podium block + const block = this.add.rectangle(x, baseY, colW - 14, heightByRank, entry.color, 0.85); + block.setStrokeStyle(2, 0xffffff, 0.4); + block.setOrigin(0.5, 1); + + // Glow halo for top 3 + if (entry.rank <= 3) { + const glow = this.add.rectangle(x, baseY, colW - 8, heightByRank + 10, entry.color, 0.3); + glow.setOrigin(0.5, 1); + glow.setBlendMode(Phaser.BlendModes.ADD); + this.tweens.add({ + targets: glow, + alpha: { from: 0.2, to: 0.5 }, + duration: 1100 + index * 100, + yoyo: true, + repeat: -1, + }); } - for (const ship of deadShips) { - this.add.text(width / 2, yPos, ` ${ship.playerName} (eliminated)`, { - fontSize: '20px', - color: '#666666', - fontFamily: 'monospace', + // Rank number + this.add.text(x, baseY - heightByRank + 28, `${entry.rank}`, { + fontFamily: FONTS.display, fontSize: '32px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5).setShadow(0, 0, '#ffffff', 8, true, true); + + // Player name (on top of podium) + this.add.text(x, baseY - heightByRank - 16, entry.ship.playerName, { + fontFamily: FONTS.body, fontSize: '16px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5); + + // Status (eliminated) below name + if (!entry.ship.alive) { + this.add.text(x, baseY - heightByRank - 38, 'βœ— ELIMINATED', { + fontFamily: FONTS.display, fontSize: '11px', color: '#ff3b6b', }).setOrigin(0.5); - yPos += 30; + } else if (isWinner) { + this.add.text(x, baseY - heightByRank - 38, 'β˜… CHAMPION β˜…', { + fontFamily: FONTS.display, fontSize: '11px', color: '#ffcc00', + }).setOrigin(0.5).setShadow(0, 0, '#ffcc00', 8, true, true); } - // Play again button - const playAgainBtn = this.add.text(width / 2, height - 100, '[ BACK TO LOBBY ]', { - fontSize: '28px', - color: '#00ccff', - fontFamily: 'monospace', - backgroundColor: '#112244', - padding: { x: 20, y: 10 }, - }).setOrigin(0.5).setInteractive({ useHandCursor: true }); + // Winner bouncy scale-in + if (isWinner) { + block.setScale(0.7, 0.7); + this.tweens.add({ + targets: block, + scaleX: 1, + scaleY: 1, + duration: 800, + ease: 'Back.easeOut', + }); + } + } - playAgainBtn.on('pointerover', () => playAgainBtn.setStyle({ backgroundColor: '#223366' })); - playAgainBtn.on('pointerout', () => playAgainBtn.setStyle({ backgroundColor: '#112244' })); - playAgainBtn.on('pointerdown', () => { + private spawnConfetti(): void { + const colors = [0x00e5ff, 0xff2bd6, 0xffcc00, 0x00ff9c, 0xff3b6b]; + const confetti = this.add.particles(0, 0, 'ship', { + x: { min: 0, max: W }, + y: -20, + lifespan: 4000, + speedY: { min: 60, max: 140 }, + speedX: { min: -60, max: 60 }, + gravityY: 80, + scale: { min: 0.08, max: 0.2 }, + rotate: { min: 0, max: 360 }, + alpha: { start: 1, end: 0.4 }, + tint: colors, + quantity: 2, + frequency: 30, + blendMode: Phaser.BlendModes.ADD, + }); + confetti.setDepth(5); + } + + private createNeonButton(x: number, y: number, label: string, fillColor: number, glowColor: number): void { + const w = 320, h = 56; + const container = this.add.container(x, y); + + const glow = this.add.rectangle(0, 0, w + 12, h + 12, glowColor, 0.22); + glow.setBlendMode(Phaser.BlendModes.ADD); + + const bg = this.add.rectangle(0, 0, w, h, fillColor, 1); + bg.setStrokeStyle(2, 0xffffff, 0.6); + + const text = this.add.text(0, 0, label, { + fontFamily: FONTS.display, fontSize: '20px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5).setShadow(0, 0, glowColor, 12, true, true); + + container.add([glow, bg, text]); + container.setSize(w, h); + container.setInteractive(new Phaser.Geom.Rectangle(-w/2, -h/2, w, h), Phaser.Geom.Rectangle.Contains); + + container.on('pointerover', () => { + this.tweens.add({ targets: container, scaleX: 1.05, scaleY: 1.05, duration: 120 }); + this.audio.feedback('cardSelect', { haptic: false }); + }); + container.on('pointerout', () => { + this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 120 }); + }); + container.on('pointerdown', () => { + this.audio.feedback('go', { haptic: false }); + this.tweens.add({ targets: container, scaleX: 0.96, scaleY: 0.96, duration: 80, yoyo: true }); this.socket.disconnect(); window.location.reload(); }); + + this.tweens.add({ + targets: glow, + alpha: { from: 0.18, to: 0.42 }, + duration: 1300, + yoyo: true, + repeat: -1, + }); } } diff --git a/tv/src/scenes/Starfield.ts b/tv/src/scenes/Starfield.ts new file mode 100644 index 0000000..8cbf6aa --- /dev/null +++ b/tv/src/scenes/Starfield.ts @@ -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, + }); +} diff --git a/tv/src/style.css b/tv/src/style.css new file mode 100644 index 0000000..17fb5ef --- /dev/null +++ b/tv/src/style.css @@ -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); +} diff --git a/tv/vite.config.ts b/tv/vite.config.ts index 67765a1..adb577e 100644 --- a/tv/vite.config.ts +++ b/tv/vite.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from 'vite'; export default defineConfig({ + base: '/', server: { port: 3000, proxy: {