Adding repo after 2 days of vibecoding
This commit is contained in:
149
public/index.html
Normal file
149
public/index.html
Normal 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
748
public/src/main.js
Normal 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 2–32 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();
|
||||
64
public/src/planetEconomy.js
Normal file
64
public/src/planetEconomy.js
Normal 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",
|
||||
},
|
||||
};
|
||||
|
||||
76
public/src/planetGeneration.js
Normal file
76
public/src/planetGeneration.js
Normal 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
249
public/src/planetStat.js
Normal 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
30
public/src/rng.js
Normal 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
560
public/style.css
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user