2026-05-14 20:15:23 +09:00
|
|
|
function requestRender() {
|
|
|
|
|
renderDirty = true;
|
|
|
|
|
}
|
|
|
|
|
function drawFrame(forceStats = false) {
|
|
|
|
|
render();
|
|
|
|
|
updateStats(forceStats);
|
|
|
|
|
renderTooltip();
|
|
|
|
|
renderDirty = false;
|
|
|
|
|
lastRenderAt = performance.now();
|
2026-09-16 20:53:17 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
}
|
|
|
|
|
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);
|
2026-09-16 20:53:17 +09:00
|
|
|
const stepBudget = LoopConfig.stepBudgetMs;
|
2026-05-14 20:15:23 +09:00
|
|
|
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();
|
|
|
|
|
}
|
2026-09-16 20:53:17 +09:00
|
|
|
const shouldRender = !document.hidden && renderDirty &&
|
|
|
|
|
(!running || now - lastRenderAt >= LoopConfig.renderMs);
|
2026-05-14 20:15:23 +09:00
|
|
|
if (shouldRender) drawFrame();
|
|
|
|
|
scheduleLoop();
|
|
|
|
|
}
|
2026-09-16 20:53:17 +09:00
|
|
|
|
|
|
|
|
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 --";
|
2026-05-14 20:15:23 +09:00
|
|
|
els.canvas.width = size;
|
|
|
|
|
els.canvas.height = size;
|
|
|
|
|
renderImage = null;
|
|
|
|
|
renderImageSize = 0;
|
2026-09-16 13:51:43 +09:00
|
|
|
sim = new Simulation(size, initialPopulation);
|
2026-09-16 20:53:17 +09:00
|
|
|
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("ボタンを選び、地図上をクリックして実行します。");
|
2026-05-14 20:15:23 +09:00
|
|
|
setLegend();
|
|
|
|
|
drawFrame(true);
|
|
|
|
|
renderStateGraph();
|
|
|
|
|
}
|
2026-09-16 20:53:17 +09:00
|
|
|
function openRegenerateDialog() {
|
|
|
|
|
if (els.worldSize && sim) els.worldSize.value = String(sim.world.size);
|
|
|
|
|
if (els.regenerateDialog?.showModal) els.regenerateDialog.showModal();
|
|
|
|
|
}
|
2026-05-14 20:15:23 +09:00
|
|
|
function refreshStateGraph() {
|
|
|
|
|
renderStateGraph();
|
|
|
|
|
clearStateGraphInfo();
|
|
|
|
|
}
|
|
|
|
|
els.toggleRun.addEventListener("click", () => {
|
|
|
|
|
running = !running;
|
2026-09-16 20:53:17 +09:00
|
|
|
els.toggleRun.textContent = running ? "一時停止" : "再開";
|
2026-05-14 20:15:23 +09:00
|
|
|
lastSimTickAt = 0;
|
|
|
|
|
if (running && loopTimer) {
|
|
|
|
|
clearTimeout(loopTimer);
|
|
|
|
|
loopTimer = null;
|
|
|
|
|
loopScheduled = false;
|
|
|
|
|
scheduleLoop();
|
|
|
|
|
} else if (running) {
|
|
|
|
|
scheduleLoop();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-09-16 20:53:17 +09:00
|
|
|
els.resetWorld.addEventListener("click", openRegenerateDialog);
|
|
|
|
|
els.confirmRegenerate?.addEventListener("click", () => {
|
|
|
|
|
const size = Number(els.worldSize?.value || SimConfig.world?.defaultSize || 160);
|
|
|
|
|
els.regenerateDialog?.close();
|
|
|
|
|
reset(size);
|
2026-05-14 20:15:23 +09:00
|
|
|
});
|
2026-09-16 20:53:17 +09:00
|
|
|
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"));
|
2026-05-14 20:15:23 +09:00
|
|
|
els.viewMode.addEventListener("change", () => {
|
|
|
|
|
setLegend();
|
|
|
|
|
requestRender();
|
|
|
|
|
drawFrame();
|
|
|
|
|
});
|
|
|
|
|
els.canvas.addEventListener("mousemove", showTooltip);
|
|
|
|
|
els.canvas.addEventListener("mouseleave", hideTooltip);
|
2026-09-16 20:53:17 +09:00
|
|
|
els.canvas.addEventListener("click", handleMapIntervention);
|
2026-05-14 20:15:23 +09:00
|
|
|
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();
|
|
|
|
|
});
|
2026-09-16 20:53:17 +09:00
|
|
|
els.toggleEventIcons?.addEventListener("click", () => {
|
|
|
|
|
graphState.showEventIcons = !graphState.showEventIcons;
|
|
|
|
|
els.toggleEventIcons.setAttribute("aria-pressed", String(graphState.showEventIcons));
|
|
|
|
|
refreshStateGraph();
|
|
|
|
|
});
|
2026-05-14 20:15:23 +09:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-09-16 20:53:17 +09:00
|
|
|
reset(SimConfig.world?.defaultSize ?? 160);
|
2026-05-14 20:15:23 +09:00
|
|
|
loop();
|