292 lines
11 KiB
JavaScript
292 lines
11 KiB
JavaScript
"use strict";
|
||
|
||
const managedDialogOrigins = new WeakMap();
|
||
|
||
function managedDialogFocusable(dialog) {
|
||
if (!dialog) return [];
|
||
return [...dialog.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')]
|
||
.filter(el => !el.hidden && el.getClientRects().length > 0);
|
||
}
|
||
|
||
function openManagedDialog(dialog, opener = document.activeElement) {
|
||
if (!dialog) return;
|
||
if (opener instanceof HTMLElement && !dialog.contains(opener)) managedDialogOrigins.set(dialog, opener);
|
||
if (!dialog.hasAttribute("tabindex")) dialog.tabIndex = -1;
|
||
dialog.classList.remove("hidden");
|
||
dialog.setAttribute("aria-hidden", "false");
|
||
requestAnimationFrame(() => {
|
||
const focusables = managedDialogFocusable(dialog);
|
||
(focusables[0] || dialog).focus?.({ preventScroll: true });
|
||
});
|
||
}
|
||
|
||
function closeManagedDialog(dialog) {
|
||
if (!dialog) return;
|
||
dialog.classList.add("hidden");
|
||
dialog.setAttribute("aria-hidden", "true");
|
||
const origin = managedDialogOrigins.get(dialog);
|
||
managedDialogOrigins.delete(dialog);
|
||
if (origin?.isConnected) requestAnimationFrame(() => origin.focus?.({ preventScroll: true }));
|
||
}
|
||
|
||
function managedDialogVisible(dialog) {
|
||
if (!dialog || dialog.hidden || dialog.getAttribute("aria-hidden") === "true") return false;
|
||
if (dialog.classList.contains("hidden") || dialog.closest(".hidden")) return false;
|
||
return dialog.getClientRects().length > 0;
|
||
}
|
||
|
||
function topVisibleDialog() {
|
||
const dialogs = [...document.querySelectorAll('[role="dialog"], [role="alertdialog"]')].filter(managedDialogVisible);
|
||
return dialogs[dialogs.length - 1] || null;
|
||
}
|
||
|
||
document.addEventListener("keydown", (event) => {
|
||
const dialog = topVisibleDialog();
|
||
if (!dialog) return;
|
||
if (event.key === "Escape") {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
dialog.dispatchEvent(new CustomEvent("tarinai:dialog-close-request"));
|
||
return;
|
||
}
|
||
if (event.key !== "Tab") return;
|
||
const focusables = managedDialogFocusable(dialog);
|
||
if (!focusables.length) {
|
||
event.preventDefault();
|
||
dialog.focus?.();
|
||
return;
|
||
}
|
||
const first = focusables[0];
|
||
const last = focusables[focusables.length - 1];
|
||
if (event.shiftKey && document.activeElement === first) {
|
||
event.preventDefault();
|
||
last.focus();
|
||
} else if (!event.shiftKey && document.activeElement === last) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
} else if (!dialog.contains(document.activeElement)) {
|
||
event.preventDefault();
|
||
first.focus();
|
||
}
|
||
}, true);
|
||
|
||
window.TarinaiManagedDialogs = Object.freeze({ open: openManagedDialog, close: closeManagedDialog });
|
||
|
||
function resizeCanvas() {
|
||
const rect = canvas.getBoundingClientRect();
|
||
const perfScale = window.TarinaiPerf.dprScale();
|
||
const dpr = Math.max(0.5, Math.min((window.devicePixelRatio || 1) * perfScale, 2));
|
||
const nextW = Math.max(1, Math.floor(rect.width * dpr));
|
||
const nextH = Math.max(1, Math.floor(rect.height * dpr));
|
||
if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;
|
||
uiCache.canvasDpr = dpr;
|
||
canvas.width = nextW;
|
||
canvas.height = nextH;
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
ctx.fillStyle = "#cfe3d3";
|
||
ctx.fillRect(0, 0, Math.max(1, rect.width), Math.max(1, rect.height));
|
||
world.setViewportSize(Math.max(1, rect.width), Math.max(1, rect.height), { resizeWorld: true, scaleContents: true });
|
||
uiCache.lastChartDraw = "";
|
||
if (typeof render === "function") render();
|
||
}
|
||
|
||
function applyFieldLayout(fieldType = world.fieldType || "garden") {
|
||
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
|
||
const layout = FIELD_LAYOUT;
|
||
const root = document.documentElement;
|
||
root.style.setProperty("--field-height", layout.height);
|
||
root.style.setProperty("--field-min-height", layout.minHeight);
|
||
root.style.setProperty("--field-mobile-height", layout.mobileHeight);
|
||
root.style.setProperty("--field-mobile-min-height", layout.mobileMinHeight);
|
||
world.fieldType = field.id;
|
||
const rect = canvas.getBoundingClientRect();
|
||
world.setViewportSize(rect.width, rect.height, { resizeWorld: true, scaleContents: true });
|
||
resizeCanvas();
|
||
}
|
||
|
||
function normalizeResetPresetId(presetId = "default") {
|
||
if (window.TarinaiResetPresets?.normalize) return window.TarinaiResetPresets.normalize(presetId);
|
||
return new Set(["empty", "default", "athletic", "crowded", "family", "war", "happy", "hair_trigger"]).has(String(presetId || "")) ? String(presetId) : "default";
|
||
}
|
||
|
||
function syncFieldDialogChoices() {
|
||
const fieldType = FIELD_TYPES?.[uiCache.resetFieldType]?.id || world?.fieldType || "garden";
|
||
const presetId = normalizeResetPresetId(uiCache.resetPreset || "default");
|
||
uiCache.resetFieldType = fieldType;
|
||
uiCache.resetPreset = presetId;
|
||
ui.fieldDialog?.querySelectorAll("[data-field-type]").forEach((btn) => {
|
||
const active = btn.dataset.fieldType === fieldType;
|
||
btn.classList.toggle("active", active);
|
||
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
||
});
|
||
ui.fieldDialog?.querySelectorAll("[data-reset-preset]").forEach((btn) => {
|
||
const active = btn.dataset.resetPreset === presetId;
|
||
btn.classList.toggle("active", active);
|
||
btn.setAttribute("aria-pressed", active ? "true" : "false");
|
||
});
|
||
}
|
||
|
||
function selectResetFieldType(fieldType = "garden") {
|
||
uiCache.resetFieldType = FIELD_TYPES?.[fieldType]?.id || "garden";
|
||
syncFieldDialogChoices();
|
||
}
|
||
|
||
function selectResetPreset(presetId = "default") {
|
||
uiCache.resetPreset = normalizeResetPresetId(presetId);
|
||
syncFieldDialogChoices();
|
||
}
|
||
|
||
function openFieldDialog() {
|
||
uiCache.resetFieldType = FIELD_TYPES?.[world?.fieldType]?.id || uiCache.resetFieldType || "garden";
|
||
uiCache.resetPreset = normalizeResetPresetId(uiCache.resetPreset || "default");
|
||
syncFieldDialogChoices();
|
||
openManagedDialog(ui.fieldDialog);
|
||
}
|
||
|
||
function closeFieldDialog() {
|
||
closeManagedDialog(ui.fieldDialog);
|
||
}
|
||
|
||
function hydrateLazyImages(root) {
|
||
const scope = root || document;
|
||
for (const img of scope.querySelectorAll("img[data-src]")) {
|
||
if (!img.getAttribute("src")) img.setAttribute("src", img.dataset.src || "");
|
||
}
|
||
}
|
||
|
||
function renderEcologyCards() {
|
||
if (!ui.ecologyGrid || ui.ecologyGrid.dataset.ready === "1") return;
|
||
const cards = window.TEXT_CATALOG?.ecologyCards || [];
|
||
ui.ecologyGrid.innerHTML = cards.map(card => `
|
||
<section class="ecology-card">
|
||
<img data-src="${window.TarinaiUIHelpers.htmlEscape(card.image || "assets/sprites/tarinai_01_smile.webp")}" loading="lazy" decoding="async" alt="">
|
||
<div><strong>${window.TarinaiUIHelpers.htmlEscape(card.title || "")}</strong><p>${window.TarinaiUIHelpers.htmlEscape(card.text || "")}</p></div>
|
||
</section>`).join("");
|
||
ui.ecologyGrid.dataset.ready = "1";
|
||
}
|
||
|
||
function openEcologyDialog() {
|
||
renderEcologyCards();
|
||
hydrateLazyImages(ui.ecologyDialog);
|
||
openManagedDialog(ui.ecologyDialog);
|
||
}
|
||
|
||
function closeEcologyDialog() {
|
||
closeManagedDialog(ui.ecologyDialog);
|
||
}
|
||
|
||
function openArchiveResetDialog() {
|
||
openManagedDialog(ui.archiveResetDialog);
|
||
}
|
||
|
||
function closeArchiveResetDialog() {
|
||
closeManagedDialog(ui.archiveResetDialog);
|
||
}
|
||
|
||
function resetWithField(fieldType, presetId = uiCache.resetPreset || "default") {
|
||
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
|
||
const preset = normalizeResetPresetId(presetId);
|
||
uiCache.resetFieldType = field.id;
|
||
uiCache.resetPreset = preset;
|
||
closeFieldDialog();
|
||
applyFieldLayout(field.id);
|
||
world.reset(null, field.id, preset);
|
||
uiCache.archiveVersion = "";
|
||
renderLog(world.logs);
|
||
renderArchive();
|
||
renderStats();
|
||
render();
|
||
}
|
||
|
||
|
||
function resizeFieldFromWheel(e) {
|
||
const itemType = toolItemType(world?.tool || "");
|
||
if ((e.shiftKey || e.altKey) && itemType && isRotatableItemType(itemType) && world?.rotateToolAngle) {
|
||
e.preventDefault();
|
||
const step = (e.altKey ? 1 : 5) * Math.PI / 180;
|
||
world.rotateToolAngle(itemType, (e.deltaY > 0 ? 1 : -1) * step);
|
||
render();
|
||
return;
|
||
}
|
||
if (!world.setFieldZoom) return;
|
||
e.preventDefault();
|
||
const rect = canvas.getBoundingClientRect();
|
||
const sx = e.clientX - rect.left;
|
||
const sy = e.clientY - rect.top;
|
||
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
|
||
const factor = e.deltaY > 0 ? 1 / 1.08 : 1.08;
|
||
if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return;
|
||
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
|
||
const scale = world.viewScale ? world.viewScale() : 1;
|
||
world.cameraX = focus.x - (sx - off.x) / scale;
|
||
world.cameraY = focus.y - (sy - off.y) / scale;
|
||
world.clampCamera?.();
|
||
render();
|
||
}
|
||
|
||
function selectTool(tool) {
|
||
const wasCopyArmed = world.tool === "copy" && tool === "copy" && Boolean(world.copyBuffer);
|
||
const result = window.TarinaiCommands.dispatch(world, { type: "tool.select", toolId: tool });
|
||
const activeTool = result?.toolId || world.tool || tool;
|
||
if (!["rope", "rod", "spring", "wire", "insulated_wire"].includes(activeTool)) world.pendingLinkEndpoint = null;
|
||
if (activeTool !== "copy") world.copyBuffer = null;
|
||
else if (wasCopyArmed) {
|
||
world.copyBuffer = null;
|
||
showToast("\u30b3\u30d4\u30fc\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002");
|
||
}
|
||
for (const btn of uiCache.toolButtons) {
|
||
btn.classList.toggle("selected", btn.dataset.tool === activeTool);
|
||
}
|
||
syncToolSizeBadges();
|
||
canvas.style.cursor = window.TarinaiUIInputShared.cursorForTool(activeTool) || (activeTool === "observe" ? "default" : (activeTool === "pinch" ? "grab" : (activeTool === "poke" || activeTool === "delete" ? "pointer" : "crosshair")));
|
||
}
|
||
|
||
function showToast(text) {
|
||
ui.toast.textContent = text;
|
||
ui.toast.classList.remove("hidden");
|
||
clearTimeout(showToast._timer);
|
||
showToast._timer = setTimeout(() => ui.toast.classList.add("hidden"), 1400);
|
||
}
|
||
|
||
function syncTopButtons() {
|
||
ui.pauseBtn.textContent = world.paused ? "\u518d\u958b" : "\u4e00\u6642\u505c\u6b62";
|
||
if (ui.speedSelect) ui.speedSelect.value = String(world.speed);
|
||
if (ui.speedMenuBtn) ui.speedMenuBtn.textContent = `速度 ×${world.speed}`;
|
||
for (const option of ui.speedMenuPanel?.querySelectorAll?.("[data-speed-value]") || []) {
|
||
const active = Number(option.dataset.speedValue) === Number(world.speed);
|
||
option.classList.toggle("active", active);
|
||
option.setAttribute("aria-checked", active ? "true" : "false");
|
||
}
|
||
syncAudioControls();
|
||
syncArchiveUpdateButton();
|
||
}
|
||
|
||
function archiveAutoUpdateEnabled() {
|
||
return Boolean(uiCache.archiveUpdateEnabled);
|
||
}
|
||
|
||
function syncArchiveUpdateButton() {
|
||
const btn = ui.archiveUpdateToggleBtn;
|
||
if (!btn) return;
|
||
const on = archiveAutoUpdateEnabled();
|
||
btn.textContent = on ? "\u66f4\u65b0 ON" : "\u66f4\u65b0 OFF";
|
||
btn.classList.toggle("active", on);
|
||
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||
}
|
||
|
||
function setArchiveUpdateEnabled(on) {
|
||
uiCache.archiveUpdateEnabled = Boolean(on);
|
||
syncArchiveUpdateButton();
|
||
if (uiCache.archiveUpdateEnabled) {
|
||
uiCache.archiveVersion = "";
|
||
uiCache.archiveFamilyVersion = null;
|
||
uiCache.archiveViewActivated = true;
|
||
world.familyTreeDirty = true;
|
||
renderArchive?.({ userRequested: true });
|
||
showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002");
|
||
} else {
|
||
if (uiCache.archiveRenderTimer) { clearTimeout(uiCache.archiveRenderTimer); uiCache.archiveRenderTimer = 0; }
|
||
uiCache.archiveScheduled = false;
|
||
showToast("\u5bb6\u7cfb\u56f3\u306e\u66f4\u65b0\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
|
||
}
|
||
}
|