Files
Battle-for-Tismo/client/js/app.js
T
adrien f3e94a3d53 Added the devlog, fixed the map labels and reworked joining
- Political map country names were invisible because the label SVG had a zero-sized viewport, which some browsers never paint; size it to the world and guard it with a test.
- Added a Devlog button showing the ten latest commits, generated into client/js/devlog_data.js by scripts/generate-devlog.js and refreshed by the pre-commit hook.
- Worked through FIXME.md: the city button and a unit double-click now open the city, and the stat tooltip escapes the HUD so the unit panel cannot cover it.
- The server creates its game at startup and clients only join it; the Create game screen and the configure_game path are gone, and testing is a --testing server flag.
- A page on a loopback host signs in as adrien, claims France and gets the free, instant testing buttons, while the server grants free orders only to a loopback socket whose Origin is also loopback.
2026-09-21 23:08:05 +02:00

304 lines
9.7 KiB
JavaScript

// Application bootstrap: the main menu and the join flow. A page served from
// the local machine signs in as the developer and claims France without showing
// the join form, and the server grants it the testing shortcuts.
import { Network } from "./net.js";
import { GameScreen } from "./game_screen.js";
import { bootLog } from "./loading.js";
import { audio } from "./audio.js";
import { renderCredits } from "./credits.js";
import { DEVLOG } from "./devlog_data.js";
import { renderDevlog } from "./devlog.js";
import { chooseMenuBackground } from "./menu_background.js";
import { CIVILISATIONS } from "../../shared/data.js";
const network = new Network();
// A page on the local machine signs in automatically. The server independently
// checks the connection's origin before honouring any free order.
function isLoopbackHost(hostname) {
const host = String(hostname || "").replace(/^\[|\]$/g, "");
return host === "localhost" || host === "::1" || host.startsWith("127.");
}
const isLocalhost = isLoopbackHost(window.location.hostname);
const LOCAL_NAME = "adrien";
const LOCAL_PASSWORD = "admin";
const LOCAL_CIV = "france";
const config = {
playerName: "",
password: "",
// Shows the free, instant testing buttons in the city and tile panels.
testing: isLocalhost,
};
let gameScreen = null;
let joining = false;
let games = [];
let selectedGame = -1;
function showScreen(id) {
$(".screen").removeClass("active");
$(`#${id}`).addClass("active");
}
function status(id, message) {
$(`#${id}`).text(message);
}
// ---------------------------------------------------------- appearance ----
// Two pieces of key art ship; pick one for this launch. It backs the main menu
// and stays behind the boot log while a world loads.
chooseMenuBackground();
// -------------------------------------------------------------- audio -----
// The sound toggle appears both on the main menu and in the in-game dock, so
// both buttons are relabelled whenever the setting changes.
function refreshAudioButtons() {
$("#menu-audio").text(audio.enabled ? "Sound: On" : "Sound: Off");
$("#audio-button").text(audio.enabled ? "Sound on" : "Sound off");
}
audio.onChange = refreshAudioButtons;
refreshAudioButtons();
// Every button in the game clicks; the manager ignores the call when muted.
$(document).on("click", "button", () => audio.play("click"));
$("#menu-audio, #audio-button").on("click", () => audio.toggle());
// ------------------------------------------------------------ credits -----
renderCredits($("#credits-list"));
const $creditsModal = $("#modal-credits");
$("#menu-credits").on("click", () => $creditsModal.removeClass("hidden"));
$creditsModal.find(".modal-close").on("click", () => $creditsModal.addClass("hidden"));
// ------------------------------------------------------------- devlog -----
renderDevlog($("#devlog-list"), DEVLOG);
const $devlogModal = $("#modal-devlog");
$("#menu-devlog").on("click", () => $devlogModal.removeClass("hidden"));
$devlogModal.find(".modal-close").on("click", () => $devlogModal.addClass("hidden"));
$(document).on("keydown", (event) => {
if (event.key !== "Escape") return;
$creditsModal.addClass("hidden");
$devlogModal.addClass("hidden");
});
// -------------------------------------------------------------- menu ------
// The server creates its game at startup, so the menu only has Join. A page on
// the local machine skips the form and signs in directly.
$("#menu-join").on("click", () => {
if (isLocalhost) {
autoJoinLocal();
return;
}
showScreen("screen-join");
refreshGames();
});
// -------------------------------------------------------------- join ------
$("#join-server").val(network.defaultServerUrl());
$("#join-refresh").on("click", () => refreshGames());
$("#join-back").on("click", () => {
network.close();
showScreen("screen-menu");
});
$("#join-go").on("click", joinSelected);
$("#choose-back").on("click", () => {
network.close();
showScreen("screen-join");
setJoinEnabled(true);
});
function joinServerUrl() {
return network.normalizeUrl($("#join-server").val());
}
function refreshGames(message = "") {
joining = false;
games = [];
selectedGame = -1;
$("#join-games").empty();
setJoinEnabled(false);
status("join-status", message || "Looking for games...");
network.requestGameList(joinServerUrl()).catch(() => {
status("join-status", "Could not reach the game server.");
setJoinEnabled(true);
});
}
function setJoinEnabled(enabled) {
$("#join-server, #join-name, #join-password").prop("disabled", !enabled);
$("#join-refresh, #join-go").prop("disabled", !enabled);
}
function renderGames(list) {
games = list;
selectedGame = -1;
const $list = $("#join-games").empty();
for (let i = 0; i < list.length; i++) {
const game = list[i];
const parts = [game.name || "Game", game.date || "?", `${game.players || 0} player(s)`];
if (game.civs && game.civs.length) parts.push(game.civs.join(", "));
const $item = $("<li></li>").text(parts.join(" — "));
$item.on("click", () => {
selectedGame = i;
$("#join-games li").removeClass("selected");
$item.addClass("selected");
});
$list.append($item);
}
if (list.length === 0) {
status("join-status", "No game in progress on this server.");
} else {
selectedGame = 0;
$list.children().first().addClass("selected");
status("join-status", "Select a game and enter a name and a password.");
}
setJoinEnabled(true);
}
function joinSelected() {
const name = $("#join-name").val().trim();
const password = $("#join-password").val();
if (!name || !password) {
status("join-status", "Enter a name and a password.");
return;
}
if (selectedGame < 0) {
status("join-status", "Select a game to join.");
return;
}
config.playerName = name;
config.password = password;
joining = true;
setJoinEnabled(false);
status("join-status", "Connecting...");
network.joinGame(joinServerUrl(), name, password).catch(() => {
status("join-status", "Could not reach the game server.");
setJoinEnabled(true);
});
}
// The local fast path: no server list, no login form and no nation picker. The
// server still decides what a local peer is allowed to do.
function autoJoinLocal() {
config.playerName = LOCAL_NAME;
config.password = LOCAL_PASSWORD;
joining = true;
status("join-status", "Connecting...");
network.joinGame(network.defaultServerUrl(), LOCAL_NAME, LOCAL_PASSWORD).catch(() => {
fallBackToJoin("Could not reach the game server.");
});
}
// The local fast path failed (a name claimed with a different password, a busy
// server): show the ordinary join screen so it can be fixed by hand.
function fallBackToJoin(message) {
config.testing = false;
joining = false;
showScreen("screen-join");
refreshGames(message);
}
// ------------------------------------------------------------ sessions ----
network.on("session_started", () => {
// The server follows up with the unclaimed civilisations to pick from.
status("join-status", "");
});
// Offer the unclaimed civilisations; picking one claims it for this player. A
// local page claims France (or the first free nation) without showing a picker.
network.on("choose_civ_received", (civs) => {
if (config.testing) {
const pick = (civs || []).find((c) => c.id === LOCAL_CIV) || (civs || [])[0];
if (pick) {
network.claimCiv(pick.index);
return;
}
status("choose-status", "No unclaimed nation left — joining as a spectator.");
startGameScreen();
return;
}
if (!civs || civs.length === 0) {
status("choose-status", "No unclaimed nation left — joining as a spectator.");
startGameScreen();
return;
}
renderCivChoices(civs);
showScreen("screen-choose");
});
network.on("civ_assigned", () => {
if (gameScreen === null) {
status("choose-status", "");
startGameScreen();
}
});
function civOption(civ) {
return $("<li class='civ-option'></li>")
.attr("data-civ-id", civ.id)
.append($("<img alt=''>").attr("src", `assets/flags/${civ.flag}`))
.append($("<span></span>").text(civ.name));
}
function renderCivChoices(civs) {
const $list = $("#choose-civs").empty();
for (const entry of civs) {
const civ = CIVILISATIONS.find((c) => c.id === entry.id) || entry;
const $item = civOption(civ);
$item.on("click", () => {
$list.children().removeClass("selected");
$item.addClass("selected");
config.playerCivId = entry.id;
status("choose-status", "Claiming...");
network.claimCiv(entry.index);
});
$list.append($item);
}
}
network.on("session_failed", (message) => {
if (isLocalhost && config.testing) {
fallBackToJoin(message);
return;
}
setJoinEnabled(true);
status("join-status", message);
if (joining) refreshGames(message);
});
network.on("game_list_received", (list) => renderGames(list));
network.on("game_state_received", (state) => {
if (gameScreen) gameScreen.onState(state);
});
network.on("server_disconnected", () => {
$("#disconnect-banner").text("Disconnected from the server.").removeClass("hidden");
});
function startGameScreen() {
if (gameScreen) {
gameScreen.leave();
$(window).off("keydown.game");
}
$("#disconnect-banner").addClass("hidden");
// Start the boot log before the screen is built, so the WebGL context, shader
// compile and texture load are timed and shown alongside the world generation.
bootLog.bind($("#loading-overlay")).begin();
gameScreen = new GameScreen(network, config);
gameScreen.beginLoading();
showScreen("screen-game");
gameScreen.enter();
bootLog.step("Opened the game screen");
bootLog.step("Waiting for the world from the server");
}
// Reconnect notice clears once the player navigates back to the menu.
$("#menu-join").on("click", () => $("#disconnect-banner").addClass("hidden"));