refacto: Keeping entrypoints clean and making files by purpose
This commit is contained in:
51
public/src/api.js
Normal file
51
public/src/api.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// ── Raw HTTP wrappers ─────────────────────────────────────────────────────────
|
||||
// These functions only perform fetch() calls and return the raw Response or
|
||||
// parsed JSON. No state mutations, no DOM access.
|
||||
|
||||
export async function apiFetchConfig(team) {
|
||||
const res = await fetch(`/api/config?team=${encodeURIComponent(team)}`);
|
||||
if (!res.ok) throw new Error("config_fetch_failed");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiFetchScores() {
|
||||
const res = await fetch("/api/scores");
|
||||
if (!res.ok) throw new Error("scores_fetch_failed");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Returns the raw Response so the caller can inspect status codes (410, etc.). */
|
||||
export async function apiFetchGrid(seed) {
|
||||
return fetch(`/api/grid/${encodeURIComponent(seed)}`);
|
||||
}
|
||||
|
||||
/** Returns the raw Response so the caller can inspect status codes (409, 410, etc.). */
|
||||
export async function apiRevealCell(seed, x, y, team) {
|
||||
return fetch("/api/cell/reveal", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seed, x, y, team }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiLogin(username, password) {
|
||||
return fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiRegister(username, email, password, team) {
|
||||
return fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, email, password, team }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function apiGetMe(token) {
|
||||
return fetch("/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
147
public/src/auth.js
Normal file
147
public/src/auth.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { apiLogin, apiRegister, apiGetMe } from "./api.js";
|
||||
import { setCurrentTeam, updateTeamSegmented, refreshFromServer } from "./game.js";
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
const userDisplayEl = document.getElementById("userDisplay");
|
||||
const logoutBtn = document.getElementById("logoutBtn");
|
||||
|
||||
// ── Auth state ────────────────────────────────────────────────────────────────
|
||||
|
||||
export let authToken = localStorage.getItem("authToken") ?? null;
|
||||
export let currentUser = null;
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function showError(el, msg) { el.textContent = msg; el.classList.remove("hidden"); }
|
||||
function clearError(el) { el.textContent = ""; el.classList.add("hidden"); }
|
||||
|
||||
export function showAuthOverlay() { authOverlay.classList.remove("hidden"); }
|
||||
export function hideAuthOverlay() { authOverlay.classList.add("hidden"); }
|
||||
|
||||
// ── Apply user (after login / register / session restore) ─────────────────────
|
||||
|
||||
export function applyUser(user, token) {
|
||||
currentUser = user;
|
||||
authToken = token;
|
||||
localStorage.setItem("authToken", token);
|
||||
setCurrentTeam(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();
|
||||
}
|
||||
|
||||
// ── Session restore ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function tryRestoreSession() {
|
||||
if (!authToken) return false;
|
||||
try {
|
||||
const res = await apiGetMe(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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||||
|
||||
tabLogin.addEventListener("click", () => {
|
||||
tabLogin.classList.add("authTab--active");
|
||||
tabRegister.classList.remove("authTab--active");
|
||||
loginForm.classList.remove("hidden");
|
||||
registerForm.classList.add("hidden");
|
||||
clearError(loginErrorEl);
|
||||
});
|
||||
|
||||
tabRegister.addEventListener("click", () => {
|
||||
tabRegister.classList.add("authTab--active");
|
||||
tabLogin.classList.remove("authTab--active");
|
||||
registerForm.classList.remove("hidden");
|
||||
loginForm.classList.add("hidden");
|
||||
clearError(registerErrorEl);
|
||||
});
|
||||
|
||||
// ── Login form ────────────────────────────────────────────────────────────────
|
||||
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
clearError(loginErrorEl);
|
||||
const username = loginUsernameEl.value.trim();
|
||||
const password = loginPasswordEl.value;
|
||||
if (!username || !password) return;
|
||||
try {
|
||||
const res = await apiLogin(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." };
|
||||
showError(loginErrorEl, msgs[data.error] ?? "Login failed.");
|
||||
return;
|
||||
}
|
||||
applyUser(data.user, data.token);
|
||||
hideAuthOverlay();
|
||||
await refreshFromServer();
|
||||
} catch {
|
||||
showError(loginErrorEl, "Network error. Try again.");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Register form ─────────────────────────────────────────────────────────────
|
||||
|
||||
registerForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
clearError(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) { showError(registerErrorEl, "Please choose a team."); return; }
|
||||
try {
|
||||
const res = await apiRegister(username, email, password, teamInput.value);
|
||||
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.",
|
||||
};
|
||||
showError(registerErrorEl, msgs[data.error] ?? "Registration failed.");
|
||||
return;
|
||||
}
|
||||
applyUser(data.user, data.token);
|
||||
hideAuthOverlay();
|
||||
await refreshFromServer();
|
||||
} catch {
|
||||
showError(registerErrorEl, "Network error. Try again.");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Logout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
logoutBtn.addEventListener("click", logout);
|
||||
416
public/src/game.js
Normal file
416
public/src/game.js
Normal file
@@ -0,0 +1,416 @@
|
||||
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
|
||||
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
|
||||
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell } from "./api.js";
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const GRID_W = 100;
|
||||
const GRID_H = 100;
|
||||
const OUTER_RADIUS = 50;
|
||||
const INNER_RADIUS = 30;
|
||||
const PLANET_CHANCE = 0.1;
|
||||
|
||||
const COLOR_OUTSIDE = "#000000";
|
||||
const COLOR_RING_IDLE = "rgba(113,199,255,0.08)";
|
||||
const COLOR_BLUE_DISCOVERED = "rgba(90, 200, 255, 0.38)";
|
||||
const COLOR_RED_DISCOVERED = "rgba(220, 75, 85, 0.42)";
|
||||
const COLOR_OPPONENT_GREY = "rgba(95, 98, 110, 0.72)";
|
||||
|
||||
// ── Shared game state ─────────────────────────────────────────────────────────
|
||||
|
||||
export const GAME_CONFIG = {
|
||||
clickCooldownSeconds: 5,
|
||||
databaseWipeoutIntervalSeconds: 21600,
|
||||
debugModeForTeams: true,
|
||||
configReloadIntervalSeconds: 30,
|
||||
worldSeed: "",
|
||||
seedPeriodEndsAtUtc: "",
|
||||
};
|
||||
window.GAME_CONFIG = GAME_CONFIG;
|
||||
|
||||
/** @type {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} */
|
||||
export const cells = new Map();
|
||||
|
||||
export let currentTeam = "blue";
|
||||
export function setCurrentTeam(t) { currentTeam = t; }
|
||||
|
||||
export let seedStr = "";
|
||||
export let seedU32 = 0;
|
||||
export function applySeed(str) {
|
||||
seedStr = str;
|
||||
seedU32 = fnv1a32(str || "fallback");
|
||||
}
|
||||
|
||||
export const teamCooldownEndMs = { blue: 0, red: 0 };
|
||||
let rafId = 0;
|
||||
let lastPointerEvent = null;
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
const details = document.getElementById("details");
|
||||
const hint = document.getElementById("hint");
|
||||
const countdownEl = document.getElementById("countdown");
|
||||
const countdownWrap = document.getElementById("countdownWrap");
|
||||
const cooldownCfgEl = document.getElementById("cooldownConfig");
|
||||
const seedDisplayEl = document.getElementById("worldSeedDisplay");
|
||||
const nextPeriodEl = document.getElementById("nextPeriodUtc");
|
||||
const resetCountEl = document.getElementById("refreshCountdown");
|
||||
const scoreBlueEl = document.getElementById("scoreBlue");
|
||||
const scoreRedEl = document.getElementById("scoreRed");
|
||||
const teamCorner = document.getElementById("teamCorner");
|
||||
const teamTrack = document.getElementById("teamSegmentedTrack");
|
||||
const teamBlueBtn = document.getElementById("teamBlue");
|
||||
const teamRedBtn = document.getElementById("teamRed");
|
||||
|
||||
// ── Cell helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export 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 };
|
||||
}
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function cellMeta(key) { return cells.get(key) ?? null; }
|
||||
export function isOpponentTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy !== currentTeam; }
|
||||
export function isOwnTile(key) { const m = cellMeta(key); return m !== null && m.discoveredBy === currentTeam; }
|
||||
|
||||
// ── Config display ────────────────────────────────────────────────────────────
|
||||
|
||||
export 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 ?? "");
|
||||
|
||||
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
|
||||
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
|
||||
nextPeriodEl.textContent = GAME_CONFIG.seedPeriodEndsAtUtc
|
||||
? new Date(GAME_CONFIG.seedPeriodEndsAtUtc).toISOString().replace("T", " ").slice(0, 19) + "Z"
|
||||
: "—";
|
||||
|
||||
updateResetCountdown();
|
||||
|
||||
if (GAME_CONFIG.debugModeForTeams) {
|
||||
teamCorner.classList.remove("teamCorner--hidden");
|
||||
} else {
|
||||
teamCorner.classList.add("teamCorner--hidden");
|
||||
}
|
||||
}
|
||||
|
||||
export function updateResetCountdown() {
|
||||
if (!GAME_CONFIG.seedPeriodEndsAtUtc) { resetCountEl.textContent = "--:--:--"; return; }
|
||||
const diff = new Date(GAME_CONFIG.seedPeriodEndsAtUtc).getTime() - Date.now();
|
||||
if (diff <= 0) { resetCountEl.textContent = "00:00:00"; return; }
|
||||
const s = Math.floor(diff / 1000);
|
||||
resetCountEl.textContent =
|
||||
String(Math.floor(s / 3600)).padStart(2, "0") + ":" +
|
||||
String(Math.floor((s % 3600) / 60)).padStart(2, "0") + ":" +
|
||||
String(s % 60).padStart(2, "0");
|
||||
}
|
||||
|
||||
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAndApplyScores() {
|
||||
try {
|
||||
const { blue, red } = await apiFetchScores();
|
||||
scoreBlueEl.textContent = String(blue ?? 0);
|
||||
scoreRedEl.textContent = String(red ?? 0);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Config fetch + apply ──────────────────────────────────────────────────────
|
||||
|
||||
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
|
||||
export async function fetchConfig() {
|
||||
const data = await apiFetchConfig(currentTeam);
|
||||
const prevSeed = GAME_CONFIG.worldSeed;
|
||||
applyConfigPayload(data);
|
||||
applySeed(GAME_CONFIG.worldSeed);
|
||||
return prevSeed !== "" && prevSeed !== GAME_CONFIG.worldSeed;
|
||||
}
|
||||
|
||||
// ── Grid fetch ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchGridForSeed(seed, depth = 0) {
|
||||
if (depth > 4) throw new Error("grid_stale");
|
||||
const res = await apiFetchGrid(seed);
|
||||
if (res.status === 410) {
|
||||
await fetchConfig();
|
||||
return fetchGridForSeed(seedStr, depth + 1);
|
||||
}
|
||||
if (!res.ok) throw new Error("grid");
|
||||
const data = await res.json();
|
||||
cells.clear();
|
||||
for (const row of data.cells || []) {
|
||||
cells.set(cellKey(row.x, row.y), {
|
||||
discoveredBy: row.discovered_by ?? row.discoveredBy,
|
||||
hasPlanet: Boolean(row.has_planet),
|
||||
planet: row.planet_json ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Cooldown ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function cooldownActive() {
|
||||
if (GAME_CONFIG.clickCooldownSeconds <= 0) return false;
|
||||
return Date.now() < teamCooldownEndMs[currentTeam];
|
||||
}
|
||||
|
||||
function remainingSecs() {
|
||||
return Math.max(0, (teamCooldownEndMs[currentTeam] - Date.now()) / 1000);
|
||||
}
|
||||
|
||||
function tickCooldown() {
|
||||
if (GAME_CONFIG.clickCooldownSeconds <= 0) { countdownWrap.classList.add("hidden"); return; }
|
||||
const left = remainingSecs();
|
||||
if (left <= 0) {
|
||||
countdownWrap.classList.add("hidden");
|
||||
countdownEl.textContent = "0";
|
||||
teamCooldownEndMs[currentTeam] = 0;
|
||||
refreshCursorFromLast();
|
||||
return;
|
||||
}
|
||||
countdownWrap.classList.remove("hidden");
|
||||
countdownEl.textContent = String(Math.ceil(left));
|
||||
refreshCursorFromLast();
|
||||
rafId = requestAnimationFrame(tickCooldown);
|
||||
}
|
||||
|
||||
export 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(tickCooldown);
|
||||
refreshCursorFromLast();
|
||||
}
|
||||
|
||||
export function clearCooldown() {
|
||||
teamCooldownEndMs.blue = 0;
|
||||
teamCooldownEndMs.red = 0;
|
||||
cancelAnimationFrame(rafId);
|
||||
countdownWrap.classList.add("hidden");
|
||||
}
|
||||
|
||||
// ── Draw ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export 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);
|
||||
const meta = cellMeta(k);
|
||||
if (!meta) ctx.fillStyle = COLOR_RING_IDLE;
|
||||
else if (meta.discoveredBy !== currentTeam) ctx.fillStyle = COLOR_OPPONENT_GREY;
|
||||
else ctx.fillStyle = currentTeam === "blue" ? COLOR_BLUE_DISCOVERED : COLOR_RED_DISCOVERED;
|
||||
ctx.fillRect(x * cw, y * ch, cw, ch);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, meta] of 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);
|
||||
ctx.fillStyle = `hsl(${Math.floor(rng() * 360)} 85% 65%)`;
|
||||
const r = Math.max(1.6, Math.min(cw, ch) * 0.28);
|
||||
ctx.beginPath();
|
||||
ctx.arc(xs * cw + cw / 2, ys * ch + ch / 2, 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;
|
||||
ctx.moveTo(x * cw, y * ch); ctx.lineTo(x * cw, (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;
|
||||
ctx.moveTo(x * cw, y * ch); ctx.lineTo((x + 1) * cw, y * ch);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Cursor ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function pickCell(ev) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = Math.floor(((ev.clientX - rect.left) / rect.width) * GRID_W);
|
||||
const y = Math.floor(((ev.clientY - rect.top) / rect.height) * GRID_H);
|
||||
if (x < 0 || x >= GRID_W || y < 0 || y >= GRID_H) return null;
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
function refreshCursor(ev) {
|
||||
const cell = pickCell(ev);
|
||||
if (!cell || !isExploitable(cell.x, cell.y) || isOpponentTile(cellKey(cell.x, cell.y))) {
|
||||
canvas.style.cursor = "default";
|
||||
} else {
|
||||
canvas.style.cursor = "pointer";
|
||||
}
|
||||
}
|
||||
|
||||
function refreshCursorFromLast() {
|
||||
if (lastPointerEvent) refreshCursor(lastPointerEvent);
|
||||
}
|
||||
|
||||
// ── Selection display ─────────────────────────────────────────────────────────
|
||||
|
||||
function applyRevealPayload(cell) {
|
||||
cells.set(cellKey(cell.x, cell.y), {
|
||||
discoveredBy: cell.discoveredBy ?? currentTeam,
|
||||
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.`;
|
||||
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 showLocalSelection(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.`;
|
||||
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);
|
||||
planet = generatePlanet(mulberry32(h));
|
||||
}
|
||||
hint.textContent = `(${x},${y}) exploitable — planet.`;
|
||||
details.textContent = `Cell (${x},${y})\n\nStatus: Exploitable\nContains: Planet\n\n${formatPlanet(planet)}`;
|
||||
}
|
||||
|
||||
// ── Canvas click handler ──────────────────────────────────────────────────────
|
||||
|
||||
async function onCanvasClick(ev) {
|
||||
const cell = pickCell(ev);
|
||||
if (!cell || !isExploitable(cell.x, cell.y)) return;
|
||||
const key = cellKey(cell.x, cell.y);
|
||||
|
||||
if (isOpponentTile(key)) return;
|
||||
|
||||
if (isOwnTile(key)) { showLocalSelection(cell.x, cell.y); return; }
|
||||
|
||||
if (cooldownActive()) {
|
||||
hint.textContent = "Cooldown active — reveal a tile your team already discovered to view stats.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiRevealCell(seedStr, cell.x, cell.y, currentTeam);
|
||||
if (res.status === 409) {
|
||||
hint.textContent = "This tile was already discovered by the other team.";
|
||||
await fetchGridForSeed(seedStr);
|
||||
draw();
|
||||
return;
|
||||
}
|
||||
if (res.status === 410) {
|
||||
hint.textContent = "World period changed — syncing.";
|
||||
await refreshFromServer();
|
||||
return;
|
||||
}
|
||||
if (!res.ok) throw new Error("reveal");
|
||||
applyRevealPayload(await res.json());
|
||||
startCooldown();
|
||||
draw();
|
||||
fetchAndApplyScores();
|
||||
} catch (e) {
|
||||
if (e?.code === "SEED_EXPIRED") { await refreshFromServer(); return; }
|
||||
hint.textContent = "Could not save reveal — check server / database.";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Team segmented control ────────────────────────────────────────────────────
|
||||
|
||||
export function updateTeamSegmented() {
|
||||
teamTrack.dataset.active = currentTeam;
|
||||
teamBlueBtn.setAttribute("aria-pressed", currentTeam === "blue" ? "true" : "false");
|
||||
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
|
||||
}
|
||||
|
||||
// ── Full server refresh ───────────────────────────────────────────────────────
|
||||
// Exported so auth.js / main.js can call it after login or on timer.
|
||||
|
||||
export async function refreshFromServer() {
|
||||
try {
|
||||
const seedChanged = await fetchConfig();
|
||||
if (seedChanged) {
|
||||
clearCooldown();
|
||||
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.";
|
||||
}
|
||||
await fetchGridForSeed(seedStr);
|
||||
await fetchAndApplyScores();
|
||||
draw();
|
||||
refreshCursorFromLast();
|
||||
} catch {
|
||||
hint.textContent = "Could not sync with server. Is the API running?";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event listeners ───────────────────────────────────────────────────────────
|
||||
|
||||
canvas.addEventListener("mousemove", (ev) => { lastPointerEvent = ev; refreshCursor(ev); });
|
||||
canvas.addEventListener("mouseleave", () => { lastPointerEvent = null; canvas.style.cursor = "default"; });
|
||||
canvas.addEventListener("click", onCanvasClick);
|
||||
|
||||
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
||||
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); draw(); refreshCursorFromLast(); });
|
||||
@@ -1,697 +1,51 @@
|
||||
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
|
||||
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
|
||||
import {
|
||||
GAME_CONFIG,
|
||||
seedStr,
|
||||
updateTeamSegmented,
|
||||
updateResetCountdown,
|
||||
fetchConfig,
|
||||
fetchGridForSeed,
|
||||
fetchAndApplyScores,
|
||||
refreshFromServer,
|
||||
draw,
|
||||
} from "./game.js";
|
||||
|
||||
const GRID_W = 100;
|
||||
const GRID_H = 100;
|
||||
const OUTER_RADIUS = 50;
|
||||
const INNER_RADIUS = 30;
|
||||
const PLANET_CHANCE = 0.1;
|
||||
import {
|
||||
tryRestoreSession,
|
||||
showAuthOverlay,
|
||||
hideAuthOverlay,
|
||||
} from "./auth.js";
|
||||
|
||||
/** 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)";
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
const hint = document.getElementById("hint");
|
||||
const cooldownEl = document.getElementById("cooldownConfig");
|
||||
|
||||
// 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;
|
||||
// ── Polling ───────────────────────────────────────────────────────────────────
|
||||
|
||||
let configPollTimer = 0;
|
||||
let scorePollTimer = 0;
|
||||
/** @type {ReturnType<typeof setInterval> | null} */
|
||||
let refreshTimer = null;
|
||||
let scorePollTimer = 0;
|
||||
let resetTimer = 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 scheduleConfigPoll() {
|
||||
clearTimeout(configPollTimer);
|
||||
const ms = Math.max(5_000, GAME_CONFIG.configReloadIntervalSeconds * 1_000);
|
||||
configPollTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
const changed = await fetchConfig();
|
||||
if (changed) await refreshFromServer();
|
||||
} catch { /* ignore */ }
|
||||
scheduleConfigPoll();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
function scheduleScorePoll() {
|
||||
clearTimeout(scorePollTimer);
|
||||
scorePollTimer = window.setTimeout(async () => {
|
||||
await fetchScores();
|
||||
await fetchAndApplyScores();
|
||||
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);
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
@@ -699,50 +53,36 @@ function scheduleClientConfigPoll() {
|
||||
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;
|
||||
} else {
|
||||
hideAuthOverlay();
|
||||
}
|
||||
|
||||
hideAuthOverlay();
|
||||
|
||||
try {
|
||||
await fetchConfig();
|
||||
applyWorldSeedFromConfig();
|
||||
await fetchGridForSeed(seedStr);
|
||||
await fetchScores();
|
||||
await fetchAndApplyScores();
|
||||
} catch {
|
||||
hint.textContent = "API unavailable — start the Node server (docker-compose).";
|
||||
cooldownConfigEl.textContent = "?";
|
||||
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
||||
cooldownEl.textContent = "?";
|
||||
}
|
||||
|
||||
draw();
|
||||
refreshCursorFromLast();
|
||||
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = setInterval(updateRefreshCountdown, 1000);
|
||||
updateRefreshCountdown();
|
||||
scheduleClientConfigPoll();
|
||||
if (resetTimer) clearInterval(resetTimer);
|
||||
resetTimer = setInterval(updateResetCountdown, 1_000);
|
||||
updateResetCountdown();
|
||||
|
||||
scheduleConfigPoll();
|
||||
scheduleScorePoll();
|
||||
}
|
||||
|
||||
// ── Global event listeners ────────────────────────────────────────────────────
|
||||
|
||||
refreshBtn.addEventListener("click", () => refreshFromServer());
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
boot();
|
||||
Reference in New Issue
Block a user