feat: Adding economic system to do scoring
This commit is contained in:
@@ -48,4 +48,20 @@ export async function apiGetMe(token) {
|
||||
return fetch("/api/auth/me", {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetchEconScores() {
|
||||
const res = await fetch("/api/econ-scores");
|
||||
if (!res.ok) throw new Error("econ_scores_fetch_failed");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiTickEconScores(seed, blue, red) {
|
||||
const res = await fetch("/api/econ-scores/tick", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ seed, blue, red }),
|
||||
});
|
||||
if (!res.ok) throw new Error("econ_tick_failed");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
125
public/src/economy.js
Normal file
125
public/src/economy.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { resources } from "./planetEconomy.js";
|
||||
|
||||
// ── Sort state ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 0=Ressource, 1=Rareté, 2=Valeur, 3=Revenu/s */
|
||||
let _sortCol = 3;
|
||||
let _sortDir = "desc";
|
||||
|
||||
export function setEconSort(col, dir) {
|
||||
_sortCol = col;
|
||||
_sortDir = dir;
|
||||
}
|
||||
|
||||
export function getEconSort() {
|
||||
return { col: _sortCol, dir: _sortDir };
|
||||
}
|
||||
|
||||
// ── Label → resource key lookup ───────────────────────────────────────────────
|
||||
|
||||
/** Map from French label string → { cat: "common"|"rare", key: string } */
|
||||
const LABEL_TO_RESOURCE = (() => {
|
||||
const map = new Map();
|
||||
for (const [cat, entries] of Object.entries(resources)) {
|
||||
for (const [key, label] of Object.entries(entries)) {
|
||||
map.set(label, { cat, key });
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
|
||||
// ── Income calculation ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compute income per second for a team based on their discovered planets.
|
||||
*
|
||||
* @param {string} team - "blue" or "red"
|
||||
* @param {Map<string, { discoveredBy: string, hasPlanet: boolean, planet: object|null }>} cells
|
||||
* @param {object} resourceWorth - { common: { rock: 1, ... }, rare: { rock: 3, ... } }
|
||||
* @returns {{ total: number, byResource: Map<string, number> }}
|
||||
* byResource keys are resource label strings (French names), values are credits/sec
|
||||
*/
|
||||
export function computeTeamIncome(team, cells, resourceWorth) {
|
||||
/** @type {Map<string, number>} label → cumulative income/sec */
|
||||
const byResource = new Map();
|
||||
let total = 0;
|
||||
|
||||
for (const [, meta] of cells) {
|
||||
if (meta.discoveredBy !== team) continue;
|
||||
if (!meta.hasPlanet || !meta.planet) continue;
|
||||
const { naturalResources } = meta.planet;
|
||||
if (!naturalResources) continue;
|
||||
|
||||
for (const [label, pct] of Object.entries(naturalResources)) {
|
||||
const info = LABEL_TO_RESOURCE.get(label);
|
||||
if (!info) continue;
|
||||
const worth = resourceWorth?.[info.cat]?.[info.key] ?? 0;
|
||||
if (worth === 0) continue;
|
||||
const income = (pct / 100) * worth;
|
||||
byResource.set(label, (byResource.get(label) ?? 0) + income);
|
||||
total += income;
|
||||
}
|
||||
}
|
||||
|
||||
return { total, byResource };
|
||||
}
|
||||
|
||||
// ── Resource table for the sidebar ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Renders the resource overview table for the economy panel.
|
||||
*
|
||||
* @param {object} resourceWorth - { common: {…}, rare: {…} }
|
||||
* @param {Map<string, number>} teamByResource - income/sec per label for current team
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
export function renderResourceTable(resourceWorth, teamByResource) {
|
||||
const rows = [];
|
||||
|
||||
for (const [cat, entries] of Object.entries(resources)) {
|
||||
for (const [key, label] of Object.entries(entries)) {
|
||||
const worth = resourceWorth?.[cat]?.[key] ?? 0;
|
||||
const income = teamByResource?.get(label) ?? 0;
|
||||
const incomeStr = income > 0 ? `+${income.toFixed(3)}/s` : "—";
|
||||
const catLabel = cat === "rare" ? "Rare" : "Commun";
|
||||
rows.push({ label, catLabel, worth, income, incomeStr });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by selected column
|
||||
const mult = _sortDir === "asc" ? 1 : -1;
|
||||
rows.sort((a, b) => {
|
||||
if (_sortCol === 0) return mult * a.label.localeCompare(b.label, "fr");
|
||||
if (_sortCol === 1) return mult * a.catLabel.localeCompare(b.catLabel, "fr");
|
||||
if (_sortCol === 2) return mult * (a.worth - b.worth);
|
||||
if (_sortCol === 3) return mult * (a.income - b.income);
|
||||
return b.income - a.income || b.worth - a.worth;
|
||||
});
|
||||
|
||||
const tableRows = rows
|
||||
.map(({ label, catLabel, worth, incomeStr, income }) => {
|
||||
const incomeClass = income > 0 ? " econ-income--positive" : "";
|
||||
return `<tr>
|
||||
<td class="econ-label">${label}</td>
|
||||
<td class="econ-cat econ-cat--${catLabel.toLowerCase()}">${catLabel}</td>
|
||||
<td class="econ-worth">${worth}</td>
|
||||
<td class="econ-income${incomeClass}">${incomeStr}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const thLabels = ["Ressource", "Rareté", "Valeur", "Revenu/s"];
|
||||
const headers = thLabels
|
||||
.map((lbl, i) => {
|
||||
const isActive = i === _sortCol;
|
||||
const indicator = isActive ? (_sortDir === "asc" ? " ▲" : " ▼") : " ⇅";
|
||||
const activeClass = isActive ? " econTh--active" : "";
|
||||
return `<th class="econTh${activeClass}" data-sort-col="${i}">${lbl}<span class="econSortIcon">${indicator}</span></th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return `<table class="econTable">
|
||||
<thead><tr>${headers}</tr></thead>
|
||||
<tbody>${tableRows}</tbody>
|
||||
</table>`;
|
||||
}
|
||||
@@ -1,30 +1,33 @@
|
||||
import { fnv1a32, hash2u32, mulberry32 } from "./rng.js";
|
||||
import { formatPlanet, generatePlanet } from "./planetGeneration.js";
|
||||
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell } from "./api.js";
|
||||
import { apiFetchConfig, apiFetchScores, apiFetchGrid, apiRevealCell, apiFetchEconScores, apiTickEconScores } from "./api.js";
|
||||
import { computeTeamIncome, renderResourceTable, setEconSort, getEconSort } from "./economy.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)";
|
||||
// SVG playfield dimensions (must match /graphism/playground.svg viewBox)
|
||||
const SVG_W = 1000;
|
||||
const SVG_H = 1000;
|
||||
|
||||
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,
|
||||
debugModeForTeams: false,
|
||||
configReloadIntervalSeconds: 30,
|
||||
worldSeed: "",
|
||||
seedPeriodEndsAtUtc: "",
|
||||
resourceWorth: { common: {}, rare: {} },
|
||||
};
|
||||
window.GAME_CONFIG = GAME_CONFIG;
|
||||
|
||||
@@ -45,6 +48,93 @@ export const teamCooldownEndMs = { blue: 0, red: 0 };
|
||||
let rafId = 0;
|
||||
let lastPointerEvent = null;
|
||||
|
||||
// ── Playfield mask ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Set of "x,y" keys for exploitable tiles (populated by loadPlayfieldMask). */
|
||||
let exploitableTiles = null;
|
||||
|
||||
/** Preloaded SVG image for background rendering. */
|
||||
let playfieldImg = null;
|
||||
|
||||
/**
|
||||
* Loads /graphism/playground.svg, rasterises it to an offscreen canvas,
|
||||
* then classifies each 100×100 grid tile as exploitable if ≥80 % of its
|
||||
* sampled pixels are the black playable zone (R≤20, G≤20, B≤20).
|
||||
*/
|
||||
export function loadPlayfieldMask() {
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
playfieldImg = img;
|
||||
|
||||
// ── Rasterise SVG at native size onto an offscreen canvas ──
|
||||
const oc = document.createElement("canvas");
|
||||
oc.width = SVG_W;
|
||||
oc.height = SVG_H;
|
||||
const octx = oc.getContext("2d");
|
||||
|
||||
// White fill so transparent SVG areas appear white (not black).
|
||||
octx.fillStyle = "#ffffff";
|
||||
octx.fillRect(0, 0, SVG_W, SVG_H);
|
||||
octx.drawImage(img, 0, 0, SVG_W, SVG_H);
|
||||
|
||||
let imageData;
|
||||
try {
|
||||
imageData = octx.getImageData(0, 0, SVG_W, SVG_H);
|
||||
} catch (e) {
|
||||
console.warn("[playfield] canvas tainted – no exploitable tiles:", e);
|
||||
exploitableTiles = new Set();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = imageData.data; // RGBA flat array
|
||||
|
||||
const SAMPLES_X = 10;
|
||||
const SAMPLES_Y = 10;
|
||||
const cellW = SVG_W / GRID_W; // 7.5 px
|
||||
const cellH = SVG_H / GRID_H; // 10 px
|
||||
const tiles = new Set();
|
||||
|
||||
for (let gy = 0; gy < GRID_H; gy++) {
|
||||
for (let gx = 0; gx < GRID_W; gx++) {
|
||||
let blackCount = 0;
|
||||
|
||||
for (let sy = 0; sy < SAMPLES_Y; sy++) {
|
||||
for (let sx = 0; sx < SAMPLES_X; sx++) {
|
||||
const px = Math.min(
|
||||
SVG_W - 1,
|
||||
Math.floor(gx * cellW + (sx + 0.5) * cellW / SAMPLES_X)
|
||||
);
|
||||
const py = Math.min(
|
||||
SVG_H - 1,
|
||||
Math.floor(gy * cellH + (sy + 0.5) * cellH / SAMPLES_Y)
|
||||
);
|
||||
const idx = (py * SVG_W + px) * 4;
|
||||
const r = data[idx], g = data[idx + 1], b = data[idx + 2];
|
||||
// Pure black = playable zone (path2, fill:#000000)
|
||||
if (r <= 20 && g <= 20 && b <= 20) blackCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const coverage = blackCount / (SAMPLES_X * SAMPLES_Y);
|
||||
if (coverage >= 0.8) tiles.add(cellKey(gx, gy));
|
||||
}
|
||||
}
|
||||
|
||||
exploitableTiles = tiles;
|
||||
console.log(`[playfield] ${tiles.size} exploitable tiles loaded.`);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.warn("[playfield] failed to load playground.svg");
|
||||
exploitableTiles = new Set();
|
||||
resolve();
|
||||
};
|
||||
img.src = "/graphism/playground.svg";
|
||||
});
|
||||
}
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const canvas = document.getElementById("canvas");
|
||||
@@ -57,8 +147,15 @@ 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 scoreBlueEl = document.getElementById("scoreBlue");
|
||||
const scoreRedEl = document.getElementById("scoreRed");
|
||||
const incomeBlueEl = document.getElementById("incomeBlue");
|
||||
const incomeRedEl = document.getElementById("incomeRed");
|
||||
const resourceTableEl = document.getElementById("resourceTableBody");
|
||||
const econScoreBlueEl = document.getElementById("econScoreBlue");
|
||||
const econScoreRedEl = document.getElementById("econScoreRed");
|
||||
const econDeltaBlueEl = document.getElementById("econDeltaBlue");
|
||||
const econDeltaRedEl = document.getElementById("econDeltaRed");
|
||||
const teamCorner = document.getElementById("teamCorner");
|
||||
const teamTrack = document.getElementById("teamSegmentedTrack");
|
||||
const teamBlueBtn = document.getElementById("teamBlue");
|
||||
@@ -68,16 +165,9 @@ const teamRedBtn = document.getElementById("teamRed");
|
||||
|
||||
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;
|
||||
if (!exploitableTiles) return false;
|
||||
return exploitableTiles.has(cellKey(x, y));
|
||||
}
|
||||
|
||||
function hasPlanetAt(x, y) {
|
||||
@@ -98,6 +188,9 @@ export function applyConfigPayload(data) {
|
||||
GAME_CONFIG.configReloadIntervalSeconds = Math.max(5, Number(data.configReloadIntervalSeconds) || 30);
|
||||
GAME_CONFIG.worldSeed = String(data.worldSeed ?? "");
|
||||
GAME_CONFIG.seedPeriodEndsAtUtc = String(data.seedPeriodEndsAtUtc ?? "");
|
||||
if (data.resourceWorth && typeof data.resourceWorth === "object") {
|
||||
GAME_CONFIG.resourceWorth = data.resourceWorth;
|
||||
}
|
||||
|
||||
cooldownCfgEl.textContent = String(GAME_CONFIG.clickCooldownSeconds);
|
||||
seedDisplayEl.textContent = GAME_CONFIG.worldSeed || "—";
|
||||
@@ -107,11 +200,8 @@ export function applyConfigPayload(data) {
|
||||
|
||||
updateResetCountdown();
|
||||
|
||||
if (GAME_CONFIG.debugModeForTeams) {
|
||||
teamCorner.classList.remove("teamCorner--hidden");
|
||||
} else {
|
||||
teamCorner.classList.add("teamCorner--hidden");
|
||||
}
|
||||
// Team switcher visibility is managed exclusively via unlockTeamSwitcher() /
|
||||
// lockTeamSwitcher() — NOT by debugModeForTeams config.
|
||||
}
|
||||
|
||||
export function updateResetCountdown() {
|
||||
@@ -125,6 +215,18 @@ export function updateResetCountdown() {
|
||||
String(s % 60).padStart(2, "0");
|
||||
}
|
||||
|
||||
// ── Team switcher (admin-only) ────────────────────────────────────────────────
|
||||
|
||||
/** Show the team switcher widget. Called only after successful admin unlock. */
|
||||
export function unlockTeamSwitcher() {
|
||||
teamCorner.classList.remove("teamCorner--hidden");
|
||||
}
|
||||
|
||||
/** Hide the team switcher widget. */
|
||||
export function lockTeamSwitcher() {
|
||||
teamCorner.classList.add("teamCorner--hidden");
|
||||
}
|
||||
|
||||
// ── Scores ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function fetchAndApplyScores() {
|
||||
@@ -135,6 +237,92 @@ export async function fetchAndApplyScores() {
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Economy display ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Recalculates and renders team income and resource table. */
|
||||
export function updateEconomyDisplay() {
|
||||
const worth = GAME_CONFIG.resourceWorth;
|
||||
|
||||
const blueIncome = computeTeamIncome("blue", cells, worth);
|
||||
const redIncome = computeTeamIncome("red", cells, worth);
|
||||
|
||||
if (incomeBlueEl) incomeBlueEl.textContent = `+${blueIncome.total.toFixed(3)}/s`;
|
||||
if (incomeRedEl) incomeRedEl.textContent = `+${redIncome.total.toFixed(3)}/s`;
|
||||
|
||||
// Resource table shows own-team breakdown
|
||||
const teamIncome = currentTeam === "blue" ? blueIncome : redIncome;
|
||||
if (resourceTableEl) {
|
||||
resourceTableEl.innerHTML = renderResourceTable(worth, teamIncome.byResource);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Economic score ────────────────────────────────────────────────────────────
|
||||
|
||||
let econScoreBlue = 0;
|
||||
let econScoreRed = 0;
|
||||
|
||||
/** Trigger the delta fade animation on an element. */
|
||||
function showEconDelta(el, delta) {
|
||||
if (!el || delta <= 0) return;
|
||||
el.textContent = `+${delta.toFixed(3)}`;
|
||||
el.classList.remove("econDelta--active");
|
||||
// Force reflow so removing/re-adding the class restarts the animation
|
||||
void el.offsetWidth;
|
||||
el.classList.add("econDelta--active");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called every ECON_TICK_MS milliseconds.
|
||||
* Adds income × (interval in seconds) to each team's cumulative score,
|
||||
* updates the score display, and triggers the delta animation.
|
||||
*
|
||||
* @param {number} intervalSeconds - how many seconds elapsed since last tick
|
||||
*/
|
||||
export async function tickEconScore(intervalSeconds) {
|
||||
const worth = GAME_CONFIG.resourceWorth;
|
||||
|
||||
const blueIncome = computeTeamIncome("blue", cells, worth);
|
||||
const redIncome = computeTeamIncome("red", cells, worth);
|
||||
|
||||
const blueDelta = blueIncome.total * intervalSeconds;
|
||||
const redDelta = redIncome.total * intervalSeconds;
|
||||
|
||||
try {
|
||||
const scores = await apiTickEconScores(seedStr, blueDelta, redDelta);
|
||||
econScoreBlue = scores.blue;
|
||||
econScoreRed = scores.red;
|
||||
} catch {
|
||||
// fallback: update locally if server unreachable
|
||||
econScoreBlue += blueDelta;
|
||||
econScoreRed += redDelta;
|
||||
}
|
||||
|
||||
if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
|
||||
if (econScoreRedEl) econScoreRedEl.textContent = econScoreRed.toFixed(3);
|
||||
|
||||
showEconDelta(econDeltaBlueEl, blueDelta);
|
||||
showEconDelta(econDeltaRedEl, redDelta);
|
||||
}
|
||||
|
||||
/** Load economic scores from server (called on boot / after seed change). */
|
||||
export async function loadEconScores() {
|
||||
try {
|
||||
const scores = await apiFetchEconScores();
|
||||
econScoreBlue = scores.blue ?? 0;
|
||||
econScoreRed = scores.red ?? 0;
|
||||
if (econScoreBlueEl) econScoreBlueEl.textContent = econScoreBlue.toFixed(3);
|
||||
if (econScoreRedEl) econScoreRedEl.textContent = econScoreRed.toFixed(3);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** Reset economic scores (called when world period changes). */
|
||||
export function resetEconScores() {
|
||||
econScoreBlue = 0;
|
||||
econScoreRed = 0;
|
||||
if (econScoreBlueEl) econScoreBlueEl.textContent = "0.000";
|
||||
if (econScoreRedEl) econScoreRedEl.textContent = "0.000";
|
||||
}
|
||||
|
||||
// ── Config fetch + apply ──────────────────────────────────────────────────────
|
||||
|
||||
/** Fetches /api/config, updates GAME_CONFIG, returns true if the world seed changed. */
|
||||
@@ -186,6 +374,8 @@ function tickCooldown() {
|
||||
countdownEl.textContent = "0";
|
||||
teamCooldownEndMs[currentTeam] = 0;
|
||||
refreshCursorFromLast();
|
||||
// Auto-refresh from server when cooldown expires
|
||||
refreshFromServer();
|
||||
return;
|
||||
}
|
||||
countdownWrap.classList.remove("hidden");
|
||||
@@ -222,24 +412,33 @@ export function clearCooldown() {
|
||||
export function draw() {
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
ctx.fillStyle = COLOR_OUTSIDE;
|
||||
|
||||
// 1. Dark base fill (shows in areas outside the SVG paths)
|
||||
ctx.fillStyle = "#050810";
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 2. Draw the SVG playfield as the background layer
|
||||
if (playfieldImg) {
|
||||
ctx.drawImage(playfieldImg, 0, 0, w, h);
|
||||
}
|
||||
|
||||
const cw = w / GRID_W;
|
||||
const ch = h / GRID_H;
|
||||
|
||||
// 3. Draw game tile overlays (only for exploitable tiles)
|
||||
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; }
|
||||
if (!isExploitable(x, y)) 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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Draw planet dots on own discovered tiles
|
||||
for (const [key, meta] of cells) {
|
||||
if (meta.discoveredBy !== currentTeam) continue;
|
||||
const [xs, ys] = key.split(",").map(Number);
|
||||
@@ -253,6 +452,7 @@ export function draw() {
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 5. Grid lines between adjacent exploitable tiles
|
||||
ctx.strokeStyle = "rgba(255,255,255,0.10)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
@@ -369,6 +569,7 @@ async function onCanvasClick(ev) {
|
||||
if (!res.ok) throw new Error("reveal");
|
||||
applyRevealPayload(await res.json());
|
||||
startCooldown();
|
||||
updateEconomyDisplay();
|
||||
draw();
|
||||
fetchAndApplyScores();
|
||||
} catch (e) {
|
||||
@@ -385,6 +586,18 @@ export function updateTeamSegmented() {
|
||||
teamRedBtn.setAttribute("aria-pressed", currentTeam === "red" ? "true" : "false");
|
||||
}
|
||||
|
||||
// ── Lightweight grid refresh (called every second) ────────────────────────────
|
||||
|
||||
/** Fetches the current grid and redraws without touching config or scores. */
|
||||
export async function refreshGridDisplay() {
|
||||
try {
|
||||
await fetchGridForSeed(seedStr);
|
||||
updateEconomyDisplay();
|
||||
draw();
|
||||
refreshCursorFromLast();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Full server refresh ───────────────────────────────────────────────────────
|
||||
// Exported so auth.js / main.js can call it after login or on timer.
|
||||
|
||||
@@ -393,12 +606,15 @@ export async function refreshFromServer() {
|
||||
const seedChanged = await fetchConfig();
|
||||
if (seedChanged) {
|
||||
clearCooldown();
|
||||
resetEconScores();
|
||||
loadEconScores();
|
||||
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.";
|
||||
hint.textContent = "World period changed — grid reset. Click a cell in the playfield.";
|
||||
}
|
||||
await fetchGridForSeed(seedStr);
|
||||
await fetchAndApplyScores();
|
||||
updateEconomyDisplay();
|
||||
draw();
|
||||
refreshCursorFromLast();
|
||||
} catch {
|
||||
@@ -408,9 +624,25 @@ export async function refreshFromServer() {
|
||||
|
||||
// ── Event listeners ───────────────────────────────────────────────────────────
|
||||
|
||||
// ── Resource table sort (delegated, set up once) ──────────────────────────────
|
||||
|
||||
resourceTableEl?.addEventListener("click", (ev) => {
|
||||
const th = ev.target.closest("th[data-sort-col]");
|
||||
if (!th) return;
|
||||
const col = Number(th.dataset.sortCol);
|
||||
const { col: curCol, dir: curDir } = getEconSort();
|
||||
// Toggle direction if same column; otherwise default to desc for numeric cols, asc for text
|
||||
const newDir = col === curCol
|
||||
? (curDir === "asc" ? "desc" : "asc")
|
||||
: (col === 2 || col === 3 ? "desc" : "asc");
|
||||
setEconSort(col, newDir);
|
||||
updateEconomyDisplay();
|
||||
});
|
||||
|
||||
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(); });
|
||||
// Team switcher buttons (kept in codebase, only functional when admin-unlocked)
|
||||
teamBlueBtn.addEventListener("click", () => { setCurrentTeam("blue"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });
|
||||
teamRedBtn.addEventListener( "click", () => { setCurrentTeam("red"); updateTeamSegmented(); updateEconomyDisplay(); draw(); refreshCursorFromLast(); });
|
||||
|
||||
@@ -6,8 +6,14 @@ import {
|
||||
fetchConfig,
|
||||
fetchGridForSeed,
|
||||
fetchAndApplyScores,
|
||||
updateEconomyDisplay,
|
||||
tickEconScore,
|
||||
loadEconScores,
|
||||
refreshFromServer,
|
||||
refreshGridDisplay,
|
||||
loadPlayfieldMask,
|
||||
draw,
|
||||
unlockTeamSwitcher,
|
||||
} from "./game.js";
|
||||
|
||||
import {
|
||||
@@ -18,9 +24,14 @@ import {
|
||||
|
||||
// ── DOM refs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const refreshBtn = document.getElementById("refreshBtn");
|
||||
const hint = document.getElementById("hint");
|
||||
const cooldownEl = document.getElementById("cooldownConfig");
|
||||
const hint = document.getElementById("hint");
|
||||
const cooldownEl = document.getElementById("cooldownConfig");
|
||||
const burgerBtn = document.getElementById("burgerBtn");
|
||||
const closeMenuBtn = document.getElementById("closeMenuBtn");
|
||||
const infoColumn = document.getElementById("infoColumn");
|
||||
const adminPasswordIn = document.getElementById("adminPasswordInput");
|
||||
const adminUnlockBtn = document.getElementById("adminUnlockBtn");
|
||||
const adminStatus = document.getElementById("adminStatus");
|
||||
|
||||
// ── Polling ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -40,19 +51,87 @@ function scheduleConfigPoll() {
|
||||
}, ms);
|
||||
}
|
||||
|
||||
const ECON_TICK_SECONDS = 5;
|
||||
|
||||
function scheduleScorePoll() {
|
||||
clearTimeout(scorePollTimer);
|
||||
scorePollTimer = window.setTimeout(async () => {
|
||||
await fetchAndApplyScores();
|
||||
tickEconScore(ECON_TICK_SECONDS);
|
||||
scheduleScorePoll();
|
||||
}, 5_000);
|
||||
}, ECON_TICK_SECONDS * 1_000);
|
||||
}
|
||||
|
||||
// ── Burger / mobile menu ──────────────────────────────────────────────────────
|
||||
|
||||
function openMenu() {
|
||||
infoColumn.classList.add("infoColumn--open");
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
infoColumn.classList.remove("infoColumn--open");
|
||||
}
|
||||
|
||||
burgerBtn.addEventListener("click", openMenu);
|
||||
closeMenuBtn.addEventListener("click", closeMenu);
|
||||
|
||||
// Close when clicking outside the panel (on the galaxy overlay)
|
||||
document.addEventListener("click", (ev) => {
|
||||
if (
|
||||
infoColumn.classList.contains("infoColumn--open") &&
|
||||
!infoColumn.contains(ev.target) &&
|
||||
ev.target !== burgerBtn
|
||||
) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Admin password unlock ─────────────────────────────────────────────────────
|
||||
|
||||
function showAdminStatus(message, isOk) {
|
||||
adminStatus.textContent = message;
|
||||
adminStatus.className = "adminStatus " + (isOk ? "adminStatus--ok" : "adminStatus--err");
|
||||
}
|
||||
|
||||
adminUnlockBtn.addEventListener("click", async () => {
|
||||
const password = adminPasswordIn.value.trim();
|
||||
if (!password) {
|
||||
showAdminStatus("Enter a password.", false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/admin/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
showAdminStatus("Admin unlocked — team switcher enabled.", true);
|
||||
unlockTeamSwitcher();
|
||||
adminPasswordIn.value = "";
|
||||
} else {
|
||||
showAdminStatus("Invalid password.", false);
|
||||
}
|
||||
} catch {
|
||||
showAdminStatus("Could not verify — server unreachable.", false);
|
||||
}
|
||||
});
|
||||
|
||||
// Allow Enter key in password field
|
||||
adminPasswordIn.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") adminUnlockBtn.click();
|
||||
});
|
||||
|
||||
// ── Boot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function boot() {
|
||||
updateTeamSegmented();
|
||||
|
||||
// Load the SVG playfield mask before any drawing or data fetch
|
||||
await loadPlayfieldMask();
|
||||
|
||||
const restored = await tryRestoreSession();
|
||||
if (!restored) {
|
||||
showAuthOverlay();
|
||||
@@ -64,6 +143,8 @@ async function boot() {
|
||||
await fetchConfig();
|
||||
await fetchGridForSeed(seedStr);
|
||||
await fetchAndApplyScores();
|
||||
await loadEconScores();
|
||||
updateEconomyDisplay();
|
||||
} catch {
|
||||
hint.textContent = "API unavailable — start the Node server (docker-compose up --build).";
|
||||
cooldownEl.textContent = "?";
|
||||
@@ -77,12 +158,11 @@ async function boot() {
|
||||
|
||||
scheduleConfigPoll();
|
||||
scheduleScorePoll();
|
||||
|
||||
// Refresh grid every second so all clients see new tiles promptly
|
||||
setInterval(refreshGridDisplay, 1_000);
|
||||
}
|
||||
|
||||
// ── Global event listeners ────────────────────────────────────────────────────
|
||||
|
||||
refreshBtn.addEventListener("click", () => refreshFromServer());
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
boot();
|
||||
Reference in New Issue
Block a user