SpaceRace: Full-width TV field, death line scrolling, camera following leader, jump fix, path never fully blocked, player color on controller

This commit is contained in:
2026-06-24 16:41:37 +02:00
commit 5e2ab43ca3
46 changed files with 6581 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title>SpaceRace - Controller</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<div id="app">
<div id="screen-join" class="screen active">
<h1>🚀 SpaceRace</h1>
<div class="join-form">
<input type="text" id="room-input" placeholder="Room Code" maxlength="4" autocomplete="off" />
<input type="text" id="name-input" placeholder="Your Name" maxlength="12" autocomplete="off" />
<button id="join-btn">Join Race</button>
<p id="join-error" class="error"></p>
</div>
<div id="qr-container"></div>
</div>
<div id="screen-planning" class="screen">
<div class="planning-header">
<div class="header-left">
<span id="color-dot" class="color-dot"></span>
<span id="round-label">Round 1</span>
</div>
<div class="header-center">
<span id="timer-label">45s</span>
</div>
<div class="header-right">
<span id="ap-label">AP: 0/4</span>
</div>
</div>
<!-- AP bar -->
<div class="ap-bar">
<div id="ap-fill" class="ap-fill"></div>
</div>
<!-- Action buttons grid -->
<div id="action-grid" class="action-grid">
<button class="act-btn" data-action="TURN_LEFT">
<span class="act-icon"></span>
<span class="act-label">Left</span>
<span class="act-cost">1 AP</span>
</button>
<button class="act-btn primary" data-action="MOVE_FORWARD">
<span class="act-icon"></span>
<span class="act-label">Forward</span>
<span class="act-cost">1 AP</span>
</button>
<button class="act-btn" data-action="TURN_RIGHT">
<span class="act-icon"></span>
<span class="act-label">Right</span>
<span class="act-cost">1 AP</span>
</button>
<button class="act-btn accent" data-action="BOOST">
<span class="act-icon"></span>
<span class="act-label">Boost</span>
<span class="act-cost">2 AP</span>
</button>
</div>
<!-- Action queue -->
<div id="action-queue" class="action-queue">
<div id="queue-list" class="queue-list"></div>
</div>
<!-- Card hand -->
<div id="card-hand" class="card-hand">
<div id="card-list" class="card-list"></div>
</div>
<!-- Bottom buttons -->
<div class="planning-bottom">
<button id="clear-plan-btn" class="btn-clear">✕ Clear</button>
<button id="submit-plan-btn" class="btn-submit" disabled>GO!</button>
</div>
</div>
<div id="screen-waiting" class="screen">
<h2>Executing...</h2>
<p id="waiting-message">Watch the TV!</p>
</div>
<div id="screen-spectator" class="screen">
<h2>💀 Eliminated</h2>
<p>You're out! Watch the TV to see who wins.</p>
</div>
<div id="screen-lobby" class="screen">
<h2>Lobby</h2>
<p id="lobby-room">Room: ----</p>
<div id="lobby-players">
<h3>Players</h3>
<ul id="lobby-player-list"></ul>
</div>
<p id="lobby-waiting">Waiting for host to start...</p>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@spacerace/controller",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite --port 3001",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@spacerace/shared": "*",
"socket.io-client": "^4.7.5",
"qr-code-styling": "^1.6.0-rc.1"
},
"devDependencies": {
"typescript": "^5.5.0",
"vite": "^5.3.0"
}
}
+74
View File
@@ -0,0 +1,74 @@
import { GameGridState, Ship, GRID_WIDTH, TileType } from '@spacerace/shared';
export class MiniMap {
private container: HTMLElement;
private ships: Ship[];
constructor(container: HTMLElement, ships: Ship[]) {
this.container = container;
this.ships = ships;
}
update(grid: GameGridState, ships: Ship[]): void {
this.ships = ships;
this.container.innerHTML = '';
const deathLineY = grid.deathLineY;
const visibleRows = 8;
const cellSize = this.container.clientHeight / visibleRows;
const cellW = this.container.clientWidth / GRID_WIDTH;
// Render grid cells
for (let dy = 0; dy < visibleRows; dy++) {
const gridY = deathLineY - dy;
const row = grid.rows[gridY] || Array(GRID_WIDTH).fill('space');
for (let x = 0; x < GRID_WIDTH; x++) {
const tile = (row[x] as TileType) || 'space';
const cell = document.createElement('div');
cell.className = 'minimap-cell';
if (tile !== 'space') cell.classList.add(tile);
cell.style.left = `${x * cellW}px`;
cell.style.top = `${dy * cellSize}px`;
cell.style.width = `${cellW}px`;
cell.style.height = `${cellSize}px`;
this.container.appendChild(cell);
}
}
// Render ships
const colors = ['#00ccff', '#ff4444', '#44ff44', '#ffaa00', '#ff44ff', '#ffff44'];
for (let i = 0; i < ships.length; i++) {
const ship = ships[i];
if (!ship.alive) continue;
const relativeY = deathLineY - ship.position.y;
if (relativeY < 0 || relativeY >= visibleRows) continue;
const shipEl = document.createElement('div');
shipEl.className = 'minimap-ship';
shipEl.style.left = `${ship.position.x * cellW}px`;
shipEl.style.top = `${relativeY * cellSize}px`;
shipEl.style.width = `${cellW}px`;
shipEl.style.height = `${cellSize}px`;
shipEl.style.backgroundColor = colors[i % colors.length];
shipEl.style.borderRadius = '50%';
shipEl.style.fontSize = '8px';
shipEl.textContent = ship.playerId.substring(0, 3);
this.container.appendChild(shipEl);
}
// Death line
const dl = document.createElement('div');
dl.style.cssText = `
position: absolute;
left: 0;
top: ${visibleRows * cellSize}px;
width: 100%;
height: 2px;
background: #ff0000;
opacity: 0.6;
`;
this.container.appendChild(dl);
}
}
+89
View File
@@ -0,0 +1,89 @@
import { connectControllerSocket, ControllerSocket } from './network/ControllerSocket.js';
import { JoinScreen } from './screens/JoinScreen.js';
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';
class App {
socket: ControllerSocket;
private currentScreen: string = 'join';
isHost: boolean = false;
roomCode: string = '';
playerId: string = '';
constructor() {
this.socket = connectControllerSocket();
this.initScreens();
this.initSocketEvents();
}
private initScreens(): void {
new JoinScreen(this);
new LobbyScreen(this);
new PlanningScreen(this);
new WaitingScreen(this);
new SpectatorScreen(this);
}
private initSocketEvents(): void {
this.socket.on('disconnect', () => {
this.clearAllScreens();
});
this.socket.on('roomJoined', (data) => {
this.isHost = data.isHost;
this.roomCode = data.roomCode;
this.playerId = data.playerId;
this.showScreen('lobby');
});
this.socket.on('gameStarting', () => {
this.showScreen('planning');
});
this.socket.on('planningRequest', (data) => {
this.showScreen('planning');
// Pass data to planning screen
document.dispatchEvent(new CustomEvent('planningRequest', { detail: data }));
});
this.socket.on('executionTick', (data) => {
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.showScreen('spectator');
}
});
this.socket.on('gameOver', (data) => {
this.showScreen('spectator');
});
}
showScreen(name: string): void {
document.querySelectorAll('.screen').forEach((el) => el.classList.remove('active'));
const el = document.getElementById(`screen-${name}`);
if (el) el.classList.add('active');
this.currentScreen = name;
}
clearAllScreens(): void {
document.querySelectorAll('.screen').forEach((el) => el.classList.remove('active'));
document.getElementById('screen-join')?.classList.add('active');
}
}
// Boot
document.addEventListener('DOMContentLoaded', () => {
new App();
});
@@ -0,0 +1,12 @@
import { io, Socket } from 'socket.io-client';
import { ServerToMobileEvents, MobileToServerEvents } from '@spacerace/shared';
export type ControllerSocket = Socket<ServerToMobileEvents, MobileToServerEvents>;
export function connectControllerSocket(): ControllerSocket {
const socket: ControllerSocket = io('/', {
transports: ['websocket', 'polling'],
reconnection: true,
});
return socket;
}
+50
View File
@@ -0,0 +1,50 @@
import { App } from '../main.js';
export class JoinScreen {
private app: App;
constructor(app: App) {
this.app = app;
this.init();
}
private init(): void {
const joinBtn = document.getElementById('join-btn')!;
const roomInput = document.getElementById('room-input') as HTMLInputElement;
const nameInput = document.getElementById('name-input') as HTMLInputElement;
const errorEl = document.getElementById('join-error')!;
// Pre-fill room code from URL if present
const params = new URLSearchParams(window.location.search);
const roomParam = params.get('room');
if (roomParam) {
roomInput.value = roomParam.toUpperCase();
}
joinBtn.addEventListener('click', () => {
const roomCode = roomInput.value.trim().toUpperCase();
const playerName = nameInput.value.trim();
if (!roomCode || roomCode.length !== 4) {
errorEl.textContent = 'Enter a 4-character room code';
return;
}
if (!playerName) {
errorEl.textContent = 'Enter your name';
return;
}
errorEl.textContent = '';
joinBtn.textContent = 'Joining...';
joinBtn.disabled = true;
this.app.socket.emit('mobile:joinRoom', { roomCode, playerName }, (res) => {
joinBtn.textContent = 'Join Race';
joinBtn.disabled = false;
if (!res.ok) {
errorEl.textContent = res.error || 'Failed to join';
}
});
});
}
}
+105
View File
@@ -0,0 +1,105 @@
import { App } from '../main.js';
export class LobbyScreen {
private app: App;
private startBtn: HTMLButtonElement | null = null;
constructor(app: App) {
this.app = app;
this.createStartButton();
app.socket.on('roomJoined', (data) => {
document.getElementById('lobby-room')!.textContent = `Room: ${data.roomCode}`;
this.updatePlayerList(data.players);
this.updateHostUI();
});
app.socket.on('playerJoined', (data) => {
this.appendPlayer(data.playerId, data.name);
this.updateHostUI();
});
app.socket.on('playerLeft', (data) => {
const li = document.getElementById(`player-${data.playerId}`);
if (li) li.remove();
this.updateHostUI();
});
app.socket.on('hostChanged', (data) => {
this.app.isHost = data.hostPlayerId === this.app.playerId;
this.updateHostUI();
});
}
private createStartButton(): void {
const div = document.createElement('div');
div.id = 'host-controls';
div.style.cssText = 'margin-top: 16px; text-align: center; display: none;';
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.addEventListener('click', () => {
if (!this.startBtn) return;
this.startBtn.textContent = 'Starting...';
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;
alert(res.error || 'Cannot start');
}
});
});
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);
}
private updateHostUI(): void {
const div = document.getElementById('host-controls')!;
const waitingEl = document.getElementById('lobby-waiting')!;
if (this.app.isHost) {
div.style.display = 'block';
waitingEl.style.display = 'none';
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...';
}
}
private updatePlayerList(players: { id: string; name: string }[]): void {
const list = document.getElementById('lobby-player-list')!;
list.innerHTML = '';
for (const p of players) {
this.appendPlayer(p.id, p.name);
}
}
private appendPlayer(id: string, name: string): void {
const list = document.getElementById('lobby-player-list')!;
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}`;
list.appendChild(li);
}
}
+237
View File
@@ -0,0 +1,237 @@
import { App } from '../main.js';
import { Action, CardType, CARD_DEFS, PlayerView, GameGridState, Ship, actionApCost } from '@spacerace/shared';
const CARD_ICONS: Record<CardType, string> = {
METEOR_STRIKE: '☄️',
SHIELD: '🛡️',
BOOST: '🚀',
EMP: '⚡',
JUMP: '🦘',
MINE: '💣',
TELEPORT: '🌀',
PHASE_SHIFT: '👻',
};
export class PlanningScreen {
private app: App;
private plannedActions: Action[] = [];
private playerView: PlayerView | null = null;
private apTotal: number = 4;
private selectedCard: CardType | null = null;
private timerInterval: ReturnType<typeof setInterval> | null = null;
private timerSeconds: number = 0;
constructor(app: App) {
this.app = app;
document.addEventListener('planningRequest', ((e: CustomEvent) => {
this.onPlanningRequest(e.detail);
}) as EventListener);
this.initActionButtons();
this.initBottomButtons();
}
private onPlanningRequest(data: {
round: number; playerView: PlayerView; grid: GameGridState; ships: Ship[]; timer: number;
}): void {
this.playerView = data.playerView;
this.plannedActions = [];
this.selectedCard = null;
this.apTotal = data.playerView.ap;
this.timerSeconds = data.timer;
this.setPlayerColor(data.playerView.colorIndex);
document.getElementById('round-label')!.textContent = `Round ${data.round}`;
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
submitBtn.disabled = true;
submitBtn.textContent = 'GO!';
this.updateApDisplay();
this.renderQueue();
this.renderCards(data.playerView.hand);
this.updateTimer();
if (this.timerInterval) clearInterval(this.timerInterval);
this.timerInterval = setInterval(() => {
this.timerSeconds--;
this.updateTimer();
if (this.timerSeconds <= 0 && this.timerInterval) {
clearInterval(this.timerInterval);
}
}, 1000);
}
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];
}
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';
}
private initActionButtons(): void {
const grid = document.getElementById('action-grid')!;
grid.querySelectorAll('.act-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const actionType = (btn as HTMLElement).dataset.action!;
this.addAction({ type: actionType as Action['type'] });
});
});
}
private initBottomButtons(): void {
document.getElementById('clear-plan-btn')!.addEventListener('click', () => {
this.plannedActions = [];
this.selectedCard = null;
this.renderQueue();
this.updateApDisplay();
this.renderCards(this.playerView?.hand || []);
});
document.getElementById('submit-plan-btn')!.addEventListener('click', () => {
if (this.plannedActions.length === 0) return;
const btn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
btn.disabled = true;
btn.textContent = '...';
this.app.socket.emit('mobile:submitPlan', { actions: this.plannedActions }, (res) => {
if (!res.ok) {
btn.disabled = false;
btn.textContent = 'GO!';
alert(res.error || 'Invalid plan');
}
});
});
}
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;
this.plannedActions.push({ type: 'CARD', card: this.selectedCard });
this.selectedCard = null;
this.renderCards(this.playerView.hand);
this.renderQueue();
this.updateApDisplay();
return;
}
const cost = actionApCost(action);
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
if (used + cost > this.apTotal) return;
this.plannedActions.push(action);
this.renderQueue();
this.updateApDisplay();
const submitBtn = document.getElementById('submit-plan-btn')! as HTMLButtonElement;
submitBtn.disabled = this.plannedActions.length === 0;
}
private renderQueue(): void {
const list = document.getElementById('queue-list')!;
list.innerHTML = '';
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;
}
div.textContent = icon;
div.title = action.type + (action.card ? ` ${action.card}` : '');
// X button to remove
const remove = document.createElement('span');
remove.className = 'remove-hint';
remove.textContent = '×';
div.appendChild(remove);
div.addEventListener('click', () => {
this.plannedActions.splice(i, 1);
this.renderQueue();
this.updateApDisplay();
this.renderCards(this.playerView?.hand || []);
});
list.appendChild(div);
}
}
private renderCards(hand: CardType[]): void {
const list = document.getElementById('card-list')!;
list.innerHTML = '';
// If a card was already used in the plan, dim it
const usedCards = new Set<CardType>();
for (const a of this.plannedActions) {
if (a.type === 'CARD' && a.card) usedCards.add(a.card);
}
const availableCards = hand.filter((c) => !usedCards.has(c));
for (const card of availableCards) {
const def = CARD_DEFS[card];
const div = document.createElement('div');
div.className = 'card-item';
if (this.selectedCard === card) div.classList.add('selected');
div.innerHTML = `
<div class="card-icon">${CARD_ICONS[card]}</div>
<div class="card-name">${def.name}</div>
<div class="card-cost">${def.apCost} AP</div>
`;
div.addEventListener('click', () => {
const wasSelected = this.selectedCard === card;
this.selectedCard = wasSelected ? null : card;
this.renderCards(hand);
});
list.appendChild(div);
}
if (availableCards.length === 0) {
list.innerHTML = '<div style="color:#445566;padding:8px;font-size:12px">No cards available</div>';
}
}
private updateApDisplay(): void {
const used = this.plannedActions.reduce((s, a) => s + actionApCost(a), 0);
const remaining = this.apTotal - used;
document.getElementById('ap-label')!.textContent = `AP: ${remaining}/${this.apTotal}`;
const fill = document.getElementById('ap-fill')!;
fill.style.width = `${(remaining / this.apTotal) * 100}%`;
if (remaining === 0) {
fill.style.background = '#ff4444';
document.getElementById('ap-label')!.style.color = '#ff4444';
} else if (remaining === this.apTotal) {
fill.style.background = '#00ff88';
document.getElementById('ap-label')!.style.color = '#00ff88';
} else {
fill.style.background = '#ffcc00';
document.getElementById('ap-label')!.style.color = '#ffcc00';
}
}
}
+14
View File
@@ -0,0 +1,14 @@
import { App } from '../main.js';
export class SpectatorScreen {
constructor(app: App) {
app.socket.on('gameOver', (data) => {
const h2 = document.querySelector('#screen-spectator h2')!;
if (data.winnerId === app.socket.data?.playerId) {
h2.textContent = '🏆 You Win!';
} else {
h2.textContent = `💀 ${data.winnerName} Wins!`;
}
});
}
}
+7
View File
@@ -0,0 +1,7 @@
import { App } from '../main.js';
export class WaitingScreen {
constructor(app: App) {
// Mostly handled by main.ts socket events
}
}
+335
View File
@@ -0,0 +1,335 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
font-family: monospace;
background: #0a0a2e;
color: #ffffff;
user-select: none;
-webkit-user-select: none;
touch-action: manipulation;
}
#app {
width: 100%;
height: 100%;
position: relative;
}
.screen {
display: none;
width: 100%;
height: 100%;
padding: 16px;
overflow-y: auto;
flex-direction: column;
position: absolute;
top: 0;
left: 0;
}
.screen.active {
display: flex;
}
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; }
/* Join Screen */
#screen-join { align-items: center; justify-content: flex-start; padding-top: 40px; }
.join-form {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 320px;
}
input {
padding: 14px;
font-size: 20px;
font-family: monospace;
border: 2px solid #334466;
background: #111133;
color: #ffffff;
border-radius: 8px;
text-align: center;
text-transform: uppercase;
}
input:focus {
border-color: #00ccff;
outline: none;
}
#name-input { text-transform: none; }
button {
padding: 14px;
font-size: 18px;
font-family: monospace;
font-weight: bold;
border: none;
border-radius: 8px;
cursor: pointer;
background: #00ccff;
color: #000;
}
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-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #0d0d35;
border-bottom: 2px solid #1a1a55;
}
.header-left, .header-right { flex: 1; }
.header-left { display: flex; align-items: center; gap: 8px; }
.color-dot {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.4);
flex-shrink: 0;
}
.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;
}
.ap-fill {
height: 100%;
background: #00ff88;
transition: width 0.2s;
}
/* Action grid */
.action-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 8px;
padding: 12px;
flex: 1;
}
.act-btn {
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;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.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);
}
.act-icon {
font-size: 36px;
line-height: 1;
}
.act-label {
font-size: 14px;
font-weight: bold;
color: #ccccff;
}
.act-cost {
font-size: 11px;
color: #6666aa;
}
/* Action queue */
.action-queue {
padding: 8px 12px;
border-top: 1px solid #1a1a44;
background: #080820;
}
.queue-list {
display: flex;
gap: 6px;
overflow-x: auto;
min-height: 44px;
align-items: center;
}
.queue-item {
flex-shrink: 0;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: #1a3355;
border: 2px solid #3366aa;
border-radius: 10px;
font-size: 13px;
font-weight: bold;
position: relative;
cursor: pointer;
}
.queue-item:active {
background: #442222;
border-color: #ff4444;
}
.queue-item .remove-hint {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
background: #ff4444;
color: #fff;
border-radius: 50%;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
/* Card hand */
.card-hand {
padding: 8px 12px;
border-top: 1px solid #1a1a44;
background: #0a0a28;
}
.card-list {
display: flex;
gap: 8px;
overflow-x: auto;
}
.card-item {
flex-shrink: 0;
width: 110px;
min-height: 60px;
padding: 8px 10px;
background: #151535;
border: 2px solid #334466;
border-radius: 12px;
cursor: pointer;
touch-action: manipulation;
}
.card-item.selected {
border-color: #ffaa00;
background: #221a10;
box-shadow: 0 0 12px #ffaa0044;
}
.card-item .card-icon {
font-size: 22px;
text-align: center;
}
.card-item .card-name {
font-size: 11px;
font-weight: bold;
color: #ffaa00;
text-align: center;
}
.card-item .card-cost {
font-size: 10px;
color: #8888aa;
text-align: center;
}
/* Bottom row */
.planning-bottom {
display: flex;
gap: 8px;
padding: 8px 12px 16px;
border-top: 1px solid #1a1a44;
background: #0d0d35;
}
.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;
cursor: pointer;
}
.btn-submit {
flex: 2;
padding: 14px;
font-size: 20px;
font-family: monospace;
font-weight: bold;
border: none;
border-radius: 12px;
cursor: pointer;
background: #00cc44;
color: #000;
}
.btn-submit:disabled {
background: #223344;
color: #556677;
cursor: not-allowed;
}
/* Waiting Screen */
#screen-waiting { align-items: center; justify-content: center; }
#waiting-message { font-size: 16px; color: #8888aa; margin-top: 12px; }
/* Spectator Screen */
#screen-spectator { align-items: center; justify-content: center; }
#screen-spectator p { color: #8888aa; margin-top: 8px; }
/* 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; }
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve"
},
"include": ["src"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 3001,
proxy: {
'/socket.io': {
target: 'http://localhost:8080',
ws: true,
},
},
},
build: {
outDir: 'dist',
},
});