tarinai/js/ui_bind.js
2026-06-22 02:03:19 +09:00

826 lines
33 KiB
JavaScript

"use strict";
function bindUI() {
renderToolPalette();
loadLogPushSettings();
syncLogPushControls();
syncAudioControls();
applyToolTips();
renderEcologyCards();
window.TarinaiSaveSystem?.bindSaveSystem?.();
window.TarinaiFreezeSystem?.bindFreezeSystem?.();
uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []);
syncToolSizeBadges();
for (const toggle of document.querySelectorAll(".panel-card-toggle")) {
toggle.addEventListener("click", () => {
const card = toggle.closest(".collapsible-card");
if (!card) return;
audio.uiFold?.();
const collapsed = card.classList.toggle("collapsed");
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
const state = toggle.querySelector(".panel-card-state");
if (state) state.textContent = collapsed ? "\u5c55\u958b" : "\u6298\u308a\u7573\u307f";
});
}
ui.colonyChartTabs?.addEventListener("click", (e) => {
const btn = e.target.closest("button[data-chart]");
if (!btn) return;
uiCache.chartMode = btn.dataset.chart || "population";
for (const b of ui.colonyChartTabs.querySelectorAll("button")) b.classList.toggle("active", b === btn);
uiCache.lastChartDraw = "";
uiCache.lastChartDrawAt = 0;
renderStats();
});
syncToolSizeBadges();
ui.logPushEnabled?.addEventListener("change", () => {
uiCache.logPushEnabled = Boolean(ui.logPushEnabled.checked);
audio.notify?.();
saveLogPushSettings();
showToast(uiCache.logPushEnabled ? "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u30ed\u30b0\u306e\u30d7\u30c3\u30b7\u30e5\u901a\u77e5\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
});
ui.logPushControls?.addEventListener("change", (e) => {
const input = e.target.closest("[data-push-kind]");
if (!input) return;
const kind = input.dataset.pushKind || "";
audio.uiClick?.();
if (input.checked) uiCache.logPushKinds.add(kind);
else uiCache.logPushKinds.delete(kind);
saveLogPushSettings();
});
ui.soundCategoryControls?.addEventListener("change", (e) => {
const input = e.target.closest("[data-sound-category]");
if (!input) return;
const category = input.dataset.soundCategory || "";
audio.setCategory?.(category, Boolean(input.checked));
if (audio.enabled && input.checked) audio.uiClick?.();
syncAudioControls();
showToast(`${audio.soundCategories?.[category] || category} ${input.checked ? "ON" : "OFF"}`);
});
ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true });
window.addEventListener("scroll", schedulePendingArchiveRender, { passive: true });
window.addEventListener("resize", schedulePendingArchiveRender, { passive: true });
const bindArchiveHorizontal = (root) => {
if (!root) return;
root.addEventListener("wheel", (e) => {
const scroller = e.target.closest(".lineage-family-tree, .lineage-main-content");
if (!scroller) return;
const canX = scroller.scrollWidth > scroller.clientWidth + 4;
if (!canX) return;
const dominant = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
if (!dominant) return;
e.preventDefault();
scroller.scrollLeft += dominant;
}, { passive: false });
};
bindArchiveHorizontal(ui.archiveContent);
ui.archiveContent?.addEventListener("click", (e) => {
const node = e.target.closest("[data-family-tarinai-id]");
if (!node) return;
const id = node.getAttribute("data-family-tarinai-id");
const t = world.tarinai.find(x => (x.id === id || x.familyKey === id) && !x.dead);
if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; }
world.selected = t;
t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15);
selectTool("observe");
if (Number.isFinite(t.x) && Number.isFinite(t.y)) {
world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2;
world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2;
world.clampCamera?.();
}
renderStats();
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
});
if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") {
uiCache.archiveAutoRefreshTimer = window.setInterval(() => {
if (!ui.archiveContent || !archiveAutoUpdateEnabled()) return;
scheduleArchiveWindowRender();
}, 5000);
}
ui.selectedInfo?.addEventListener("click", (e) => {
const categoryToggle = e.target.closest(".selected-category-toggle");
if (categoryToggle) {
const category = categoryToggle.closest(".selected-category");
const id = category?.dataset?.selectedCategory || "";
if (!category || !id) return;
if (!uiCache.selectedCollapsedCategories) uiCache.selectedCollapsedCategories = new Set();
audio.uiFold?.();
category.classList.toggle("collapsed");
const collapsed = category.classList.contains("collapsed");
if (collapsed) uiCache.selectedCollapsedCategories.add(id);
else uiCache.selectedCollapsedCategories.delete(id);
categoryToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
return;
}
const focusBtn = e.target.closest("[data-focus-tarinai-id]");
if (focusBtn) {
e.preventDefault();
focusTarinaiById(focusBtn.getAttribute("data-focus-tarinai-id"));
return;
}
const btn = e.target.closest("[data-selected-action=\"favorite\"]");
if (!btn || !world.selected || world.selected.dead) return;
world.selected.favorite = !world.selected.favorite;
uiCache.selectedSnapshot = "";
renderSelected();
render();
showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002");
});
ui.selectedInfo?.addEventListener("change", (e) => {
const input = e.target.closest("[data-selected-name]");
if (!input) return;
renameSelectedTarinai(input.value);
});
ui.selectedInfo?.addEventListener("keydown", (e) => {
const input = e.target.closest("[data-selected-name]");
if (!input) return;
if (e.key === "Enter") { e.preventDefault(); input.blur(); }
if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); }
});
ui.selectedCloseBtn?.addEventListener("click", () => {
world.selected = null;
uiCache.selectedSnapshot = "";
uiCache.selectedEmpty = false;
renderSelected();
render();
});
ui.pauseBtn?.addEventListener("click", () => {
audio.uiClick?.();
world.paused = !world.paused;
const archiveStale = uiCache.archiveFamilyVersion !== (world.familyVersion || 0);
if (archiveAutoUpdateEnabled() && world.paused && (uiCache.archivePendingWhileRunning || archiveStale)) {
uiCache.archivePendingWhileRunning = false;
scheduleArchiveWindowRender();
}
syncTopButtons();
});
ui.speedBtn?.addEventListener("click", () => {
audio.uiClick?.();
const speeds = [1, 2, 4];
const i = speeds.indexOf(world.speed);
world.speed = speeds[(i + 1) % speeds.length];
syncTopButtons();
});
ui.ecologyBtn?.addEventListener("click", () => { audio.uiClick?.(); openEcologyDialog(); });
ui.soundBtn?.addEventListener("click", (e) => {
e.stopPropagation();
toggleSoundPanel();
});
ui.soundPanel?.addEventListener("click", (e) => e.stopPropagation());
ui.soundMasterToggle?.addEventListener("change", () => {
const on = audio.setEnabled ? audio.setEnabled(ui.soundMasterToggle.checked) : audio.toggle();
if (on) audio.uiClick?.();
syncAudioControls();
showToast(on ? "\u52b9\u679c\u97f3\u3092ON\u306b\u3057\u307e\u3057\u305f\u3002" : "\u52b9\u679c\u97f3\u3092OFF\u306b\u3057\u307e\u3057\u305f\u3002");
});
document.addEventListener("click", (e) => {
if (!ui.soundMenu || ui.soundMenu.contains(e.target)) return;
closeSoundPanel();
});
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); openCreditsDialog(); });
ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog);
ui.creditsDialog?.addEventListener("click", (e) => {
if (e.target === ui.creditsDialog) closeCreditsDialog();
});
ui.resetBtn?.addEventListener("click", () => {
audio.uiClick?.();
openFieldDialog();
});
ui.fieldCancelBtn?.addEventListener("click", closeFieldDialog);
ui.fieldDialog?.addEventListener("click", (e) => {
if (e.target === ui.fieldDialog) { closeFieldDialog(); return; }
const btn = e.target.closest("[data-field-type]");
if (!btn) return;
resetWithField(btn.dataset.fieldType || "garden");
});
ui.ecologyCloseBtn?.addEventListener("click", closeEcologyDialog);
ui.ecologyDialog?.addEventListener("click", (e) => {
if (e.target === ui.ecologyDialog) closeEcologyDialog();
});
ui.archiveUpdateToggleBtn?.addEventListener("click", () => { audio.uiClick?.(); setArchiveUpdateEnabled(!archiveAutoUpdateEnabled()); });
syncArchiveUpdateButton();
ui.archiveResetBtn?.addEventListener("click", () => { audio.uiClick?.(); openArchiveResetDialog(); });
ui.archiveResetCancelBtn?.addEventListener("click", closeArchiveResetDialog);
ui.archiveResetDialog?.addEventListener("click", (e) => {
if (e.target === ui.archiveResetDialog) closeArchiveResetDialog();
});
ui.archiveResetConfirmBtn?.addEventListener("click", () => {
world.resetFamilyTree?.();
window.resetArchiveRenderState?.();
closeArchiveResetDialog();
renderArchive();
renderSelected();
showToast("\u5bb6\u7cfb\u56f3\u3092\u30ea\u30bb\u30c3\u30c8\u3057\u307e\u3057\u305f\u3002");
});
ui.toolPalette?.addEventListener("click", (e) => {
const toggle = e.target.closest(".tool-category-toggle");
if (toggle) {
const category = toggle.closest(".tool-category");
if (!category) return;
audio.uiFold?.();
category.classList.toggle("collapsed");
toggle.setAttribute("aria-expanded", category.classList.contains("collapsed") ? "false" : "true");
return;
}
const btn = e.target.closest(".tool");
if (!btn) return;
const tool = btn.dataset.tool;
audio.uiClick?.();
if (world.tool === tool && cycleToolSize(tool)) return;
selectTool(tool);
});
const chainScroll = (el) => {
if (!el) return;
el.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
if ((e.deltaY < 0 && atTop) || (e.deltaY > 0 && atBottom)) {
const page = document.scrollingElement || document.documentElement;
page.scrollTop += e.deltaY;
}
}, { passive: true });
};
const transferEdgeScroll = (child, parent) => {
if (!child || !parent) return;
child.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const childCanScroll = child.scrollHeight > child.clientHeight + 1;
const atTop = child.scrollTop <= 0;
const atBottom = child.scrollTop + child.clientHeight >= child.scrollHeight - 1;
const movingInside = childCanScroll && ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom));
e.preventDefault();
e.stopPropagation();
if (movingInside) {
child.scrollTop += e.deltaY;
return;
}
parent.scrollTop += e.deltaY;
}, { passive: false });
};
const rightPanel = document.querySelector(".panel");
chainScroll(rightPanel);
chainScroll(ui.archiveContent);
transferEdgeScroll(ui.log, rightPanel);
const inputModeManager = window.TarinaiInputMode;
const setMobileInputMode = (mode = "auto") => {
mode = inputModeManager?.setMode?.(mode) || (["auto", "camera", "tool", "family"].includes(mode) ? mode : "auto");
uiCache.mobileInputMode = mode;
ui.mobileModeControls?.querySelectorAll("[data-mobile-mode]").forEach(btn => btn.classList.toggle("active", btn.dataset.mobileMode === mode));
document.body.classList.toggle("mobile-mode-camera", mode === "camera");
document.body.classList.toggle("mobile-mode-tool", mode === "tool");
document.body.classList.toggle("mobile-mode-family", mode === "family");
};
const updateTouchMobileUiClass = () => {
const enabled = Boolean(inputModeManager?.refresh?.().touchFirst);
document.body.classList.toggle("touch-mobile-ui", enabled);
if (ui.mobileModeControls) ui.mobileModeControls.hidden = !enabled;
};
const mobileInputMode = () => inputModeManager?.currentMode || uiCache.mobileInputMode || "auto";
updateTouchMobileUiClass();
window.addEventListener("resize", updateTouchMobileUiClass, { passive: true });
window.matchMedia?.("(pointer: coarse)")?.addEventListener?.("change", updateTouchMobileUiClass);
setMobileInputMode(uiCache.mobileInputMode || "auto");
ui.mobileModeControls?.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mobile-mode]");
if (!btn) return;
audio.uiClick?.();
setMobileInputMode(btn.dataset.mobileMode || "auto");
showToast(`\u30b9\u30de\u30db\u64cd\u4f5c: ${btn.textContent || btn.dataset.mobileMode}`);
});
const touchPoint = (touches, index = 0) => {
const t = touches?.[index];
if (!t) return null;
return { clientX: t.clientX, clientY: t.clientY };
};
const touchCenter = (touches) => {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a) return null;
if (!b) return a;
return { clientX: (a.clientX + b.clientX) / 2, clientY: (a.clientY + b.clientY) / 2 };
};
const touchDistance = (touches) => {
const a = touchPoint(touches, 0);
const b = touchPoint(touches, 1);
if (!a || !b) return 0;
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
};
const screenToWorldClient = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
return world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy, inside: sx >= 0 && sy >= 0 };
};
const clientToWorldRaw = (clientX, clientY) => {
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const scale = world.viewScale ? world.viewScale() : 1;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const rawX = (sx - off.x) / scale + (world.cameraX || 0);
const rawY = (sy - off.y) / scale + (world.cameraY || 0);
return { x: rawX, y: rawY, inside: rawX >= 0 && rawY >= 0 && rawX <= world.w && rawY <= world.h };
};
const setZoomAroundClientPoint = (clientX, clientY, nextZoom) => {
if (!world.setFieldZoom) return false;
const rect = canvas.getBoundingClientRect();
const sx = clientX - rect.left;
const sy = clientY - rect.top;
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
if (!world.setFieldZoom(nextZoom)) return false;
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?.();
return true;
};
function isClientOverFrozenPanel(clientX, clientY) {
const panel = document.getElementById("frozenPanel");
if (!panel) return false;
const r = panel.getBoundingClientRect();
return clientX >= r.left && clientX <= r.right && clientY >= r.top && clientY <= r.bottom;
}
function rememberGrabOrigin(target) {
if (!target || uiCache.grabKind !== "tarinai") return;
uiCache.grabOrigin = { x: target.x, y: target.y, vx: target.vx || 0, vy: target.vy || 0 };
}
function finishTarinaiGrab(target, clientX, clientY) {
if (!target || uiCache.grabKind !== "tarinai") return false;
if (isClientOverFrozenPanel(clientX, clientY) && window.TarinaiFreezeSystem?.freezeTarinai) {
window.TarinaiFreezeSystem.freezeTarinai(target, world);
uiCache.grabOrigin = null;
return true;
}
const p = screenToWorldClient(clientX, clientY);
if (!p.inside && uiCache.grabOrigin) {
target.x = uiCache.grabOrigin.x;
target.y = uiCache.grabOrigin.y;
target.vx = uiCache.grabOrigin.vx || 0;
target.vy = uiCache.grabOrigin.vy || 0;
target.thought = "フィールドに戻された";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
showToast("フィールド外なので元の位置に戻しました。");
uiCache.grabOrigin = null;
return true;
}
uiCache.grabOrigin = null;
return false;
}
const beginTouchGrab = (p, e) => {
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) return false;
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.touches?.[0]?.clientX || 0;
uiCache.grabStartY = e.touches?.[0]?.clientY || 0;
if (uiCache.grabKind === "tarinai") {
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
uiCache.touchMode = "grab";
renderStats();
return true;
};
canvas?.addEventListener("touchstart", (e) => {
if (!e.touches?.length) return;
e.preventDefault();
uiCache.touchMoved = false;
const mode = mobileInputMode();
if ((mode === "camera" || mode === "family") && e.touches.length === 1) {
const t = e.touches[0];
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchMode = "cameraPan";
return;
}
if (e.touches.length >= 2) {
const c = touchCenter(e.touches);
uiCache.touchMode = "pinchZoom";
uiCache.pinchStartDistance = Math.max(1, touchDistance(e.touches));
uiCache.pinchStartZoom = world.fieldZoom || 1;
uiCache.pinchLastCenterX = c?.clientX || 0;
uiCache.pinchLastCenterY = c?.clientY || 0;
return;
}
const t = e.touches[0];
const p = screenToWorldClient(t.clientX, t.clientY);
uiCache.touchStartX = t.clientX;
uiCache.touchStartY = t.clientY;
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
uiCache.touchLastWorldX = p.x;
uiCache.touchLastWorldY = p.y;
if (mode === "tool" && world.tool === "observe") {
uiCache.touchMode = "toolTapOnly";
return;
}
if (world.tool === "water_hose" && p.inside) {
audio.waterHose?.();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = t.clientX;
uiCache.hoseStartY = t.clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
uiCache.touchMode = "hose";
render();
return;
}
if (world.tool === "pinch" && p.inside && beginTouchGrab(p, e)) return;
uiCache.touchMode = mode === "tool" ? "toolTapOnly" : "panOrTap";
}, { passive: false });
canvas?.addEventListener("touchmove", (e) => {
if (!e.touches?.length || !uiCache.touchMode) return;
e.preventDefault();
if (uiCache.touchMode === "pinchZoom" && e.touches.length >= 2) {
const c = touchCenter(e.touches);
const distNow = Math.max(1, touchDistance(e.touches));
const ratio = distNow / Math.max(1, uiCache.pinchStartDistance || distNow);
setZoomAroundClientPoint(c.clientX, c.clientY, (uiCache.pinchStartZoom || 1) * ratio);
const dx = c.clientX - (uiCache.pinchLastCenterX || c.clientX);
const dy = c.clientY - (uiCache.pinchLastCenterY || c.clientY);
uiCache.pinchLastCenterX = c.clientX;
uiCache.pinchLastCenterY = c.clientY;
if (Math.hypot(dx, dy) > 0.1) world.panCamera?.(-dx, -dy);
uiCache.touchMoved = true;
render();
return;
}
const t = e.touches[0];
const p = screenToWorldClient(t.clientX, t.clientY);
const screenDx = t.clientX - (uiCache.touchLastX || t.clientX);
const screenDy = t.clientY - (uiCache.touchLastY || t.clientY);
const totalMove = Math.hypot(t.clientX - (uiCache.touchStartX || t.clientX), t.clientY - (uiCache.touchStartY || t.clientY));
uiCache.touchLastX = t.clientX;
uiCache.touchLastY = t.clientY;
if (uiCache.touchMode === "cameraPan") {
if (totalMove > 2) uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
return;
}
if (uiCache.touchMode === "toolTapOnly") {
if (totalMove > 8) uiCache.touchMoved = true;
return;
}
if (uiCache.touchMode === "hose" && uiCache.hosing) {
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
if (totalMove > 3) { uiCache.hoseMoved = true; uiCache.touchMoved = true; }
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
render();
return;
}
if (uiCache.touchMode === "grab" && uiCache.grabbing) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.touchMode = "";
return;
}
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
if (totalMove > 3) { uiCache.grabMoved = true; uiCache.touchMoved = true; }
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.touches?.[0]?.clientX || uiCache.touchLastX || 0, e.touches?.[0]?.clientY || uiCache.touchLastY || 0);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("つままれている");
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
render();
return;
}
if (uiCache.touchMode === "panOrTap") {
if (totalMove > 7) {
uiCache.touchMoved = true;
world.panCamera?.(-screenDx, -screenDy);
render();
}
}
}, { passive: false });
canvas?.addEventListener("touchend", (e) => {
e.preventDefault();
if (uiCache.hosing) {
uiCache.hosing = false;
world.rebuildSpatial?.();
renderStats();
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, e.changedTouches?.[0]?.clientX || uiCache.touchLastX || 0, e.changedTouches?.[0]?.clientY || uiCache.touchLastY || 0);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
renderStats();
} else if (uiCache.touchMode === "toolTapOnly" && !uiCache.touchMoved) {
const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
} else if (uiCache.touchMode === "panOrTap" && !uiCache.touchMoved) {
const p = screenToWorldClient(uiCache.touchLastX || uiCache.touchStartX, uiCache.touchLastY || uiCache.touchStartY);
if (p.inside) {
world.handleClick(p.x, p.y);
renderStats();
}
}
if (!e.touches?.length) {
uiCache.touchMode = "";
uiCache.touchMoved = false;
}
}, { passive: false });
canvas?.addEventListener("touchcancel", () => {
uiCache.touchMode = "";
uiCache.touchMoved = false;
uiCache.hosing = false;
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
}, { passive: true });
canvas?.addEventListener("contextmenu", (e) => e.preventDefault());
canvas?.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "water_hose") {
audio.waterHose?.();
const p = screenToWorld(e);
if (!p.inside) return;
e.preventDefault();
uiCache.hosing = true;
uiCache.hoseMoved = false;
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
uiCache.hoseStartX = e.clientX;
uiCache.hoseStartY = e.clientY;
world.applyWaterHose?.(p.x, p.y, 0, 0, 0.10);
render();
return;
}
if (e.button === 0 && world.tool === "pinch") {
const p = screenToWorld(e);
if (!p.inside) return;
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) {
showToast("\u3064\u307e\u3081\u308b\u5bfe\u8c61\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return;
}
e.preventDefault();
audio.grab?.();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
rememberGrabOrigin(target);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.clientX;
uiCache.grabStartY = e.clientY;
if (uiCache.grabKind === "tarinai") {
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
canvas.style.cursor = "grabbing";
renderStats();
return;
}
if (e.button !== 2) return;
e.preventDefault();
uiCache.panning = true;
uiCache.panMoved = false;
uiCache.panButton = e.button;
uiCache.panStartX = e.clientX;
uiCache.panStartY = e.clientY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
});
window.addEventListener("mouseup", (e) => {
if (uiCache.hosing) {
uiCache.hosing = false;
canvas.style.cursor = world.tool === "water_hose" ? "crosshair" : (world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair")));
world.rebuildSpatial?.();
renderStats();
return;
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
finishTarinaiGrab(target, e.clientX, e.clientY);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
audio.drop?.();
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = 0;
canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
renderStats();
return;
}
if (!uiCache.panning) return;
if (uiCache.panButton !== null && e.button !== uiCache.panButton) return;
uiCache.panning = false;
uiCache.panButton = null;
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
});
window.addEventListener("mousemove", (e) => {
if (uiCache.hosing) {
const p = screenToWorld(e);
const dx = p.x - (uiCache.hoseLastWorldX ?? p.x);
const dy = p.y - (uiCache.hoseLastWorldY ?? p.y);
uiCache.hoseLastWorldX = p.x;
uiCache.hoseLastWorldY = p.y;
const totalMove = Math.hypot(e.clientX - (uiCache.hoseStartX || e.clientX), e.clientY - (uiCache.hoseStartY || e.clientY));
if (totalMove > 3) uiCache.hoseMoved = true;
if (p.inside) world.applyWaterHose?.(p.x, p.y, dx, dy, 0.10);
canvas.style.cursor = "crosshair";
render();
return;
}
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
canvas.style.cursor = world.tool === "pinch" ? "grab" : "default";
return;
}
const p = screenToWorld(e);
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY));
if (totalMove > 3) uiCache.grabMoved = true;
const pad = target.radius || target.r || 16;
if (uiCache.grabKind === "tarinai" && !p.inside) {
const raw = clientToWorldRaw(e.clientX, e.clientY);
target.x = raw.x;
target.y = raw.y;
} else {
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
}
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("つままれている");
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
const now = performance.now();
if (!uiCache.grabLastSpatialAt || now - uiCache.grabLastSpatialAt > 80) {
world.rebuildSpatial?.();
uiCache.grabLastSpatialAt = now;
}
canvas.style.cursor = "grabbing";
return;
}
if (!uiCache.panning) return;
const dx = e.clientX - uiCache.panLastX;
const dy = e.clientY - uiCache.panLastY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
const totalMove = Math.hypot(e.clientX - uiCache.panStartX, e.clientY - uiCache.panStartY);
if (totalMove > 8) {
canvas.style.cursor = "grabbing";
world.panCamera?.(-dx, -dy);
uiCache.panMoved = true;
render();
}
});
canvas?.addEventListener("click", (e) => {
if (uiCache.hoseMoved) {
uiCache.hoseMoved = false;
return;
}
if (uiCache.grabMoved) {
uiCache.grabMoved = false;
return;
}
if (uiCache.panMoved) {
uiCache.panMoved = false;
return;
}
const p = screenToWorld(e);
if (!p.inside) return;
world.handleClick(p.x, p.y);
renderStats();
});
canvas?.addEventListener("mousemove", (e) => {
const p = screenToWorld(e);
const prevX = world.pointer.x;
const prevY = world.pointer.y;
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = !!p.inside;
const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0));
world.pointer.motion = clamp(delta / 18, 0, 2);
world.pointer.movedAt = performance.now();
});
canvas?.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
canvas?.addEventListener("mouseleave", () => {
world.pointer.inside = false;
world.pointer.motion = 0;
});
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
closeFieldDialog();
closeEcologyDialog();
closeArchiveResetDialog();
closeCreditsDialog();
});
window.addEventListener("resize", resizeCanvas);
}