1950 lines
56 KiB
Svelte
1950 lines
56 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import * as THREE from "three";
|
|
import { DEFAULT_PRESSURE_RANGE_MAX, DEFAULT_PRESSURE_RANGE_MIN } from "$lib/config/pressure-range";
|
|
import { pressureColorPalettes } from "$lib/config/color-map";
|
|
import type { LocaleCode, PressureColorMapPreset } from "$lib/types/hud";
|
|
|
|
type CornerId = "tl" | "tr" | "bl" | "br";
|
|
|
|
interface CornerMap {
|
|
tl: number;
|
|
tr: number;
|
|
bl: number;
|
|
br: number;
|
|
}
|
|
|
|
interface Brick {
|
|
mesh: THREE.Mesh<THREE.BoxGeometry, THREE.MeshStandardMaterial>;
|
|
alive: boolean;
|
|
left: number;
|
|
right: number;
|
|
top: number;
|
|
bottom: number;
|
|
}
|
|
|
|
interface Copy {
|
|
title: string;
|
|
start: string;
|
|
restart: string;
|
|
pause: string;
|
|
resume: string;
|
|
running: string;
|
|
paused: string;
|
|
over: string;
|
|
score: string;
|
|
combo: string;
|
|
lives: string;
|
|
level: string;
|
|
bricks: string;
|
|
chase: string;
|
|
pausedOverlay: string;
|
|
online: string;
|
|
local: string;
|
|
createRoom: string;
|
|
discoverRooms: string;
|
|
joinRoom: string;
|
|
pairingCode: string;
|
|
joinCode: string;
|
|
playerName: string;
|
|
ready: string;
|
|
cancelReady: string;
|
|
disconnect: string;
|
|
opponent: string;
|
|
waitingOpponent: string;
|
|
roomStatus: string;
|
|
latency: string;
|
|
noRooms: string;
|
|
apiOffline: string;
|
|
connected: string;
|
|
connecting: string;
|
|
hosting: string;
|
|
discovering: string;
|
|
}
|
|
|
|
type LanStatus = "offline" | "discovering" | "hosting" | "connecting" | "lobby" | "running" | "error";
|
|
type PlayerRole = "host" | "guest";
|
|
type GamePhase = "lobby" | "countdown" | "running" | "paused" | "finished";
|
|
|
|
interface LanRoom {
|
|
roomId: string;
|
|
pairingCode: string;
|
|
hostName: string;
|
|
address: string;
|
|
port: number;
|
|
players: number;
|
|
maxPlayers: number;
|
|
protocolVersion: number;
|
|
appVersion: string;
|
|
}
|
|
|
|
interface LanPlayer {
|
|
id: string;
|
|
name: string;
|
|
ready: boolean;
|
|
role: PlayerRole;
|
|
score?: number;
|
|
lives?: number;
|
|
latencyMs?: number;
|
|
}
|
|
|
|
interface JoinRoomResponse {
|
|
roomId: string;
|
|
playerId: string;
|
|
ticket: string;
|
|
wsUrl: string;
|
|
pairingCode?: string;
|
|
}
|
|
|
|
interface CreateRoomResponse extends JoinRoomResponse {
|
|
pairingCode: string;
|
|
expiresAt?: number;
|
|
}
|
|
|
|
type ClientMessage =
|
|
| {
|
|
type: "hello";
|
|
protocolVersion: 1;
|
|
roomId: string;
|
|
playerId: string;
|
|
ticket: string;
|
|
playerName: string;
|
|
}
|
|
| { type: "ready"; ready: boolean }
|
|
| { type: "restart" }
|
|
| {
|
|
type: "input";
|
|
seq: number;
|
|
clientTime: number;
|
|
axis: number;
|
|
launch: boolean;
|
|
pause: boolean;
|
|
}
|
|
| { type: "ping"; clientTime: number };
|
|
|
|
type ServerMessage =
|
|
| {
|
|
type: "welcome";
|
|
roomId: string;
|
|
playerId: string;
|
|
role: PlayerRole;
|
|
tickRate: number;
|
|
seed: number;
|
|
}
|
|
| { type: "lobby"; players: LanPlayer[] }
|
|
| { type: "start"; startAt: number; seed: number }
|
|
| {
|
|
type: "snapshot";
|
|
tick: number;
|
|
serverTime: number;
|
|
phase: GamePhase;
|
|
players: Array<{
|
|
id: string;
|
|
name?: string;
|
|
role?: PlayerRole;
|
|
ready?: boolean;
|
|
paddleX: number;
|
|
score: number;
|
|
lives: number;
|
|
latencyMs?: number;
|
|
}>;
|
|
ball: { x: number; y: number; vx: number; vy: number };
|
|
bricks: string;
|
|
}
|
|
| { type: "pong"; clientTime: number; serverTime: number }
|
|
| { type: "error"; code: string; message: string };
|
|
|
|
export let locale: LocaleCode = "zh-CN";
|
|
export let pressureMatrix: number[] | null = null;
|
|
export let matrixRows = 12;
|
|
export let matrixCols = 7;
|
|
export let rangeMin = DEFAULT_PRESSURE_RANGE_MIN;
|
|
export let rangeMax = DEFAULT_PRESSURE_RANGE_MAX;
|
|
export let colorMapPreset: PressureColorMapPreset = "emerald";
|
|
|
|
const FIELD_HALF_W = 46;
|
|
const FIELD_HALF_H = 62;
|
|
const PADDLE_Y = -53;
|
|
const PADDLE_W = 16;
|
|
const PADDLE_H = 2.6;
|
|
const PADDLE_SPEED = 74;
|
|
const BALL_RADIUS = 1.45;
|
|
const BASE_BALL_SPEED = 58;
|
|
const MAX_BALL_SPEED = 106;
|
|
const BRICK_COLS = 12;
|
|
const BRICK_ROWS = 7;
|
|
const BRICK_W = 6.2;
|
|
const BRICK_H = 2.9;
|
|
const BRICK_GAP_X = 0.8;
|
|
const BRICK_GAP_Y = 0.9;
|
|
const BRICK_TOP = 44;
|
|
const START_DELAY_MS = 520;
|
|
const PAUSE_COOLDOWN_MS = 640;
|
|
const UPPER_STALL_THRESHOLD_MS = 1700;
|
|
const UPPER_STALL_Y = 14;
|
|
const ANTI_STALL_COOLDOWN_MS = 420;
|
|
const MAX_BRICK_HITS_WITHOUT_PADDLE = 5;
|
|
const LAN_PROTOCOL_VERSION = 1;
|
|
const LAN_API_BASE_STORAGE_KEY = "je-skin-lan-api-base";
|
|
const LAN_DEFAULT_API_BASE = "http://127.0.0.1:47888";
|
|
const LAN_INPUT_INTERVAL_MS = 33;
|
|
|
|
const copyByLocale: Record<LocaleCode, Copy> = {
|
|
"zh-CN": {
|
|
title: "NEON BREAKOUT",
|
|
start: "开始",
|
|
restart: "重开",
|
|
pause: "暂停",
|
|
resume: "继续",
|
|
running: "运行中",
|
|
paused: "已暂停",
|
|
over: "结束",
|
|
score: "得分",
|
|
combo: "连击",
|
|
lives: "生命",
|
|
level: "关卡",
|
|
bricks: "剩余",
|
|
chase: "追击",
|
|
pausedOverlay: "已暂停 / 顶部双角施压继续",
|
|
online: "联机",
|
|
local: "本地",
|
|
createRoom: "创建房间",
|
|
discoverRooms: "搜索",
|
|
joinRoom: "加入",
|
|
pairingCode: "配对码",
|
|
joinCode: "输入配对码",
|
|
playerName: "玩家名",
|
|
ready: "准备",
|
|
cancelReady: "取消准备",
|
|
disconnect: "断开",
|
|
opponent: "对手",
|
|
waitingOpponent: "等待对手加入",
|
|
roomStatus: "房间",
|
|
latency: "延迟",
|
|
noRooms: "暂无局域网房间",
|
|
apiOffline: "联机后端未启动或不可访问",
|
|
connected: "已连接",
|
|
connecting: "连接中",
|
|
hosting: "房主等待中",
|
|
discovering: "搜索中"
|
|
},
|
|
"en-US": {
|
|
title: "NEON BREAKOUT",
|
|
start: "Start",
|
|
restart: "Restart",
|
|
pause: "Pause",
|
|
resume: "Resume",
|
|
running: "Running",
|
|
paused: "Paused",
|
|
over: "Game Over",
|
|
score: "Score",
|
|
combo: "Combo",
|
|
lives: "Lives",
|
|
level: "Level",
|
|
bricks: "Bricks",
|
|
chase: "Chase",
|
|
pausedOverlay: "Paused / Top corners to resume",
|
|
online: "Online",
|
|
local: "Local",
|
|
createRoom: "Create",
|
|
discoverRooms: "Scan",
|
|
joinRoom: "Join",
|
|
pairingCode: "Code",
|
|
joinCode: "Pairing code",
|
|
playerName: "Name",
|
|
ready: "Ready",
|
|
cancelReady: "Cancel",
|
|
disconnect: "Disconnect",
|
|
opponent: "Opponent",
|
|
waitingOpponent: "Waiting for opponent",
|
|
roomStatus: "Room",
|
|
latency: "Ping",
|
|
noRooms: "No LAN rooms found",
|
|
apiOffline: "LAN backend is offline or unreachable",
|
|
connected: "Connected",
|
|
connecting: "Connecting",
|
|
hosting: "Hosting",
|
|
discovering: "Scanning"
|
|
}
|
|
};
|
|
|
|
let hostEl: HTMLElement | undefined;
|
|
let canvasEl: HTMLCanvasElement | undefined;
|
|
let rafId: number | null = null;
|
|
let renderer: THREE.WebGLRenderer | null = null;
|
|
let scene: THREE.Scene | null = null;
|
|
let camera: THREE.OrthographicCamera | null = null;
|
|
let paddle: THREE.Mesh<THREE.BoxGeometry, THREE.MeshStandardMaterial> | null = null;
|
|
let ball: THREE.Mesh<THREE.SphereGeometry, THREE.MeshStandardMaterial> | null = null;
|
|
let brickGroup: THREE.Group | null = null;
|
|
let borderLine: THREE.LineLoop | null = null;
|
|
let bgPlane: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial> | null = null;
|
|
let ambientLight: THREE.AmbientLight | null = null;
|
|
let keyLight: THREE.PointLight | null = null;
|
|
let rimLight: THREE.PointLight | null = null;
|
|
let flashSprite: THREE.Sprite | null = null;
|
|
let flashLife = 0;
|
|
let flashMaxLife = 0.2;
|
|
let lanSocket: WebSocket | null = null;
|
|
let lanMode: "local" | "online" = "local";
|
|
let lanStatus: LanStatus = "offline";
|
|
let lanRooms: LanRoom[] = [];
|
|
let lanNotice = "";
|
|
let lanApiBase = LAN_DEFAULT_API_BASE;
|
|
let playerName = "Player";
|
|
let joinCode = "";
|
|
let pairingCode = "";
|
|
let roomId = "";
|
|
let localPlayerId = "";
|
|
let localRole: PlayerRole | null = null;
|
|
let localReady = false;
|
|
let lanPlayers: LanPlayer[] = [];
|
|
let pingMs: number | null = null;
|
|
let lastLanInputAt = 0;
|
|
let lanInputSeq = 0;
|
|
let intentionalLanDisconnect = false;
|
|
let lanLaunchAt = 0;
|
|
|
|
let keyLeft = false;
|
|
let keyRight = false;
|
|
let ballLaunched = false;
|
|
let nextLaunchAt = 0;
|
|
let paddleX = 0;
|
|
const ballPos = new THREE.Vector2(0, 0);
|
|
const ballVel = new THREE.Vector2(0, 0);
|
|
let bricks: Brick[] = [];
|
|
|
|
let gameState: "idle" | "running" | "paused" | "over" = "idle";
|
|
let score = 0;
|
|
let combo = 0;
|
|
let lives = 3;
|
|
let level = 1;
|
|
let bricksLeft = 0;
|
|
let lastFrameTs = 0;
|
|
let prevPauseGesture = false;
|
|
let pauseGestureLockUntil = 0;
|
|
let themedBgTexture: THREE.CanvasTexture | null = null;
|
|
let lastPaddleContactAt = 0;
|
|
let bricksSincePaddle = 0;
|
|
let lastAntiStallAt = 0;
|
|
|
|
let cornerForce: CornerMap = { tl: 0, tr: 0, bl: 0, br: 0 };
|
|
$: cornerForce = readCorners(pressureMatrix, matrixRows, matrixCols);
|
|
$: themePalette = pressureColorPalettes[colorMapPreset] ?? pressureColorPalettes.emerald;
|
|
$: ui = copyByLocale[locale];
|
|
$: leftForce = cornerForce.tl + cornerForce.bl;
|
|
$: rightForce = cornerForce.tr + cornerForce.br;
|
|
$: topForce = cornerForce.tl + cornerForce.tr;
|
|
$: pauseGestureThreshold = Math.max(420, Math.round(Math.max(1000, rangeMax - rangeMin) * 0.07));
|
|
$: statusText =
|
|
gameState === "running" ? ui.running : gameState === "paused" ? ui.paused : gameState === "over" ? ui.over : ui.start;
|
|
$: isLanBusy = lanStatus === "discovering" || lanStatus === "hosting" || lanStatus === "connecting";
|
|
$: isLanConnected = lanStatus === "lobby" || lanStatus === "running";
|
|
$: selfPlayer = lanPlayers.find((player) => player.id === localPlayerId) ?? null;
|
|
$: opponentPlayers = lanPlayers.filter((player) => player.id !== localPlayerId);
|
|
$: primaryOpponent = opponentPlayers[0] ?? null;
|
|
$: roomStatusText =
|
|
lanStatus === "discovering"
|
|
? ui.discovering
|
|
: lanStatus === "hosting"
|
|
? ui.hosting
|
|
: lanStatus === "connecting"
|
|
? ui.connecting
|
|
: isLanConnected
|
|
? ui.connected
|
|
: lanStatus === "error"
|
|
? ui.apiOffline
|
|
: ui.local;
|
|
|
|
$: if (scene && renderer) {
|
|
applyTheme();
|
|
}
|
|
|
|
function rgbTripletToColor(rgbTriplet: string): THREE.Color {
|
|
return new THREE.Color(`rgb(${rgbTriplet.replace(/\s+/g, ", ")})`);
|
|
}
|
|
|
|
function rgbaFromTriplet(rgbTriplet: string, alpha: number): string {
|
|
return `rgba(${rgbTriplet.replace(/\s+/g, ",")},${alpha})`;
|
|
}
|
|
|
|
function themedBrickPalette(): string[] {
|
|
const { rangeStops } = themePalette;
|
|
return [rangeStops[1], rangeStops[2], rangeStops[3], rangeStops[4], rangeStops[5], rangeStops[2], rangeStops[3]];
|
|
}
|
|
|
|
function applyTheme(): void {
|
|
if (!scene || !renderer) return;
|
|
const uiTheme = themePalette.uiTheme;
|
|
scene.background = new THREE.Color(uiTheme.bg30);
|
|
|
|
ambientLight?.color.copy(rgbTripletToColor(uiTheme.textMainRgb));
|
|
keyLight?.color.copy(rgbTripletToColor(uiTheme.glowAltRgb));
|
|
rimLight?.color.copy(rgbTripletToColor(uiTheme.glowRgb));
|
|
|
|
const borderMaterial = borderLine?.material as THREE.LineBasicMaterial | undefined;
|
|
if (borderMaterial) {
|
|
borderMaterial.color.copy(rgbTripletToColor(uiTheme.glowRgb));
|
|
borderMaterial.opacity = 0.48;
|
|
borderMaterial.needsUpdate = true;
|
|
}
|
|
|
|
if (paddle) {
|
|
paddle.material.color.copy(new THREE.Color(themePalette.rangeStops[2]));
|
|
paddle.material.emissive.copy(new THREE.Color(themePalette.rangeStops[2]));
|
|
paddle.material.emissiveIntensity = 0.72;
|
|
paddle.material.needsUpdate = true;
|
|
}
|
|
|
|
if (ball) {
|
|
ball.material.color.copy(new THREE.Color(themePalette.rangeStops[0]));
|
|
ball.material.emissive.copy(new THREE.Color(themePalette.rangeStops[3]));
|
|
ball.material.emissiveIntensity = 0.92;
|
|
ball.material.needsUpdate = true;
|
|
}
|
|
|
|
if (bgPlane) {
|
|
const material = bgPlane.material;
|
|
themedBgTexture?.dispose();
|
|
themedBgTexture = createGridTexture(themePalette);
|
|
material.map = themedBgTexture;
|
|
material.opacity = 0.78;
|
|
material.needsUpdate = true;
|
|
}
|
|
|
|
const colors = themedBrickPalette();
|
|
for (let index = 0; index < bricks.length; index += 1) {
|
|
const brick = bricks[index];
|
|
const color = colors[index % colors.length] ?? "#7ad0ff";
|
|
brick.mesh.material.color.set(color);
|
|
brick.mesh.material.emissive.set(color);
|
|
brick.mesh.material.emissiveIntensity = 0.7;
|
|
brick.mesh.material.needsUpdate = true;
|
|
}
|
|
}
|
|
|
|
function getLanApiBase(): string {
|
|
if (typeof window === "undefined") {
|
|
return LAN_DEFAULT_API_BASE;
|
|
}
|
|
|
|
return window.localStorage.getItem(LAN_API_BASE_STORAGE_KEY)?.trim() || LAN_DEFAULT_API_BASE;
|
|
}
|
|
|
|
function normalizePairingCode(value: string): string {
|
|
return value.replace(/\D/g, "").slice(0, 8);
|
|
}
|
|
|
|
function makeDefaultPlayerName(): string {
|
|
if (typeof window === "undefined") {
|
|
return "Player";
|
|
}
|
|
|
|
const saved = window.localStorage.getItem("je-skin-lan-player-name")?.trim();
|
|
if (saved) {
|
|
return saved.slice(0, 18);
|
|
}
|
|
|
|
return `JE-${Math.floor(1000 + Math.random() * 9000)}`;
|
|
}
|
|
|
|
function persistPlayerName(): void {
|
|
if (typeof window === "undefined") {
|
|
return;
|
|
}
|
|
|
|
const safeName = playerName.trim().slice(0, 18) || makeDefaultPlayerName();
|
|
playerName = safeName;
|
|
window.localStorage.setItem("je-skin-lan-player-name", safeName);
|
|
}
|
|
|
|
async function lanFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const response = await fetch(`${lanApiBase}${path}`, {
|
|
...init,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(init?.headers ?? {})
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
let message = "";
|
|
try {
|
|
const body = (await response.json()) as { message?: string };
|
|
message = body.message ?? "";
|
|
} catch {
|
|
message = "";
|
|
}
|
|
|
|
if (response.status === 404) {
|
|
throw new Error(locale === "zh-CN" ? "未找到这个配对码,请确认房主已创建房间。" : "Pairing code not found. Make sure the host created a room.");
|
|
}
|
|
|
|
throw new Error(message || `LAN API ${response.status}`);
|
|
}
|
|
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
function setLanError(error: unknown): void {
|
|
lanStatus = "error";
|
|
lanNotice = error instanceof Error ? error.message : ui.apiOffline;
|
|
console.error("[lan] operation failed:", error);
|
|
}
|
|
|
|
async function discoverLanRooms(): Promise<void> {
|
|
lanMode = "online";
|
|
lanStatus = "discovering";
|
|
lanNotice = "";
|
|
lanApiBase = getLanApiBase();
|
|
|
|
try {
|
|
const result = await lanFetch<{ rooms: LanRoom[] }>("/lan/rooms");
|
|
lanRooms = result.rooms ?? [];
|
|
lanStatus = "offline";
|
|
lanNotice = lanRooms.length ? "" : ui.noRooms;
|
|
} catch (error) {
|
|
lanRooms = [];
|
|
setLanError(error);
|
|
}
|
|
}
|
|
|
|
async function createLanRoom(): Promise<void> {
|
|
lanMode = "online";
|
|
lanStatus = "hosting";
|
|
lanNotice = "";
|
|
persistPlayerName();
|
|
lanApiBase = getLanApiBase();
|
|
|
|
try {
|
|
const result = await lanFetch<CreateRoomResponse>("/lan/rooms", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
playerName,
|
|
protocolVersion: LAN_PROTOCOL_VERSION
|
|
})
|
|
});
|
|
|
|
await connectLanSocket(result);
|
|
pairingCode = result.pairingCode;
|
|
} catch (error) {
|
|
setLanError(error);
|
|
}
|
|
}
|
|
|
|
async function joinLanRoom(code = joinCode): Promise<void> {
|
|
const normalizedCode = normalizePairingCode(code);
|
|
if (!normalizedCode) {
|
|
return;
|
|
}
|
|
|
|
joinCode = normalizedCode;
|
|
lanMode = "online";
|
|
lanStatus = "connecting";
|
|
lanNotice = "";
|
|
persistPlayerName();
|
|
lanApiBase = getLanApiBase();
|
|
|
|
try {
|
|
const result = await lanFetch<JoinRoomResponse>("/lan/join", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
pairingCode: normalizedCode,
|
|
playerName,
|
|
protocolVersion: LAN_PROTOCOL_VERSION
|
|
})
|
|
});
|
|
|
|
await connectLanSocket(result);
|
|
pairingCode = result.pairingCode ?? normalizedCode;
|
|
} catch (error) {
|
|
setLanError(error);
|
|
}
|
|
}
|
|
|
|
async function connectLanSocket(result: JoinRoomResponse): Promise<void> {
|
|
disconnectLanSocket(false);
|
|
intentionalLanDisconnect = false;
|
|
roomId = result.roomId;
|
|
localPlayerId = result.playerId;
|
|
lanStatus = "connecting";
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
const socket = new WebSocket(result.wsUrl);
|
|
lanSocket = socket;
|
|
|
|
socket.onopen = () => {
|
|
sendLanMessage({
|
|
type: "hello",
|
|
protocolVersion: LAN_PROTOCOL_VERSION,
|
|
roomId: result.roomId,
|
|
playerId: result.playerId,
|
|
ticket: result.ticket,
|
|
playerName
|
|
});
|
|
resolve();
|
|
};
|
|
|
|
socket.onerror = () => {
|
|
reject(new Error("WebSocket connect failed"));
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
handleLanMessage(JSON.parse(event.data) as ServerMessage);
|
|
};
|
|
|
|
socket.onclose = () => {
|
|
if (!intentionalLanDisconnect) {
|
|
lanStatus = "offline";
|
|
lanNotice = locale === "zh-CN" ? "联机连接已断开。" : "LAN connection closed.";
|
|
}
|
|
lanSocket = null;
|
|
};
|
|
});
|
|
}
|
|
|
|
function disconnectLanSocket(resetMode = true): void {
|
|
intentionalLanDisconnect = true;
|
|
lanSocket?.close(1000, "client disconnect");
|
|
lanSocket = null;
|
|
lanStatus = "offline";
|
|
localReady = false;
|
|
localRole = null;
|
|
localPlayerId = "";
|
|
roomId = "";
|
|
pairingCode = "";
|
|
pingMs = null;
|
|
lanPlayers = [];
|
|
lanLaunchAt = 0;
|
|
if (resetMode) {
|
|
lanMode = "local";
|
|
lanNotice = "";
|
|
}
|
|
}
|
|
|
|
function sendLanMessage(message: ClientMessage): void {
|
|
if (lanSocket?.readyState !== WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
lanSocket.send(JSON.stringify(message));
|
|
}
|
|
|
|
function handleLanMessage(message: ServerMessage): void {
|
|
if (message.type === "welcome") {
|
|
roomId = message.roomId;
|
|
localPlayerId = message.playerId;
|
|
localRole = message.role;
|
|
lanStatus = "lobby";
|
|
lanNotice = "";
|
|
return;
|
|
}
|
|
|
|
if (message.type === "lobby") {
|
|
lanPlayers = message.players;
|
|
localReady = lanPlayers.find((player) => player.id === localPlayerId)?.ready ?? localReady;
|
|
lanStatus = "lobby";
|
|
return;
|
|
}
|
|
|
|
if (message.type === "start") {
|
|
lanStatus = "running";
|
|
if (gameState !== "running") {
|
|
startGame();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (message.type === "snapshot") {
|
|
lanStatus = message.phase === "running" ? "running" : "lobby";
|
|
applyLanSnapshot(message);
|
|
return;
|
|
}
|
|
|
|
if (message.type === "pong") {
|
|
pingMs = Math.max(0, Math.round(performance.now() - message.clientTime));
|
|
return;
|
|
}
|
|
|
|
if (message.type === "error") {
|
|
lanNotice = message.message;
|
|
lanStatus = "error";
|
|
}
|
|
}
|
|
|
|
function applyLanSnapshot(snapshot: Extract<ServerMessage, { type: "snapshot" }>): void {
|
|
const phaseToState: Record<GamePhase, typeof gameState> = {
|
|
lobby: "idle",
|
|
countdown: "idle",
|
|
running: "running",
|
|
paused: "paused",
|
|
finished: "over"
|
|
};
|
|
|
|
const prevGameState = gameState;
|
|
gameState = phaseToState[snapshot.phase];
|
|
|
|
// Detect transition to running → schedule launch after delay
|
|
if (gameState === "running" && prevGameState !== "running" && lanLaunchAt === 0) {
|
|
lanLaunchAt = performance.now() + START_DELAY_MS;
|
|
}
|
|
|
|
// Reset launch timer when game ends or returns to lobby
|
|
if (gameState === "over" || gameState === "idle") {
|
|
lanLaunchAt = 0;
|
|
}
|
|
ballPos.set(snapshot.ball.x, snapshot.ball.y);
|
|
ballVel.set(snapshot.ball.vx, snapshot.ball.vy);
|
|
ball?.position.set(ballPos.x, ballPos.y, 1.2);
|
|
|
|
const playersById = new Map(lanPlayers.map((player) => [player.id, player]));
|
|
lanPlayers = snapshot.players.map((player, index) => {
|
|
const previous = playersById.get(player.id);
|
|
return {
|
|
id: player.id,
|
|
name: player.name ?? previous?.name ?? (index === 0 ? "Host" : "Guest"),
|
|
role: player.role ?? previous?.role ?? (index === 0 ? "host" : "guest"),
|
|
ready: player.ready ?? previous?.ready ?? true,
|
|
score: player.score,
|
|
lives: player.lives,
|
|
latencyMs: player.latencyMs ?? previous?.latencyMs
|
|
};
|
|
});
|
|
|
|
const selfSnapshot = snapshot.players.find((player) => player.id === localPlayerId) ?? snapshot.players[0];
|
|
if (selfSnapshot) {
|
|
paddleX = Math.min(FIELD_HALF_W - PADDLE_W * 0.5, Math.max(-FIELD_HALF_W + PADDLE_W * 0.5, selfSnapshot.paddleX));
|
|
paddle?.position.setX(paddleX);
|
|
score = selfSnapshot.score;
|
|
lives = selfSnapshot.lives;
|
|
}
|
|
|
|
applyBrickMask(snapshot.bricks);
|
|
}
|
|
|
|
function applyBrickMask(mask: string): void {
|
|
if (!mask || !bricks.length) {
|
|
return;
|
|
}
|
|
|
|
let aliveCount = 0;
|
|
for (let index = 0; index < bricks.length; index += 1) {
|
|
const alive = mask[index] !== "0";
|
|
bricks[index].alive = alive;
|
|
bricks[index].mesh.visible = alive;
|
|
if (alive) {
|
|
aliveCount += 1;
|
|
}
|
|
}
|
|
bricksLeft = aliveCount;
|
|
}
|
|
|
|
function requestLanRestart(): void {
|
|
if (isLanConnected) {
|
|
sendLanMessage({ type: "restart" });
|
|
} else {
|
|
startGame();
|
|
}
|
|
}
|
|
|
|
function toggleLanReady(): void {
|
|
if (!isLanConnected) {
|
|
return;
|
|
}
|
|
|
|
localReady = !localReady;
|
|
sendLanMessage({ type: "ready", ready: localReady });
|
|
}
|
|
|
|
function sendLanInput(nowMs: number, axis: number, launch: boolean, pause: boolean): void {
|
|
if (!isLanConnected || nowMs - lastLanInputAt < LAN_INPUT_INTERVAL_MS) {
|
|
return;
|
|
}
|
|
|
|
lastLanInputAt = nowMs;
|
|
sendLanMessage({
|
|
type: "input",
|
|
seq: ++lanInputSeq,
|
|
clientTime: nowMs,
|
|
axis,
|
|
launch,
|
|
pause
|
|
});
|
|
|
|
if (lanInputSeq % 60 === 0) {
|
|
sendLanMessage({ type: "ping", clientTime: nowMs });
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
if (!hostEl || !canvasEl) return;
|
|
lanApiBase = getLanApiBase();
|
|
playerName = makeDefaultPlayerName();
|
|
const uiTheme = themePalette.uiTheme;
|
|
const paddleColor = themePalette.rangeStops[2];
|
|
const ballColor = themePalette.rangeStops[0];
|
|
const ballGlow = themePalette.rangeStops[3];
|
|
|
|
const nextScene = new THREE.Scene();
|
|
nextScene.background = new THREE.Color(uiTheme.bg30);
|
|
scene = nextScene;
|
|
|
|
const nextCamera = new THREE.OrthographicCamera(-60, 60, 70, -70, 0.1, 200);
|
|
nextCamera.position.set(0, 0, 54);
|
|
nextCamera.lookAt(0, 0, 0);
|
|
camera = nextCamera;
|
|
|
|
const nextRenderer = new THREE.WebGLRenderer({
|
|
canvas: canvasEl,
|
|
antialias: true,
|
|
alpha: true,
|
|
powerPreference: "high-performance"
|
|
});
|
|
renderer = nextRenderer;
|
|
nextRenderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
|
|
const ambient = new THREE.AmbientLight(rgbTripletToColor(uiTheme.textMainRgb), 0.44);
|
|
const key = new THREE.PointLight(rgbTripletToColor(uiTheme.glowAltRgb), 1.2, 180, 1.8);
|
|
key.position.set(0, 20, 38);
|
|
const rim = new THREE.PointLight(rgbTripletToColor(uiTheme.glowRgb), 0.65, 160, 1.7);
|
|
rim.position.set(-22, -14, 26);
|
|
nextScene.add(ambient, key, rim);
|
|
ambientLight = ambient;
|
|
keyLight = key;
|
|
rimLight = rim;
|
|
|
|
const bgTexture = createGridTexture(themePalette);
|
|
themedBgTexture = bgTexture;
|
|
const bg = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(160, 170),
|
|
new THREE.MeshBasicMaterial({
|
|
map: bgTexture,
|
|
transparent: true,
|
|
opacity: 0.78,
|
|
depthWrite: false
|
|
})
|
|
);
|
|
bg.position.set(0, 0, -12);
|
|
nextScene.add(bg);
|
|
bgPlane = bg;
|
|
|
|
const border = new THREE.LineLoop(
|
|
new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(-FIELD_HALF_W, -FIELD_HALF_H, 0),
|
|
new THREE.Vector3(FIELD_HALF_W, -FIELD_HALF_H, 0),
|
|
new THREE.Vector3(FIELD_HALF_W, FIELD_HALF_H, 0),
|
|
new THREE.Vector3(-FIELD_HALF_W, FIELD_HALF_H, 0)
|
|
]),
|
|
new THREE.LineBasicMaterial({ color: rgbTripletToColor(uiTheme.glowRgb), transparent: true, opacity: 0.46 })
|
|
);
|
|
nextScene.add(border);
|
|
borderLine = border;
|
|
|
|
const nextBrickGroup = new THREE.Group();
|
|
brickGroup = nextBrickGroup;
|
|
nextScene.add(nextBrickGroup);
|
|
|
|
const paddleMesh = new THREE.Mesh(
|
|
new THREE.BoxGeometry(PADDLE_W, PADDLE_H, 2.1),
|
|
new THREE.MeshStandardMaterial({
|
|
color: paddleColor,
|
|
emissive: paddleColor,
|
|
emissiveIntensity: 0.72,
|
|
roughness: 0.34,
|
|
metalness: 0.18
|
|
})
|
|
);
|
|
paddleMesh.position.set(0, PADDLE_Y, 0.4);
|
|
nextScene.add(paddleMesh);
|
|
paddle = paddleMesh;
|
|
|
|
const ballMesh = new THREE.Mesh(
|
|
new THREE.SphereGeometry(BALL_RADIUS, 22, 16),
|
|
new THREE.MeshStandardMaterial({
|
|
color: ballColor,
|
|
emissive: ballGlow,
|
|
emissiveIntensity: 0.88,
|
|
roughness: 0.2,
|
|
metalness: 0.04
|
|
})
|
|
);
|
|
nextScene.add(ballMesh);
|
|
ball = ballMesh;
|
|
|
|
const flashTexture = createFlashTexture(themePalette);
|
|
const flash = new THREE.Sprite(
|
|
new THREE.SpriteMaterial({
|
|
map: flashTexture,
|
|
color: "#74ffff",
|
|
transparent: true,
|
|
blending: THREE.AdditiveBlending,
|
|
opacity: 0,
|
|
depthWrite: false
|
|
})
|
|
);
|
|
flash.scale.set(1, 1, 1);
|
|
flash.visible = false;
|
|
nextScene.add(flash);
|
|
flashSprite = flash;
|
|
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.code === "ArrowLeft" || event.code === "KeyA") keyLeft = true;
|
|
if (event.code === "ArrowRight" || event.code === "KeyD") keyRight = true;
|
|
if (event.code === "Space" && gameState !== "running") startGame();
|
|
};
|
|
const onKeyUp = (event: KeyboardEvent) => {
|
|
if (event.code === "ArrowLeft" || event.code === "KeyA") keyLeft = false;
|
|
if (event.code === "ArrowRight" || event.code === "KeyD") keyRight = false;
|
|
};
|
|
|
|
window.addEventListener("keydown", onKeyDown);
|
|
window.addEventListener("keyup", onKeyUp);
|
|
|
|
const resize = () => {
|
|
if (!hostEl || !renderer || !camera) return;
|
|
const width = hostEl.clientWidth;
|
|
const height = hostEl.clientHeight;
|
|
if (width <= 0 || height <= 0) return;
|
|
renderer.setSize(width, height, false);
|
|
const aspect = width / Math.max(1, height);
|
|
const halfH = 70;
|
|
const halfW = halfH * aspect;
|
|
camera.left = -halfW;
|
|
camera.right = halfW;
|
|
camera.top = halfH;
|
|
camera.bottom = -halfH;
|
|
camera.updateProjectionMatrix();
|
|
};
|
|
|
|
const observer = new ResizeObserver(() => resize());
|
|
observer.observe(hostEl);
|
|
resize();
|
|
|
|
resetGameToIdle();
|
|
lastFrameTs = performance.now();
|
|
const loop = (ts: number) => {
|
|
const dt = Math.min((ts - lastFrameTs) / 1000, 0.034);
|
|
lastFrameTs = ts;
|
|
update(dt, ts);
|
|
renderer?.render(nextScene, nextCamera);
|
|
rafId = window.requestAnimationFrame(loop);
|
|
};
|
|
rafId = window.requestAnimationFrame(loop);
|
|
|
|
return () => {
|
|
if (rafId != null) window.cancelAnimationFrame(rafId);
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("keyup", onKeyUp);
|
|
observer.disconnect();
|
|
disposeBricks();
|
|
border.geometry.dispose();
|
|
(border.material as THREE.LineBasicMaterial).dispose();
|
|
bg.geometry.dispose();
|
|
(bg.material as THREE.MeshBasicMaterial).map?.dispose();
|
|
(bg.material as THREE.MeshBasicMaterial).dispose();
|
|
paddleMesh.geometry.dispose();
|
|
paddleMesh.material.dispose();
|
|
ballMesh.geometry.dispose();
|
|
ballMesh.material.dispose();
|
|
(flash.material as THREE.SpriteMaterial).map?.dispose();
|
|
(flash.material as THREE.SpriteMaterial).dispose();
|
|
disconnectLanSocket(false);
|
|
renderer?.dispose();
|
|
};
|
|
});
|
|
|
|
function createGridTexture(palette = themePalette): THREE.CanvasTexture {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 512;
|
|
canvas.height = 512;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return new THREE.CanvasTexture(canvas);
|
|
|
|
const { uiTheme } = palette;
|
|
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
|
gradient.addColorStop(0, uiTheme.bg20);
|
|
gradient.addColorStop(0.5, uiTheme.bg10);
|
|
gradient.addColorStop(1, uiTheme.bg30);
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.strokeStyle = rgbaFromTriplet(uiTheme.glowRgb, 0.18);
|
|
ctx.lineWidth = 1;
|
|
for (let y = 0; y < canvas.height; y += 24) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(canvas.width, y);
|
|
ctx.stroke();
|
|
}
|
|
ctx.strokeStyle = rgbaFromTriplet(uiTheme.glowAltRgb, 0.08);
|
|
for (let x = 0; x < canvas.width; x += 24) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, canvas.height);
|
|
ctx.stroke();
|
|
}
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.needsUpdate = true;
|
|
return texture;
|
|
}
|
|
|
|
function createFlashTexture(palette = themePalette): THREE.CanvasTexture {
|
|
const canvas = document.createElement("canvas");
|
|
canvas.width = 128;
|
|
canvas.height = 128;
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return new THREE.CanvasTexture(canvas);
|
|
const { uiTheme } = palette;
|
|
const gradient = ctx.createRadialGradient(64, 64, 8, 64, 64, 64);
|
|
gradient.addColorStop(0, "rgba(255,255,255,1)");
|
|
gradient.addColorStop(0.25, rgbaFromTriplet(uiTheme.glowAltRgb, 0.86));
|
|
gradient.addColorStop(1, rgbaFromTriplet(uiTheme.glowRgb, 0));
|
|
ctx.fillStyle = gradient;
|
|
ctx.fillRect(0, 0, 128, 128);
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
texture.needsUpdate = true;
|
|
return texture;
|
|
}
|
|
|
|
function rand(min: number, max: number): number {
|
|
return Math.random() * (max - min) + min;
|
|
}
|
|
|
|
function disposeBricks(): void {
|
|
if (!brickGroup) return;
|
|
for (const brick of bricks) {
|
|
brick.mesh.geometry.dispose();
|
|
brick.mesh.material.dispose();
|
|
brickGroup.remove(brick.mesh);
|
|
}
|
|
bricks = [];
|
|
bricksLeft = 0;
|
|
}
|
|
|
|
function buildBricks(): void {
|
|
if (!brickGroup) return;
|
|
disposeBricks();
|
|
const palette = themedBrickPalette();
|
|
const totalWidth = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP_X;
|
|
const startX = -totalWidth / 2 + BRICK_W / 2;
|
|
for (let row = 0; row < BRICK_ROWS; row += 1) {
|
|
for (let col = 0; col < BRICK_COLS; col += 1) {
|
|
const color = palette[(row + col + level) % palette.length] ?? "#7ad0ff";
|
|
const material = new THREE.MeshStandardMaterial({
|
|
color,
|
|
emissive: color,
|
|
emissiveIntensity: 0.7,
|
|
roughness: 0.35,
|
|
metalness: 0.15
|
|
});
|
|
const mesh = new THREE.Mesh(new THREE.BoxGeometry(BRICK_W, BRICK_H, 2.4), material);
|
|
const x = startX + col * (BRICK_W + BRICK_GAP_X);
|
|
const y = BRICK_TOP - row * (BRICK_H + BRICK_GAP_Y);
|
|
mesh.position.set(x, y, 0.2);
|
|
brickGroup.add(mesh);
|
|
bricks.push({
|
|
mesh,
|
|
alive: true,
|
|
left: x - BRICK_W / 2,
|
|
right: x + BRICK_W / 2,
|
|
top: y + BRICK_H / 2,
|
|
bottom: y - BRICK_H / 2
|
|
});
|
|
}
|
|
}
|
|
bricksLeft = bricks.length;
|
|
}
|
|
|
|
function launchBall(nowMs: number): void {
|
|
const speed = Math.min(MAX_BALL_SPEED, BASE_BALL_SPEED + (level - 1) * 4.2);
|
|
const directionX = rand(-0.72, 0.72);
|
|
ballVel.set(directionX, 1).normalize().multiplyScalar(speed);
|
|
ballLaunched = true;
|
|
nextLaunchAt = nowMs;
|
|
lastPaddleContactAt = nowMs;
|
|
lastAntiStallAt = nowMs;
|
|
bricksSincePaddle = 0;
|
|
}
|
|
|
|
function prepareBall(nowMs: number, delayMs: number): void {
|
|
ballLaunched = false;
|
|
ballVel.set(0, 0);
|
|
ballPos.set(paddleX, PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4);
|
|
nextLaunchAt = nowMs + delayMs;
|
|
bricksSincePaddle = 0;
|
|
}
|
|
|
|
function resetGameToIdle(): void {
|
|
if (!paddle || !ball) return;
|
|
score = 0;
|
|
combo = 0;
|
|
lives = 3;
|
|
level = 1;
|
|
gameState = "idle";
|
|
prevPauseGesture = false;
|
|
pauseGestureLockUntil = 0;
|
|
lastPaddleContactAt = performance.now();
|
|
lastAntiStallAt = 0;
|
|
bricksSincePaddle = 0;
|
|
paddleX = 0;
|
|
paddle.position.x = 0;
|
|
buildBricks();
|
|
prepareBall(performance.now(), Number.POSITIVE_INFINITY);
|
|
ball.position.set(ballPos.x, ballPos.y, 1.2);
|
|
}
|
|
|
|
function startGame(): void {
|
|
if (!paddle || !ball) return;
|
|
resetGameToIdle();
|
|
gameState = "running";
|
|
prepareBall(performance.now(), START_DELAY_MS);
|
|
}
|
|
|
|
function restartGame(): void {
|
|
startGame();
|
|
}
|
|
|
|
function togglePause(): void {
|
|
if (gameState === "running") {
|
|
gameState = "paused";
|
|
return;
|
|
}
|
|
if (gameState === "paused") {
|
|
gameState = "running";
|
|
lastFrameTs = performance.now();
|
|
}
|
|
}
|
|
|
|
function readCorners(matrix: number[] | null, rows: number, cols: number): CornerMap {
|
|
if (!matrix || matrix.length === 0) return { tl: 0, tr: 0, bl: 0, br: 0 };
|
|
const r = Math.max(Math.floor(rows), 1);
|
|
const c = Math.max(Math.floor(cols), 1);
|
|
const rs = Math.min(2, r);
|
|
const cs = Math.min(2, c);
|
|
const avg = (r0: number, r1: number, c0: number, c1: number): number => {
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (let i = r0; i < r1; i += 1) {
|
|
for (let j = c0; j < c1; j += 1) {
|
|
const v = Number(matrix[i * c + j] ?? 0);
|
|
if (!Number.isFinite(v)) continue;
|
|
sum += Math.max(0, v);
|
|
count += 1;
|
|
}
|
|
}
|
|
return count ? sum / count : 0;
|
|
};
|
|
return {
|
|
tl: avg(0, rs, 0, cs),
|
|
tr: avg(0, rs, c - cs, c),
|
|
bl: avg(r - rs, r, 0, cs),
|
|
br: avg(r - rs, r, c - cs, c)
|
|
};
|
|
}
|
|
|
|
function sensorControlAxis(): number {
|
|
const span = Math.max(1200, (rangeMax - rangeMin) * 0.22);
|
|
const raw = (rightForce - leftForce) / span;
|
|
if (Math.abs(raw) < 0.045) return 0;
|
|
return Math.min(1, Math.max(-1, raw));
|
|
}
|
|
|
|
function handleSensorPauseGesture(nowMs: number): void {
|
|
if (gameState !== "idle" && gameState !== "running" && gameState !== "paused") {
|
|
prevPauseGesture = false;
|
|
return;
|
|
}
|
|
|
|
const active = topForce >= pauseGestureThreshold;
|
|
if (active && !prevPauseGesture && nowMs >= pauseGestureLockUntil) {
|
|
if (gameState === "idle") {
|
|
startGame();
|
|
} else {
|
|
togglePause();
|
|
}
|
|
pauseGestureLockUntil = nowMs + PAUSE_COOLDOWN_MS;
|
|
}
|
|
prevPauseGesture = active;
|
|
}
|
|
|
|
function update(dt: number, nowMs: number): void {
|
|
if (!paddle || !ball) return;
|
|
handleSensorPauseGesture(nowMs);
|
|
|
|
const keyAxis = (keyRight ? 1 : 0) - (keyLeft ? 1 : 0);
|
|
const axis = keyAxis !== 0 ? keyAxis : sensorControlAxis();
|
|
const shouldLaunch = lanLaunchAt > 0 && nowMs >= lanLaunchAt;
|
|
const launchIntent = shouldLaunch || (gameState !== "running" && topForce >= pauseGestureThreshold);
|
|
const pauseIntent = topForce >= pauseGestureThreshold;
|
|
sendLanInput(nowMs, axis, launchIntent, pauseIntent);
|
|
|
|
// Reset launch timer after sending
|
|
if (shouldLaunch) {
|
|
lanLaunchAt = 0;
|
|
}
|
|
|
|
if (gameState !== "running") {
|
|
ball.position.set(ballPos.x, ballPos.y, 1.2);
|
|
updateFlash(dt);
|
|
return;
|
|
}
|
|
|
|
if (!isLanConnected) {
|
|
paddleX += axis * PADDLE_SPEED * dt;
|
|
paddleX = Math.min(FIELD_HALF_W - PADDLE_W * 0.5, Math.max(-FIELD_HALF_W + PADDLE_W * 0.5, paddleX));
|
|
paddle.position.x = paddleX;
|
|
}
|
|
|
|
if (!ballLaunched) {
|
|
ballPos.set(paddleX, PADDLE_Y + PADDLE_H * 0.5 + BALL_RADIUS + 0.4);
|
|
if (nowMs >= nextLaunchAt) launchBall(nowMs);
|
|
} else if (!isLanConnected) {
|
|
ballPos.addScaledVector(ballVel, dt);
|
|
resolveWallCollision(nowMs);
|
|
resolvePaddleCollision();
|
|
resolveBrickCollision(nowMs);
|
|
applyAntiStall(nowMs);
|
|
}
|
|
|
|
ball.position.set(ballPos.x, ballPos.y, 1.2);
|
|
updateFlash(dt);
|
|
}
|
|
|
|
function resolveWallCollision(nowMs: number): void {
|
|
if (ballPos.x - BALL_RADIUS <= -FIELD_HALF_W) {
|
|
ballPos.x = -FIELD_HALF_W + BALL_RADIUS;
|
|
ballVel.x = Math.abs(ballVel.x);
|
|
} else if (ballPos.x + BALL_RADIUS >= FIELD_HALF_W) {
|
|
ballPos.x = FIELD_HALF_W - BALL_RADIUS;
|
|
ballVel.x = -Math.abs(ballVel.x);
|
|
}
|
|
|
|
if (ballPos.y + BALL_RADIUS >= FIELD_HALF_H) {
|
|
ballPos.y = FIELD_HALF_H - BALL_RADIUS;
|
|
ballVel.y = -Math.abs(ballVel.y);
|
|
if (nowMs - lastPaddleContactAt > UPPER_STALL_THRESHOLD_MS * 0.6) {
|
|
forceBallDownward(0.62);
|
|
}
|
|
}
|
|
|
|
if (ballPos.y - BALL_RADIUS < -FIELD_HALF_H) {
|
|
lives -= 1;
|
|
combo = 0;
|
|
if (lives <= 0) {
|
|
gameState = "over";
|
|
ballLaunched = false;
|
|
} else {
|
|
prepareBall(nowMs, 440);
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolvePaddleCollision(): void {
|
|
if (!ballLaunched || ballVel.y >= 0) return;
|
|
const paddleTop = PADDLE_Y + PADDLE_H * 0.5;
|
|
const paddleBottom = PADDLE_Y - PADDLE_H * 0.5;
|
|
const left = paddleX - PADDLE_W * 0.5;
|
|
const right = paddleX + PADDLE_W * 0.5;
|
|
const hit =
|
|
ballPos.y - BALL_RADIUS <= paddleTop &&
|
|
ballPos.y + BALL_RADIUS >= paddleBottom &&
|
|
ballPos.x >= left - BALL_RADIUS &&
|
|
ballPos.x <= right + BALL_RADIUS;
|
|
|
|
if (!hit) return;
|
|
ballPos.y = paddleTop + BALL_RADIUS + 0.04;
|
|
const offset = (ballPos.x - paddleX) / (PADDLE_W * 0.5);
|
|
const speed = Math.min(MAX_BALL_SPEED, Math.max(BASE_BALL_SPEED, ballVel.length()));
|
|
ballVel.set(offset * speed * 0.9, Math.abs(ballVel.y) + 4).normalize().multiplyScalar(speed);
|
|
combo = 0;
|
|
lastPaddleContactAt = performance.now();
|
|
bricksSincePaddle = 0;
|
|
}
|
|
|
|
function resolveBrickCollision(nowMs: number): void {
|
|
if (!ballLaunched) return;
|
|
for (const brick of bricks) {
|
|
if (!brick.alive) continue;
|
|
const closestX = Math.max(brick.left, Math.min(ballPos.x, brick.right));
|
|
const closestY = Math.max(brick.bottom, Math.min(ballPos.y, brick.top));
|
|
const dx = ballPos.x - closestX;
|
|
const dy = ballPos.y - closestY;
|
|
if (dx * dx + dy * dy > BALL_RADIUS * BALL_RADIUS) continue;
|
|
|
|
const overlapX = Math.min(ballPos.x + BALL_RADIUS - brick.left, brick.right - (ballPos.x - BALL_RADIUS));
|
|
const overlapY = Math.min(ballPos.y + BALL_RADIUS - brick.bottom, brick.top - (ballPos.y - BALL_RADIUS));
|
|
if (overlapX < overlapY) {
|
|
ballVel.x *= -1;
|
|
} else {
|
|
ballVel.y *= -1;
|
|
}
|
|
|
|
brick.alive = false;
|
|
brick.mesh.visible = false;
|
|
bricksLeft -= 1;
|
|
combo += 1;
|
|
score += 40 + combo * 5;
|
|
bricksSincePaddle += 1;
|
|
|
|
const nextSpeed = Math.min(MAX_BALL_SPEED, ballVel.length() * 1.014);
|
|
ballVel.normalize().multiplyScalar(nextSpeed);
|
|
if (bricksSincePaddle >= MAX_BRICK_HITS_WITHOUT_PADDLE && ballPos.y > UPPER_STALL_Y) {
|
|
forceBallDownward(1);
|
|
lastAntiStallAt = nowMs;
|
|
}
|
|
triggerFlash(brick.mesh.position.x, brick.mesh.position.y, 10 + combo * 0.4);
|
|
|
|
if (bricksLeft <= 0) {
|
|
level += 1;
|
|
buildBricks();
|
|
prepareBall(nowMs, 560);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function triggerFlash(x: number, y: number, scale: number): void {
|
|
if (!flashSprite) return;
|
|
flashLife = flashMaxLife;
|
|
flashSprite.visible = true;
|
|
flashSprite.position.set(x, y, 2.5);
|
|
flashSprite.scale.set(scale, scale, 1);
|
|
const material = flashSprite.material as THREE.SpriteMaterial;
|
|
material.opacity = 0.76;
|
|
}
|
|
|
|
function updateFlash(dt: number): void {
|
|
if (!flashSprite || flashLife <= 0) return;
|
|
flashLife -= dt;
|
|
const t = Math.max(0, flashLife / flashMaxLife);
|
|
const material = flashSprite.material as THREE.SpriteMaterial;
|
|
material.opacity = t * 0.78;
|
|
flashSprite.scale.multiplyScalar(1 + dt * 2.6);
|
|
if (flashLife <= 0) flashSprite.visible = false;
|
|
}
|
|
|
|
function forceBallDownward(forceScale: number): void {
|
|
const speed = Math.min(MAX_BALL_SPEED, Math.max(BASE_BALL_SPEED, ballVel.length()));
|
|
let dirX = ballVel.x + rand(-8, 8);
|
|
if (Math.abs(dirX) < 6) dirX = (Math.random() < 0.5 ? -1 : 1) * 6;
|
|
const dirY = -Math.abs(ballVel.y) - 6 * forceScale;
|
|
ballVel.set(dirX, dirY).normalize().multiplyScalar(speed);
|
|
}
|
|
|
|
function applyAntiStall(nowMs: number): void {
|
|
if (!ballLaunched) return;
|
|
if (ballPos.y < UPPER_STALL_Y) return;
|
|
if (nowMs - lastPaddleContactAt < UPPER_STALL_THRESHOLD_MS) return;
|
|
if (nowMs - lastAntiStallAt < ANTI_STALL_COOLDOWN_MS) return;
|
|
forceBallDownward(0.85);
|
|
lastAntiStallAt = nowMs;
|
|
}
|
|
</script>
|
|
|
|
<section class="breakout-root" bind:this={hostEl}>
|
|
<canvas class="breakout-canvas" bind:this={canvasEl} aria-label="Neon Breakout"></canvas>
|
|
|
|
<div class="breakout-overlay">
|
|
<header class="overlay-head">
|
|
<div class="title-block">
|
|
<p class="title">{ui.title}</p>
|
|
</div>
|
|
<div class="action-group">
|
|
<button type="button" on:click={startGame}>{gameState === "idle" ? ui.start : ui.restart}</button>
|
|
<button type="button" on:click={togglePause} disabled={gameState === "idle" || gameState === "over"}>
|
|
{gameState === "paused" ? ui.resume : ui.pause}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="hud-grid">
|
|
<article><span>{ui.score}</span><strong>{Math.round(score)}</strong></article>
|
|
<article><span>{ui.combo}</span><strong>{combo}</strong></article>
|
|
<article><span>{ui.lives}</span><strong>{lives}</strong></article>
|
|
<article><span>{ui.level}</span><strong>{level}</strong></article>
|
|
<article><span>{ui.bricks}</span><strong>{Math.max(0, bricksLeft)}</strong></article>
|
|
</div>
|
|
|
|
<section class="lan-panel" class:is-online={lanMode === "online"} aria-label="LAN Battle">
|
|
<header class="lan-panel-head">
|
|
<div>
|
|
<p class="lan-kicker">{ui.online}</p>
|
|
<strong>{roomStatusText}</strong>
|
|
</div>
|
|
<div class="lan-head-actions">
|
|
{#if pingMs !== null}
|
|
<span class="lan-ping">{ui.latency} {pingMs}ms</span>
|
|
{/if}
|
|
<button type="button" class="lan-mode-btn" on:click={() => (lanMode = lanMode === "online" ? "local" : "online")}>
|
|
{lanMode === "online" ? ui.local : ui.online}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{#if lanMode === "online"}
|
|
<div class="lan-controls">
|
|
<label class="lan-input-label">
|
|
<span>{ui.playerName}</span>
|
|
<input bind:value={playerName} maxlength="18" on:blur={persistPlayerName} />
|
|
</label>
|
|
|
|
<div class="lan-action-row">
|
|
<button type="button" disabled={isLanBusy || isLanConnected} on:click={createLanRoom}>{ui.createRoom}</button>
|
|
<button type="button" disabled={isLanBusy} on:click={discoverLanRooms}>{ui.discoverRooms}</button>
|
|
{#if isLanConnected}
|
|
<button type="button" class="is-danger" on:click={() => disconnectLanSocket()}>{ui.disconnect}</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="lan-join-row">
|
|
<label class="lan-input-label is-code">
|
|
<span>{ui.joinCode}</span>
|
|
<input
|
|
value={joinCode}
|
|
inputmode="numeric"
|
|
maxlength="8"
|
|
on:input={(event) => (joinCode = normalizePairingCode((event.currentTarget as HTMLInputElement).value))}
|
|
/>
|
|
</label>
|
|
<button type="button" disabled={isLanBusy || isLanConnected || !joinCode} on:click={() => joinLanRoom()}>{ui.joinRoom}</button>
|
|
</div>
|
|
|
|
{#if pairingCode}
|
|
<div class="pair-code-card">
|
|
<span>{ui.pairingCode}</span>
|
|
<strong>{pairingCode}</strong>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if lanRooms.length > 0 && !isLanConnected}
|
|
<div class="room-list">
|
|
{#each lanRooms as room (room.roomId)}
|
|
<button type="button" class="room-card" on:click={() => joinLanRoom(room.pairingCode)}>
|
|
<span>{room.hostName || room.address}</span>
|
|
<strong>{room.pairingCode}</strong>
|
|
<em>{room.players}/{room.maxPlayers}</em>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if lanNotice}
|
|
<p class="lan-notice">{lanNotice}</p>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if isLanConnected}
|
|
<div class="versus-strip">
|
|
<article>
|
|
<span>{playerName || "You"} {localRole ? `/${localRole}` : ""}</span>
|
|
<strong>{selfPlayer?.ready || localReady ? ui.ready : statusText}</strong>
|
|
<em>{Math.round(score)} / {lives}</em>
|
|
</article>
|
|
<article class:waiting={!primaryOpponent}>
|
|
<span>{ui.opponent}</span>
|
|
<strong>{primaryOpponent?.name ?? ui.waitingOpponent}</strong>
|
|
<em>
|
|
{#if primaryOpponent}
|
|
{(primaryOpponent.score ?? 0)} / {(primaryOpponent.lives ?? 3)}
|
|
{:else}
|
|
--
|
|
{/if}
|
|
</em>
|
|
</article>
|
|
<button type="button" on:click={toggleLanReady}>{localReady ? ui.cancelReady : ui.ready}</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</section>
|
|
|
|
<footer class="overlay-foot">
|
|
<p class="status">{ui.chase} / {statusText}</p>
|
|
</footer>
|
|
</div>
|
|
|
|
{#if gameState === "paused"}
|
|
<div class="pause-mask" aria-live="polite">
|
|
<div class="pause-panel">
|
|
<p>{ui.pausedOverlay}</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if gameState === "over"}
|
|
<div class="pause-mask is-over" aria-live="assertive">
|
|
<div class="pause-panel is-over">
|
|
<p>{ui.over}</p>
|
|
<button type="button" class="overlay-restart-btn" on:click={requestLanRestart}>{ui.restart}</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.breakout-root {
|
|
position: absolute;
|
|
inset: 0;
|
|
overflow: hidden;
|
|
border-radius: 0.58rem;
|
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.28);
|
|
background: linear-gradient(180deg, rgb(var(--hud-surface-deep-rgb) / 0.92), rgb(var(--hud-surface-rgb) / 0.86));
|
|
box-shadow:
|
|
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.08),
|
|
0 0 24px rgb(var(--hud-glow-rgb) / 0.12);
|
|
}
|
|
|
|
.breakout-canvas {
|
|
position: absolute;
|
|
inset: 0;
|
|
inline-size: 100%;
|
|
block-size: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.breakout-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
display: grid;
|
|
grid-template-rows: auto auto 1fr auto;
|
|
gap: 0.42rem;
|
|
padding: 0.5rem 0.58rem 0.42rem;
|
|
}
|
|
|
|
.overlay-head {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.title {
|
|
margin: 0;
|
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
|
font-size: 0.68rem;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.action-group {
|
|
display: inline-flex;
|
|
gap: 0.28rem;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.action-group button {
|
|
min-block-size: 1.65rem;
|
|
border: 1px solid rgb(var(--hud-cyan-rgb) / 0.56);
|
|
border-radius: 999px;
|
|
padding: 0.14rem 0.52rem;
|
|
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.92), rgb(var(--hud-surface-rgb) / 0.86));
|
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
|
font: inherit;
|
|
font-size: 0.6rem;
|
|
letter-spacing: 0.06em;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.action-group button:disabled {
|
|
opacity: 0.42;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.hud-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 0.24rem;
|
|
}
|
|
|
|
.hud-grid article {
|
|
min-block-size: 1.88rem;
|
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.24);
|
|
border-radius: 0.42rem;
|
|
padding: 0.16rem 0.34rem 0.24rem;
|
|
background: rgb(var(--hud-surface-rgb) / 0.68);
|
|
}
|
|
|
|
.hud-grid span {
|
|
display: block;
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
|
font-size: 0.5rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.hud-grid strong {
|
|
color: rgb(var(--hud-text-main-rgb) / 0.98);
|
|
font-size: 0.84rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.lan-panel {
|
|
align-self: start;
|
|
justify-self: end;
|
|
inline-size: min(20rem, 58%);
|
|
display: grid;
|
|
gap: 0.42rem;
|
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.24);
|
|
border-radius: 0.56rem;
|
|
padding: 0.48rem;
|
|
background:
|
|
linear-gradient(180deg, rgb(var(--hud-surface-rgb) / 0.72), rgb(var(--hud-surface-deep-rgb) / 0.78)),
|
|
radial-gradient(circle at 50% 0, rgb(var(--hud-glow-rgb) / 0.05), transparent 58%);
|
|
box-shadow: inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.07);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.lan-panel:not(.is-online) {
|
|
inline-size: auto;
|
|
padding: 0.32rem;
|
|
background: rgb(var(--hud-surface-deep-rgb) / 0.54);
|
|
}
|
|
|
|
.lan-panel-head,
|
|
.lan-head-actions,
|
|
.lan-action-row,
|
|
.lan-join-row,
|
|
.versus-strip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.36rem;
|
|
}
|
|
|
|
.lan-panel-head {
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.lan-panel-head div:first-child {
|
|
min-inline-size: 0;
|
|
display: grid;
|
|
gap: 0.08rem;
|
|
}
|
|
|
|
.lan-kicker {
|
|
margin: 0;
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.84);
|
|
font-size: 0.48rem;
|
|
letter-spacing: 0.12em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.lan-panel-head strong {
|
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
|
font-size: 0.66rem;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.lan-ping,
|
|
.lan-notice {
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.84);
|
|
font-size: 0.54rem;
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.lan-controls {
|
|
display: grid;
|
|
gap: 0.36rem;
|
|
}
|
|
|
|
.lan-input-label {
|
|
min-inline-size: 0;
|
|
display: grid;
|
|
gap: 0.18rem;
|
|
}
|
|
|
|
.lan-input-label span {
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
|
font-size: 0.5rem;
|
|
letter-spacing: 0.09em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.lan-input-label input {
|
|
min-inline-size: 0;
|
|
inline-size: 100%;
|
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.34);
|
|
border-radius: 999px;
|
|
padding: 0.28rem 0.52rem;
|
|
background: rgb(var(--hud-surface-deep-rgb) / 0.78);
|
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
|
font: inherit;
|
|
font-size: 0.64rem;
|
|
letter-spacing: 0.05em;
|
|
outline: none;
|
|
}
|
|
|
|
.lan-input-label input:focus {
|
|
border-color: rgb(var(--hud-cyan-rgb) / 0.52);
|
|
box-shadow: 0 0 0 2px rgb(var(--hud-cyan-rgb) / 0.12);
|
|
}
|
|
|
|
.lan-input-label.is-code {
|
|
flex: 1;
|
|
}
|
|
|
|
.lan-action-row,
|
|
.lan-join-row {
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.lan-mode-btn,
|
|
.lan-action-row button,
|
|
.lan-join-row button,
|
|
.versus-strip button,
|
|
.room-card {
|
|
min-block-size: 1.42rem;
|
|
border: 1px solid rgb(var(--hud-cyan-rgb) / 0.38);
|
|
border-radius: 999px;
|
|
padding: 0.18rem 0.52rem;
|
|
background: rgb(var(--hud-surface-alt-rgb) / 0.74);
|
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
|
font: inherit;
|
|
font-size: 0.56rem;
|
|
letter-spacing: 0.05em;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.lan-mode-btn:hover,
|
|
.lan-action-row button:hover:not(:disabled),
|
|
.lan-join-row button:hover:not(:disabled),
|
|
.versus-strip button:hover:not(:disabled),
|
|
.room-card:hover {
|
|
border-color: rgb(var(--hud-lime-rgb) / 0.54);
|
|
box-shadow: 0 0 10px rgb(var(--hud-lime-rgb) / 0.1);
|
|
}
|
|
|
|
.lan-action-row button:disabled,
|
|
.lan-join-row button:disabled,
|
|
.versus-strip button:disabled {
|
|
opacity: 0.42;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.lan-action-row button.is-danger {
|
|
border-color: rgb(var(--hud-orange-rgb) / 0.46);
|
|
color: rgb(var(--hud-orange-rgb) / 0.96);
|
|
background: rgb(var(--hud-surface-deep-rgb) / 0.82);
|
|
}
|
|
|
|
.pair-code-card {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
border: 1px solid rgb(var(--hud-lime-rgb) / 0.36);
|
|
border-radius: 0.5rem;
|
|
padding: 0.36rem 0.48rem;
|
|
background: rgb(var(--hud-surface-alt-rgb) / 0.58);
|
|
}
|
|
|
|
.pair-code-card span {
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.84);
|
|
font-size: 0.5rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.pair-code-card strong {
|
|
color: rgb(var(--hud-lime-rgb) / 0.96);
|
|
font-size: 1rem;
|
|
letter-spacing: 0.18em;
|
|
}
|
|
|
|
.room-list {
|
|
display: grid;
|
|
gap: 0.24rem;
|
|
}
|
|
|
|
.room-card {
|
|
inline-size: 100%;
|
|
display: grid;
|
|
grid-template-columns: 1fr auto auto;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
border-radius: 0.44rem;
|
|
text-align: left;
|
|
}
|
|
|
|
.room-card span {
|
|
min-inline-size: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.room-card strong {
|
|
color: rgb(var(--hud-lime-rgb) / 0.96);
|
|
letter-spacing: 0.12em;
|
|
}
|
|
|
|
.room-card em {
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
|
font-style: normal;
|
|
}
|
|
|
|
.lan-notice {
|
|
margin: 0;
|
|
color: rgb(var(--hud-orange-rgb) / 0.94);
|
|
}
|
|
|
|
.versus-strip {
|
|
justify-content: stretch;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.versus-strip article {
|
|
flex: 1;
|
|
min-inline-size: 0;
|
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.26);
|
|
border-radius: 0.46rem;
|
|
padding: 0.28rem 0.38rem;
|
|
background: rgb(var(--hud-surface-rgb) / 0.58);
|
|
}
|
|
|
|
.versus-strip article.waiting {
|
|
border-style: dashed;
|
|
opacity: 0.78;
|
|
}
|
|
|
|
.versus-strip span,
|
|
.versus-strip em {
|
|
display: block;
|
|
color: rgb(var(--hud-text-dim-rgb) / 0.82);
|
|
font-size: 0.48rem;
|
|
letter-spacing: 0.07em;
|
|
text-transform: uppercase;
|
|
font-style: normal;
|
|
}
|
|
|
|
.versus-strip strong {
|
|
display: block;
|
|
margin-block: 0.1rem;
|
|
overflow: hidden;
|
|
color: rgb(var(--hud-text-main-rgb) / 0.96);
|
|
font-size: 0.62rem;
|
|
letter-spacing: 0.04em;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.overlay-foot {
|
|
display: flex;
|
|
justify-content: flex-start;
|
|
align-items: center;
|
|
gap: 0.52rem;
|
|
}
|
|
|
|
.status {
|
|
margin: 0;
|
|
border: 1px solid rgb(var(--hud-border-rgb) / 0.3);
|
|
border-radius: 999px;
|
|
padding: 0.18rem 0.56rem;
|
|
background: rgb(var(--hud-surface-deep-rgb) / 0.66);
|
|
color: rgb(var(--hud-text-main-rgb) / 0.94);
|
|
font-size: 0.56rem;
|
|
letter-spacing: 0.07em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.pause-mask {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 12;
|
|
pointer-events: none;
|
|
display: grid;
|
|
place-items: center;
|
|
background:
|
|
radial-gradient(circle at center, rgb(var(--hud-surface-rgb) / 0.24), rgb(var(--hud-bg-30) / 0.72)),
|
|
repeating-linear-gradient(
|
|
180deg,
|
|
rgb(var(--hud-glow-alt-rgb) / 0.08) 0,
|
|
rgb(var(--hud-glow-alt-rgb) / 0.08) 1px,
|
|
transparent 1px,
|
|
transparent 4px
|
|
);
|
|
backdrop-filter: blur(3px);
|
|
}
|
|
|
|
.pause-panel {
|
|
border: 1px solid rgb(var(--hud-lime-rgb) / 0.56);
|
|
border-radius: 0.66rem;
|
|
padding: 0.62rem 0.92rem;
|
|
background: rgb(var(--hud-surface-deep-rgb) / 0.8);
|
|
box-shadow:
|
|
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.1),
|
|
0 0 26px rgb(var(--hud-lime-rgb) / 0.22);
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.pause-panel p {
|
|
margin: 0;
|
|
color: rgb(var(--hud-text-main-rgb) / 0.98);
|
|
font-size: clamp(0.76rem, 1.5vw, 1.08rem);
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
text-shadow: 0 0 10px rgb(var(--hud-lime-rgb) / 0.28);
|
|
}
|
|
|
|
.pause-mask.is-over {
|
|
background:
|
|
radial-gradient(circle at center, rgb(var(--hud-surface-rgb) / 0.22), rgb(var(--hud-bg-30) / 0.8)),
|
|
repeating-linear-gradient(
|
|
180deg,
|
|
rgb(var(--hud-orange-rgb) / 0.1) 0,
|
|
rgb(var(--hud-orange-rgb) / 0.1) 1px,
|
|
transparent 1px,
|
|
transparent 4px
|
|
);
|
|
}
|
|
|
|
.pause-panel.is-over {
|
|
border-color: rgb(var(--hud-orange-rgb) / 0.62);
|
|
box-shadow:
|
|
inset 0 1px 0 rgb(var(--hud-border-strong-rgb) / 0.1),
|
|
0 0 32px rgb(var(--hud-orange-rgb) / 0.24);
|
|
min-inline-size: min(24rem, 72%);
|
|
display: grid;
|
|
gap: 0.72rem;
|
|
justify-items: center;
|
|
}
|
|
|
|
.pause-panel.is-over p {
|
|
color: rgb(var(--hud-orange-rgb) / 0.98);
|
|
text-shadow: 0 0 12px rgb(var(--hud-orange-rgb) / 0.36);
|
|
}
|
|
|
|
.overlay-restart-btn {
|
|
min-block-size: clamp(2.2rem, 4.2vh, 2.8rem);
|
|
min-inline-size: clamp(8.8rem, 18vw, 13rem);
|
|
border: 1px solid rgb(var(--hud-cyan-rgb) / 0.62);
|
|
border-radius: 999px;
|
|
padding: 0.24rem 1rem;
|
|
background: linear-gradient(180deg, rgb(var(--hud-surface-alt-rgb) / 0.96), rgb(var(--hud-surface-rgb) / 0.9));
|
|
color: rgb(var(--hud-text-main-rgb) / 0.98);
|
|
font: inherit;
|
|
font-size: clamp(0.74rem, 1.3vw, 0.96rem);
|
|
font-weight: 600;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
cursor: pointer;
|
|
box-shadow: 0 0 16px rgb(var(--hud-cyan-rgb) / 0.2);
|
|
transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease;
|
|
}
|
|
|
|
.overlay-restart-btn:hover {
|
|
transform: translateY(-1px);
|
|
border-color: rgb(var(--hud-lime-rgb) / 0.66);
|
|
box-shadow: 0 0 20px rgb(var(--hud-lime-rgb) / 0.28);
|
|
}
|
|
|
|
@media (max-width: 1120px) {
|
|
.hud-grid {
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
}
|
|
}
|
|
</style>
|