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();