tarinai/js/world_placement_log.js
2026-07-10 16:40:06 +09:00

1523 lines
84 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_placement_log.js");
const SIGNBOARD_CHARS_PER_LINE = 6;
const SIGNBOARD_LINES = 3;
const SIGNBOARD_TEXT_LIMIT = SIGNBOARD_CHARS_PER_LINE * SIGNBOARD_LINES;
function ensureEditorDialog(id, className, html) {
let dialog = document.getElementById(id);
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.id = id;
dialog.className = className;
dialog.innerHTML = html;
document.body.appendChild(dialog);
return dialog;
}
function ensureSignboardEditor() {
return ensureEditorDialog("signboardEditor", "signboard-editor hidden", `
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
<div class="signboard-editor-titlebar">
<h2 id="signboardEditorTitle">\u770b\u677f</h2>
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
</div>
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT + SIGNBOARD_LINES - 1}" rows="3" cols="6" wrap="off" spellcheck="false" inputmode="text"></textarea>
<div class="signboard-editor-meta"><span>6\u6587\u5b57\u00d73\u884c\u307e\u3067</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
<div class="signboard-editor-actions">
<button id="signboardEditorClear" class="btn" type="button">\u6d88\u3059</button>
<button id="signboardEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
<button id="signboardEditorApply" class="btn primary" type="button">\u66f8\u304d\u8fbc\u3080</button>
</div>
</div>`);
}
function sliceGraphemes(text, limit) {
const chars = Array.from(String(text || ""));
return chars.slice(0, Math.max(0, limit)).join("");
}
function sanitizeSignboardText(value = "", opts = {}) {
const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " ");
const rawLines = normalized.split("\n").slice(0, SIGNBOARD_LINES);
const out = [];
for (let i = 0; i < SIGNBOARD_LINES; i += 1) {
const rawLine = rawLines[i];
if (rawLine == null) break;
out.push(sliceGraphemes(String(rawLine), SIGNBOARD_CHARS_PER_LINE));
}
const next = out.join("\n");
return opts.trimTrailing === false ? next : next.replace(/\n+$/g, "");
}
function signboardVisibleLength(text = "") {
return Array.from(String(text || "").replace(/\n/g, "")).length;
}
function replaceSignboardEditorSelection(area, text = "") {
if (!area) return;
const value = String(area.value || "");
const start = Number(area.selectionStart || 0);
const end = Number(area.selectionEnd || start);
const next = sanitizeSignboardText(value.slice(0, start) + text + value.slice(end), { trimTrailing: false });
area.value = next;
const caret = Math.min(next.length, start + String(text || "").length);
area.selectionStart = caret;
area.selectionEnd = caret;
}
function openSignboardEditor(sign, worldRef) {
if (!sign) return false;
const dialog = ensureSignboardEditor();
const area = dialog.querySelector("#signboardEditorText");
const count = dialog.querySelector("#signboardEditorCount");
const apply = dialog.querySelector("#signboardEditorApply");
const cancel = dialog.querySelector("#signboardEditorCancel");
const clear = dialog.querySelector("#signboardEditorClear");
const closeBtn = dialog.querySelector("#signboardEditorClose");
const close = () => {
dialog.classList.add("hidden");
};
const syncCount = () => {
if (!area || !count) return;
count.textContent = `${signboardVisibleLength(area.value || "")}/${SIGNBOARD_TEXT_LIMIT}`;
};
const commit = (value) => {
const next = sanitizeSignboardText(value);
sign.text = next;
worldRef?.log?.(sign.text ? `\u770b\u677f\u3092\u66f8\u304d\u63db\u3048\u305f\u3002` : `\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f\u3002`, "observe");
worldRef.drawListDirty = true;
global.render?.();
close();
};
if (area) {
area.value = sanitizeSignboardText(sign.text || "");
area.onkeydown = (e) => {
const value = String(area.value || "");
const start = Number(area.selectionStart || 0);
const end = Number(area.selectionEnd || start);
const before = value.slice(0, start);
const selected = value.slice(start, end);
const lineIndex = before.split("\n").length - 1;
const lines = value.split("\n");
const currentLine = lines[lineIndex] || "";
const replacing = selected.length > 0;
if (e.key === "Enter") {
const selectedLineBreaks = (selected.match(/\n/g) || []).length;
e.preventDefault();
if (lines.length - selectedLineBreaks < SIGNBOARD_LINES) {
replaceSignboardEditorSelection(area, "\n");
syncCount();
}
return;
}
if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const selectedSameLine = !selected.includes("\n");
const selectedCount = selectedSameLine ? Array.from(selected).length : 0;
if (!replacing && Array.from(currentLine).length >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
else if (replacing && selectedSameLine && Array.from(currentLine).length - selectedCount >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
}
};
area.onpaste = (e) => {
e.preventDefault();
const text = e.clipboardData?.getData?.("text") || "";
replaceSignboardEditorSelection(area, text);
syncCount();
};
area.oninput = () => {
const clean = sanitizeSignboardText(area.value || "", { trimTrailing: false });
if (area.value !== clean) area.value = clean;
syncCount();
};
setTimeout(() => { area.focus(); area.select(); syncCount(); }, 0);
}
if (apply) apply.onclick = () => commit(area?.value || "");
if (clear) clear.onclick = () => commit("");
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
syncCount();
return true;
}
function ensureRobotCleanerEditor() {
return ensureEditorDialog("robotCleanerEditor", "signboard-editor robot-cleaner-editor hidden", `
<div class="signboard-editor-panel robot-cleaner-editor-panel" role="dialog" aria-modal="true" aria-labelledby="robotCleanerEditorTitle">
<div class="signboard-editor-titlebar">
<h2 id="robotCleanerEditorTitle">\u30ed\u30dc\u6383\u9664\u6a5f</h2>
<button id="robotCleanerEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
</div>
<div class="robot-cleaner-editor-help">\u7247\u4ed8\u3051\u308b\u5bfe\u8c61\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002</div>
<div class="robot-cleaner-targets">
<label><input type="checkbox" data-robot-target="zunchi"> \u305a\u3093\u3061</label>
<label><input type="checkbox" data-robot-target="ant_corpse"> \u6b7b\u9ab8</label>
<label><input type="checkbox" data-robot-target="tarinai"> \u305f\u308a\u306a\u3044</label>
<label><input type="checkbox" data-robot-target="grass"> \u8349</label>
<label><input type="checkbox" data-robot-target="grass_bed"> \u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9</label>
<label><input type="checkbox" data-robot-target="water"> \u6c34\u6ef4</label>
</div>
<div class="robot-cleaner-targets robot-cleaner-target-scan">
<label><input id="robotCleanerHighSpeed" type="checkbox"> \u9ad8\u901f\u30e2\u30fc\u30c9</label>
</div>
<div class="signboard-editor-actions">
<button id="robotCleanerDefault" class="btn" type="button">\u65e2\u5b9a\u306b\u623b\u3059</button>
<button id="robotCleanerCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
<button id="robotCleanerApply" class="btn primary" type="button">\u4fdd\u5b58</button>
</div>
</div>`);
}
function openRobotCleanerEditor(robot, worldRef) {
if (!robot || robot.type !== "robot_cleaner") return false;
const api = global.TarinaiItemDynamicToolSystem;
const dialog = ensureRobotCleanerEditor();
const inputs = [...dialog.querySelectorAll("[data-robot-target]")];
const closeBtn = dialog.querySelector("#robotCleanerEditorClose");
const cancel = dialog.querySelector("#robotCleanerCancel");
const apply = dialog.querySelector("#robotCleanerApply");
const def = dialog.querySelector("#robotCleanerDefault");
const highSpeed = dialog.querySelector("#robotCleanerHighSpeed");
const bits = api?.robotCleanerTargetBits || {};
const defaultMask = api?.robotCleanerDefaultMask?.() ?? 3;
const readMask = () => inputs.reduce((mask, input) => input.checked ? (mask | (bits[input.dataset.robotTarget] || 0)) : mask, 0);
const writeMask = (mask) => {
for (const input of inputs) input.checked = Boolean((Number(mask) | 0) & (bits[input.dataset.robotTarget] || 0));
};
const close = () => {
dialog.classList.add("hidden");
};
const commit = (mask) => {
api?.setRobotCleanerMask?.(robot, mask);
api?.setRobotCleanerHighSpeed?.(robot, Boolean(highSpeed?.checked));
robot.robotCleanerTargetId = "";
robot.robotCleanerTargetType = "";
robot.robotCleanerScanTimer = 0;
worldRef?.markItemBucketsDirty?.("robot-cleaner-settings");
if (worldRef) worldRef.drawListDirty = true;
worldRef?.log?.("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u5bfe\u8c61\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002", "observe");
if (typeof showToast === "function") showToast("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u3092\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002");
global.render?.();
close();
};
writeMask(api?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? defaultMask);
if (highSpeed) highSpeed.checked = Boolean(api?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed);
if (closeBtn) closeBtn.onclick = close;
if (cancel) cancel.onclick = close;
if (def) def.onclick = () => { writeMask(defaultMask); if (highSpeed) highSpeed.checked = false; };
if (apply) apply.onclick = () => commit(readMask());
dialog.onclick = (e) => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
return true;
}
function ensureFanEditor() {
return ensureEditorDialog("fanEditor", "rotator-editor fan-editor hidden", `
<div class="rotator-editor-panel fan-editor-panel" role="dialog" aria-modal="true" aria-labelledby="fanEditorTitle">
<div class="rotator-editor-titlebar">
<h2 id="fanEditorTitle">扇風機</h2>
<button id="fanEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button>
</div>
<div class="fan-editor-help">クリックした扇風機の風向きと首振りを設定します。保存しても本体の見た目の向きは変えず、風向き矢印だけが変わります。</div>
<div class="rotator-editor-controls fan-editor-controls">
<label><span>向き</span><input id="fanCenterAngle" type="range" min="0" max="355" step="5"><input id="fanCenterAngleNum" type="number" min="0" max="355" step="5"><span>度</span></label>
<label><span>振り幅</span><input id="fanSwingRange" type="range" min="0" max="120" step="5"><input id="fanSwingRangeNum" type="number" min="0" max="150" step="5"><span>度</span></label>
<label><span>速度</span><input id="fanSwingSpeed" type="range" min="0" max="180" step="5"><input id="fanSwingSpeedNum" type="number" min="0" max="360" step="5"><span>度/秒</span></label>
<label class="fan-editor-check"><input id="fanSwingEnabled" type="checkbox"> 首振りON</label>
</div>
<p class="rotator-editor-hint">振り幅0度なら固定向きです。配置済み扇風機の本体角度は固定したまま、風向きだけを調整します。</p>
<div class="rotator-editor-actions">
<button id="fanEditorStop" class="btn" type="button">固定にする</button>
<button id="fanEditorCancel" class="btn" type="button">キャンセル</button>
<button id="fanEditorApply" class="btn primary" type="button">保存</button>
</div>
</div>`);
}
function degForAngle(angle = 0) {
const rad = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angle, 0) : (((Number(angle) || 0) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
return Math.round((rad * 180 / Math.PI) % 360 + 360) % 360;
}
function syncEditorInputPair(rangeInput, numberInput, onChange = null) {
const sync = (source, target) => {
if (!source || !target) return;
target.value = source.value;
if (typeof onChange === "function") onChange();
};
if (rangeInput) rangeInput.oninput = () => sync(rangeInput, numberInput);
if (numberInput) numberInput.oninput = () => sync(numberInput, rangeInput);
}
function openFanEditor(item, worldRef) {
if (!item || item.type !== "fan") return false;
const dialog = ensureFanEditor();
const center = dialog.querySelector("#fanCenterAngle");
const centerNum = dialog.querySelector("#fanCenterAngleNum");
const swingRange = dialog.querySelector("#fanSwingRange");
const swingRangeNum = dialog.querySelector("#fanSwingRangeNum");
const swingSpeed = dialog.querySelector("#fanSwingSpeed");
const swingSpeedNum = dialog.querySelector("#fanSwingSpeedNum");
const enabled = dialog.querySelector("#fanSwingEnabled");
const stop = dialog.querySelector("#fanEditorStop");
const apply = dialog.querySelector("#fanEditorApply");
const cancel = dialog.querySelector("#fanEditorCancel");
const closeBtn = dialog.querySelector("#fanEditorClose");
if (!Number.isFinite(Number(item.fanBodyAngle))) item.fanBodyAngle = itemAngleFor(item);
const currentAngle = item.fanSwingOn ? (Number(item.fanSwingCenter) || itemAngleFor(item)) : itemAngleFor(item);
const centerDeg = degForAngle(currentAngle);
const rangeDeg = Math.round(Math.max(0, Math.min(150, Number(item.fanSwingRange ?? (35 * Math.PI / 180)) * 180 / Math.PI || 35)));
const speedDeg = Math.round(Math.max(0, Math.min(360, Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) * 180 / Math.PI || 48)));
if (center) center.value = String(Math.max(0, Math.min(355, centerDeg)));
if (centerNum) centerNum.value = String(centerDeg);
if (swingRange) swingRange.value = String(Math.max(0, Math.min(120, rangeDeg)));
if (swingRangeNum) swingRangeNum.value = String(rangeDeg);
if (swingSpeed) swingSpeed.value = String(Math.max(0, Math.min(180, speedDeg)));
if (swingSpeedNum) swingSpeedNum.value = String(speedDeg);
if (enabled) enabled.checked = Boolean(item.fanSwingOn);
syncEditorInputPair(center, centerNum);
syncEditorInputPair(swingRange, swingRangeNum);
syncEditorInputPair(swingSpeed, swingSpeedNum);
const close = () => dialog.classList.add("hidden");
const commit = (forceStop = false) => {
global.TarinaiHistory.capture(worldRef, "fan-swing-edit");
const cDeg = Math.max(0, Math.min(355, Number(centerNum?.value || center?.value || 0) || 0));
const rDeg = Math.max(0, Math.min(150, Number(swingRangeNum?.value || swingRange?.value || 0) || 0));
const sDeg = Math.max(0, Math.min(360, Number(swingSpeedNum?.value || swingSpeed?.value || 0) || 0));
const previousBodyAngle = Number.isFinite(Number(item.fanBodyAngle)) ? Number(item.fanBodyAngle) : itemAngleFor(item);
item.fanSwingCenter = (typeof normalizedItemAngle === "function" ? normalizedItemAngle(cDeg * Math.PI / 180, 0) : cDeg * Math.PI / 180);
item.fanSwingRange = rDeg * Math.PI / 180;
item.fanSwingSpeed = sDeg * Math.PI / 180;
item.fanSwingOn = forceStop ? false : Boolean(enabled?.checked && rDeg > 0 && sDeg > 0);
item.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0;
item.fanBodyAngle = previousBodyAngle;
item.angle = item.fanSwingCenter;
worldRef?.markSpatialDirty?.("fan-swing-settings");
if (worldRef) worldRef.drawListDirty = true;
worldRef?.log?.(item.fanSwingOn ? "扇風機の首振り設定を変更した。" : "扇風機を固定向きにした。", "observe");
if (typeof showToast === "function") showToast(item.fanSwingOn ? "扇風機: 首振りON" : "扇風機: 固定向き");
global.render?.();
close();
};
if (stop) stop.onclick = () => commit(true);
if (apply) apply.onclick = () => commit(false);
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
return true;
}
function ensureRotatorEditor() {
const dialog = ensureEditorDialog("rotatorEditor", "rotator-editor hidden", `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="rotatorEditorTitle">
<div class="rotator-editor-titlebar">
<h2 id="rotatorEditorTitle">\u56de\u8ee2\u4f53</h2>
<button id="rotatorEditorClose" class="rotator-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
</div>
<div class="rotator-editor-controls">
<label>\u901f\u5ea6 <input id="mechMotorSpeed" type="range" min="-360" max="360" step="5"><input id="mechMotorSpeedNum" type="number" min="-720" max="720" step="5"> \u5ea6/\u79d2</label>
<label>\u592a\u3055 <input id="mechShapeThickness" type="range" min="4" max="30" step="1"><input id="mechShapeThicknessNum" type="number" min="4" max="34" step="1"></label>
<label><input id="mechMotorPowered" type="checkbox"> \u52d5\u529bON</label>
<label id="poisonCollisionLabel"><input id="poisonSolidEnabled" type="checkbox"> \u5f53\u305f\u308a\u5224\u5b9aON</label>
</div>
<div class="rotator-editor-tools" role="toolbar" aria-label="\u63cf\u753b\u65b9\u6cd5">
<button type="button" data-rotator-mode="line" class="active">\u2501 \u76f4\u7dda</button>
<button type="button" data-rotator-mode="free">\u3030 \u81ea\u7531\u63cf\u753b</button>
<button type="button" data-rotator-mode="erase">\u232b \u6d88\u3057\u30b4\u30e0</button>
<button type="button" data-rotator-template="bar">\u2503 \u68d2</button>
<button type="button" data-rotator-template="cross">\uff0b \u5341\u5b57</button>
<button type="button" data-rotator-template="circle">\u25cb \u5186</button>
<button id="rotatorUndo" type="button">\u21b6 \u4e00\u3064\u623b\u3059</button>
<button id="rotatorClear" type="button">\ud83d\uddd1 \u5168\u6d88\u3057</button>
</div>
<canvas id="rotatorCanvas" width="520" height="360" aria-label="\u56de\u8ee2\u4f53\u306e\u8f2a\u90ed\u7de8\u96c6"></canvas>
<p class="rotator-editor-hint">\u4e2d\u592e\u304c\u56de\u8ee2\u8ef8\u3002\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002</p>
<div class="rotator-editor-actions">
<button id="rotatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
<button id="rotatorApply" class="btn primary" type="button">\u53cd\u6620</button>
</div>
</div>`);
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "rotator");
return dialog;
}
function physicsApi() { return global.TarinaiPhysicsBodySystem || null; }
function phyScalar(item, key, fallback = 0) { return physicsApi()?.scalar?.(item, key, fallback) ?? fallback; }
function phySet(item, key, value, reason = "editor") { return physicsApi()?.setScalar?.(item, key, value, reason) || false; }
function cloneSegmentsForRotator(item) {
const fallback = item?.type === "poison_block" ? [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]] : [[-78, 0, 78, 0], [0, -52, 0, 52]];
const src = physicsApi()?.segments?.(item) || fallback;
return src.filter(seg => Array.isArray(seg) && seg.length >= 4).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]);
}
function physicsShapeExtent(item, segments = cloneSegmentsForRotator(item), thick = phyScalar(item, "thickness", item?.type === "poison_block" ? 11 : 12)) {
return Math.max(...segments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + Math.max(4, thick) + 8;
}
function openRotatorEditor(item, worldRef) {
if (!item || (item.type !== "rotator" && item.type !== "poison_block")) return false;
const isPoisonBlock = item.type === "poison_block";
const dialog = ensureRotatorEditor();
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "rotator");
const canvas = dialog.querySelector("#rotatorCanvas");
const ctx = canvas?.getContext?.("2d");
const speed = dialog.querySelector("#mechMotorSpeed");
const speedNum = dialog.querySelector("#mechMotorSpeedNum");
const thick = dialog.querySelector("#mechShapeThickness");
const thickNum = dialog.querySelector("#mechShapeThicknessNum");
const poweredInput = dialog.querySelector("#mechMotorPowered");
const poisonCollisionInput = dialog.querySelector("#poisonSolidEnabled");
const poisonCollisionLabel = dialog.querySelector("#poisonCollisionLabel");
const titleEl = dialog.querySelector("#rotatorEditorTitle");
const apply = dialog.querySelector("#rotatorApply");
const cancel = dialog.querySelector("#rotatorCancel");
const closeBtn = dialog.querySelector("#rotatorEditorClose");
if (!canvas || !ctx) return false;
const editor = global.TarinaiPhysicsShapeEditorSystem;
const controller = editor.createSegmentEditorController({
dialog,
canvas,
ctx,
prefix: "rotator",
segments: cloneSegmentsForRotator(item),
fallback: [-78, 0, 78, 0],
lineWidth: () => Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)),
eraseRadius: (lw) => Math.max(24, lw * 1.35),
strokeStyle: () => isPoisonBlock ? "rgba(92,172,76,0.92)" : "rgba(127,92,190,0.92)",
drawCenter: () => {
ctx.fillStyle = isPoisonBlock ? "#eefee7" : "#f5edff";
ctx.strokeStyle = isPoisonBlock ? "#3c8f32" : "#6d4ca0";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
},
});
if (!controller) return false;
const deg = Math.round((Number(phyScalar(item, "motorSpeed", 0) || 0) || 0) * 180 / Math.PI);
const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12)));
if (titleEl) titleEl.textContent = isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u56de\u8ee2\u4f53";
const speedLabel = speed?.closest?.("label");
const poweredLabel = poweredInput?.closest?.("label");
if (speedLabel) speedLabel.style.display = isPoisonBlock ? "none" : "";
if (poweredLabel) poweredLabel.style.display = isPoisonBlock ? "none" : "";
if (poisonCollisionLabel) poisonCollisionLabel.style.display = "";
if (poisonCollisionInput) poisonCollisionInput.checked = phyScalar(item, "solid", true) !== false;
if (speed) speed.value = String(Math.max(-360, Math.min(360, deg)));
if (speedNum) speedNum.value = String(deg);
if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness);
if (poweredInput) { poweredInput.checked = phyScalar(item, "motorOn", true) !== false; poweredInput.disabled = Boolean(item._signalDriven); poweredInput.title = item._signalDriven ? "電線信号で制御中" : ""; }
editor.syncInputPair(speed, speedNum, controller.draw);
editor.syncInputPair(thick, thickNum, controller.draw);
const close = () => {
dialog.classList.add("hidden");
controller.close();
};
if (apply) apply.onclick = () => {
global.TarinaiHistory.capture(worldRef, isPoisonBlock ? "poison-block-edit" : "rotator-edit");
controller.cleanSegments();
const editedSegments = controller.getSegments();
physicsApi()?.setSegments?.(item, editedSegments, isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12))), "editor-thickness");
phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
if (isPoisonBlock) {
phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage");
} else {
phySet(item, "motorSpeed", (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180, "editor-speed");
if (!item._signalDriven) phySet(item, "motorOn", poweredInput ? Boolean(poweredInput.checked) : phyScalar(item, "motorOn", true) !== false, "editor-power");
if (phyScalar(item, "motorOn", true)) phySet(item, "spin", 0, "editor-spin-reset");
}
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, isPoisonBlock ? "poison-block-edited" : "rotator-edited")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
const extent = worldRef?.rotatorExtent?.(item);
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, isPoisonBlock ? "poison-block-link-remap" : "rotator-link-remap");
worldRef?.markSpatialDirty?.(isPoisonBlock ? "poison-block-edited" : "rotator-edited");
worldRef.drawListDirty = true;
worldRef?.log?.(isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002" : "\u56de\u8ee2\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe");
global.render?.();
close();
};
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
controller.draw();
dialog.classList.remove("hidden");
return true;
}
function ensureReciprocatorEditor() {
const dialog = ensureEditorDialog("reciprocatorEditor", "rotator-editor hidden", `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="reciprocatorEditorTitle">
<div class="rotator-editor-titlebar">
<h2 id="reciprocatorEditorTitle">\u5f80\u5fa9\u4f53</h2>
<button id="reciprocatorEditorClose" class="rotator-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
</div>
<div class="rotator-editor-controls">
<label>\u65b9\u5411 <input id="reciprocatorAngle" type="range" min="0" max="355" step="5"><input id="reciprocatorAngleNum" type="number" min="0" max="355" step="5"> \u5ea6</label>
<label>\u901f\u5ea6 <input id="railMotorSpeedControl" type="range" min="0" max="240" step="5"><input id="railMotorSpeedControlNum" type="number" min="0" max="360" step="5"> px/\u79d2</label>
<label>\u5f80\u5fa9\u5e45 <input id="railTravelControl" type="range" min="48" max="360" step="4"><input id="railTravelControlNum" type="number" min="24" max="520" step="4"> px</label>
<label>\u592a\u3055 <input id="reciprocatorThickness" type="range" min="4" max="30" step="1"><input id="reciprocatorThicknessNum" type="number" min="4" max="34" step="1"></label>
<label><input id="railMotorPowered" type="checkbox"> \u52d5\u529bON</label>
<label><input id="reciprocatorSolidEnabled" type="checkbox"> \u5f53\u305f\u308a\u5224\u5b9aON</label>
</div>
<div class="rotator-editor-tools" role="toolbar" aria-label="\u63cf\u753b\u65b9\u6cd5">
<button type="button" data-reciprocator-mode="line" class="active">\u2501 \u76f4\u7dda</button>
<button type="button" data-reciprocator-mode="free">\u3030 \u81ea\u7531\u63cf\u753b</button>
<button type="button" data-reciprocator-mode="erase">\u232b \u6d88\u3057\u30b4\u30e0</button>
<button type="button" data-reciprocator-template="bar">\u2503 \u68d2</button>
<button type="button" data-reciprocator-template="cross">\uff0b \u5341\u5b57</button>
<button type="button" data-reciprocator-template="circle">\u25cb \u5186</button>
<button id="reciprocatorUndo" type="button">\u21b6 \u4e00\u3064\u623b\u3059</button>
<button id="reciprocatorClear" type="button">\ud83d\uddd1 \u5168\u6d88\u3057</button>
</div>
<canvas id="reciprocatorCanvas" width="520" height="360" aria-label="\u5f80\u5fa9\u4f53\u306e\u8f2a\u90ed\u7de8\u96c6"></canvas>
<p class="rotator-editor-hint">\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002\u52d5\u529bOFF\u3067\u306f\u3001\u305f\u308a\u306a\u3044\u30fb\u305a\u3093\u3061\u30fb\u4ed6\u306e\u6a5f\u69cb\u304b\u3089\u62bc\u3055\u308c\u305f\u3068\u304d\u3060\u3051\u52d5\u304d\u307e\u3059\u3002</p>
<div class="rotator-editor-actions">
<button id="reciprocatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
<button id="reciprocatorApply" class="btn primary" type="button">\u53cd\u6620</button>
</div>
</div>`);
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "reciprocator");
return dialog;
}
function openReciprocatorEditor(item, worldRef) {
if (!item || item.type !== "reciprocator") return false;
const dialog = ensureReciprocatorEditor();
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "reciprocator");
const canvas = dialog.querySelector("#reciprocatorCanvas");
const ctx = canvas?.getContext?.("2d");
const angle = dialog.querySelector("#reciprocatorAngle");
const angleNum = dialog.querySelector("#reciprocatorAngleNum");
const speed = dialog.querySelector("#railMotorSpeedControl");
const speedNum = dialog.querySelector("#railMotorSpeedControlNum");
const travel = dialog.querySelector("#railTravelControl");
const travelNum = dialog.querySelector("#railTravelControlNum");
const thick = dialog.querySelector("#reciprocatorThickness");
const thickNum = dialog.querySelector("#reciprocatorThicknessNum");
const powered = dialog.querySelector("#railMotorPowered");
const solidInput = dialog.querySelector("#reciprocatorSolidEnabled");
const apply = dialog.querySelector("#reciprocatorApply");
const cancel = dialog.querySelector("#reciprocatorCancel");
const closeBtn = dialog.querySelector("#reciprocatorEditorClose");
if (!canvas || !ctx) return false;
const editor = global.TarinaiPhysicsShapeEditorSystem;
const controller = editor.createSegmentEditorController({
dialog,
canvas,
ctx,
prefix: "reciprocator",
segments: cloneSegmentsForRotator(item),
fallback: [-78, 0, 78, 0],
lineWidth: () => Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)),
eraseRadius: (lw) => Math.max(28, lw * 1.55),
strokeStyle: "rgba(80,150,205,0.92)",
drawBeforeSegments: () => {
const travelNow = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150));
ctx.save();
ctx.strokeStyle = "rgba(70,120,170,0.42)";
ctx.lineWidth = 2;
ctx.setLineDash([7, 6]);
ctx.beginPath(); ctx.moveTo(-travelNow * 0.5, 22); ctx.lineTo(travelNow * 0.5, 22); ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
},
drawCenter: () => {
ctx.fillStyle = "#eaf6ff";
ctx.strokeStyle = "#4d8cba";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
},
});
if (!controller) return false;
const axisRad = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", (itemAngleFor(item)));
const deg = Math.round((axisRad * 180 / Math.PI) % 360 + 360) % 360;
const speedVal = Math.round(Number(phyScalar(item, "railMotorSpeed", 92)) || 0);
const travelVal = Math.round(Number(phyScalar(item, "railTravel", 150)) || 150);
const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", 12) || 12) || 12));
if (angle) angle.value = String(Math.max(0, Math.min(355, deg)));
if (angleNum) angleNum.value = String(deg);
if (speed) speed.value = String(Math.max(0, Math.min(240, speedVal)));
if (speedNum) speedNum.value = String(speedVal);
if (travel) travel.value = String(Math.max(48, Math.min(360, travelVal)));
if (travelNum) travelNum.value = String(travelVal);
if (thick) thick.value = String(initialThickness);
if (thickNum) thickNum.value = String(initialThickness);
if (powered) { powered.checked = phyScalar(item, "railOn", true) !== false; powered.disabled = Boolean(item._signalDriven); powered.title = item._signalDriven ? "電線信号で制御中" : ""; }
if (solidInput) solidInput.checked = phyScalar(item, "solid", true) !== false;
editor.syncInputPair(angle, angleNum);
editor.syncInputPair(speed, speedNum);
editor.syncInputPair(travel, travelNum, controller.draw);
editor.syncInputPair(thick, thickNum, controller.draw);
const close = () => {
dialog.classList.add("hidden");
controller.close();
};
if (apply) apply.onclick = () => {
global.TarinaiHistory.capture(worldRef, "reciprocator-edit");
controller.cleanSegments();
const editedSegments = controller.getSegments();
const angleDeg = Math.max(0, Math.min(355, Number(angleNum?.value || angle?.value || 0) || 0));
const railAxis = normalizedItemAngle(angleDeg * Math.PI / 180, 0);
phySet(item, "railAxis", railAxis, "editor-rail-axis");
phySet(item, "railMotorSpeed", Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0)), "editor-rail-speed");
phySet(item, "railTravel", Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150)), "editor-rail-travel");
if (!item._signalDriven) phySet(item, "railOn", powered ? Boolean(powered.checked) : phyScalar(item, "railOn", true) !== false, "editor-rail-power");
phySet(item, "solid", solidInput ? Boolean(solidInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
if (phyScalar(item, "railOn", true)) phySet(item, "slideSpeed", 0, "editor-slide-reset");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)), "editor-thickness");
physicsApi()?.setSegments?.(item, editedSegments, "editor-segments");
const sa = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", itemAngleFor(item));
const halfTravel = Math.max(12, phyScalar(item, "railTravel", 150) * 0.5);
const phase = Number(phyScalar(item, "railPhase", 0) || 0) || 0;
phySet(item, "railAnchorX", item.x - Math.cos(sa) * halfTravel * phase, "editor-rail-anchor");
phySet(item, "railAnchorY", item.y - Math.sin(sa) * halfTravel * phase, "editor-rail-anchor");
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, "reciprocator-edited")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
const extent = worldRef?.rotatorExtent?.(item);
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, "reciprocator-link-remap");
worldRef?.markSpatialDirty?.("reciprocator-edited");
worldRef.drawListDirty = true;
worldRef?.log?.("\u5f80\u5fa9\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe");
global.render?.();
close();
};
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
controller.draw();
dialog.classList.remove("hidden");
return true;
}
function copyTargetAt(worldRef, x, y) {
let blockedReason = "";
const collision = global.TarinaiCollisionFootprints;
const items = worldRef?.items || [];
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i];
if (!it || it.dead) continue;
const type = String(it.type || "");
if (type === "trace" || type === "splat" || type === "ant_corpse") continue;
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(type)) {
const runtime = global.TarinaiLinkRuntime;
const pb = physicsApi();
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), worldRef);
const d = global.TarinaiGeometry.pointSegmentDistance(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y);
const tolerance = worldRef?.screenSizeToWorld ? worldRef.screenSizeToWorld(12) : 12;
if (d <= tolerance && !blockedReason) blockedReason = "link";
continue;
}
if (isPinType(type) && it.pinState === "lodged") {
const d = distXY(x, y, it.x, it.y);
if (d <= Math.max(24, (it.r || 12) * 1.8) && !blockedReason) blockedReason = "lodged_pin";
continue;
}
const d = distXY(x, y, it.x, it.y);
let result = null;
if (collision?.hitTestItem) {
const isPrecise = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(type) || worldRef?.isFenceType?.(type) || type === "nest_box" || type === "pipe");
result = collision.hitTestItem(worldRef, it, x, y, {
padding: isPrecise ? 10 : 4,
radiusMultiplier: isPrecise ? 1.12 : 1.18,
});
}
const hit = result ? Boolean(result.hit) : d <= Math.max(16, (it.r || 16) * 1.18);
if (!hit) continue;
return { item: it, reason: "" };
}
return { item: null, reason: blockedReason };
}
function makeCopyBufferForItem(item) {
if (!item) return null;
const data = {
type: item.type,
r: Number(item.r || itemRadiusFor?.(item.type, 12) || 12),
amount: Math.max(1, Number(item.amount || itemAmountFor?.(item.type, 80) || 80)),
angle: Number(item.angle || 0) || 0,
toolSize: item.toolSize || "medium",
foodServingScale: Number(item.foodServingScale || 1) || 1,
foodServingsMax: Number(item.foodServingsMax || 0) || 0,
seed: Number.isFinite(Number(item.seed)) ? Number(item.seed) : null,
spin: Number.isFinite(Number(item.spin)) ? Number(item.spin) : 0,
zunchiVariant: item.zunchiVariant || "",
grassStage: Number.isFinite(Number(item.grassStage)) ? Number(item.grassStage) : null,
growth: Number.isFinite(Number(item.growth)) ? Number(item.growth) : null,
health: Number.isFinite(Number(item.health)) ? Number(item.health) : null,
fuseMax: Number.isFinite(Number(item.fuseMax)) ? Number(item.fuseMax) : null,
};
if (item.type === "signboard") data.text = item.text || "";
if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen);
if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; }
if (item.type === "robot_cleaner") { data.robotCleanerMask = Number(item.robotCleanerMask ?? 3) | 0; data.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); }
if (item.type === "fan") {
data.fanSwingOn = Boolean(item.fanSwingOn);
data.fanSwingCenter = Number(item.fanSwingCenter ?? item.angle ?? 0) || 0;
data.fanSwingRange = Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0;
data.fanSwingSpeed = Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0;
data.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0;
data.fanBodyAngle = Number(item.fanBodyAngle ?? item.angle ?? 0) || 0;
}
if (item.type === "rotator") {
data.physics = { solid: phyScalar(item, "solid", true) !== false, motorOn: phyScalar(item, "motorOn", true) !== false, motorSpeed: Number(phyScalar(item, "motorSpeed", 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
}
if (item.type === "poison_block") {
data.physics = { solid: phyScalar(item, "solid", true) !== false, damage: Number(phyScalar(item, "damage", 7) || 7), thickness: Number(phyScalar(item, "thickness", 11) || 11), segments: cloneSegmentsForRotator(item) };
}
if (item.type === "reciprocator") {
data.physics = { solid: phyScalar(item, "solid", true) !== false, railOn: phyScalar(item, "railOn", true) !== false, railMotorSpeed: Number(phyScalar(item, "railMotorSpeed", 92) || 92), railTravel: Number(phyScalar(item, "railTravel", 150) || 150), railPhase: Number(phyScalar(item, "railPhase", 0) || 0), railDir: Number(phyScalar(item, "railDir", 1) || 1), railAxis: Number(phyScalar(item, "railAxis", item.angle || 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
}
return data;
}
function applyCopyBufferToItem(item, data) {
if (!item || !data) return item;
item.r = Number(data.r || item.r || itemRadiusFor?.(item.type, 12) || 12);
if (item.type === "duplicator") item.r = Math.min(24, Math.max(14, Number(item.r || 24) || 24));
item.amount = Math.max(1, Number(data.amount || item.amount || 1));
if (Number.isFinite(Number(data.seed))) item.seed = Number(data.seed);
if (Number.isFinite(Number(data.spin))) item.spin = Number(data.spin);
if (item.type === "zunchi") {
if (data.zunchiVariant) item.zunchiVariant = String(data.zunchiVariant);
item.stage = "fresh";
item.stageTimer = 0;
item.freshness = 1;
item.fertility = 0;
item.spawnGrace = Math.max(Number(item.spawnGrace || 0) || 0, 10);
}
if (item.type === "grass") {
if (Number.isFinite(Number(data.grassStage))) item.grassStage = Number(data.grassStage);
if (Number.isFinite(Number(data.growth))) item.growth = Number(data.growth);
if (Number.isFinite(Number(data.health))) item.health = Number(data.health);
}
if (item.type === "firecracker") {
if (Number.isFinite(Number(data.fuseMax))) item.fuseMax = Number(data.fuseMax);
item.fuseTimer = Math.max(0.1, Number(item.fuseMax || 5) || 5);
}
if (isRotatableItemType(item.type)) item.angle = Number(data.angle || 0) || 0;
if (item.type === "fan") {
item.fanSwingOn = Boolean(data.fanSwingOn);
item.fanSwingCenter = Number(data.fanSwingCenter ?? item.angle ?? 0) || 0;
item.fanSwingRange = Number(data.fanSwingRange ?? (35 * Math.PI / 180)) || 0;
item.fanSwingSpeed = Number(data.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0;
item.fanSwingPhase = Number(data.fanSwingPhase || 0) || 0;
item.fanBodyAngle = Number(data.fanBodyAngle ?? item.fanBodyAngle ?? item.angle ?? 0) || 0;
}
if (item.type === "signboard") item.text = String(data.text || "");
if (item.type === "gate_fence") item.gateOpen = Boolean(data.gateOpen);
if (item.type === "duplicator") {
item.storedFoodType = String(data.storedFoodType || "");
item.storedFoodLabel = String(data.storedFoodLabel || (item.storedFoodType ? toolLabel(item.storedFoodType) : ""));
if (item.roles) item.roles.food = Boolean(item.storedFoodType);
}
if (item.type === "robot_cleaner") {
const mask = Number(data.robotCleanerMask);
item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3);
item.robotCleanerHighSpeed = Boolean(data.robotCleanerHighSpeed);
item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60);
item.robotCleanerTargetId = "";
item.robotCleanerTargetType = "";
}
if (item.type === "rotator") {
const ph = data.physics || {};
phySet(item, "solid", ph.solid !== false, "copy");
phySet(item, "motorOn", ph.motorOn !== false, "copy");
phySet(item, "motorSpeed", Number(ph.motorSpeed || 0) || 0, "copy");
phySet(item, "spin", 0, "copy");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 12) || 12)), "copy");
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
item.r = Math.max(item.r || 64, Math.min(460, physicsShapeExtent(item)));
}
if (item.type === "poison_block") {
const ph = data.physics || {};
phySet(item, "solid", ph.solid !== false, "copy");
phySet(item, "damage", Math.max(1, Number(ph.damage || 7) || 7), "copy");
phySet(item, "spin", 0, "copy");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 11) || 11)), "copy");
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
const extent = physicsShapeExtent(item);
item.r = Math.max(item.r || 64, Math.min(460, extent));
}
if (item.type === "reciprocator") {
const ph = data.physics || {};
phySet(item, "solid", ph.solid !== false, "copy");
phySet(item, "railOn", ph.railOn !== false, "copy");
phySet(item, "railMotorSpeed", Math.max(0, Number(ph.railMotorSpeed || 92) || 92), "copy");
phySet(item, "railTravel", Math.max(24, Number(ph.railTravel || 150) || 150), "copy");
phySet(item, "railPhase", Math.max(-1, Math.min(1, Number(ph.railPhase || 0) || 0)), "copy");
phySet(item, "railDir", Number(ph.railDir || 1) || 1, "copy");
phySet(item, "railAxis", Number.isFinite(Number(ph.railAxis)) ? Number(ph.railAxis) : (Number(item.angle || 0) || 0), "copy");
phySet(item, "slideSpeed", 0, "copy");
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 12) || 12)), "copy");
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
const sa = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", itemAngleFor(item));
const halfTravel = Math.max(12, phyScalar(item, "railTravel", 150) * 0.5);
const phase = Number(phyScalar(item, "railPhase", 0) || 0) || 0;
phySet(item, "railAnchorX", item.x - Math.cos(sa) * halfTravel * phase, "copy");
phySet(item, "railAnchorY", item.y - Math.sin(sa) * halfTravel * phase, "copy");
const extent = physicsShapeExtent(item);
item.r = Math.max(item.r || 64, Math.min(460, extent));
}
if (global.TarinaiMechanicalSystem.isMechanicalType(item.type)) {
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, "item-data-restore")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
}
if (isServingFoodType(item.type)) {
item.toolSize = data.toolSize || item.toolSize || "medium";
item.foodServingsMax = Math.max(1, Number(data.foodServingsMax || item.foodServingsMax || item.amount || 1));
item.foodServingsRemaining = clamp(Number(data.foodServingsRemaining ?? item.foodServingsMax) || item.foodServingsMax, 0, item.foodServingsMax);
item.amount = item.foodServingsRemaining;
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
}
if (Number.isFinite(Number(item.x))) item.prevX = item.x;
if (Number.isFinite(Number(item.y))) item.prevY = item.y;
item.vx = 0;
item.vy = 0;
item.spinVelocity = 0;
if (isPinType(item.type)) {
global.TarinaiPinAttachmentSystem?.resetLooseState?.(item);
}
return item;
}
function ensureSignalItemEditor() {
return ensureEditorDialog("signalItemEditor", "rotator-editor hidden", `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signalItemEditorTitle">
<div class="rotator-editor-titlebar"><h2 id="signalItemEditorTitle">検知器設定</h2><button id="signalItemEditorClose" class="rotator-editor-close" type="button">×</button></div>
<div class="rotator-editor-controls">
<label>検知対象 <select id="pressureSwitchTarget"><option value="tarinai">たりない</option><option value="item">アイテム</option></select></label>
<label>起動個数 <input id="pressureSwitchCount" type="number" min="1" max="999" step="1"></label>
<label>横幅 <input id="pressureSwitchWidth" type="range" min="60" max="840" step="20"><input id="pressureSwitchWidthNum" type="number" min="60" max="840" step="20"> px</label>
<label>縦幅 <input id="pressureSwitchHeight" type="range" min="60" max="840" step="20"><input id="pressureSwitchHeightNum" type="number" min="60" max="840" step="20"> px</label>
</div>
<div class="rotator-editor-actions"><button id="signalItemEditorCancel" class="btn" type="button">キャンセル</button><button id="signalItemEditorApply" class="btn primary" type="button">保存</button></div>
</div>`);
}
function openSignalItemEditor(item, worldRef) {
if (!item || item.type !== "pressure_switch") return false;
const dialog = ensureSignalItemEditor();
const target = dialog.querySelector("#pressureSwitchTarget");
const count = dialog.querySelector("#pressureSwitchCount");
const width = dialog.querySelector("#pressureSwitchWidth");
const widthNum = dialog.querySelector("#pressureSwitchWidthNum");
const height = dialog.querySelector("#pressureSwitchHeight");
const heightNum = dialog.querySelector("#pressureSwitchHeightNum");
target.value = item.pressureTarget === "item" ? "item" : "tarinai";
count.value = String(item.pressureThreshold || 1);
width.value = widthNum.value = String(item.pressureWidth || 220);
height.value = heightNum.value = String(item.pressureHeight || 160);
const updateOverlay = () => {
worldRef._pressureSwitchOverlay = {
item,
width: Math.max(60, Math.min(840, Number(widthNum.value || width.value || 220) | 0)),
height: Math.max(60, Math.min(840, Number(heightNum.value || height.value || 160) | 0)),
};
global.render?.();
};
syncEditorInputPair(width, widthNum, updateOverlay);
syncEditorInputPair(height, heightNum, updateOverlay);
updateOverlay();
const close = () => {
dialog.classList.add("hidden");
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
global.render?.();
};
dialog.querySelector("#signalItemEditorApply").onclick = () => {
global.TarinaiHistory.capture(worldRef, "detector-edit");
item.pressureTarget = target.value === "item" ? "item" : "tarinai";
item.pressureThreshold = Math.max(1, Math.min(999, Number(count.value || 1) | 0));
item.pressureWidth = Math.max(60, Math.min(840, Number(widthNum.value || width.value || 220) | 0));
item.pressureHeight = Math.max(60, Math.min(840, Number(heightNum.value || height.value || 160) | 0));
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("detector-edit");
global.render?.();
close();
};
dialog.querySelector("#signalItemEditorCancel").onclick = close;
dialog.querySelector("#signalItemEditorClose").onclick = close;
dialog.onclick = e => { if (e.target === dialog) close(); };
dialog.classList.remove("hidden");
return true;
}
function isEditableItem(item) {
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan" || item.type === "pressure_switch");
}
function findEditableItemAt(worldRef, x, y) {
let best = null;
let bestD = Infinity;
for (let i = (worldRef?.items || []).length - 1; i >= 0; i--) {
const it = worldRef.items[i];
if (!isEditableItem(it)) continue;
let d = distXY(x, y, it.x, it.y);
let hit = d <= Math.max(30, (it.r || 24) * 1.35);
if (global.TarinaiMechanicalSystem.isMechanicalType(it.type) || worldRef?.isFenceType?.(it.type)) {
const result = global.TarinaiCollisionFootprints.hitTestItem(worldRef, it, x, y, { padding: 14, radiusMultiplier: 1.25 }) || { hit: false, distance: d };
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
}
if (hit && d < bestD) { best = it; bestD = d; }
}
return best;
}
function openEditableItemEditor(item, worldRef) {
if (!isEditableItem(item)) return false;
if (item.type === "signboard") return openSignboardEditor(item, worldRef);
if (item.type === "robot_cleaner") return openRobotCleanerEditor(item, worldRef);
if (item.type === "rotator" || item.type === "poison_block") return openRotatorEditor(item, worldRef);
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
if (item.type === "fan") return openFanEditor(item, worldRef);
if (item.type === "pressure_switch") return openSignalItemEditor(item, worldRef);
if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen;
worldRef?.markSpatialDirty?.("gate-toggle");
worldRef.drawListDirty = true;
worldRef?.log?.(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5\u3092\u958b\u3051\u305f\u3002" : "\u30b2\u30fc\u30c8\u67f5\u3092\u9589\u3081\u305f\u3002", "observe");
if (typeof showToast === "function") showToast(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5: \u958b" : "\u30b2\u30fc\u30c8\u67f5: \u9589");
global.render?.();
return true;
}
return false;
}
function isPlacementTool(tool) {
const def = toolDefinition(tool);
return Boolean(def?.placeable && toolItemType(tool));
}
function findDuplicatorAt(worldRef, x, y) {
let best = null;
let bestD = Infinity;
const runtime = global.TarinaiItemLifecycleRuntimeSupport;
for (let i = (worldRef?.items || []).length - 1; i >= 0; i -= 1) {
const it = worldRef.items[i];
if (!it || it.dead || it.type !== "duplicator") continue;
const r = it.r || 34;
const centerD = distXY(x, y, it.x, it.y);
const load = runtime?.duplicatorLoadPoint?.(it) || { x: it.x + r * 0.62, y: it.y + r * 0.24 };
const slotD = distXY(x, y, load.x, load.y);
const hit = Math.max(44, r * 1.45);
if (centerD <= hit || slotD <= Math.max(30, r * 0.86)) {
const d = Math.min(centerD, slotD);
if (d < bestD) { best = it; bestD = d; }
}
}
return best;
}
function directSetDuplicatorAt(worldRef, x, y, itemType = "") {
const type = String(itemType || "");
if (!type || type === "duplicator") return false;
const runtime = global.TarinaiItemLifecycleRuntimeSupport;
const loadType = runtime?.duplicatorLoadTypeForItem?.({ type, amount: 1, foodServingsRemaining: 1, dead: false }) || "";
if (!loadType) return false;
const duplicator = findDuplicatorAt(worldRef, x, y);
if (!duplicator) return false;
const ok = runtime?.duplicatorSetStoredType?.(duplicator, loadType, worldRef, { direct: true, force: false });
if (!ok) return false;
duplicator.duplicatorLoadSuppressedUntil = Math.max(duplicator.duplicatorLoadSuppressedUntil || 0, (worldRef?.time || 0) + 0.45);
if (typeof showToast === "function") showToast(`\u8907\u88fd\u6a5f: ${duplicator.storedFoodLabel || toolLabel(loadType)}\u3092\u30bb\u30c3\u30c8`);
worldRef.drawListDirty = true;
global.render?.();
return true;
}
function isPhysicsObstacleItemType(type = "") {
const key = String(type || "");
return Boolean(
key === "rope" || key === "rod" || key === "spring"
|| global.TarinaiMechanicalSystem.isMechanicalType(key)
|| (typeof isFenceItemType === "function" && isFenceItemType(key))
);
}
function isSoftPlacementIgnoredItemType(type = "") {
return global.TarinaiCollisionFootprints?.isSoftPlacementIgnoredType?.(type) || false;
}
function softItemRadius(item) {
if (!item) return 12;
return Math.max(8, Number(item.r || item.radius || itemRadiusFor?.(item.type, 12) || 12) || 12);
}
function placementRectsForItem(worldRef, item) {
const solid = worldRef?.solidObstacleRects?.(item) || [];
if (solid.length) return solid;
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || [];
return solid;
}
function placementItemOverlapsSoftItem(worldRef, placedItem, softItem) {
if (!worldRef || !placedItem || !softItem || softItem.dead) return false;
const r = softItemRadius(softItem);
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(placedItem.type)) {
const runtime = global.TarinaiLinkRuntime;
const pb = physicsApi();
const a = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 0), worldRef);
const b = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 1), worldRef);
if (a && b) {
const d = global.TarinaiGeometry.pointSegmentDistance(softItem.x, softItem.y, a.x, a.y, b.x, b.y);
return Number.isFinite(d) && d <= r + (placedItem.type === "rod" ? 9 : 7);
}
}
const rects = placementRectsForItem(worldRef, placedItem);
if (rects.length) return rects.some(rect => global.TarinaiCollisionFootprints.rectCircleOverlap(rect, softItem.x, softItem.y, r, 0.5));
const pr = Math.max(10, Number(placedItem.r || placedItem.radius || itemRadiusFor?.(placedItem.type, 12) || 12) || 12);
return distXY(placedItem.x, placedItem.y, softItem.x, softItem.y) <= pr + r;
}
function placementSoftSearchRadius(item) {
let search = Math.max(80, Number(item?.r || item?.radius || 36) + 90);
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) search = Math.max(search, (global.TarinaiMechanicalSystem.reach(item) || item.r || 80) + 96);
else if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(item?.type)) search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96);
return search;
}
function collectSoftUnderPlacement(worldRef, item) {
if (!worldRef || !item || item.type === "trace" || item.type === "splat") return [];
const out = [];
const search = placementSoftSearchRadius(item);
for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) {
if (!soft || soft === item || soft.dead || !isSoftPlacementIgnoredItemType(soft.type)) continue;
if (placementItemOverlapsSoftItem(worldRef, item, soft)) out.push(soft);
}
return out;
}
function clearSoftItemsUnderPlacementItem(worldRef, item, reason = "placement-soft-overlap-clear") {
const items = collectSoftUnderPlacement(worldRef, item);
let removed = 0;
for (const soft of items) {
const ok = global.TarinaiStructureLifecycle.deleteItem(worldRef, soft, {
reason,
userReason: "\u7f6e\u304d\u3082\u306e\u304c\u91cd\u306a\u3063\u305f",
wake: true,
});
if (ok) removed += 1;
}
if (removed) {
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.drawListDirty = true;
}
return removed;
}
function clearSoftItemsUnderPhysicsItem(worldRef, item, reason = "physics-overlap-clear") {
if (!worldRef || !item || !isPhysicsObstacleItemType(item.type)) return 0;
return clearSoftItemsUnderPlacementItem(worldRef, item, reason);
}
global.TarinaiPhysicsSoftClear = Object.freeze({
isSoftPlacementType: isSoftPlacementIgnoredItemType,
collectForPlacement: collectSoftUnderPlacement,
clearForItem: clearSoftItemsUnderPhysicsItem,
});
function ballPlacementBlocked(worldRef, item) {
if (!worldRef || !item || item.type !== "ball") return false;
const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback);
const r = Math.max(8, Number(item.r || item.radius || radiusFor(item.type, 18) || 18) || 18);
const search = Math.max(72, r * 3.2);
for (const other of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) {
if (!other || other === item || other.dead || other.type !== "ball") continue;
const or = Math.max(8, Number(other.r || other.radius || radiusFor(other.type, 18) || 18) || 18);
if (distXY(item.x, item.y, other.x, other.y) < r + or - 1.0) return true;
}
return false;
}
function clearGrassBedsForPlacement(worldRef, item) {
return clearSoftItemsUnderPlacementItem(worldRef, item, "placement-soft-overlap-clear");
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
directSetDuplicatorAt(x, y, itemType = "") {
return directSetDuplicatorAt(this, x, y, itemType);
},
copyPreviewItemAt(x, y) {
if (!this.copyBuffer?.type) return null;
const item = new Item(this.copyBuffer.type, x, y);
applyCopyBufferToItem(item, this.copyBuffer);
item.x = x; item.y = y;
item.world = this;
return item;
},
useCopyToolAt(x, y) {
if (!this.copyBuffer) {
const targetInfo = copyTargetAt(this, x, y);
const target = targetInfo.item;
if (!target) {
if (targetInfo.reason === "link") showToast("\u30ed\u30fc\u30d7\u3068\u68d2\u306f\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3002");
else if (targetInfo.reason === "lodged_pin") showToast("\u523a\u3055\u3063\u305f\u30d4\u30f3\u306f\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3002");
else showToast("\u30b3\u30d4\u30fc\u3067\u304d\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
return true;
}
this.copyBuffer = makeCopyBufferForItem(target);
showToast(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u307e\u3057\u305f\u3002`);
this.log?.(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u305f\u3002`, "observe");
return true;
}
const item = this.copyPreviewItemAt?.(x, y);
if (item) {
const placed = this.placeItem(item, false);
if (placed) {
placed.world = this;
if (placed.type === "grass" || placed.type === "trace" || placed.type === "splat") this.markTerrainDirtyAt?.(placed.x, placed.y, Math.max(placed.r || 24, 36), "copy-paste");
this._forceImmediateToolVisualRefresh?.("tool-change");
const label = toolLabel(item.type);
this.log?.(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u305f\u3002`, "observe");
showToast(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u307e\u3057\u305f\u3002`);
return true;
}
}
showToast("\u305d\u3053\u306b\u306f\u8cbc\u308a\u4ed8\u3051\u3067\u304d\u307e\u305b\u3093\u3002");
return true;
},
fencePlacementBlocked(item) {
if (!item || !this.isFenceType(item.type)) return false;
const rect = this.fenceRect(item);
if (!rect) return false;
const baseMargin = item.type === "gate_fence" ? 1.5 : Math.max(3, (item.r || 42) * 0.12);
if (rect.left < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.top < 0 || rect.bottom > this.h) return true;
const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 56;
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
if (!it || it === item || it.dead) continue;
const otherIsGate = it.type === "gate_fence";
const margin = (item.type === "gate_fence" || otherIsGate) ? 1.5 : baseMargin;
for (const r of this.solidObstacleRects(it)) {
if (global.TarinaiGeometry.rectsOverlap(rect, r, margin)) return true;
}
}
return false;
},
importantPlacementOverlapBlocked(item) {
return global.TarinaiCollisionFootprints.itemPlacementOverlapBlocked(this, item) ?? false;
},
placementBlocked(item) {
if (!item || item.dead) return true;
if (item.type === "genkotsu") return false;
const rects = this.solidObstacleRects(item);
const sharedBoundsBlocked = global.TarinaiPlacementPreviewSystem.boundsBlocked(this, item, { rects });
if (sharedBoundsBlocked === true) return true;
if (sharedBoundsBlocked !== false) {
const pad = CONFIG.worldPadding || 30;
if (rects.length) {
for (const rect of rects) {
if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true;
}
} else {
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true;
}
}
if (item.type === "grass" && (this.grassBlockedAt?.(item.x, item.y, item, { ignoreSoftPlacement: true }) || this.grassOnTarinaiAt?.(item.x, item.y))) return true;
if (ballPlacementBlocked(this, item)) return true;
if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true;
if (this.importantPlacementOverlapBlocked(item)) return true;
if (item.type === "nest_box") {
const margin = Math.max(8, (item.r || 42) * 0.18);
for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) {
if (!it || it === item || it.dead) continue;
for (const rect of this.solidObstacleRects(it)) {
for (const own of rects) {
const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom;
if (!separated) return true;
}
}
}
}
return false;
},
placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) {
const shared = global.TarinaiPlacementPreviewSystem.clampPointFor(this, item, x, y);
if (shared) return shared;
const pad = CONFIG.worldPadding || 30;
const rects = this.solidObstacleRects(item);
if (rects.length) {
let left = 0, right = 0, top = 0, bottom = 0;
for (const rect of rects) {
left = Math.max(left, (item.x || 0) - rect.left);
right = Math.max(right, rect.right - (item.x || 0));
top = Math.max(top, (item.y || 0) - rect.top);
bottom = Math.max(bottom, rect.bottom - (item.y || 0));
}
return {
x: clamp(x, pad + left, this.w - pad - right),
y: clamp(y, pad + top, this.h - pad - bottom),
};
}
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
return {
x: clamp(x, pad + radius, this.w - pad - radius),
y: clamp(y, pad + radius, this.h - pad - radius),
};
},
placementProbeItem(item, x, y) {
return { ...item, x, y, dead: false };
},
findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) {
if (!item || item.dead) return null;
if (item.type === "grass") {
const spot = this.findGrassPlantingSpot
? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true })
: null;
if (!spot) return null;
const probe = this.placementProbeItem(item, spot.x, spot.y);
return this.placementBlocked(probe) ? null : spot;
}
const start = this.placementClampPointFor(item, item.x, item.y);
const original = this.placementProbeItem(item, start.x, start.y);
if (allowOriginal && !this.placementBlocked(original)) return start;
const solid = this.isFenceType(item.type) || (item.type === "nest_box" || item.type === "pipe") || item.type === "rotator" || item.type === "reciprocator";
const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2));
const count = attempts || (solid ? 72 : 36);
const golden = Math.PI * (3 - Math.sqrt(5));
let best = null;
let bestD = Infinity;
for (let i = 0; i < count; i++) {
const t = count <= 1 ? 1 : i / (count - 1);
const radius = Math.max(8, searchRadius * Math.sqrt(t));
const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2;
const rawX = item.x + Math.cos(angle) * radius;
const rawY = item.y + Math.sin(angle) * radius * 0.74;
const p = this.placementClampPointFor(item, rawX, rawY);
const probe = this.placementProbeItem(item, p.x, p.y);
if (this.placementBlocked(probe)) continue;
const d = distXY(item.x, item.y, p.x, p.y);
if (d < bestD) { best = p; bestD = d; }
if (d < Math.max(18, (item.r || 12) * 0.75)) break;
}
return best;
},
findGrabTargetAt(x, y) {
let foundT = null;
for (let i = this.tarinai.length - 1; i >= 0; i--) {
const t = this.tarinai[i];
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue;
if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; }
}
let foundItem = null, foundItemD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type === "trace" || it.type === "splat" || it.type === "plushie") continue;
let hit = false;
let d = distXY(x, y, it.x, it.y);
if (global.TarinaiMechanicalSystem.isMechanicalType(it.type)) {
const result = global.TarinaiMechanicalSystem.hitTest(this, it, x, y, { padding: 16 });
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
} else {
const result = global.TarinaiCollisionFootprints.hitTestItem(this, it, x, y, { radiusMultiplier: (it.type === "ball" || it.type === "balloon") ? 4.0 : 2.2 }) || { hit: false, distance: d };
hit = Boolean(result.hit);
d = Math.min(d, result.distance ?? d);
}
if (hit && d < foundItemD) { foundItem = it; foundItemD = d; }
}
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "ball_lodged") {
const carrier = (this.items || []).find(it => it && !it.dead && it.type === "ball" && it.id === foundItem.pinBallId);
if (carrier) return carrier;
}
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem;
if (foundT) {
const lodged = foundT.currentLodgedPin?.() || (this.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === foundT.id);
if (lodged) return lodged;
}
if (foundT && foundItem) {
const td = distXY(x, y, foundT.x, foundT.y);
return td <= foundItemD + 8 ? foundT : foundItem;
}
return foundT || foundItem;
},
placeItem(item, dropped = true) {
if (!item) return null;
if (!(this.canAddObjects?.(1) ?? true)) {
showToast(`オブジェクト総数は${this.objectLimit}個までです。`);
return null;
}
if (item.type === "grass" && !this.canAddGrass?.(1)) {
showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
return null;
}
if (this.placementBlocked(item)) {
showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002");
return null;
}
if (dropped && (item.type === "genkotsu" || item.type === "stone" || item.type === "ball" || item.type === "balloon" || isPinType(item.type))) {
item.dropMax = item.type === "genkotsu" ? 0.22 : (item.type === "stone" ? 0.72 : 0.48);
item.dropTimer = item.dropMax;
item.dropImpactDone = false;
} else {
item.dropMax = 0;
item.dropTimer = 0;
item.dropImpactDone = true;
}
const removedSoft = clearGrassBedsForPlacement(this, item);
if (removedSoft) this.updateItemCounts?.("placement-soft-overlap-clear");
const placed = this.addItem?.(item, `place:${item.type}`);
if (!placed) {
if (!(this.canAddObjects?.(1) ?? true)) showToast(`オブジェクト総数は${this.objectLimit}個までです。`);
else if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
return null;
}
this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type });
this.drawListDirty = true;
this.ensureSpatial?.(`place:${item.type}`);
if (typeof global.render === "function") global.render();
return item;
},
handleClick(x, y) {
const tool = this.tool;
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new"].includes(tool)) {
const editable = findEditableItemAt(this, x, y);
if (editable && openEditableItemEditor(editable, this)) {
global.TarinaiCommands.setSelection(this, null) || (this.selected = null);
return;
}
}
if (tool === "observe") {
let found = null;
for (let i = this.tarinai.length - 1; i >= 0; i--) {
if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; }
}
if (!found) {
const editable = findEditableItemAt(this, x, y);
if (editable && openEditableItemEditor(editable, this)) {
global.TarinaiCommands.setSelection(this, null) || (this.selected = null);
return;
}
}
const wasSelectionEmpty = !this.selected;
if (found && wasSelectionEmpty) this._scrollSelectedDataTopOnNextSelection = true;
else this._scrollSelectedDataTopOnNextSelection = false;
global.TarinaiCommands.setSelection(this, found) || (this.selected = found);
return;
}
if (tool === "poke") {
let mechanismTarget = null;
let mechanismTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || !global.TarinaiMechanicalSystem.isMechanicalType(it.type)) continue;
const result = global.TarinaiMechanicalSystem.hitTest(this, it, x, y, { padding: 20 });
const extent = global.TarinaiMechanicalSystem.reach(it) || (it.r || 64);
const d = Math.min(distXY(x, y, it.x, it.y), result.distance ?? Infinity);
if ((result.hit || distXY(x, y, it.x, it.y) <= Math.max(46, extent + 22)) && d < mechanismTargetD) { mechanismTarget = it; mechanismTargetD = d; }
}
if (mechanismTarget) {
if (global.TarinaiMechanicalSystem.applyPokeImpulse(mechanismTarget, x, y, this)) {
const label = mechanismTarget.type === "rotator" ? "\u56de\u8ee2\u4f53" : (mechanismTarget.type === "poison_block" ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u5f80\u5fa9\u4f53");
const color = mechanismTarget.type === "rotator" ? "rgba(184,132,230,0.40)" : (mechanismTarget.type === "poison_block" ? "rgba(92,188,68,0.40)" : "rgba(90,150,214,0.40)");
this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color }));
this.drawListDirty = true;
audio.poke?.();
this.log(`${label}\u3092\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f\u3002`, "observe", { eventType: "mechanism_poke" });
return;
}
}
let plushieTarget = null;
let plushieTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type !== "plushie") continue;
const owner = it.carriedById ? this.liveTarinaiById?.(it.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === it.carriedById) : null;
const cx = owner ? owner.x : it.x;
const cy = owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : it.y;
const hitX = Math.max(72, (owner?.radius || 24) * 1.45, (it.r || 7) * 9.0);
const hitY = Math.max(86, (owner?.radius || 24) * 1.75, (it.r || 7) * 10.5);
const ndx = (x - cx) / hitX;
const ndy = (y - cy) / hitY;
const elliptical = ndx * ndx + ndy * ndy;
const d = Math.sqrt(Math.max(0, elliptical));
if (elliptical <= 1.0 && d < plushieTargetD) {
plushieTarget = it;
plushieTargetD = d;
}
}
if (plushieTarget) {
const owner = plushieTarget.carriedById ? this.liveTarinaiById?.(plushieTarget.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === plushieTarget.carriedById) : null;
if (owner && typeof plushieTarget.fling === "function") {
const side = Math.sign((owner.x || 0) - ((this.w || 0) / 2)) || Number(owner.facing || owner.faceDir || 1) || 1;
const speed = 1550 / 3;
const vx = side * speed;
const vy = -560 / 3;
if (plushieTarget.fling(this, x, y, { source: "poke", vx, vy, speed, life: 5.2, destroyOffscreen: true })) {
this.effects?.push(new Effect("ring", x, y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" }));
this.drawListDirty = true;
audio.poke?.();
return;
}
}
if (typeof plushieTarget.fling === "function") {
const cx = owner ? owner.x : plushieTarget.x;
const cy = owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : plushieTarget.y;
let dx = (Number(cx || 0) || 0) - (Number(x || 0) || 0);
let dy = (Number(cy || 0) || 0) - (Number(y || 0) || 0);
if (Math.hypot(dx, dy) < 12 || Math.abs(dx) < Math.abs(dy) * 0.36) {
dx = Math.sign(dx) || Number(owner?.facing || owner?.faceDir || 1) || 1;
dy = -0.42;
}
const len = Math.max(1, Math.hypot(dx, dy));
const speed = 1350 / 3;
const vx = dx / len * speed;
const vy = Math.min(dy / len * speed - 80, -120);
if (plushieTarget.fling(this, x, y, { source: "poke", vx, vy, speed, life: 4.8, destroyOffscreen: true })) {
this.effects?.push(new Effect("ring", x, y, { size: 24, life: 0.18, color: "rgba(190,142,92,0.34)" }));
this.drawListDirty = true;
audio.poke?.();
return;
}
}
}
let ballTarget = null;
let ballTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || (it.type !== "ball" && it.type !== "balloon")) continue;
const hit = Math.max(72, (it.r || 18) * 4.0);
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; }
}
if (ballTarget && this.pokeBall(ballTarget, x, y)) return;
const target = [...this.tarinai].reverse().find(t => !this.isTarinaiHiddenInNestBox?.(t) && t.contains(x, y));
if (target) {
target.poke();
if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] });
}
return;
}
if (tool === "new") {
const spot = this.edgeSpawnPoint(x, y, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 });
if (!t) { showToast(`たりない個体数は${this.tarinaiPopulationLimit}匹までです。`); return; }
t.wanderAngle = spot.angle;
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
return;
}
if (tool === "copy") { this.useCopyToolAt?.(x, y); return; }
if (this.useToolAt?.(x, y)) return;
const itemType = toolItemType(tool);
// In placement mode, clicks always mean placement.
// Editing rotators / reciprocators is reserved for observe or non-placement tools.
if (itemType && this.directSetDuplicatorAt?.(x, y, itemType)) return;
if (itemType) {
let px = x, py = y;
const rawItem = this.applyToolSize(new Item(itemType, px, py));
if (isRotatableItemType(itemType)) {
const requestedAngle = this.toolAngleFor ? this.toolAngleFor(itemType) : defaultItemAngle(itemType);
global.TarinaiPlacementPreviewSystem.applyToolOrientation(this, rawItem, requestedAngle);
}
// Placement uses the same footprint that the preview validates.
const placed = this.placeItem(rawItem, true);
if (!placed) return;
this._forceImmediateToolVisualRefresh?.("tool-change");
// Item placement intentionally no longer writes observation-log entries.
// Placement audio is still emitted through tool:placed.
}
},
inferLogKind(text) {
const s = String(text || "").toLowerCase();
if (s.includes("\u914d\u7f6e") || s.includes("\u8a2d\u7f6e") || s.includes("placed") || s.includes("selected")) return "observe";
if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident";
if (s.includes("fight") || s.includes("lost to") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051")) return "fight";
if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth";
if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:") || s.includes("\u6b7b\u56e0:")) return "death";
if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather";
if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food";
if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass";
if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe";
if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation";
return "note";
},
shouldHideFromObservationRecords(entry = {}) {
const type = typeof entry === "string" ? "" : String(entry.eventType || "");
const text = typeof entry === "string" ? String(entry || "") : String(entry.text || "");
if (type === "ball_poke" || type === "avoid_relationship" || type === "mechanism_poke") return true;
if (/^(player_|tool_|tool:)/.test(type) || type.includes("tool") || type.includes("player")) return true;
if (type.includes("robot")) return true;
if (/ロボ掃除機|掃除機/.test(text)) return true;
if (/範囲削除|コピーした|貼り付けた|つないだ|設定を変更|看板を書き換え|看板の文字を消した|を消した。|つついて動かした/.test(text)) return true;
return false;
},
log(text, kind = null, options = {}) {
const entry = {
time: this.time,
text,
kind: kind || this.inferLogKind(text),
eventType: options.eventType || "",
hiddenFromObservation: Boolean(options.hiddenFromObservation),
sound: options.sound === false ? false : true,
participants: (options.participants || []).filter(Boolean).map(t => ({
id: t.id || t.releasedLiveId || "",
liveId: t.id || t.releasedLiveId || "",
liveToken: t.liveToken || t.releasedLiveToken || 0,
familyKey: this.tarinaiFamilyKey(t),
name: t.name || "",
type: t.type || "",
generation: t.generation || 1,
})),
participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id),
participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({
liveId: t.id || t.releasedLiveId || "",
liveToken: t.liveToken || t.releasedLiveToken || 0,
familyKey: this.tarinaiFamilyKey(t),
name: t.name || "",
type: t.type || "",
generation: t.generation || 1,
})),
};
if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true;
const countInStats = options.countInStats ?? !entry.hiddenFromObservation;
entry.countInStats = Boolean(countInStats);
if (!this.eventCounters) this.eventCounters = {};
if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
for (const t of options.participants || []) {
if (!t) continue;
if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind);
}
this.logs.unshift(entry);
this.logs = this.logs.slice(0, 100);
this.emit("log:entry", { world: this, entry });
}
}));
})(typeof window !== "undefined" ? window : globalThis);