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

This commit is contained in:
2026-06-24 20:32:34 +02:00
parent 5e2ab43ca3
commit 8531864b25
30 changed files with 3036 additions and 629 deletions
+29 -3
View File
@@ -4,14 +4,40 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=1280, initial-scale=1.0" />
<title>SpaceRace - TV</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;600;700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/style.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #000; }
#game-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #050514; }
#game-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; }
.qr-overlay {
position: absolute;
bottom: 8%;
left: 50%;
transform: translateX(-50%);
color: #a0a0c8;
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
text-align: center;
padding: 6px 16px;
background: rgba(10,10,36,0.85);
border: 1px solid rgba(0,229,255,0.25);
border-radius: 6px;
text-decoration: none;
user-select: all;
pointer-events: auto;
z-index: 100;
display: none;
}
.qr-overlay:hover { border-color: #00e5ff; color: #00e5ff; }
</style>
</head>
<body>
<div id="game-container"></div>
<div id="game-container">
<a id="qr-url" class="qr-overlay" href="#" target="_blank"></a>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@spacerace/shared": "*",
"phaser": "^3.80.1",
"qrcode-generator": "^1.5.2",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
+329
View File
@@ -0,0 +1,329 @@
import Phaser from 'phaser';
import { Position, GRID_WIDTH, COLORS } from '@spacerace/shared';
const TILE_SIZE = 64;
const GRID_HEIGHT = GRID_WIDTH;
/**
* Centralized visual effects for the TV gameplay scene. Each method spawns
* particles / sprites / tweens for a specific game event. All effects are
* parented to the `fieldLayer` container so they scroll together with the
* game field when the death line advances.
*/
export class EffectRenderer {
private scene: Phaser.Scene;
private layer: Phaser.GameObjects.Container;
private gridXToScreen: (gy: number) => number;
private gridYToScreen: (gx: number) => number;
constructor(
scene: Phaser.Scene,
layer: Phaser.GameObjects.Container,
gridXToScreen: (gy: number) => number,
gridYToScreen: (gx: number) => number,
) {
this.scene = scene;
this.layer = layer;
this.gridXToScreen = gridXToScreen;
this.gridYToScreen = gridYToScreen;
}
posToScreen(p: Position): { x: number; y: number } {
return { x: this.gridXToScreen(p.y), y: this.gridYToScreen(p.x) };
}
// ── Movement & turn ─────────────────────────────────────────────
/** Engine trail at the ship's current screen position. */
engineTrail(x: number, y: number, color: number, scale = 1): void {
const p = this.scene.add.circle(x, y, 4 * scale, color, 0.6);
p.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(p);
this.scene.tweens.add({
targets: p,
alpha: 0,
scaleX: 2.2,
scaleY: 2.2,
duration: 420,
onComplete: () => p.destroy(),
});
}
/** Speed lines for BOOST — fast streaks behind the ship. */
speedLines(x: number, y: number, color: number, direction: 'N' | 'E' | 'S' | 'W'): void {
const dirVec = { N: { x: 0, y: -1 }, E: { x: 1, y: 0 }, S: { x: 0, y: 1 }, W: { x: -1, y: 0 } }[direction];
for (let i = 0; i < 10; i++) {
const line = this.scene.add.rectangle(
x - dirVec.x * (i * 8) + (Math.random() - 0.5) * 12,
y - dirVec.y * (i * 8) + (Math.random() - 0.5) * 12,
18, 2, color, 0.85
);
line.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(line);
this.scene.tweens.add({
targets: line,
alpha: 0,
scaleX: 2.5,
scaleY: 2.5,
x: x - dirVec.x * 60,
y: y - dirVec.y * 60,
duration: 380,
delay: i * 12,
onComplete: () => line.destroy(),
});
}
}
/** Burst of small particles around the ship on a TURN event. */
turnBurst(x: number, y: number, color: number): void {
const px = this.scene.add.particles(x, y, 'asteroid', {
speed: { min: 60, max: 160 },
angle: { min: 0, max: 360 },
scale: { start: 0.15, end: 0 },
lifespan: 320,
quantity: 14,
emitting: false,
tint: color,
alpha: { start: 0.9, end: 0 },
blendMode: Phaser.BlendModes.ADD,
});
this.layer.add(px);
px.explode();
this.scene.time.delayedCall(400, () => px.destroy());
}
// ── Cards ───────────────────────────────────────────────────────
/** Cyan hex bubble around the ship, expanding and fading. */
shieldBubble(x: number, y: number): void {
const ring = this.scene.add.circle(x, y, 22, 0x00e5ff, 0);
ring.setStrokeStyle(3, 0x00e5ff, 0.9);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring,
radius: 60,
alpha: 0,
duration: 700,
ease: 'Cubic.easeOut',
onComplete: () => ring.destroy(),
});
// Inner glow flash
const inner = this.scene.add.circle(x, y, 24, 0x00e5ff, 0.4);
inner.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(inner);
this.scene.tweens.add({
targets: inner,
scaleX: 1.6, scaleY: 1.6, alpha: 0,
duration: 500, ease: 'Cubic.easeOut',
onComplete: () => inner.destroy(),
});
}
/** Red ring pulse at the target tile — mine placed. */
minePlaced(x: number, y: number): void {
const ring = this.scene.add.circle(x, y, 8, 0xff3b6b, 0);
ring.setStrokeStyle(3, 0xffaa00, 1);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring,
radius: 36, alpha: 0, duration: 600, ease: 'Cubic.easeOut',
onComplete: () => ring.destroy(),
});
}
/** White flash + horizontal afterimage — JUMP. */
jumpFlash(x: number, y: number): void {
const flash = this.scene.add.circle(x, y, 30, 0xffffff, 1);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 2, scaleY: 2, alpha: 0, duration: 320,
onComplete: () => flash.destroy(),
});
// Spark ring
const spark = this.scene.add.circle(x, y, 18, 0x00e5ff, 0);
spark.setStrokeStyle(2, 0x00e5ff, 1);
spark.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(spark);
this.scene.tweens.add({
targets: spark, radius: 50, alpha: 0, duration: 400,
onComplete: () => spark.destroy(),
});
}
/** EMP — three electric arcs radiating outward + screen flash. */
empPulse(x: number, y: number, color = 0x00e5ff): void {
// Lightning bolts
for (let i = 0; i < 6; i++) {
const angle = (i / 6) * Math.PI * 2;
const bolt = this.scene.add.line(0, 0, x, y,
x + Math.cos(angle) * 80, y + Math.sin(angle) * 80,
color, 1);
bolt.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(bolt);
this.scene.tweens.add({
targets: bolt,
alpha: 0,
duration: 220,
delay: i * 30,
onComplete: () => bolt.destroy(),
});
}
// Expanding ring
const ring = this.scene.add.circle(x, y, 20, color, 0);
ring.setStrokeStyle(3, color, 1);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring, radius: 100, alpha: 0, duration: 500,
onComplete: () => ring.destroy(),
});
// Soft flash
const flash = this.scene.add.circle(x, y, 50, color, 0.3);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 1.8, scaleY: 1.8, alpha: 0, duration: 300,
onComplete: () => flash.destroy(),
});
}
/** Phase shift — ship leaves a cyan ghost that fades. */
phaseGhost(x: number, y: number, tint: number): void {
const ghost = this.scene.add.sprite(x, y, 'ship');
ghost.setTint(tint);
ghost.setAlpha(0.7);
this.layer.add(ghost);
this.scene.tweens.add({
targets: ghost,
alpha: 0,
scaleX: 1.3, scaleY: 1.3,
duration: 600,
onComplete: () => ghost.destroy(),
});
// Two faint rings
for (let i = 0; i < 2; i++) {
const r = this.scene.add.circle(x, y, 12, 0x00e5ff, 0);
r.setStrokeStyle(2, 0x00e5ff, 0.6);
r.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(r);
this.scene.tweens.add({
targets: r,
radius: 40 + i * 20,
alpha: 0,
duration: 700,
delay: i * 100,
onComplete: () => r.destroy(),
});
}
}
/** Meteor strike — falling meteor with impact. */
meteorStrike(x: number, y: number, color: number): void {
// Falling meteor from above
const meteor = this.scene.add.sprite(x, y - 320, 'meteor');
meteor.setDisplaySize(TILE_SIZE - 4, TILE_SIZE - 4);
this.layer.add(meteor);
this.scene.tweens.add({
targets: meteor,
y: y,
duration: 500,
ease: 'Cubic.easeIn',
onComplete: () => {
meteor.destroy();
this.impact(x, y, color, 60);
},
});
}
// ── Combat & physics ────────────────────────────────────────────
/** Big impact: expanding ring + sparks + brief flash. */
impact(x: number, y: number, color: number, radius = 40): void {
const ring = this.scene.add.circle(x, y, 8, color, 0);
ring.setStrokeStyle(3, color, 1);
ring.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring, radius, alpha: 0, duration: 400,
onComplete: () => ring.destroy(),
});
// Sparks
const sparks = this.scene.add.particles(x, y, 'asteroid', {
speed: { min: 80, max: 220 },
angle: { min: 0, max: 360 },
scale: { start: 0.2, end: 0 },
lifespan: 600,
quantity: 24,
emitting: false,
tint: color,
alpha: { start: 1, end: 0 },
blendMode: Phaser.BlendModes.ADD,
});
this.layer.add(sparks);
sparks.explode();
this.scene.time.delayedCall(700, () => sparks.destroy());
// Brief flash
const flash = this.scene.add.circle(x, y, 16, 0xffffff, 0.85);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 1.6, scaleY: 1.6, alpha: 0, duration: 180,
onComplete: () => flash.destroy(),
});
}
/** Big explosion (used on eliminated). */
explosion(x: number, y: number, color: number): void {
// Inner flash
const flash = this.scene.add.circle(x, y, 18, 0xffffff, 1);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, scaleX: 2.4, scaleY: 2.4, alpha: 0, duration: 220,
onComplete: () => flash.destroy(),
});
// Outer blast
this.impact(x, y, color, 80);
// Long-lived sparks
const sparks = this.scene.add.particles(x, y, 'asteroid', {
speed: { min: 60, max: 240 },
scale: { start: 0.4, end: 0 },
lifespan: 1100,
quantity: 40,
emitting: false,
tint: color,
alpha: { start: 1, end: 0 },
blendMode: Phaser.BlendModes.ADD,
});
this.layer.add(sparks);
sparks.explode();
this.scene.time.delayedCall(1300, () => sparks.destroy());
// Smoke ring
const ring = this.scene.add.circle(x, y, 20, 0x000000, 0);
ring.setStrokeStyle(4, 0x555555, 0.5);
this.layer.add(ring);
this.scene.tweens.add({
targets: ring, radius: 100, alpha: 0, duration: 900,
onComplete: () => ring.destroy(),
});
}
/** Death-line advance — flashing overlay pulse on the death line. */
deathLinePulse(x: number, y: number, height: number): void {
const flash = this.scene.add.rectangle(x, y, 80, height, 0xff3b6b, 0.4);
flash.setBlendMode(Phaser.BlendModes.ADD);
this.layer.add(flash);
this.scene.tweens.add({
targets: flash, alpha: 0, duration: 600,
onComplete: () => flash.destroy(),
});
}
}
+95 -25
View File
@@ -4,6 +4,7 @@ import {
TileType,
Position,
GRID_WIDTH,
COLORS,
} from '@spacerace/shared';
const TILE_SIZE = 64;
@@ -18,7 +19,9 @@ export class GridRenderer {
private fieldContainer: Phaser.GameObjects.Container;
private shipLayer: Phaser.GameObjects.Container;
private deathLineRect: Phaser.GameObjects.Rectangle | null = null;
private deathLineGlow: Phaser.GameObjects.Rectangle | null = null;
private deathLineY: number = 0;
private gridOverlay: Phaser.GameObjects.Graphics | null = null;
constructor(scene: Phaser.Scene, offsetY: number, tileSize: number) {
this.scene = scene;
@@ -70,6 +73,9 @@ export class GridRenderer {
}
}
// Draw grid lines overlay once
this.renderGridOverlay();
if (scrollInPx > 0) {
this.fieldContainer.x = -scrollInPx;
this.shipLayer.x = -scrollInPx;
@@ -88,29 +94,88 @@ export class GridRenderer {
}
private renderCell(col: number, lane: number, tile: TileType, screenX: number, screenY: number): void {
const bgAlpha = tile === 'space' ? 0.12 : 0.35;
const bgColor =
tile === 'asteroid' ? 0x555555 :
tile === 'meteor' ? 0x884400 :
tile === 'mine' ? 0x885500 :
0x1a1a4e;
if (tile === 'space') {
// Subtle dark base
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, 0x0a0a2e, 0.5);
bg.setStrokeStyle(1, 0x1a1a4e, 0.5);
this.fieldContainer.add(bg);
return;
}
const textureName =
tile === 'asteroid' ? 'asteroid' :
tile === 'meteor' ? 'meteor' :
tile === 'mine' ? 'mine' :
'asteroid';
// Tile background — gives the obstacle a contained feel
let bgColor: number;
let bgAlpha: number;
if (tile === 'asteroid') { bgColor = 0x2a2a3a; bgAlpha = 0.7; }
else if (tile === 'meteor') { bgColor = 0x3a1a0a; bgAlpha = 0.7; }
else if (tile === 'mine') { bgColor = 0x3a2a00; bgAlpha = 0.7; }
else { bgColor = 0x2a2a3a; bgAlpha = 0.7; }
const bg = this.scene.add.rectangle(screenX, screenY, this.tileSize - 2, this.tileSize - 2, bgColor, bgAlpha);
bg.setStrokeStyle(1, 0x223366, 0.2);
bg.setStrokeStyle(1, 0x223366, 0.6);
this.fieldContainer.add(bg);
if (tile !== 'space') {
const textureName =
tile === 'asteroid' ? 'asteroid' :
tile === 'meteor' ? 'meteor' :
'mine';
// Slight random rotation/scale for visual variety (deterministic by lane/col)
const sprite = this.scene.add.sprite(screenX, screenY, textureName);
sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12);
const angle = ((col * 13 + lane * 37) % 60) - 30; // ±30°
sprite.setAngle(angle);
this.fieldContainer.add(sprite);
const sprite = this.scene.add.sprite(screenX, screenY, textureName);
sprite.setDisplaySize(this.tileSize - 12, this.tileSize - 12);
this.fieldContainer.add(sprite);
// Glow for dangerous tiles
if (tile === 'meteor' || tile === 'mine') {
const glow = this.scene.add.circle(screenX, screenY, this.tileSize * 0.5,
tile === 'meteor' ? 0xff6a1a : 0xffaa00, 0.18);
glow.setBlendMode(Phaser.BlendModes.ADD);
this.fieldContainer.add(glow);
}
}
private renderGridOverlay(): void {
if (this.gridOverlay) this.gridOverlay.destroy();
const g = this.scene.add.graphics();
g.setDepth(0.5);
const w = this.scene.scale.width;
const h = this.scene.scale.height;
// Outer border around the play area
const playLeft = DEATH_LINE_SCREEN_X;
const playTop = this.offsetY - 4;
const playRight = DEATH_LINE_SCREEN_X + VISIBLE_COLS * this.tileSize;
const playBottom = this.offsetY + GRID_HEIGHT * this.tileSize + 4;
g.lineStyle(1, 0x00e5ff, 0.2);
g.strokeRect(playLeft, playTop, playRight - playLeft, playBottom - playTop);
// Lane separators (horizontal)
for (let i = 1; i < GRID_HEIGHT; i++) {
const y = this.offsetY + i * this.tileSize;
g.lineStyle(1, 0x223366, 0.35);
g.beginPath();
g.moveTo(playLeft, y);
g.lineTo(playRight, y);
g.strokePath();
}
// Column separators (vertical) — light, only for visible area
g.lineStyle(1, 0x1a1a4e, 0.4);
for (let i = 0; i <= VISIBLE_COLS; i++) {
const x = playLeft + i * this.tileSize;
g.beginPath();
g.moveTo(x, playTop);
g.lineTo(x, playBottom);
g.strokePath();
}
this.gridOverlay = g;
}
updateTile(pos: Position, tile: TileType): void {
const screenX = this.gridXToScreen(pos.y);
const screenY = this.gridYToScreen(pos.x);
@@ -120,20 +185,25 @@ export class GridRenderer {
private renderDeathLine(): void {
if (this.deathLineRect) this.deathLineRect.destroy();
if (this.deathLineGlow) this.deathLineGlow.destroy();
this.deathLineRect = this.scene.add.rectangle(
DEATH_LINE_SCREEN_X,
this.offsetY + (GRID_HEIGHT * this.tileSize) / 2,
6,
GRID_HEIGHT * this.tileSize,
0xff0000,
0.9
);
const x = DEATH_LINE_SCREEN_X;
const yCenter = this.offsetY + (GRID_HEIGHT * this.tileSize) / 2;
const h = GRID_HEIGHT * this.tileSize;
// Outer glow
this.deathLineGlow = this.scene.add.rectangle(x, yCenter, 16, h, 0xff3b6b, 0.25);
this.deathLineGlow.setBlendMode(Phaser.BlendModes.ADD);
this.deathLineGlow.setDepth(19);
// Main line
this.deathLineRect = this.scene.add.rectangle(x, yCenter, 4, h, 0xff3b6b, 0.95);
this.deathLineRect.setDepth(20);
// Pulse animation
this.scene.tweens.add({
targets: this.deathLineRect,
alpha: 0.3,
targets: this.deathLineGlow,
alpha: { from: 0.2, to: 0.55 },
duration: 600,
yoyo: true,
repeat: -1,
+35 -15
View File
@@ -1,9 +1,12 @@
import Phaser from 'phaser';
import { Ship as ShipType } from '@spacerace/shared';
import { FONTS } from '@spacerace/shared';
export class ShipSprite extends Phaser.GameObjects.Container {
private shipData: ShipType;
private label: Phaser.GameObjects.Text;
private glow: Phaser.GameObjects.Arc;
private body: Phaser.GameObjects.Sprite;
constructor(
scene: Phaser.Scene,
@@ -18,25 +21,42 @@ export class ShipSprite extends Phaser.GameObjects.Container {
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
const baseAngle = angles[ship.direction] || 0;
// Ship body
const body = scene.add.rectangle(0, 0, 40, 36, color);
body.setStrokeStyle(2, 0xffffff);
// Glow halo behind the ship (uses player's color)
this.glow = scene.add.circle(0, 0, 28, color, 0.35);
this.glow.setBlendMode(Phaser.BlendModes.ADD);
this.glow.setDepth(-1);
// Direction indicator (small triangle)
const indicator = scene.add.triangle(0, -22, 0, 8, 5, 0, 10, 8, 0xffffff);
// Ship body — uses the procedurally generated 'ship' texture
this.body = scene.add.sprite(0, 0, 'ship');
// Recolor the cyan body to the player's color via tint (preserves the white outline)
this.body.setTint(color);
this.add([body, indicator]);
this.add([this.glow, this.body]);
// Player name label in a pill below
const name = ship.playerName || ship.playerId.substring(0, 6);
const labelBg = scene.add.rectangle(0, 30, Math.max(48, name.length * 7 + 14), 18, 0x000000, 0.7);
labelBg.setStrokeStyle(1, color, 0.9);
this.label = scene.add.text(0, 30, name, {
fontFamily: FONTS.body,
fontSize: '12px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.add([labelBg, this.label]);
this.setAngle(baseAngle);
// Player name label
this.label = scene.add.text(0, 28, ship.playerName || ship.playerId.substring(0, 6), {
fontSize: '11px',
color: '#ffffff',
fontFamily: 'monospace',
backgroundColor: '#00000088',
padding: { x: 2, y: 1 },
}).setOrigin(0.5);
this.add(this.label);
// Gentle pulse on the halo
scene.tweens.add({
targets: this.glow,
scaleX: { from: 0.85, to: 1.15 },
scaleY: { from: 0.85, to: 1.15 },
alpha: { from: 0.25, to: 0.5 },
duration: 1400,
yoyo: true,
repeat: -1,
});
scene.add.existing(this);
this.setDepth(10);
+122 -29
View File
@@ -1,68 +1,161 @@
import Phaser from 'phaser';
import { COLORS, AudioEngine } from '@spacerace/shared';
import { connectTvSocket, TvSocket } from '../network/TvSocket.js';
import { createStarfield } from './Starfield.js';
export class BootScene extends Phaser.Scene {
socket!: TvSocket;
audio!: AudioEngine;
constructor() {
super({ key: 'BootScene' });
}
preload(): void {
// Generate placeholder assets as textures
this.createPlaceholderTextures();
}
create(): void {
this.socket = connectTvSocket();
this.scene.start('LobbyScene', { socket: this.socket });
this.audio = new AudioEngine();
// Browsers require a user gesture before audio context creation.
// Wire any pointer/touch on the canvas to unlock it.
const unlock = (): void => {
this.audio.unlock();
this.input.off('pointerdown', unlock);
this.input.off('touchstart', unlock);
};
this.input.on('pointerdown', unlock);
this.input.on('touchstart', unlock);
createStarfield(this);
this.scene.start('LobbyScene', { socket: this.socket, audio: this.audio });
}
private createPlaceholderTextures(): void {
// Ship placeholder (triangle pointing up)
// ── Ship — angular hull pointing up, with cockpit and engines ──
// Anchor at center. Heading "up" = +Y in texture (ship sprite is rotated by direction).
const shipGfx = this.make.graphics({ x: 0, y: 0, add: false });
shipGfx.fillStyle(0x00ccff);
shipGfx.fillTriangle(20, 0, 0, 36, 40, 36);
shipGfx.generateTexture('ship', 40, 36);
// Outer hull (white outline) — drawn first
shipGfx.fillStyle(0xffffff, 1);
shipGfx.beginPath();
shipGfx.moveTo(20, 0);
shipGfx.lineTo(38, 14);
shipGfx.lineTo(34, 32);
shipGfx.lineTo(30, 36);
shipGfx.lineTo(10, 36);
shipGfx.lineTo(6, 32);
shipGfx.lineTo(2, 14);
shipGfx.closePath();
shipGfx.fillPath();
// Fill cutout by drawing a slightly smaller version of the hull on top with a transparent rect
shipGfx.fillStyle(0x00e5ff, 1);
shipGfx.beginPath();
shipGfx.moveTo(20, 4);
shipGfx.lineTo(34, 16);
shipGfx.lineTo(31, 30);
shipGfx.lineTo(9, 30);
shipGfx.lineTo(6, 16);
shipGfx.closePath();
shipGfx.fillPath();
// Cockpit window
shipGfx.fillStyle(0x00121a, 1);
shipGfx.fillCircle(20, 14, 4);
shipGfx.fillStyle(0x00e5ff, 0.7);
shipGfx.fillCircle(20, 14, 2.5);
// Engine glow
shipGfx.fillStyle(0xff2bd6, 0.9);
shipGfx.fillCircle(13, 33, 3);
shipGfx.fillCircle(27, 33, 3);
shipGfx.fillStyle(0xffcc00, 1);
shipGfx.fillCircle(13, 33, 1.5);
shipGfx.fillCircle(27, 33, 1.5);
shipGfx.generateTexture('ship', 40, 40);
shipGfx.destroy();
// Asteroid placeholder (rough circle)
// ── Asteroid — irregular rocky shape ──
const asteroidGfx = this.make.graphics({ x: 0, y: 0, add: false });
asteroidGfx.fillStyle(0x888888);
asteroidGfx.fillCircle(20, 20, 18);
asteroidGfx.fillStyle(0xffffff, 1);
asteroidGfx.beginPath();
const aShape = [
[20, 3], [30, 8], [36, 18], [34, 28], [28, 35], [18, 36],
[8, 32], [3, 22], [5, 12], [12, 6]
];
asteroidGfx.moveTo(aShape[0][0], aShape[0][1]);
for (let i = 1; i < aShape.length; i++) asteroidGfx.lineTo(aShape[i][0], aShape[i][1]);
asteroidGfx.closePath();
asteroidGfx.fillPath();
asteroidGfx.fillStyle(COLORS.asteroid, 1);
const aShapeIn = [
[20, 7], [28, 11], [32, 19], [30, 27], [26, 31], [18, 32],
[10, 28], [7, 21], [9, 13], [14, 9]
];
asteroidGfx.moveTo(aShapeIn[0][0], aShapeIn[0][1]);
for (let i = 1; i < aShapeIn.length; i++) asteroidGfx.lineTo(aShapeIn[i][0], aShapeIn[i][1]);
asteroidGfx.closePath();
asteroidGfx.fillPath();
// Crater detail
asteroidGfx.fillStyle(0x4a4a5a, 0.8);
asteroidGfx.fillCircle(14, 18, 2);
asteroidGfx.fillCircle(24, 22, 1.5);
asteroidGfx.fillCircle(20, 12, 1);
asteroidGfx.generateTexture('asteroid', 40, 40);
asteroidGfx.destroy();
// Meteor placeholder (red circle)
// ── Meteor — burning rock with flame tail ──
const meteorGfx = this.make.graphics({ x: 0, y: 0, add: false });
meteorGfx.fillStyle(0xff4400);
meteorGfx.fillCircle(20, 20, 18);
// Flame tail (fading yellow → red)
meteorGfx.fillStyle(0xffcc00, 0.4);
meteorGfx.fillTriangle(20, 6, 4, 36, 16, 36);
meteorGfx.fillStyle(0xff6600, 0.7);
meteorGfx.fillTriangle(20, 8, 8, 36, 18, 36);
meteorGfx.fillStyle(0xff3b00, 0.95);
meteorGfx.fillTriangle(20, 12, 14, 36, 22, 36);
// Core
meteorGfx.fillStyle(0xffffff, 1);
meteorGfx.fillCircle(20, 18, 11);
meteorGfx.fillStyle(0xff8c1a, 1);
meteorGfx.fillCircle(20, 18, 8);
meteorGfx.fillStyle(0xffcc00, 0.8);
meteorGfx.fillCircle(20, 18, 5);
meteorGfx.generateTexture('meteor', 40, 40);
meteorGfx.destroy();
// Mine placeholder (orange circle with X)
// ── Mine — spiky orange orb with warning glow ──
const mineGfx = this.make.graphics({ x: 0, y: 0, add: false });
mineGfx.fillStyle(0xff8800);
mineGfx.fillCircle(20, 20, 16);
mineGfx.lineStyle(3, 0x000000);
mineGfx.lineBetween(10, 10, 30, 30);
mineGfx.lineBetween(30, 10, 10, 30);
// Spikes (8-pointed star)
const cx = 20, cy = 20;
const spikes = 8;
const outer = 18, inner = 7;
mineGfx.fillStyle(0xffaa00, 1);
mineGfx.beginPath();
for (let i = 0; i < spikes * 2; i++) {
const r = i % 2 === 0 ? outer : inner;
const a = (i / (spikes * 2)) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(a) * r;
const y = cy + Math.sin(a) * r;
if (i === 0) mineGfx.moveTo(x, y);
else mineGfx.lineTo(x, y);
}
mineGfx.closePath();
mineGfx.fillPath();
// Core
mineGfx.fillStyle(0x2a1500, 1);
mineGfx.fillCircle(cx, cy, 6);
// Warning dot
mineGfx.fillStyle(0xff3b6b, 1);
mineGfx.fillCircle(cx, cy, 2.5);
mineGfx.generateTexture('mine', 40, 40);
mineGfx.destroy();
// Background tile
const bgGfx = this.make.graphics({ x: 0, y: 0, add: false });
bgGfx.fillStyle(0x0a0a2e);
bgGfx.fillRect(0, 0, 64, 64);
bgGfx.lineStyle(1, 0x1a1a4e);
bgGfx.strokeRect(0, 0, 64, 64);
bgGfx.generateTexture('bg_tile', 64, 64);
bgGfx.destroy();
// Death line texture
// ── Death line texture — animated stripe ──
const dlGfx = this.make.graphics({ x: 0, y: 0, add: false });
dlGfx.fillStyle(0xff0000, 0.6);
dlGfx.fillStyle(0xff3b6b, 0.95);
dlGfx.fillRect(0, 0, 512, 4);
// Inner highlight
dlGfx.fillStyle(0xffffff, 0.9);
dlGfx.fillRect(0, 1, 512, 1);
dlGfx.generateTexture('death_line', 512, 4);
dlGfx.destroy();
}
+287 -79
View File
@@ -7,61 +7,112 @@ import {
GRID_WIDTH,
ShipUpdate,
GridUpdate,
COLORS,
FONTS,
AudioEngine,
CardType,
} from '@spacerace/shared';
import { GridRenderer } from '../objects/GridRenderer.js';
import { ShipSprite } from '../objects/ShipSprite.js';
import { EffectRenderer } from '../effects/EffectRenderer.js';
import { createStarfield } from './Starfield.js';
const W = 1280;
const H = 720;
const TILE_SIZE = 64;
const GRID_OFFSET_Y = (720 - GRID_WIDTH * TILE_SIZE) / 2;
const GRID_OFFSET_Y = (H - GRID_WIDTH * TILE_SIZE) / 2;
const ANIM_DURATION = 900;
const DEATH_LINE_ANIM = 1800;
export class GameScene extends Phaser.Scene {
private socket!: TvSocket;
private audio!: AudioEngine;
private gridRenderer!: GridRenderer;
private effects!: EffectRenderer;
private shipSprites: Map<string, ShipSprite> = new Map();
private gridState!: GameGridState;
private roundText!: Phaser.GameObjects.Text;
private statusText!: Phaser.GameObjects.Text;
private messageText!: Phaser.GameObjects.Text;
private phaseText!: Phaser.GameObjects.Text;
private phaseTextGlow!: Phaser.GameObjects.Text;
private messageLog: string[] = [];
private messageTexts: Phaser.GameObjects.Text[] = [];
private initialGrid: GameGridState | null = null;
private initialShips: ShipType[] | null = null;
private execQueue: { updates: ShipUpdate[]; gridUpdates: GridUpdate[]; msgs: string[] }[] = [];
private animating = false;
private pendingDeathLineY: number | null = null;
private pendingPlanning: { round: number; grid: GameGridState; ships: ShipType[] } | null = null;
private playerColors = new Map<string, number>();
constructor() {
super({ key: 'GameScene' });
}
init(data: { socket: TvSocket; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void {
init(data: { socket: TvSocket; audio: AudioEngine; roomCode: string; players: { id: string; name: string }[]; grid?: GameGridState; ships?: ShipType[] }): void {
this.socket = data.socket;
this.audio = data.audio;
this.initialGrid = data.grid ?? null;
this.initialShips = data.ships ?? null;
(data.players || []).forEach((p, i) => {
this.playerColors.set(p.id, COLORS.player[i % COLORS.player.length]);
});
}
create(): void {
this.cameras.main.setBackgroundColor('#050515');
this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE);
this.cameras.main.setBackgroundColor('#050514');
createStarfield(this);
this.roundText = this.add.text(16, 16, 'Round 0', {
fontSize: '24px', color: '#ffffff', fontFamily: 'monospace',
});
this.statusText = this.add.text(1280 / 2, 16, 'PLANNING PHASE', {
fontSize: '24px', color: '#ffcc00', fontFamily: 'monospace', fontStyle: 'bold',
}).setOrigin(0.5, 0);
this.messageText = this.add.text(16, 700, '', {
fontSize: '17px', color: '#cccccc', fontFamily: 'monospace', wordWrap: { width: 1248 },
});
this.gridRenderer = new GridRenderer(this, GRID_OFFSET_Y, TILE_SIZE);
this.effects = new EffectRenderer(
this,
this.gridRenderer.field,
(gy) => this.gridRenderer.gridXToScreen(gy),
(gx) => this.gridRenderer.gridYToScreen(gx),
);
// ── HUD ──
this.add.rectangle(20, 20, 220, 56, 0x0a0a24, 0.85)
.setOrigin(0, 0)
.setStrokeStyle(1, 0x00e5ff, 0.5);
this.add.text(36, 36, 'ROUND', {
fontFamily: FONTS.display, fontSize: '12px', color: '#6a6a8a',
}).setOrigin(0, 0);
this.roundText = this.add.text(36, 52, '1', {
fontFamily: FONTS.display, fontSize: '26px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0, 0);
const phaseX = W / 2;
const phaseY = 36;
this.phaseTextGlow = this.add.text(phaseX, phaseY, 'PLANNING PHASE', {
fontFamily: FONTS.display, fontSize: '28px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
this.phaseTextGlow.setShadow(0, 0, '#ffcc00', 18, true, true);
this.phaseTextGlow.setAlpha(0.6);
this.phaseText = this.add.text(phaseX, phaseY, 'PLANNING PHASE', {
fontFamily: FONTS.display, fontSize: '28px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(W - 20, 28, 'PILOTS', {
fontFamily: FONTS.display, fontSize: '11px', color: '#6a6a8a',
}).setOrigin(1, 0);
for (let i = 0; i < 3; i++) {
const t = this.add.text(20, H - 100 + i * 22, '', {
fontFamily: FONTS.body, fontSize: '15px', color: '#cccccc',
});
t.setOrigin(0, 0);
this.messageTexts.push(t);
}
if (this.initialGrid && this.initialShips) {
this.applyPlanning(1, this.initialGrid, this.initialShips);
this.renderPlayerLegend(this.initialShips);
}
this.socket.on('planningStarted', (data) => {
if (data.round === 1 && this.initialGrid) {
this.roundText.setText(`Round ${data.round}`);
this.roundText.setText(`${data.round}`);
return;
}
this.onPlanningStarted(data.round, data.grid, data.ships);
@@ -74,6 +125,7 @@ export class GameScene extends Phaser.Scene {
this.socket.on('gameOver', (data) => {
this.scene.start('ResultScene', {
socket: this.socket,
audio: this.audio,
winnerId: data.winnerId,
winnerName: data.winnerName,
ships: data.ships,
@@ -91,9 +143,9 @@ export class GameScene extends Phaser.Scene {
private applyPlanning(round: number, grid: GameGridState, ships: ShipType[]): void {
this.gridState = grid;
this.roundText.setText(`Round ${round}`);
this.statusText.setText('PLANNING PHASE').setColor('#ffcc00');
this.messageText.setText('Players are planning their moves...');
this.roundText.setText(`${round}`);
this.setPhase('PLANNING PHASE', '#ffcc00', '#ffcc00');
this.audio.feedback('round', { haptic: false });
const oldDeathLineY = this.gridRenderer.viewOffset;
const newDeathLineY = grid.deathLineY;
@@ -101,12 +153,15 @@ export class GameScene extends Phaser.Scene {
this.gridRenderer.renderGrid(grid, ships, scrollInPx);
this.updateShipSprites(ships);
this.renderPlayerLegend(ships);
this.pushMessage('— Planning round —', '#a0a0c8');
}
// ── Execution animation: queue-based, sequential ──
// ── Execution animation ──
private onExecutionTick(data: ExecutionResult): void {
this.statusText.setText('EXECUTING').setColor('#00ff88');
this.setPhase('EXECUTING', '#00ff9c', '#00ff9c');
this.audio.feedback('phaseChange', { haptic: false });
this.execQueue.push({
updates: data.shipUpdates,
gridUpdates: data.gridUpdates,
@@ -121,6 +176,7 @@ export class GameScene extends Phaser.Scene {
if (this.pendingDeathLineY !== null) {
const deathLineAnim = this.pendingDeathLineY;
this.pendingDeathLineY = null;
this.audio.feedback('deathLine', { haptic: false });
this.gridRenderer.animateDeathLine(deathLineAnim, DEATH_LINE_ANIM, () => {
this.finishExecution();
});
@@ -132,72 +188,195 @@ export class GameScene extends Phaser.Scene {
this.animating = true;
const batch = this.execQueue.shift()!;
this.messageText.setText(batch.msgs.slice(0, 3).join(' | '));
for (const msg of batch.msgs.slice(0, 3)) {
this.pushMessage(msg, '#ffffff');
}
// Tile changes (from mine drop, etc.) — small sound + spark on the new tile
for (const u of batch.gridUpdates) {
if (u.type === 'tile_change') this.gridRenderer.updateTile(u.position, u.tile);
if (u.type === 'tile_change') {
this.gridRenderer.updateTile(u.position, u.tile);
if (u.tile === 'mine') {
const p = this.effects.posToScreen(u.position);
this.effects.minePlaced(p.x, p.y);
this.audio.feedback('mine', { haptic: false });
} else if (u.tile === 'meteor') {
const p = this.effects.posToScreen(u.position);
this.effects.meteorStrike(p.x, p.y, 0xff6a1a);
this.audio.feedback('meteor', { haptic: false });
}
}
if (u.type === 'death_line') this.pendingDeathLineY = u.y;
}
for (const u of batch.updates) {
const sprite = this.shipSprites.get(u.shipId);
if (!sprite) continue;
switch (u.type) {
case 'move': {
const tx = this.gridRenderer.gridXToScreen(u.to.y);
const ty = this.gridRenderer.gridYToScreen(u.to.x);
this.tweens.add({
targets: sprite, x: tx, y: ty,
duration: ANIM_DURATION, ease: 'Sine.easeInOut',
});
break;
}
case 'turn': {
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION);
break;
}
case 'eliminated': {
this.shipSprites.delete(u.shipId);
const px = this.add.particles(sprite.x, sprite.y, 'asteroid', {
speed: { min: 40, max: 180 }, scale: { start: 0.3, end: 0 },
lifespan: 800, quantity: 20, emitting: false,
});
px.explode();
this.tweens.add({
targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1,
duration: 700, delay: 200,
onComplete: () => { sprite.destroy(); px.destroy(); },
});
break;
}
case 'collision': {
this.tweens.add({
targets: sprite,
alpha: 0.2,
duration: 100,
yoyo: true,
repeat: 3,
});
break;
}
case 'shield_used': {
this.tweens.add({
targets: sprite,
alpha: 0.2,
duration: 150,
yoyo: true,
repeat: 2,
});
break;
}
}
this.handleShipUpdate(u);
}
this.time.delayedCall(ANIM_DURATION + 120, () => this.playNext());
}
private handleShipUpdate(u: ShipUpdate): void {
const sprite = this.shipSprites.get(u.shipId);
const color = this.playerColors.get(u.shipId) ?? 0x00e5ff;
switch (u.type) {
case 'move': {
const tx = this.gridRenderer.gridXToScreen(u.to.y);
const ty = this.gridRenderer.gridYToScreen(u.to.x);
const source = u.source ?? 'walk';
if (source === 'jump') {
// JUMP: instant snap, no engine drone (the jump sound + flash already happened)
if (sprite) {
sprite.setPosition(tx, ty);
this.effects.engineTrail(tx, ty, color, 0.7);
}
break;
}
const isBoost = source === 'boost';
const animDuration = isBoost ? ANIM_DURATION * 0.6 : ANIM_DURATION;
const pitch = isBoost ? 1.5 : 1;
const peak = isBoost ? 0.65 : 0.55;
// Initial engine burst
if (sprite) this.effects.engineTrail(sprite.x, sprite.y, color, isBoost ? 1.4 : 1);
// Sustained engine drone for the duration of the move
this.audio.playEngine(animDuration / 1000, { pitch, peak });
if (sprite) {
const trailState = { last: 0 };
this.tweens.add({
targets: sprite, x: tx, y: ty,
duration: animDuration, ease: isBoost ? 'Cubic.easeIn' : 'Sine.easeInOut',
onUpdate: () => {
const now = this.time.now;
if (now - trailState.last < (isBoost ? 70 : 110)) return;
trailState.last = now;
this.effects.engineTrail(sprite.x, sprite.y, color, isBoost ? 0.7 : 0.5);
},
});
}
break;
}
case 'turn': {
if (sprite) {
const angles: Record<string, number> = { N: 0, E: 90, S: 180, W: 270 };
this.rotateSpriteSlow(sprite, angles[u.direction] || 0, ANIM_DURATION);
// Burst at the ship's position
this.effects.turnBurst(sprite.x, sprite.y, color);
}
this.audio.feedback('turn', { haptic: false });
break;
}
case 'card_played': {
this.playCardEffect(u.card, u.shipId, u.position, u.targetId);
break;
}
case 'eliminated': {
this.shipSprites.delete(u.shipId);
if (sprite) {
this.effects.explosion(sprite.x, sprite.y, color);
this.audio.feedback('explosion', { haptic: false });
this.cameras.main.flash(180, 255, 255, 255, false, undefined, 0.4);
this.cameras.main.shake(200, 0.012);
this.tweens.add({
targets: sprite, alpha: 0, scaleX: 0.1, scaleY: 0.1,
duration: 700, delay: 100,
onComplete: () => sprite.destroy(),
});
}
break;
}
case 'collision': {
if (sprite) {
this.effects.impact(sprite.x, sprite.y, 0xffcc00, 36);
this.tweens.add({
targets: sprite,
alpha: 0.2,
duration: 100, yoyo: true, repeat: 3,
});
this.cameras.main.shake(140, 0.008);
}
this.audio.feedback('collision', { haptic: false });
break;
}
case 'shield_used': {
if (sprite) this.effects.shieldBubble(sprite.x, sprite.y);
this.audio.feedback('shield', { haptic: false });
if (sprite) {
this.tweens.add({
targets: sprite,
alpha: 0.4,
duration: 200, yoyo: true, repeat: 2,
});
}
break;
}
}
}
// ── Card effect dispatch ───────────────────────────────────────
private playCardEffect(card: CardType, shipId: string, position: Position, targetId?: string): void {
const shipColor = this.playerColors.get(shipId) ?? 0x00e5ff;
const pos = this.effects.posToScreen(position);
const sprite = this.shipSprites.get(shipId);
switch (card) {
case 'SHIELD': {
if (sprite) this.effects.shieldBubble(sprite.x, sprite.y);
else this.effects.shieldBubble(pos.x, pos.y);
this.audio.feedback('shield', { haptic: false });
break;
}
case 'EMP': {
if (sprite) this.effects.empPulse(sprite.x, sprite.y, 0x00e5ff);
else this.effects.empPulse(pos.x, pos.y, 0x00e5ff);
this.cameras.main.flash(120, 0, 229, 255, false, undefined, 0.25);
this.audio.feedback('emp', { haptic: false });
break;
}
case 'JUMP': {
// Where the ship left from
if (targetId) {
const [x, y] = targetId.split(',').map(Number);
const from = this.effects.posToScreen({ x, y });
this.effects.jumpFlash(from.x, from.y);
}
// Where the ship appeared
this.effects.jumpFlash(pos.x, pos.y);
this.audio.feedback('jump', { haptic: false });
break;
}
case 'MINE': {
this.effects.minePlaced(pos.x, pos.y);
this.audio.feedback('mine', { haptic: false });
break;
}
case 'BOOST': {
if (sprite) this.effects.speedLines(sprite.x, sprite.y, shipColor, sprite.shipData.direction);
else this.effects.speedLines(pos.x, pos.y, shipColor, 'E');
this.audio.feedback('boost', { haptic: false });
break;
}
case 'PHASE_SHIFT': {
if (sprite) this.effects.phaseGhost(sprite.x, sprite.y, shipColor);
else this.effects.phaseGhost(pos.x, pos.y, shipColor);
this.audio.feedback('phaseShift', { haptic: false });
break;
}
}
}
// ── Phase helpers ─────────────────────────────────────────────
private finishExecution(): void {
if (this.pendingPlanning) {
const p = this.pendingPlanning;
@@ -231,15 +410,18 @@ export class GameScene extends Phaser.Scene {
this.shipSprites.delete(id);
}
}
const colors = [0x00ccff, 0xff4444, 0x44ff44, 0xffaa00, 0xff44ff, 0xffff44];
for (let i = 0; i < ships.length; i++) {
const ship = ships[i];
if (!ship.alive) continue;
const color = this.playerColors.get(ship.playerId) ?? COLORS.player[i % COLORS.player.length];
this.playerColors.set(ship.id, color);
this.playerColors.set(ship.playerId, color);
const x = this.gridRenderer.gridXToScreen(ship.position.y);
const y = this.gridRenderer.gridYToScreen(ship.position.x);
let sprite = this.shipSprites.get(ship.id);
if (!sprite) {
sprite = new ShipSprite(this, x, y, ship, colors[i % colors.length]);
sprite = new ShipSprite(this, x, y, ship, color);
this.children.remove(sprite);
this.gridRenderer.ships.add(sprite);
this.shipSprites.set(ship.id, sprite);
@@ -249,4 +431,30 @@ export class GameScene extends Phaser.Scene {
}
}
}
}
// ── HUD helpers ──
private setPhase(text: string, color: string, glow: string): void {
this.phaseText.setText(text).setColor(color);
this.phaseTextGlow.setText(text).setColor(glow);
this.phaseTextGlow.setShadow(0, 0, glow, 18, true, true);
}
private pushMessage(text: string, color: string): void {
this.messageLog.push(text);
if (this.messageLog.length > 3) this.messageLog.shift();
for (let i = 0; i < 3; i++) {
const msg = this.messageLog[i];
if (msg) {
this.messageTexts[i].setText(msg).setColor(color).setAlpha(1);
} else {
this.messageTexts[i].setText('').setAlpha(0);
}
}
}
private renderPlayerLegend(ships: ShipType[]): void {
// Reserved for future detailed legend.
}
}
+246 -60
View File
@@ -1,99 +1,138 @@
import Phaser from 'phaser';
import { TvSocket } from '../network/TvSocket.js';
import { COLORS, FONTS, AudioEngine } from '@spacerace/shared';
import { createStarfield } from './Starfield.js';
import QRCode from 'qrcode-generator';
const W = 1280;
const H = 720;
const BASE_URL = window.location.origin;
export class LobbyScene extends Phaser.Scene {
private socket!: TvSocket;
private audio!: AudioEngine;
private roomCodeText!: Phaser.GameObjects.Text;
private playerListText!: Phaser.GameObjects.Text;
private players: { id: string; name: string }[] = [];
private playerListContainer!: Phaser.GameObjects.Container;
private titleGlow!: Phaser.GameObjects.Text;
private startBtn!: Phaser.GameObjects.Container;
private startBtnLabel!: Phaser.GameObjects.Text;
private playerCountText!: Phaser.GameObjects.Text;
private qrImage!: Phaser.GameObjects.Image;
private players: { id: string; name: string; colorIndex: number }[] = [];
private roomCode: string = '';
constructor() {
super({ key: 'LobbyScene' });
}
init(data: { socket: TvSocket }): void {
init(data: { socket: TvSocket; audio: AudioEngine }): void {
this.socket = data.socket;
this.audio = data.audio;
this.players = [];
}
create(): void {
const { width, height } = this.scale;
this.cameras.main.setBackgroundColor('#050514');
createStarfield(this);
// Background
this.cameras.main.setBackgroundColor('#0a0a2e');
// ── Title with layered glow ──
this.titleGlow = this.add.text(W / 2, 80, 'SPACE RACE', {
fontFamily: FONTS.display,
fontSize: '64px',
color: '#00e5ff',
fontStyle: 'bold',
}).setOrigin(0.5);
this.titleGlow.setShadow(0, 0, '#00e5ff', 24, true, true);
this.titleGlow.setAlpha(0.6);
this.titleGlow.setDepth(0.5);
// Title
this.add.text(width / 2, 60, '🚀 SPACE RACE 🚀', {
fontSize: '48px',
color: '#00ccff',
fontFamily: 'monospace',
this.add.text(W / 2, 80, 'SPACE RACE', {
fontFamily: FONTS.display,
fontSize: '64px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
// Create room button
this.roomCodeText = this.add.text(width / 2, 160, 'Creating room...', {
fontSize: '28px',
color: '#ffffff',
fontFamily: 'monospace',
// Subtitle
this.add.text(W / 2, 138, '◆ Multiplayer Tactical Race ◆', {
fontFamily: FONTS.display,
fontSize: '16px',
color: '#ff2bd6',
}).setOrigin(0.5).setShadow(0, 0, '#ff2bd6', 12, true, true);
// ── Room code badge ──
this.add.text(W / 2, 220, 'ROOM CODE', {
fontFamily: FONTS.display,
fontSize: '13px',
color: '#6a6a8a',
}).setOrigin(0.5);
// Player list
this.playerListText = this.add.text(width / 2, 240, 'Players: 0', {
fontSize: '22px',
color: '#aaaaaa',
fontFamily: 'monospace',
align: 'center',
}).setOrigin(0.5, 0);
this.add.rectangle(W / 2, 268, 360, 72, 0x0a0a24, 0.85)
.setStrokeStyle(2, 0x00e5ff, 0.55);
// QR Code hint
this.add.text(width / 2, height - 100, 'Scan QR code or enter room code on your phone', {
fontSize: '18px',
color: '#666688',
fontFamily: 'monospace',
}).setOrigin(0.5);
this.roomCodeText = this.add.text(W / 2, 268, '----', {
fontFamily: FONTS.mono,
fontSize: '44px',
color: '#00e5ff',
fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, '#00e5ff', 16, true, true);
// Start button (hidden until players join)
const startBtn = this.add.text(width / 2, height - 160, '[ START GAME ]', {
fontSize: '32px',
color: '#00ff88',
fontFamily: 'monospace',
backgroundColor: '#115533',
padding: { x: 20, y: 10 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true }).setVisible(false);
// ── QR code ──
this.qrImage = this.add.image(W / 2, 400, '__DEFAULT').setVisible(false);
startBtn.on('pointerover', () => startBtn.setStyle({ backgroundColor: '#227744' }));
startBtn.on('pointerout', () => startBtn.setStyle({ backgroundColor: '#115533' }));
startBtn.on('pointerdown', () => {
this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => {
if (res.ok) {
console.log('Game starting...');
}
});
// ── Players section ──
this.add.text(160, 500, 'CREW MANIFEST', {
fontFamily: FONTS.display,
fontSize: '14px',
color: '#a0a0c8',
});
// Socket events
this.playerCountText = this.add.text(W - 160, 500, '0 / 6 PILOTS', {
fontFamily: FONTS.display,
fontSize: '14px',
color: '#a0a0c8',
}).setOrigin(1, 0);
this.playerListContainer = this.add.container(0, 0);
this.renderPlayerList();
// ── Bottom hint + start button ──
this.add.text(W / 2, H - 110, 'Scan the QR code on the TV, or enter the room code on your phone', {
fontFamily: FONTS.body,
fontSize: '15px',
color: '#6a6a8a',
}).setOrigin(0.5);
this.startBtn = this.createNeonButton(W / 2, H - 60, 'START RACE', '#ff2bd6', '#ff2bd6');
this.startBtn.setVisible(false);
// ── Socket events ──
this.socket.emit('tv:createRoom', {}, (res) => {
this.roomCode = res.roomCode;
this.roomCodeText.setText(`Room: ${res.roomCode}`);
this.updateQRHint();
this.roomCodeText.setText(res.roomCode);
this.generateQR(res.roomCode);
});
this.socket.on('playerJoined', (data) => {
this.players.push({ id: data.playerId, name: data.name });
this.updatePlayerList();
startBtn.setVisible(this.players.length >= 2);
this.players.push({ id: data.playerId, name: data.name, colorIndex: this.players.length });
this.audio.feedback('cardSelect', { haptic: false });
this.refreshPlayerList();
this.startBtn.setVisible(this.players.length >= 2);
});
this.socket.on('playerLeft', (data) => {
this.players = this.players.filter((p) => p.id !== data.playerId);
this.updatePlayerList();
startBtn.setVisible(this.players.length >= 2);
// Reassign color indices in join order
this.players.forEach((p, i) => { p.colorIndex = i; });
this.refreshPlayerList();
this.startBtn.setVisible(this.players.length >= 2);
});
this.socket.on('gameStarting', (data) => {
this.audio.feedback('phaseChange', { haptic: false });
this.scene.start('GameScene', {
socket: this.socket,
audio: this.audio,
roomCode: this.roomCode,
players: data.players,
grid: data.grid,
@@ -102,14 +141,161 @@ export class LobbyScene extends Phaser.Scene {
});
}
private updatePlayerList(): void {
const names = this.players.map((p, i) => ` ${i + 1}. ${p.name}`).join('\n');
this.playerListText.setText(`Players (${this.players.length}/6):\n${names}`);
private createNeonButton(x: number, y: number, label: string, fillColor: number, glowColor: number): Phaser.GameObjects.Container {
const container = this.add.container(x, y);
const w = 320, h = 64;
// Outer glow
const glow = this.add.rectangle(0, 0, w + 14, h + 14, glowColor, 0.18);
glow.setBlendMode(Phaser.BlendModes.ADD);
// Main button
const bg = this.add.rectangle(0, 0, w, h, fillColor, 1);
bg.setStrokeStyle(2, 0xffffff, 0.6);
// Top highlight gradient
const highlight = this.add.rectangle(0, -h / 4, w - 4, h / 2, 0xffffff, 0.18);
highlight.setBlendMode(Phaser.BlendModes.ADD);
const text = this.add.text(0, 0, label, {
fontFamily: FONTS.display,
fontSize: '22px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0.5);
text.setShadow(0, 0, glowColor, 12, true, true);
container.add([glow, bg, highlight, text]);
container.setSize(w, h);
container.setInteractive(new Phaser.Geom.Rectangle(-w/2, -h/2, w, h), Phaser.Geom.Rectangle.Contains);
container.on('pointerover', () => {
bg.setFillStyle(0xffffff, 0.18);
bg.setFillStyle(fillColor, 1);
this.tweens.add({ targets: container, scaleX: 1.04, scaleY: 1.04, duration: 120 });
this.audio.feedback('cardSelect', { haptic: false });
});
container.on('pointerout', () => {
this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 120 });
});
container.on('pointerdown', () => {
this.audio.feedback('go', { haptic: false });
this.tweens.add({ targets: container, scaleX: 0.96, scaleY: 0.96, duration: 80, yoyo: true });
this.socket.emit('tv:startGame', { roomCode: this.roomCode }, (res) => {
if (!res.ok) console.warn('Start failed');
});
});
// Pulsing glow
this.tweens.add({
targets: glow,
alpha: { from: 0.18, to: 0.42 },
duration: 1200,
yoyo: true,
repeat: -1,
});
return container;
}
private updateQRHint(): void {
// We'll use a canvas-based QR in the controller app
const url = `${window.location.origin}?room=${this.roomCode}`;
console.log('Join URL:', url);
private generateQR(roomCode: string): void {
const url = `${BASE_URL}/controller?room=${roomCode}`;
const qr = QRCode(0, 'M');
qr.addData(url);
qr.make();
const size = 160;
const moduleCount = qr.getModuleCount();
const moduleSize = Math.floor(size / (moduleCount + 2));
const canvasSize = (moduleCount + 2) * moduleSize;
const canvas = document.createElement('canvas');
canvas.width = canvasSize;
canvas.height = canvasSize;
const ctx = canvas.getContext('2d')!;
ctx.fillStyle = '#050514';
ctx.fillRect(0, 0, canvasSize, canvasSize);
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qr.isDark(row, col)) {
ctx.fillStyle = '#00e5ff';
ctx.fillRect((col + 1) * moduleSize, (row + 1) * moduleSize, moduleSize, moduleSize);
}
}
}
const key = `qr-${roomCode}`;
if (this.textures.exists(key)) this.textures.remove(key);
this.textures.addImage(key, canvas as unknown as HTMLImageElement);
this.qrImage.setTexture(key).setVisible(true).setDisplaySize(size, size);
const qrLink = document.getElementById('qr-url') as HTMLAnchorElement;
qrLink.href = url;
qrLink.textContent = url;
qrLink.style.display = 'block';
}
private renderPlayerList(): void {
this.playerListContainer.removeAll(true);
const startY = 530;
const rowH = 48;
const colW = 300;
const cols = 4;
const leftMargin = (W - cols * colW) / 2;
for (let i = 0; i < this.players.length; i++) {
const p = this.players[i];
const col = i % cols;
const row = Math.floor(i / cols);
const x = leftMargin + col * colW + colW / 2;
const y = startY + row * rowH;
const color = COLORS.player[p.colorIndex % COLORS.player.length];
// Card bg
const card = this.add.rectangle(x, y, colW - 16, rowH - 8, 0x0a0a24, 0.9);
card.setStrokeStyle(1, color, 0.7);
// Color avatar (left)
const avatar = this.add.circle(x - colW/2 + 28, y, 12, color);
avatar.setStrokeStyle(1, 0xffffff, 0.6);
// Player name
const name = this.add.text(x - colW/2 + 52, y, p.name, {
fontFamily: FONTS.body,
fontSize: '16px',
color: '#ffffff',
fontStyle: 'bold',
}).setOrigin(0, 0.5);
// Pilot tag (right)
this.add.text(x + colW/2 - 18, y, `P0${p.colorIndex + 1}`, {
fontFamily: FONTS.mono,
fontSize: '11px',
color: '#6a6a8a',
}).setOrigin(1, 0.5);
this.playerListContainer.add([card, avatar, name]);
}
// Empty slot
if (this.players.length === 0) {
const x = W / 2;
const y = startY + 12;
this.add.text(x, y, 'Waiting for pilots to join…', {
fontFamily: FONTS.body,
fontSize: '16px',
color: '#6a6a8a',
fontStyle: 'italic',
}).setOrigin(0.5);
}
this.playerCountText.setText(`${this.players.length} / 6 PILOTS`);
}
private refreshPlayerList(): void {
this.renderPlayerList();
}
}
+160 -54
View File
@@ -1,89 +1,195 @@
import Phaser from 'phaser';
import { TvSocket } from '../network/TvSocket.js';
import { Ship } from '@spacerace/shared';
import { Ship, COLORS, FONTS, AudioEngine } from '@spacerace/shared';
import { createStarfield } from './Starfield.js';
const W = 1280;
const H = 720;
interface PodiumEntry {
ship: Ship;
color: number;
rank: number;
}
export class ResultScene extends Phaser.Scene {
private socket!: TvSocket;
private audio!: AudioEngine;
constructor() {
super({ key: 'ResultScene' });
}
init(data: { socket: TvSocket; winnerId: string; winnerName: string; ships: Ship[] }): void {
init(data: { socket: TvSocket; audio: AudioEngine; winnerId: string; winnerName: string; ships: Ship[] }): void {
this.socket = data.socket;
// We'll use data directly in create
this.audio = data.audio;
this.registry.set('resultData', data);
}
create(): void {
const data = this.registry.get('resultData') as {
winnerId: string;
winnerName: string;
ships: Ship[];
winnerId: string; winnerName: string; ships: Ship[];
};
const { width, height } = this.scale;
this.cameras.main.setBackgroundColor('#0a0a2e');
this.cameras.main.setBackgroundColor('#050514');
createStarfield(this);
this.add.text(width / 2, 100, '🏆 RACE OVER 🏆', {
fontSize: '52px',
color: '#ffcc00',
fontFamily: 'monospace',
fontStyle: 'bold',
this.audio.feedback('fanfare', { haptic: false });
// ── Title with glow ──
const titleGlow = this.add.text(W / 2, 90, '🏆 RACE OVER 🏆', {
fontFamily: FONTS.display, fontSize: '56px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
titleGlow.setShadow(0, 0, '#ffcc00', 24, true, true);
titleGlow.setAlpha(0.6);
this.add.text(W / 2, 90, '🏆 RACE OVER 🏆', {
fontFamily: FONTS.display, fontSize: '56px', color: '#ffcc00', fontStyle: 'bold',
}).setOrigin(0.5);
this.add.text(width / 2, 220, `${data.winnerName} wins!`, {
fontSize: '40px',
color: '#00ff88',
fontFamily: 'monospace',
}).setOrigin(0.5);
// ── Winner banner ──
this.add.rectangle(W / 2, 170, 700, 70, 0x0a0a24, 0.85)
.setStrokeStyle(2, 0xff2bd6, 0.8);
this.add.text(W / 2, 170, `${data.winnerName} WINS!`, {
fontFamily: FONTS.display, fontSize: '36px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, '#ff2bd6', 16, true, true);
// Show final positions
const aliveShips = data.ships.filter((s) => s.alive);
const deadShips = data.ships.filter((s) => !s.alive);
let yPos = 320;
// ── Build podium ──
const alive = data.ships.filter((s) => s.alive);
const dead = data.ships.filter((s) => !s.alive);
const sorted = [...alive, ...dead].slice(0, 6);
sorted.forEach((ship, i) => {
const color = COLORS.player[i % COLORS.player.length];
const podiumEntry: PodiumEntry = { ship, color, rank: i + 1 };
this.renderPodiumEntry(podiumEntry, sorted.length, i);
});
this.add.text(width / 2, yPos, 'Final Standings:', {
fontSize: '24px',
color: '#aaaaaa',
fontFamily: 'monospace',
}).setOrigin(0.5);
yPos += 40;
// ── Confetti burst ──
this.spawnConfetti();
for (let i = 0; i < aliveShips.length; i++) {
const ship = aliveShips[i];
this.add.text(width / 2, yPos, `${i + 1}. ${ship.playerName}`, {
fontSize: '20px',
color: '#ffffff',
fontFamily: 'monospace',
}).setOrigin(0.5);
yPos += 30;
// ── Play again button ──
this.createNeonButton(W / 2, H - 70, 'BACK TO LOBBY', 0x00e5ff, 0x00e5ff);
}
private renderPodiumEntry(entry: PodiumEntry, total: number, index: number): void {
const isWinner = entry.rank === 1;
const colW = 180;
const cols = Math.min(total, 6);
const startX = (W - cols * colW) / 2 + colW / 2;
const x = startX + index * colW;
const baseY = 480;
const heightByRank = isWinner ? 180 : entry.rank === 2 ? 130 : entry.rank === 3 ? 90 : 60;
// Podium block
const block = this.add.rectangle(x, baseY, colW - 14, heightByRank, entry.color, 0.85);
block.setStrokeStyle(2, 0xffffff, 0.4);
block.setOrigin(0.5, 1);
// Glow halo for top 3
if (entry.rank <= 3) {
const glow = this.add.rectangle(x, baseY, colW - 8, heightByRank + 10, entry.color, 0.3);
glow.setOrigin(0.5, 1);
glow.setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({
targets: glow,
alpha: { from: 0.2, to: 0.5 },
duration: 1100 + index * 100,
yoyo: true,
repeat: -1,
});
}
for (const ship of deadShips) {
this.add.text(width / 2, yPos, ` ${ship.playerName} (eliminated)`, {
fontSize: '20px',
color: '#666666',
fontFamily: 'monospace',
// Rank number
this.add.text(x, baseY - heightByRank + 28, `${entry.rank}`, {
fontFamily: FONTS.display, fontSize: '32px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, '#ffffff', 8, true, true);
// Player name (on top of podium)
this.add.text(x, baseY - heightByRank - 16, entry.ship.playerName, {
fontFamily: FONTS.body, fontSize: '16px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5);
// Status (eliminated) below name
if (!entry.ship.alive) {
this.add.text(x, baseY - heightByRank - 38, '✗ ELIMINATED', {
fontFamily: FONTS.display, fontSize: '11px', color: '#ff3b6b',
}).setOrigin(0.5);
yPos += 30;
} else if (isWinner) {
this.add.text(x, baseY - heightByRank - 38, '★ CHAMPION ★', {
fontFamily: FONTS.display, fontSize: '11px', color: '#ffcc00',
}).setOrigin(0.5).setShadow(0, 0, '#ffcc00', 8, true, true);
}
// Play again button
const playAgainBtn = this.add.text(width / 2, height - 100, '[ BACK TO LOBBY ]', {
fontSize: '28px',
color: '#00ccff',
fontFamily: 'monospace',
backgroundColor: '#112244',
padding: { x: 20, y: 10 },
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
// Winner bouncy scale-in
if (isWinner) {
block.setScale(0.7, 0.7);
this.tweens.add({
targets: block,
scaleX: 1,
scaleY: 1,
duration: 800,
ease: 'Back.easeOut',
});
}
}
playAgainBtn.on('pointerover', () => playAgainBtn.setStyle({ backgroundColor: '#223366' }));
playAgainBtn.on('pointerout', () => playAgainBtn.setStyle({ backgroundColor: '#112244' }));
playAgainBtn.on('pointerdown', () => {
private spawnConfetti(): void {
const colors = [0x00e5ff, 0xff2bd6, 0xffcc00, 0x00ff9c, 0xff3b6b];
const confetti = this.add.particles(0, 0, 'ship', {
x: { min: 0, max: W },
y: -20,
lifespan: 4000,
speedY: { min: 60, max: 140 },
speedX: { min: -60, max: 60 },
gravityY: 80,
scale: { min: 0.08, max: 0.2 },
rotate: { min: 0, max: 360 },
alpha: { start: 1, end: 0.4 },
tint: colors,
quantity: 2,
frequency: 30,
blendMode: Phaser.BlendModes.ADD,
});
confetti.setDepth(5);
}
private createNeonButton(x: number, y: number, label: string, fillColor: number, glowColor: number): void {
const w = 320, h = 56;
const container = this.add.container(x, y);
const glow = this.add.rectangle(0, 0, w + 12, h + 12, glowColor, 0.22);
glow.setBlendMode(Phaser.BlendModes.ADD);
const bg = this.add.rectangle(0, 0, w, h, fillColor, 1);
bg.setStrokeStyle(2, 0xffffff, 0.6);
const text = this.add.text(0, 0, label, {
fontFamily: FONTS.display, fontSize: '20px', color: '#ffffff', fontStyle: 'bold',
}).setOrigin(0.5).setShadow(0, 0, glowColor, 12, true, true);
container.add([glow, bg, text]);
container.setSize(w, h);
container.setInteractive(new Phaser.Geom.Rectangle(-w/2, -h/2, w, h), Phaser.Geom.Rectangle.Contains);
container.on('pointerover', () => {
this.tweens.add({ targets: container, scaleX: 1.05, scaleY: 1.05, duration: 120 });
this.audio.feedback('cardSelect', { haptic: false });
});
container.on('pointerout', () => {
this.tweens.add({ targets: container, scaleX: 1, scaleY: 1, duration: 120 });
});
container.on('pointerdown', () => {
this.audio.feedback('go', { haptic: false });
this.tweens.add({ targets: container, scaleX: 0.96, scaleY: 0.96, duration: 80, yoyo: true });
this.socket.disconnect();
window.location.reload();
});
this.tweens.add({
targets: glow,
alpha: { from: 0.18, to: 0.42 },
duration: 1300,
yoyo: true,
repeat: -1,
});
}
}
+62
View File
@@ -0,0 +1,62 @@
import Phaser from 'phaser';
/**
* Creates a procedurally generated starfield texture and renders it as a
* slowly scrolling TileSprite in the back. Three depth layers give a parallax
* feel. Drawn from the deepest layer (setDepth = -10) so game content always
* renders on top.
*/
export function createStarfield(scene: Phaser.Scene): void {
const w = scene.scale.width;
const h = scene.scale.height;
const layers: { count: number; color: number; alpha: number; speed: number; size: [number, number] }[] = [
{ count: 90, color: 0x6a6a8a, alpha: 0.6, speed: 0.05, size: [1, 1] },
{ count: 50, color: 0xffffff, alpha: 0.85, speed: 0.12, size: [1, 2] },
{ count: 18, color: 0x00e5ff, alpha: 0.75, speed: 0.22, size: [2, 2] },
{ count: 8, color: 0xff2bd6, alpha: 0.7, speed: 0.32, size: [2, 3] },
];
const tex = scene.make.graphics({ x: 0, y: 0, add: false });
tex.fillStyle(0x050514, 1);
tex.fillRect(0, 0, w, h);
for (const layer of layers) {
tex.fillStyle(layer.color, layer.alpha);
for (let i = 0; i < layer.count; i++) {
const x = Math.floor(Math.random() * w);
const y = Math.floor(Math.random() * h);
tex.fillRect(x, y, layer.size[0], layer.size[1]);
}
}
tex.generateTexture('starfield', w, h);
tex.destroy();
// Single tile of stars is enough — we just translate it.
const tile = scene.add.tileSprite(0, 0, w, h, 'starfield').setOrigin(0, 0);
tile.setDepth(-100);
// Add a second dim layer for depth
const dimTex = scene.make.graphics({ x: 0, y: 0, add: false });
dimTex.fillStyle(0x050514, 1);
dimTex.fillRect(0, 0, w, h);
for (let i = 0; i < 40; i++) {
const x = Math.floor(Math.random() * w);
const y = Math.floor(Math.random() * h);
dimTex.fillStyle([0x4a4a6a, 0x5a3a7a, 0x3a5a7a][i % 3], 0.6);
dimTex.fillRect(x, y, 1, 1);
}
dimTex.generateTexture('starfield_dim', w, h);
dimTex.destroy();
const dim = scene.add.tileSprite(0, 0, w, h, 'starfield_dim').setOrigin(0, 0);
dim.setDepth(-99);
// Slow horizontal drift — Phaser is single-threaded so a single tween is fine
scene.tweens.add({
targets: [tile, dim],
tilePositionX: { from: 0, to: w },
duration: 90000,
repeat: -1,
});
}
+37
View File
@@ -0,0 +1,37 @@
/* TV surface — mirrors shared/src/theme.ts as CSS variables for any HTML elements.
The Phaser canvas itself is rendered to the #game-container; we just give the
page a polished frame and host the starfield that shows through during scene
fades. */
:root {
--primary: #00e5ff;
--primary-dim: #007a99;
--accent: #ff2bd6;
--success: #00ff9c;
--warning: #ffcc00;
--danger: #ff3b6b;
--bg-deep: #050514;
--bg-panel: #0a0a24;
--text: #ffffff;
--text-dim: #a0a0c8;
--text-muted: #6a6a8a;
--f-display: "Orbitron", "Rajdhani", system-ui, sans-serif;
--f-body: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
--f-mono: "JetBrains Mono", "Fira Code", monospace;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: var(--bg-deep); font-family: var(--f-body); color: var(--text); }
#game-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; position: relative; }
/* Vignette + scanline overlay drawn above the canvas edges */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 100;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
repeating-linear-gradient(0deg, rgba(0,229,255,0.02) 0px, rgba(0,229,255,0.02) 1px, transparent 1px, transparent 3px);
}
+1
View File
@@ -1,6 +1,7 @@
import { defineConfig } from 'vite';
export default defineConfig({
base: '/',
server: {
port: 3000,
proxy: {