Private
Public Access
1
0

Adding repo after 2 days of vibecoding

This commit is contained in:
gauvainboiche
2026-03-29 11:16:46 +02:00
commit 4eac0f4415
948 changed files with 99537 additions and 0 deletions

149
public/index.html Normal file
View File

@@ -0,0 +1,149 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Star Wars - Wild Space</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<!-- Auth Modal -->
<div class="authOverlay" id="authOverlay">
<div class="authModal">
<div class="authTabs">
<button type="button" class="authTab authTab--active" id="tabLogin">Login</button>
<button type="button" class="authTab" id="tabRegister">Register</button>
</div>
<!-- Login form -->
<form class="authForm" id="loginForm">
<div class="authField">
<label>Username</label>
<input type="text" id="loginUsername" autocomplete="username" required />
</div>
<div class="authField">
<label>Password</label>
<input type="password" id="loginPassword" autocomplete="current-password" required />
</div>
<div class="authError hidden" id="loginError"></div>
<button type="submit" class="authSubmit">Login</button>
</form>
<!-- Register form -->
<form class="authForm hidden" id="registerForm">
<div class="authField">
<label>Username</label>
<input type="text" id="regUsername" autocomplete="username" required />
</div>
<div class="authField">
<label>Email</label>
<input type="email" id="regEmail" autocomplete="email" required />
</div>
<div class="authField">
<label>Password <span class="authHint">(min 6 chars)</span></label>
<input type="password" id="regPassword" autocomplete="new-password" required />
</div>
<div class="authField">
<label>Team</label>
<div class="authTeamChoice">
<label class="authTeamOption">
<input type="radio" name="regTeam" value="blue" required />
<span class="authTeamBadge authTeamBadge--blue">Blue</span>
</label>
<label class="authTeamOption">
<input type="radio" name="regTeam" value="red" />
<span class="authTeamBadge authTeamBadge--red">Red</span>
</label>
</div>
</div>
<div class="authError hidden" id="registerError"></div>
<button type="submit" class="authSubmit">Create Account</button>
</form>
</div>
</div>
<!-- Debug team switcher (only visible when debugModeForTeams is enabled) -->
<div class="teamCorner teamCorner--hidden" id="teamCorner">
<span class="teamCornerLabel">Team</span>
<div class="teamSegmented" role="group" aria-label="Active team">
<div class="teamSegmentedTrack" id="teamSegmentedTrack" data-active="blue">
<button type="button" class="teamSegmentedBtn" id="teamBlue" data-team="blue">Blue</button>
<button type="button" class="teamSegmentedBtn" id="teamRed" data-team="red">Red</button>
</div>
</div>
</div>
<div class="app">
<header class="topbar">
<div class="title">
<div class="h1">Star Wars - Wild Space</div>
<div class="sub">100×100 — exploitable ring only (inner Ø60, outer Ø100)</div>
</div>
<!-- Team score display -->
<div class="scoreBoard" id="scoreBoard">
<span class="scoreTeam scoreTeam--blue">
<span class="scoreTeamName">Blue</span>
<span class="scoreValue" id="scoreBlue">0</span>
</span>
<span class="scoreSep"></span>
<span class="scoreTeam scoreTeam--red">
<span class="scoreValue" id="scoreRed">0</span>
<span class="scoreTeamName">Red</span>
</span>
</div>
<div class="topRight">
<div class="topRightTable">
<div class="topRightRow" id="userInfoRow">
<span class="trKey">player</span>
<span class="trVal">
<span id="userDisplay"></span>
<button type="button" id="logoutBtn" class="logoutBtn hidden">Logout</button>
</span>
</div>
<div class="topRightRow" id="countdownWrap" aria-live="polite">
<span class="trKey countdownLabel">Cooldown</span>
<span class="trVal countdownVal">
<span id="countdown" class="countdown">0</span>
<span class="countdownUnit">s</span>
</span>
</div>
<div class="topRightRow">
<span class="trKey muted">clickCooldownSeconds</span>
<code class="trVal" id="cooldownConfig"></code>
</div>
<div class="topRightRow">
<span class="trKey muted">worldSeed</span>
<code class="trVal" id="worldSeedDisplay"></code>
</div>
<div class="topRightRow">
<span class="trKey muted">next period (UTC)</span>
<code class="trVal" id="nextPeriodUtc"></code>
</div>
<div class="topRightRow">
<span class="trKey muted">resets in</span>
<code class="trVal" id="refreshCountdown">--:--:--</code>
</div>
</div>
<div class="controls">
<button id="refreshBtn" type="button">Refresh</button>
</div>
</div>
</header>
<main class="main">
<section class="board">
<canvas id="canvas" width="800" height="800"></canvas>
<div id="hint" class="hint">Click a cell in the ring. Planet stats stay hidden until you reveal a tile.</div>
</section>
<aside class="panel">
<div class="panelTitle">Selection</div>
<pre id="details" class="details details--hidden">Stats are hidden until you click a tile.</pre>
</aside>
</main>
</div>
<script type="module" src="./src/main.js"></script>
</body>
</html>

748
public/src/main.js Normal file
View File

@@ -0,0 +1,748 @@
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
const GRID_W = 100;
const GRID_H = 100;
const OUTER_RADIUS = 50;
const INNER_RADIUS = 30;
const PLANET_CHANCE = 0.1;
/** Outside donut: solid black, no grid. */
const COLOR_OUTSIDE = "#000000";
/** Exploitable, not yet discovered by anyone (classic ring). */
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
/** Current team's discovered tiles (blue). */
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
/** Current team's discovered tiles (red). */
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
/** Other team's tiles: empty look, not interactive. */
const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { alpha: false });
const details = document.getElementById("details");
const hint = document.getElementById("hint");
const refreshBtn = document.getElementById("refreshBtn");
const teamCorner = document.getElementById("teamCorner");
const teamSegmentedTrack = document.getElementById("teamSegmentedTrack");
const teamBlue = document.getElementById("teamBlue");
const teamRed = document.getElementById("teamRed");
const countdownEl = document.getElementById("countdown");
const countdownWrap = document.getElementById("countdownWrap");
const cooldownConfigEl = document.getElementById("cooldownConfig");
const worldSeedDisplay = document.getElementById("worldSeedDisplay");
const nextPeriodUtc = document.getElementById("nextPeriodUtc");
const refreshCountdownEl = document.getElementById("refreshCountdown");
const scoreBlueEl = document.getElementById("scoreBlue");
const scoreRedEl = document.getElementById("scoreRed");
const userDisplayEl = document.getElementById("userDisplay");
const logoutBtn = document.getElementById("logoutBtn");
// Auth elements
const authOverlay = document.getElementById("authOverlay");
const tabLogin = document.getElementById("tabLogin");
const tabRegister = document.getElementById("tabRegister");
const loginForm = document.getElementById("loginForm");
const registerForm = document.getElementById("registerForm");
const loginUsernameEl = document.getElementById("loginUsername");
const loginPasswordEl = document.getElementById("loginPassword");
const loginErrorEl = document.getElementById("loginError");
const regUsernameEl = document.getElementById("regUsername");
const regEmailEl = document.getElementById("regEmail");
const regPasswordEl = document.getElementById("regPassword");
const registerErrorEl = document.getElementById("registerError");
/** @type {{ clickCooldownSeconds: number, databaseWipeoutIntervalSeconds: number, debugModeForTeams: boolean, configReloadIntervalSeconds: number, worldSeed: string, seedPeriodEndsAtUtc: string }} */
const GAME_CONFIG = {
clickCooldownSeconds: 5,
databaseWipeoutIntervalSeconds: 21600,
debugModeForTeams: true,
configReloadIntervalSeconds: 30,
worldSeed: "",
seedPeriodEndsAtUtc: "",
};
window.GAME_CONFIG = GAME_CONFIG;
let seedStr = "";
let seedU32 = 0;
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object | null }>} */
const state = { cells: new Map() };
/** @type {"blue" | "red"} */
let currentTeam = "blue";
// Auth state
let authToken = localStorage.getItem("authToken") ?? null;
let currentUser = null; // { id, username, team, role }
let rafId = 0;
/** @type {MouseEvent | null} */
let lastPointerEvent = null;
let configPollTimer = 0;
let scorePollTimer = 0;
/** @type {ReturnType<typeof setInterval> | null} */
let refreshTimer = null;
const teamCooldownEndMs = { blue: 0, red: 0 };
// ── Helpers ──────────────────────────────────────────────────────────────────
function cellKey(x, y) { return `${x},${y}`; }
function cellCenter(x, y) {
const cx = (GRID_W - 1) / 2;
const cy = (GRID_H - 1) / 2;
return { dx: x - cx, dy: y - cy };
}
function isExploitable(x, y) {
const { dx, dy } = cellCenter(x, y);
const r = Math.hypot(dx, dy);
return r <= OUTER_RADIUS - 0.5 && r >= INNER_RADIUS - 0.5;
}
function hasPlanetAt(x, y) {
const h = hash2u32(x, y, seedU32);
return h / 4294967296 < PLANET_CHANCE;
}
function cellMeta(key) { return state.cells.get(key) ?? null; }
function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; }
function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; }
// ── Auth ─────────────────────────────────────────────────────────────────────
function showAuthOverlay() {
authOverlay.classList.remove("hidden");
}
function hideAuthOverlay() {
authOverlay.classList.add("hidden");
}
function setAuthError(el, msg) {
el.textContent = msg;
el.classList.remove("hidden");
}
function clearAuthError(el) {
el.textContent = "";
el.classList.add("hidden");
}
function applyUser(user, token) {
currentUser = user;
authToken = token;
localStorage.setItem("authToken", token);
currentTeam = user.team;
userDisplayEl.textContent = `${user.username} [${user.team}]`;
logoutBtn.classList.remove("hidden");
updateTeamSegmented();
}
function logout() {
currentUser = null;
authToken = null;
localStorage.removeItem("authToken");
userDisplayEl.textContent = "—";
logoutBtn.classList.add("hidden");
showAuthOverlay();
}
async function tryRestoreSession() {
if (!authToken) return false;
try {
const res = await fetch("/api/auth/me", {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!res.ok) {
localStorage.removeItem("authToken");
authToken = null;
return false;
}
const data = await res.json();
applyUser(data.user, data.token);
return true;
} catch {
return false;
}
}
tabLogin.addEventListener("click", () => {
tabLogin.classList.add("authTab--active");
tabRegister.classList.remove("authTab--active");
loginForm.classList.remove("hidden");
registerForm.classList.add("hidden");
clearAuthError(loginErrorEl);
});
tabRegister.addEventListener("click", () => {
tabRegister.classList.add("authTab--active");
tabLogin.classList.remove("authTab--active");
registerForm.classList.remove("hidden");
loginForm.classList.add("hidden");
clearAuthError(registerErrorEl);
});
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearAuthError(loginErrorEl);
const username = loginUsernameEl.value.trim();
const password = loginPasswordEl.value;
if (!username || !password) return;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (!res.ok) {
const msgs = {
invalid_credentials: "Invalid username or password.",
missing_fields: "Please fill in all fields.",
};
setAuthError(loginErrorEl, msgs[data.error] ?? "Login failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
setAuthError(loginErrorEl, "Network error. Try again.");
}
});
registerForm.addEventListener("submit", async (e) => {
e.preventDefault();
clearAuthError(registerErrorEl);
const username = regUsernameEl.value.trim();
const email = regEmailEl.value.trim();
const password = regPasswordEl.value;
const teamInput = registerForm.querySelector('input[name="regTeam"]:checked');
if (!teamInput) {
setAuthError(registerErrorEl, "Please choose a team.");
return;
}
const team = teamInput.value;
try {
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, email, password, team }),
});
const data = await res.json();
if (!res.ok) {
const msgs = {
username_taken: "This username is already taken.",
email_taken: "This email is already registered.",
password_too_short: "Password must be at least 6 characters.",
invalid_username: "Username must be 232 characters.",
missing_fields: "Please fill in all fields.",
invalid_team: "Invalid team selected.",
};
setAuthError(registerErrorEl, msgs[data.error] ?? "Registration failed.");
return;
}
applyUser(data.user, data.token);
hideAuthOverlay();
await refreshFromServer();
} catch {
setAuthError(registerErrorEl, "Network error. Try again.");
}
});
logoutBtn.addEventListener("click", logout);
// ── Cooldown ─────────────────────────────────────────────────────────────────
function cooldownBlocksNewReveal() {
if (GAME_CONFIG.clickCooldownSeconds <= 0) return false;
return Date.now() < teamCooldownEndMs[currentTeam];
}
function refreshCursorFromLast() {
if (lastPointerEvent) refreshCursor(lastPointerEvent);
}
function remainingCooldownSeconds() {
const left = (teamCooldownEndMs[currentTeam] - Date.now()) / 1000;
return Math.max(0, left);
}
function tickCountdown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
countdownWrap.classList.add("hidden");
cancelAnimationFrame(rafId);
return;
}
const left = remainingCooldownSeconds();
if (left <= 0) {
countdownWrap.classList.add("hidden");
countdownEl.textContent = "0";
teamCooldownEndMs[currentTeam] = 0;
refreshCursorFromLast();
cancelAnimationFrame(rafId);
return;
}
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(Math.ceil(left));
refreshCursorFromLast();
rafId = requestAnimationFrame(tickCountdown);
}
function startCooldown() {
const secs = GAME_CONFIG.clickCooldownSeconds;
if (secs <= 0) {
teamCooldownEndMs[currentTeam] = 0;
countdownWrap.classList.add("hidden");
refreshCursorFromLast();
return;
}
teamCooldownEndMs[currentTeam] = Date.now() + secs * 1000;
countdownWrap.classList.remove("hidden");
countdownEl.textContent = String(secs);
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(tickCountdown);
refreshCursorFromLast();
}
// ── Config / seed ─────────────────────────────────────────────────────────────
function applyWorldSeedFromConfig() {
seedStr = GAME_CONFIG.worldSeed || "";
seedU32 = fnv1a32(seedStr || "fallback");
}
function applyDebugTeamVisibility() {
if (GAME_CONFIG.debugModeForTeams) {
teamCorner.classList.remove("teamCorner--hidden");
} else {
teamCorner.classList.add("teamCorner--hidden");
}
}
function applyConfigPayload(data) {
GAME_CONFIG.clickCooldownSeconds = Number(data.clickCooldownSeconds) || 0;
GAME_CONFIG.databaseWipeoutIntervalSeconds = Number(data.databaseWipeoutIntervalSeconds) || 21600;
GAME_CONFIG.debugModeForTeams = Boolean(data.debugModeForTeams);
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
cooldownConfigEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
worldSeedDisplay.textContent = GAME_CONFIG.worldSeed || "—";
nextPeriodUtc.textContent = GAME_CONFIG.seedPeriodEndsAtUtc
? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
: "—";
updateRefreshCountdown();
applyDebugTeamVisibility();
}
function updateRefreshCountdown() {
if (!GAME_CONFIG.seedPeriodEndsAtUtc) {
refreshCountdownEl.textContent = "--:--:--";
return;
}
const endTime = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime();
const diffMs = endTime - Date.now();
if (diffMs <= 0) {
refreshCountdownEl.textContent = "00:00:00";
return;
}
const totalSeconds = Math.floor(diffMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
refreshCountdownEl.textContent =
String(hours).padStart(2, "0") + ":" +
String(minutes).padStart(2, "0") + ":" +
String(seconds).padStart(2, "0");
}
async function fetchConfig() {
const res = await fetch(`/api/config?team=${currentTeam}`);
if (!res.ok) throw new Error("config");
const data = await res.json();
const prevSeed = GAME_CONFIG.worldSeed;
applyConfigPayload(data);
applyWorldSeedFromConfig();
return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
}
// ── Scores ────────────────────────────────────────────────────────────────────
async function fetchScores() {
try {
const res = await fetch("/api/scores");
if (!res.ok) return;
const { blue, red } = await res.json();
scoreBlueEl.textContent = String(blue ?? 0);
scoreRedEl.textContent = String(red ?? 0);
} catch { /* ignore */ }
}
function scheduleScorePoll() {
clearTimeout(scorePollTimer);
scorePollTimer = window.setTimeout(async () => {
await fetchScores();
scheduleScorePoll();
}, 5000);
}
// ── Grid ──────────────────────────────────────────────────────────────────────
async function fetchGridForSeed(seed, depth = 0) {
if (depth > 4) throw new Error("grid_stale");
const res = await fetch(`/api/grid/${encodeURIComponent(seed)}`);
if (res.status === 410) {
await fetchConfig();
applyWorldSeedFromConfig();
return fetchGridForSeed(GAME_CONFIG.worldSeed, depth + 1);
}
if (!res.ok) throw new Error("grid");
const data = await res.json();
state.cells.clear();
for (const row of data.cells || []) {
const k = cellKey(row.x, row.y);
const discoveredBy = row.discovered_by ?? row.discoveredBy;
state.cells.set(k, {
discoveredBy,
hasPlanet: Boolean(row.has_planet),
planet: row.planet_json ?? null,
});
}
}
async function revealOnServer(x, y) {
const res = await fetch("/api/cell/reveal", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ seed: seedStr, x, y, team: currentTeam }),
});
if (res.status === 409) {
await res.json().catch(() => ({}));
const e = new Error("taken");
e.code = "TAKEN";
throw e;
}
if (res.status === 410) {
const body = await res.json().catch(() => ({}));
const e = new Error("seed_expired");
e.code = "SEED_EXPIRED";
e.worldSeed = body.worldSeed;
throw e;
}
if (!res.ok) throw new Error("reveal");
return res.json();
}
function resetUiForNewPeriod() {
teamCooldownEndMs.blue = 0;
teamCooldownEndMs.red = 0;
cancelAnimationFrame(rafId);
countdownWrap.classList.add("hidden");
details.textContent = "Stats are hidden until you click a tile.";
details.classList.add("details--hidden");
hint.textContent = "World period changed — grid reset. Click a cell in the ring.";
}
async function refreshFromServer() {
try {
const seedChanged = await fetchConfig();
applyWorldSeedFromConfig();
if (seedChanged) resetUiForNewPeriod();
await fetchGridForSeed(seedStr);
await fetchScores();
draw();
refreshCursorFromLast();
} catch {
hint.textContent = "Could not sync with server. Is the API running?";
}
}
// ── Draw ──────────────────────────────────────────────────────────────────────
function fillColorForCell(k) {
const meta = cellMeta(k);
if (!meta) return COLOR_RING_IDLE;
if (meta.discoveredBy !== currentTeam) return COLOR_OPPONENT_GREY;
return currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
}
function draw() {
const w = canvas.width;
const h = canvas.height;
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(0, 0, w, h);
const cw = w / GRID_W;
const ch = h / GRID_H;
for (let y = 0; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y)) {
ctx.fillStyle = COLOR_OUTSIDE;
ctx.fillRect(x * cw, y * ch, cw, ch);
continue;
}
const k = cellKey(x, y);
ctx.fillStyle = fillColorForCell(k);
ctx.fillRect(x * cw, y * ch, cw, ch);
}
}
for (const [key, meta] of state.cells) {
if (meta.discoveredBy !== currentTeam) continue;
const [xs, ys] = key.split(",").map(Number);
if (!isExploitable(xs, ys) || !meta.hasPlanet) continue;
const h32 = hash2u32(xs, ys, seedU32 ^ 0x1337);
const rng = mulberry32(h32);
const hue = Math.floor(rng() * 360);
ctx.fillStyle = `hsl(${hue} 85% 65%)`;
const px = xs * cw + cw / 2;
const py = ys * ch + ch / 2;
const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
ctx.beginPath();
ctx.arc(px, py, r, 0, Math.PI * 2);
ctx.fill();
}
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 1; x < GRID_W; x++) {
for (let y = 0; y < GRID_H; y++) {
if (!isExploitable(x - 1, y) || !isExploitable(x, y)) continue;
const xx = x * cw;
ctx.moveTo(xx, y * ch);
ctx.lineTo(xx, (y + 1) * ch);
}
}
for (let y = 1; y < GRID_H; y++) {
for (let x = 0; x < GRID_W; x++) {
if (!isExploitable(x, y - 1) || !isExploitable(x, y)) continue;
const yy = y * ch;
ctx.moveTo(x * cw, yy);
ctx.lineTo((x + 1) * cw, yy);
}
}
ctx.stroke();
}
// ── Cursor / click ────────────────────────────────────────────────────────────
function pickCellFromEvent(ev) {
const rect = canvas.getBoundingClientRect();
const sx = (ev.clientX - rect.left) / rect.width;
const sy = (ev.clientY - rect.top) / rect.height;
const x = Math.floor(sx * GRID_W);
const y = Math.floor(sy * GRID_H);
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
return { x, y };
}
function refreshCursor(ev) {
const cell = pickCellFromEvent(ev);
if (!cell || !isExploitable(cell.x, cell.y)) { canvas.style.cursor = "default"; return; }
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) { canvas.style.cursor = "default"; return; }
canvas.style.cursor = "pointer";
}
function applyRevealPayload(cell) {
const k = cellKey(cell.x, cell.y);
const discoveredBy = cell.discoveredBy ?? currentTeam;
state.cells.set(k, {
discoveredBy,
hasPlanet: Boolean(cell.hasPlanet),
planet: cell.planet ?? null,
});
details.classList.remove("details--hidden");
if (!cell.exploitable) {
hint.textContent = `(${cell.x},${cell.y}) not exploitable.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Not exploitable (outside donut / inside core).`;
return;
}
if (!cell.hasPlanet) {
hint.textContent = `(${cell.x},${cell.y}) exploitable — empty.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
hint.textContent = `(${cell.x},${cell.y}) exploitable — planet revealed.`;
details.textContent = `Cell (${cell.x},${cell.y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(cell.planet)}`;
}
function showSelectionFromLocal(x, y) {
const k = cellKey(x, y);
details.classList.remove("details--hidden");
if (!isOwnTile(k)) return;
if (!isExploitable(x, y)) {
hint.textContent = `(${x},${y}) not exploitable.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Not exploitable (outside donut / inside core).`;
return;
}
const meta = cellMeta(k);
if (!meta?.hasPlanet) {
hint.textContent = `(${x},${y}) exploitable — empty.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Nothing`;
return;
}
let planet = meta.planet;
if (!planet) {
const h = hash2u32(x, y, seedU32 ^ 0xa5a5a5a5);
const rng = mulberry32(h);
planet = generatePlanet(rng);
}
hint.textContent = `(${x},${y}) exploitable — planet.`;
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(planet)}`;
}
async function onCanvasClick(ev) {
const cell = pickCellFromEvent(ev);
if (!cell) return;
if (!isExploitable(cell.x, cell.y)) return;
const key = cellKey(cell.x, cell.y);
if (isOpponentTile(key)) return;
if (isOwnTile(key)) {
showSelectionFromLocal(cell.x, cell.y);
return;
}
if (cooldownBlocksNewReveal()) {
hint.textContent = "Cooldown — you cannot reveal new tiles yet. Click a tile your team already discovered to view its stats.";
return;
}
try {
const payload = await revealOnServer(cell.x, cell.y);
applyRevealPayload(payload);
startCooldown();
draw();
// Update scores after reveal
fetchScores();
} catch (e) {
if (e?.code === "TAKEN") {
hint.textContent = "This tile was already discovered by the other team.";
await fetchGridForSeed(seedStr);
draw();
return;
}
if (e?.code === "SEED_EXPIRED") {
hint.textContent = "World period changed — syncing.";
await refreshFromServer();
return;
}
hint.textContent = "Could not save reveal — check server / database.";
}
}
// ── Team segmented control ────────────────────────────────────────────────────
function updateTeamSegmented() {
teamSegmentedTrack.dataset.active = currentTeam;
teamBlue.setAttribute("aria-pressed", currentTeam === "blue" ? "true" : "false");
teamRed.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
}
function setTeam(team) {
currentTeam = team;
updateTeamSegmented();
draw();
refreshCursorFromLast();
}
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
canvas.addEventListener("click", onCanvasClick);
teamBlue.addEventListener("click", () => setTeam("blue"));
teamRed.addEventListener("click", () => setTeam("red"));
refreshBtn.addEventListener("click", () => refreshFromServer());
// ── Config poll ───────────────────────────────────────────────────────────────
function scheduleClientConfigPoll() {
clearTimeout(configPollTimer);
const ms = Math.max(5000, GAME_CONFIG.configReloadIntervalSeconds * 1000);
configPollTimer = window.setTimeout(async () => {
try {
const changed = await fetchConfig();
applyWorldSeedFromConfig();
if (changed) {
resetUiForNewPeriod();
await fetchGridForSeed(seedStr);
draw();
refreshCursorFromLast();
}
} catch { /* ignore */ }
scheduleClientConfigPoll();
}, ms);
}
// ── Boot ──────────────────────────────────────────────────────────────────────
async function boot() {
updateTeamSegmented();
// Try to restore session first
const restored = await tryRestoreSession();
if (!restored) {
showAuthOverlay();
// Still load config/grid in the background for display
try {
await fetchConfig();
applyWorldSeedFromConfig();
await fetchGridForSeed(seedStr);
await fetchScores();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose).";
cooldownConfigEl.textContent = "?";
}
draw();
refreshCursorFromLast();
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(updateRefreshCountdown, 1000);
updateRefreshCountdown();
scheduleClientConfigPoll();
scheduleScorePoll();
return;
}
hideAuthOverlay();
try {
await fetchConfig();
applyWorldSeedFromConfig();
await fetchGridForSeed(seedStr);
await fetchScores();
} catch {
hint.textContent = "API unavailable — start the Node server (docker-compose).";
cooldownConfigEl.textContent = "?";
}
draw();
refreshCursorFromLast();
if (refreshTimer) clearInterval(refreshTimer);
refreshTimer = setInterval(updateRefreshCountdown, 1000);
updateRefreshCountdown();
scheduleClientConfigPoll();
scheduleScorePoll();
}
boot();

View File

@@ -0,0 +1,64 @@
export const elements = {
common: "Matières premières",
petrol: "Hydrocarbures",
food: "Nourriture",
medic: "Médicaments",
science: "Science",
industry: "Industrie",
money: "Finance",
goods: "Biens",
};
export const resources = {
common: {
rock: "Roches communes",
wood: "Bois communs",
mineral: "Minérais communs",
stones: "Gemmes communes",
liquid: "Eau salée",
oil: "Fioul brut",
gas: "Gaz communs",
grain: "Céréales",
livestock: "Bétail commun",
fish: "Poissons commun",
plant: "Plantes communes",
goods: "Biens de consommation",
animals: "Animaux domestiques",
science: "Sites archéologiques",
factory: "Usines standards",
acid: "Acides pauvres",
},
rare: {
rock: "Roches rares",
wood: "Bois renforcés",
mineral: "Minérais rares",
stones: "Gemmes rares",
liquid: "Eau douce",
oil: "Fioul raffiné",
gas: "Gaz nobles",
grain: "Fruits",
livestock: "Bétail raffiné",
fish: "Poissons raffinés",
plant: "Plantes rares",
goods: "Biens de luxe",
animals: "Animaux exotiques",
science: "Artéfacts anciens",
factory: "Usines planétaires",
acid: "Acides riches",
},
};
export const population = {
humanoids: {
humans: "Humains",
near: "Presque'humains",
aliens: "Aliens",
},
creatures: {
casual: "Faune sauvage",
danger: "Faune hostile",
apex: "Superprédateurs",
robots: "Androïdes",
},
};

View File

@@ -0,0 +1,76 @@
import * as stat from "./planetStat.js";
const getRandomInt = (rng, min, max) => Math.floor(rng() * (max - min + 1)) + min;
const pick = (rng, array) => array[Math.floor(rng() * array.length)];
const randomPlanetTypeKey = (rng, planetType) => {
const keys = Object.keys(planetType);
return keys[Math.floor(rng() * keys.length)];
};
const distributePercentages = (rng, items) => {
if (items.length === 0) return {};
if (items.length === 1) return { [items[0]]: 100 };
const cuts = Array.from({ length: items.length - 1 }, () => rng()).sort((a, b) => a - b);
const segs = cuts.map((p, i) => (i === 0 ? p * 100 : (cuts[i] - cuts[i - 1]) * 100));
segs.push((1 - cuts[items.length - 2]) * 100);
return Object.fromEntries(items.map((item, idx) => [item, segs[idx]]));
};
export function buildPlanetTypeDistributions(rng) {
for (const key in stat.planetType) {
const type = stat.planetType[key];
if (Array.isArray(type.elements)) type.distributedElements = distributePercentages(rng, type.elements);
if (Array.isArray(type.resources)) type.distributedResources = distributePercentages(rng, type.resources);
}
}
export function generatePlanet(rng) {
// Distributions are normally created once at startup, but since we run per-cell with per-cell rng,
// we generate per planet for stable, deterministic results from the same seed.
buildPlanetTypeDistributions(rng);
const planetTypeGeneration = randomPlanetTypeKey(rng, stat.planetType);
const planetName = pick(rng, stat.planetNamePrefix) + pick(rng, stat.planetNameSuffix); // don't touch it
const planetPopulationPeople = pick(rng, stat.planetType[planetTypeGeneration].populationType);
const planetPopulationNumber =
(stat.planetType[planetTypeGeneration].population * getRandomInt(rng, 50, 150)) / 99.9;
return {
name: planetName,
type: planetTypeGeneration,
population: {
billions: Number(planetPopulationNumber.toFixed(3)),
majority: planetPopulationPeople,
},
production: stat.planetType[planetTypeGeneration].distributedElements,
naturalResources: stat.planetType[planetTypeGeneration].distributedResources,
};
}
export function formatPlanet(planet) {
const elementsDescription = Object.entries(planet.production)
.map(([k, v]) => ` ${k}: ${v.toFixed(1)}%`)
.join("\n");
const resourcesDescription = Object.entries(planet.naturalResources)
.map(([k, v]) => ` ${k}: ${v.toFixed(3)}%`)
.join("\n");
return `Planète : ${planet.name}
Type : ${planet.type}
Production :
${elementsDescription}
Ressources naturelles :
${resourcesDescription}
Population :
${planet.population.billions.toFixed(3)} milliards
Majoritairement ${planet.population.majority}`;
}

249
public/src/planetStat.js Normal file
View File

@@ -0,0 +1,249 @@
import { elements, population, resources } from "./planetEconomy.js";
export const planetType = {
Tempérée: {
population: 100,
populationType: [population.humanoids.humans, population.humanoids.near, population.humanoids.aliens],
elements: [elements.common, elements.food, elements.medic],
resources: [
resources.common.rock,
resources.common.wood,
resources.common.liquid,
resources.common.grain,
resources.common.livestock,
resources.common.fish,
resources.common.animals,
resources.rare.liquid,
resources.rare.grain,
resources.rare.livestock,
resources.rare.fish,
resources.rare.animals,
],
},
Glacée: {
population: 1,
populationType: [
population.creatures.casual,
population.creatures.danger,
population.creatures.apex,
population.humanoids.aliens,
],
elements: [elements.petrol, elements.science],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.science,
resources.rare.liquid,
resources.rare.science,
],
},
Volcanique: {
population: 2,
populationType: [
population.creatures.casual,
population.creatures.danger,
population.creatures.apex,
population.creatures.robots,
population.humanoids.aliens,
],
elements: [elements.common, elements.petrol],
resources: [
resources.common.rock,
resources.common.gas,
resources.common.acid,
resources.rare.rock,
resources.rare.mineral,
],
},
Marécageuse: {
population: 10,
populationType: [population.creatures.casual, population.creatures.danger, population.creatures.apex],
elements: [elements.common, elements.petrol],
resources: [
resources.common.wood,
resources.common.liquid,
resources.common.fish,
resources.common.plant,
resources.common.stones,
resources.rare.wood,
resources.rare.liquid,
resources.rare.fish,
resources.rare.animals,
],
},
Forestière: {
population: 20,
populationType: [population.creatures.casual, population.creatures.danger, population.humanoids.aliens],
elements: [elements.common],
resources: [
resources.common.wood,
resources.common.plant,
resources.common.animals,
resources.common.grain,
resources.rare.wood,
resources.rare.plant,
resources.rare.animals,
],
},
Océanique: {
population: 25,
populationType: [population.creatures.casual, population.creatures.apex, population.humanoids.aliens],
elements: [elements.common, elements.petrol],
resources: [
resources.common.liquid,
resources.common.fish,
resources.common.stones,
resources.rare.liquid,
resources.rare.fish,
resources.rare.stones,
],
},
Oecuménopole: {
population: 2000,
populationType: [population.humanoids.humans, population.humanoids.near],
elements: [elements.science, elements.industry, elements.money, elements.goods],
resources: [
resources.common.mineral,
resources.common.grain,
resources.common.livestock,
resources.common.goods,
resources.common.factory,
resources.rare.liquid,
resources.rare.grain,
resources.rare.livestock,
resources.rare.goods,
],
},
Désert: {
population: 50,
populationType: [
population.humanoids.near,
population.humanoids.aliens,
population.creatures.casual,
population.creatures.danger,
population.creatures.apex,
population.creatures.robots,
],
elements: [elements.common, elements.goods],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.livestock,
resources.common.science,
resources.rare.stones,
resources.rare.animals,
],
},
Minéralogique: {
population: 3,
populationType: [population.humanoids.aliens, population.creatures.robots, population.creatures.danger],
elements: [elements.common],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.stones,
resources.rare.rock,
resources.rare.mineral,
resources.rare.stones,
],
},
Gazeuse: {
population: 1,
populationType: [
population.humanoids.humans,
population.humanoids.near,
population.humanoids.aliens,
population.creatures.robots,
],
elements: [elements.petrol],
resources: [resources.common.oil, resources.common.gas, resources.rare.oil, resources.rare.gas],
},
Acide: {
population: 1,
populationType: [population.humanoids.aliens, population.creatures.casual, population.creatures.robots],
elements: [elements.petrol, elements.industry],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.factory,
resources.common.acid,
resources.rare.acid,
],
},
"Monde usine": {
population: 500,
populationType: [
population.humanoids.humans,
population.humanoids.near,
population.humanoids.aliens,
population.creatures.robots,
],
elements: [elements.petrol, elements.industry],
resources: [
resources.common.rock,
resources.common.mineral,
resources.common.oil,
resources.common.goods,
resources.common.science,
resources.common.factory,
resources.common.acid,
resources.rare.factory,
],
},
};
export const planetNamePrefix = [
"Acod",
"Acht",
"Bex",
"Carob",
"Daris",
"Ecop",
"Fron",
"Glac",
"Glad",
"Hac",
"Hor",
"Is",
"Jud",
"Kor",
"Ler",
"Marel",
"Naur",
"Olep",
"Prid",
"Plex",
"Qef",
"Rem",
"Sarit",
"Thec",
"Thex",
"Thet",
"Uv",
"Volc",
"Vold",
"Wann",
"Xal",
"Yr",
"Zot",
];
export const planetNameSuffix = [
"a Minor",
"a Major",
"ant",
"alor",
"eri",
"inia",
"irid",
"is",
"o Zion",
"or",
"oria",
"us",
"us Prime",
"us Secondus",
"land",
"yard",
];

30
public/src/rng.js Normal file
View File

@@ -0,0 +1,30 @@
export function fnv1a32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
export function hash2u32(a, b, seed = 0) {
// 2D integer hash -> uint32 (cheap, stable)
let x = (a | 0) + 0x9e3779b9 + (seed | 0);
let y = (b | 0) + 0x85ebca6b;
x ^= y + 0x27d4eb2d + (x << 6) + (x >>> 2);
x = Math.imul(x ^ (x >>> 16), 0x7feb352d);
x = Math.imul(x ^ (x >>> 15), 0x846ca68b);
x ^= x >>> 16;
return x >>> 0;
}
export function mulberry32(seedU32) {
let t = seedU32 >>> 0;
return function rand() {
t += 0x6d2b79f5;
let r = Math.imul(t ^ (t >>> 15), 1 | t);
r ^= r + Math.imul(r ^ (r >>> 7), 61 | r);
return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
}

560
public/style.css Normal file
View File

@@ -0,0 +1,560 @@
.app {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial,
"Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
color: #e9eef6;
background: radial-gradient(1200px 800px at 10% 10%, #16223a 0%, #0b1020 55%, #070a14 100%);
min-height: 100vh;
}
body {
margin: 0;
}
/* ── Auth overlay ─────────────────────────────────────────────────────────── */
.authOverlay {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(5, 8, 18, 0.88);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
}
.authOverlay.hidden {
display: none;
}
.authModal {
width: 360px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(15, 22, 42, 0.95);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
.authTabs {
display: grid;
grid-template-columns: 1fr 1fr;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.authTab {
padding: 14px;
border: none;
background: transparent;
color: rgba(233, 238, 246, 0.55);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border-radius: 0;
transition: color 0.15s, background 0.15s;
}
.authTab:hover {
color: #e9eef6;
background: rgba(255, 255, 255, 0.04);
}
.authTab--active {
color: #e9eef6;
background: rgba(113, 199, 255, 0.1);
border-bottom: 2px solid rgba(113, 199, 255, 0.7);
}
.authForm {
padding: 20px 22px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.authForm.hidden {
display: none;
}
.authField {
display: flex;
flex-direction: column;
gap: 5px;
}
.authField label {
font-size: 12px;
font-weight: 600;
opacity: 0.75;
}
.authHint {
font-weight: 400;
opacity: 0.55;
}
.authField input[type="text"],
.authField input[type="email"],
.authField input[type="password"] {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.06);
color: #e9eef6;
font-size: 14px;
outline: none;
transition: border-color 0.15s;
}
.authField input:focus {
border-color: rgba(113, 199, 255, 0.5);
}
.authTeamChoice {
display: flex;
gap: 10px;
}
.authTeamOption {
flex: 1;
cursor: pointer;
}
.authTeamOption input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.authTeamBadge {
display: block;
padding: 10px;
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.1);
text-align: center;
font-weight: 700;
font-size: 13px;
transition: border-color 0.15s, background 0.15s;
}
.authTeamBadge--blue {
color: rgba(90, 200, 255, 0.9);
}
.authTeamBadge--red {
color: rgba(220, 75, 85, 0.9);
}
.authTeamOption input:checked + .authTeamBadge--blue {
border-color: rgba(90, 200, 255, 0.8);
background: rgba(90, 200, 255, 0.12);
}
.authTeamOption input:checked + .authTeamBadge--red {
border-color: rgba(220, 75, 85, 0.8);
background: rgba(220, 75, 85, 0.12);
}
.authError {
font-size: 12px;
color: rgba(255, 130, 100, 0.95);
padding: 8px 10px;
border-radius: 8px;
background: rgba(200, 50, 30, 0.12);
border: 1px solid rgba(200, 50, 30, 0.25);
}
.authError.hidden {
display: none;
}
.authSubmit {
margin-top: 4px;
padding: 12px;
border-radius: 12px;
border: 1px solid rgba(113, 199, 255, 0.3);
background: rgba(113, 199, 255, 0.18);
color: #e9eef6;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
}
.authSubmit:hover {
background: rgba(113, 199, 255, 0.28);
}
/* ── Team corner (debug) ──────────────────────────────────────────────────── */
.teamCorner {
position: fixed;
top: 12px;
left: 12px;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(7, 10, 20, 0.85);
backdrop-filter: blur(8px);
}
.teamCornerLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.75;
}
.teamCorner--hidden {
display: none !important;
}
.teamSegmented {
min-width: 148px;
}
.teamSegmentedTrack {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.25);
}
.teamSegmentedTrack::before {
content: "";
position: absolute;
top: 2px;
bottom: 2px;
left: 2px;
width: calc(50% - 4px);
border-radius: 9px;
background: linear-gradient(180deg, rgba(90, 200, 255, 0.42), rgba(35, 95, 150, 0.55));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
transition: left 0.22s ease, background 0.22s ease;
z-index: 0;
pointer-events: none;
}
.teamSegmentedTrack[data-active="red"]::before {
left: calc(50% + 2px);
background: linear-gradient(180deg, rgba(255, 130, 130, 0.42), rgba(150, 45, 45, 0.55));
}
.teamSegmentedBtn {
position: relative;
z-index: 1;
margin: 0;
padding: 9px 12px;
border: none;
background: transparent;
color: #e9eef6;
font-weight: 700;
font-size: 13px;
cursor: pointer;
}
.teamSegmentedBtn:hover {
color: #ffffff;
}
/* ── Score board ──────────────────────────────────────────────────────────── */
.scoreBoard {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
font-size: 22px;
font-weight: 800;
font-variant-numeric: tabular-nums;
letter-spacing: 0.5px;
}
.scoreTeam {
display: flex;
align-items: center;
gap: 10px;
}
.scoreTeamName {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.8;
}
.scoreTeam--blue .scoreTeamName {
color: rgba(90, 200, 255, 0.9);
}
.scoreTeam--blue .scoreValue {
color: rgba(90, 200, 255, 1);
text-shadow: 0 0 20px rgba(90, 200, 255, 0.4);
}
.scoreTeam--red .scoreTeamName {
color: rgba(220, 75, 85, 0.9);
}
.scoreTeam--red .scoreValue {
color: rgba(220, 75, 85, 1);
text-shadow: 0 0 20px rgba(220, 75, 85, 0.4);
}
.scoreSep {
opacity: 0.4;
font-size: 18px;
}
/* ── Top bar ──────────────────────────────────────────────────────────────── */
.topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(7, 10, 20, 0.65);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 2;
}
.h1 {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.2px;
}
.sub {
font-size: 12px;
opacity: 0.75;
margin-top: 3px;
}
/* ── Top-right table ──────────────────────────────────────────────────────── */
.topRight {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
flex: 0 0 auto;
}
.topRightTable {
display: table;
border-collapse: separate;
border-spacing: 0 4px;
}
.topRightRow {
display: table-row;
}
.trKey {
display: table-cell;
text-align: right;
padding-right: 8px;
font-size: 11px;
opacity: 0.7;
white-space: nowrap;
vertical-align: middle;
}
.trVal {
display: table-cell;
text-align: left;
font-size: 11px;
white-space: nowrap;
vertical-align: middle;
}
.trVal code {
font-size: 11px;
padding: 2px 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
word-break: break-all;
}
/* Countdown row */
#countdownWrap {
opacity: 1;
transition: opacity 0.2s;
}
#countdownWrap.hidden {
visibility: hidden;
pointer-events: none;
}
.countdownLabel {
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: rgba(255, 193, 113, 0.9);
}
.countdownVal {
display: table-cell;
font-size: 14px;
font-weight: 700;
color: rgba(255, 193, 113, 1);
vertical-align: middle;
}
.countdown {
font-variant-numeric: tabular-nums;
min-width: 2ch;
text-align: right;
}
.countdownUnit {
font-size: 12px;
opacity: 0.8;
margin-left: 2px;
}
/* User display */
#userDisplay {
font-weight: 600;
font-size: 12px;
}
.logoutBtn {
margin-left: 8px;
padding: 3px 8px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.07);
color: rgba(233, 238, 246, 0.75);
font-size: 11px;
cursor: pointer;
}
.logoutBtn:hover {
background: rgba(255, 255, 255, 0.12);
color: #e9eef6;
}
.logoutBtn.hidden {
display: none;
}
.muted {
opacity: 0.7;
}
.controls {
display: flex;
align-items: end;
gap: 10px;
}
button {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(113, 199, 255, 0.16);
color: #e9eef6;
cursor: pointer;
}
button:hover {
background: rgba(113, 199, 255, 0.22);
}
.main {
display: grid;
grid-template-columns: 1fr 360px;
gap: 14px;
padding: 14px 18px 18px;
}
.board {
position: relative;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
min-height: 820px;
}
canvas {
display: block;
width: min(820px, calc(100vw - 420px));
height: min(820px, calc(100vw - 420px));
max-width: 820px;
max-height: 820px;
margin: 0 auto;
image-rendering: pixelated;
}
.hint {
position: absolute;
left: 14px;
bottom: 14px;
padding: 10px 12px;
font-size: 12px;
opacity: 0.8;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(7, 10, 20, 0.65);
backdrop-filter: blur(6px);
}
.panel {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
height: fit-content;
}
.panelTitle {
padding: 12px 14px;
font-weight: 700;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.details {
margin: 0;
padding: 12px 14px 14px;
font-size: 12px;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
color: rgba(233, 238, 246, 0.92);
}
.details--hidden {
color: rgba(233, 238, 246, 0.45);
font-style: italic;
}
@media (max-width: 1100px) {
.main {
grid-template-columns: 1fr;
}
canvas {
width: min(820px, calc(100vw - 36px));
height: min(820px, calc(100vw - 36px));
}
}