civ/boot.js
2026-09-16 20:53:17 +09:00

228 lines
8.3 KiB
JavaScript

function requestRender() {
renderDirty = true;
}
function drawFrame(forceStats = false) {
render();
updateStats(forceStats);
renderTooltip();
renderDirty = false;
lastRenderAt = performance.now();
if (!fpsWindowStartedAt) fpsWindowStartedAt = lastRenderAt;
fpsFrames++;
const fpsElapsed = lastRenderAt - fpsWindowStartedAt;
if (fpsElapsed >= 500) {
if (els.fpsCounter) els.fpsCounter.textContent = `FPS ${Math.round(fpsFrames * 1000 / fpsElapsed)}`;
fpsWindowStartedAt = lastRenderAt;
fpsFrames = 0;
}
}
function scheduleLoop() {
if (loopScheduled) return;
loopScheduled = true;
const delay = document.hidden
? LoopConfig.hiddenPollMs
: running ? 0 : LoopConfig.idlePollMs;
if (delay > 0) {
loopTimer = setTimeout(() => {
loopTimer = null;
requestAnimationFrame(loop);
}, delay);
} else {
requestAnimationFrame(loop);
}
}
function loop(now = performance.now()) {
loopScheduled = false;
if (running && !document.hidden && now - lastSimTickAt >= LoopConfig.simTickMs) {
lastSimTickAt = now;
const steps = Number(els.speed.value);
const stepBudget = LoopConfig.stepBudgetMs;
const loopStart = performance.now();
let completedSteps = 0;
for (let i = 0; i < steps; i++) {
sim.step();
completedSteps++;
if (completedSteps > 0 && performance.now() - loopStart > stepBudget) break;
}
if (completedSteps > 0) requestRender();
}
const shouldRender = !document.hidden && renderDirty &&
(!running || now - lastRenderAt >= LoopConfig.renderMs);
if (shouldRender) drawFrame();
scheduleLoop();
}
let interventionMode = null;
let manualRouteStartCityId = null;
function setInterventionStatus(text) {
if (els.interventionStatus) els.interventionStatus.textContent = text;
}
function setInterventionMode(mode) {
interventionMode = interventionMode === mode ? null : mode;
manualRouteStartCityId = null;
const buttons = [
[els.triggerDisaster, "disaster"],
[els.buildCity, "city"],
[els.connectCities, "route"]
];
for (const [button, buttonMode] of buttons) button?.setAttribute("aria-pressed", String(interventionMode === buttonMode));
if (interventionMode === "disaster") setInterventionStatus("災害を発生させる地点を地図上でクリックしてください。");
else if (interventionMode === "city") setInterventionStatus("都市を建設する地点を地図上でクリックしてください。");
else if (interventionMode === "route") setInterventionStatus("接続する1つ目の都市をクリックしてください。");
else setInterventionStatus("ボタンを選び、地図上をクリックして実行します。");
}
function eventToWorldCoordinates(event) {
const rect = els.canvas.getBoundingClientRect();
return {
x: Math.floor((event.clientX - rect.left) / rect.width * sim.world.size),
y: Math.floor((event.clientY - rect.top) / rect.height * sim.world.size)
};
}
function handleMapIntervention(event) {
if (!interventionMode || !sim) return;
const { x, y } = eventToWorldCoordinates(event);
if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) return;
if (interventionMode === "disaster") {
const result = sim.triggerManualDisasterAt(x, y);
setInterventionStatus(`災害を発生させました(中心 ${result.x}, ${result.y}/半径 ${Math.round(result.radius)})。続けて地点をクリックできます。`);
requestRender();
drawFrame(true);
renderStateGraph();
return;
}
if (interventionMode === "city") {
const result = sim.buildCityAt(x, y);
if (!result.ok) {
setInterventionStatus(result.reason === "water"
? "水域には都市を建設できません。陸地をクリックしてください。"
: "この地点には都市を建設できません。");
return;
}
setInterventionStatus(`都市${result.city.id}を建設しました(${result.city.x}, ${result.city.y})。続けて建設地点をクリックできます。`);
requestRender();
drawFrame(true);
renderStateGraph();
return;
}
if (interventionMode === "route") {
const city = cityAtDisplayTile(x, y);
if (!city) {
setInterventionStatus("都市をクリックしてください。都市アイコンの範囲内で選択できます。");
return;
}
if (manualRouteStartCityId == null) {
manualRouteStartCityId = city.id;
setInterventionStatus(`都市${city.id}を選択しました。接続する2つ目の都市をクリックしてください。`);
return;
}
if (manualRouteStartCityId === city.id) {
setInterventionStatus("同じ都市は接続できません。別の都市をクリックしてください。");
return;
}
const startId = manualRouteStartCityId;
const result = sim.createManualTradeRoute(startId, city.id);
manualRouteStartCityId = null;
if (!result.ok) {
const message = result.reason === "tooFar" ? `都市間が遠すぎます(上限 ${result.maxLength} タイル)。`
: result.reason === "noPath" ? "陸路で接続できる経路が見つかりませんでした。"
: "交通接続を作成できませんでした。";
setInterventionStatus(`${message} 1つ目の都市から選び直してください。`);
return;
}
setInterventionStatus(`都市${startId}と都市${city.id}を接続しました。続けて1つ目の都市をクリックできます。`);
requestRender();
drawFrame(true);
}
}
function reset(size = SimConfig.world?.defaultSize ?? 160) {
const initialPopulation = SimConfig.world?.initialPopulation ?? 50000;
fpsWindowStartedAt = 0;
fpsFrames = 0;
if (els.fpsCounter) els.fpsCounter.textContent = "FPS --";
els.canvas.width = size;
els.canvas.height = size;
renderImage = null;
renderImageSize = 0;
sim = new Simulation(size, initialPopulation);
if (els.worldSize) els.worldSize.value = String(size);
interventionMode = null;
manualRouteStartCityId = null;
for (const button of [els.triggerDisaster, els.buildCity, els.connectCities]) button?.setAttribute("aria-pressed", "false");
setInterventionStatus("ボタンを選び、地図上をクリックして実行します。");
setLegend();
drawFrame(true);
renderStateGraph();
}
function openRegenerateDialog() {
if (els.worldSize && sim) els.worldSize.value = String(sim.world.size);
if (els.regenerateDialog?.showModal) els.regenerateDialog.showModal();
}
function refreshStateGraph() {
renderStateGraph();
clearStateGraphInfo();
}
els.toggleRun.addEventListener("click", () => {
running = !running;
els.toggleRun.textContent = running ? "一時停止" : "再開";
lastSimTickAt = 0;
if (running && loopTimer) {
clearTimeout(loopTimer);
loopTimer = null;
loopScheduled = false;
scheduleLoop();
} else if (running) {
scheduleLoop();
}
});
els.resetWorld.addEventListener("click", openRegenerateDialog);
els.confirmRegenerate?.addEventListener("click", () => {
const size = Number(els.worldSize?.value || SimConfig.world?.defaultSize || 160);
els.regenerateDialog?.close();
reset(size);
});
els.cancelRegenerate?.addEventListener("click", () => els.regenerateDialog?.close());
els.triggerDisaster?.addEventListener("click", () => setInterventionMode("disaster"));
els.buildCity?.addEventListener("click", () => setInterventionMode("city"));
els.connectCities?.addEventListener("click", () => setInterventionMode("route"));
els.viewMode.addEventListener("change", () => {
setLegend();
requestRender();
drawFrame();
});
els.canvas.addEventListener("mousemove", showTooltip);
els.canvas.addEventListener("mouseleave", hideTooltip);
els.canvas.addEventListener("click", handleMapIntervention);
for (const control of [els.historyFilter, els.historySort, els.historyMetric]) {
control?.addEventListener("change", refreshStateGraph);
}
els.toggleActiveStates?.addEventListener("click", () => {
graphState.showActive = !graphState.showActive;
els.toggleActiveStates.setAttribute("aria-pressed", String(graphState.showActive));
refreshStateGraph();
});
els.togglePastStates?.addEventListener("click", () => {
graphState.showPast = !graphState.showPast;
els.togglePastStates.setAttribute("aria-pressed", String(graphState.showPast));
refreshStateGraph();
});
els.toggleEventIcons?.addEventListener("click", () => {
graphState.showEventIcons = !graphState.showEventIcons;
els.toggleEventIcons.setAttribute("aria-pressed", String(graphState.showEventIcons));
refreshStateGraph();
});
els.stateGraph?.addEventListener("mousemove", updateStateGraphInfo);
els.stateGraph?.addEventListener("mouseleave", clearStateGraphInfo);
document.addEventListener("visibilitychange", () => {
lastSimTickAt = 0;
requestRender();
if (!document.hidden && loopTimer) {
clearTimeout(loopTimer);
loopTimer = null;
loopScheduled = false;
scheduleLoop();
}
});
reset(SimConfig.world?.defaultSize ?? 160);
loop();