tarinai/js/world_placement_log.js
2026-06-26 22:35:26 +09:00

1453 lines
71 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 ensureSignboardEditor() {
let dialog = document.getElementById("signboardEditor");
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.id = "signboardEditor";
dialog.className = "signboard-editor hidden";
dialog.innerHTML = `
<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>`;
document.body.appendChild(dialog);
return dialog;
}
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");
dialog.__sign = null;
dialog.__worldRef = null;
};
const syncCount = () => {
if (!area || !count) return;
count.textContent = `${signboardVisibleLength(area.value || "")}/${SIGNBOARD_TEXT_LIMIT}`;
};
const commit = (value) => {
const next = sanitizeSignboardText(value);
sign.text = next;
sign.textEditedAt = worldRef?.time || 0;
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();
};
dialog.__sign = sign;
dialog.__worldRef = worldRef;
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 ensureRotatorEditor() {
let dialog = document.getElementById("rotatorEditor");
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.id = "rotatorEditor";
dialog.className = "rotator-editor hidden";
dialog.innerHTML = `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="rotatorEditorTitle">
<div class="rotator-editor-titlebar">
<h2 id="rotatorEditorTitle">回転体</h2>
<button id="rotatorEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button>
</div>
<div class="rotator-editor-controls">
<label>速度 <input id="rotatorSpeed" type="range" min="-360" max="360" step="5"><input id="rotatorSpeedNum" type="number" min="-720" max="720" step="5"> 度/秒</label>
<label>太さ <input id="rotatorThickness" type="range" min="4" max="30" step="1"><input id="rotatorThicknessNum" type="number" min="4" max="34" step="1"></label>
<label><input id="rotatorPowered" type="checkbox"> 動力ON</label>
</div>
<div class="rotator-editor-tools" role="toolbar" aria-label="描画方法">
<button type="button" data-rotator-mode="line" class="active">直線</button>
<button type="button" data-rotator-mode="free">自由描画</button>
<button type="button" data-rotator-mode="erase">消しゴム</button>
<button type="button" data-rotator-template="bar">棒</button>
<button type="button" data-rotator-template="cross">十字</button>
<button type="button" data-rotator-template="circle">円っぽい</button>
<button id="rotatorUndo" type="button">一つ戻す</button>
<button id="rotatorClear" type="button">全消し</button>
</div>
<canvas id="rotatorCanvas" width="520" height="360" aria-label="回転体の輪郭編集"></canvas>
<p class="rotator-editor-hint">中央が回転軸。直線・自由描画・消しゴムを選び、ドラッグで輪郭を編集。</p>
<div class="rotator-editor-actions">
<button id="rotatorCancel" class="btn" type="button">キャンセル</button>
<button id="rotatorApply" class="btn primary" type="button">反映</button>
</div>
</div>`;
document.body.appendChild(dialog);
return dialog;
}
function cloneSegmentsForRotator(item) {
const src = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]];
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 openRotatorEditor(item, worldRef) {
if (!item || item.type !== "rotator") return false;
const dialog = ensureRotatorEditor();
const canvas = dialog.querySelector("#rotatorCanvas");
const ctx = canvas?.getContext?.("2d");
const speed = dialog.querySelector("#rotatorSpeed");
const speedNum = dialog.querySelector("#rotatorSpeedNum");
const thick = dialog.querySelector("#rotatorThickness");
const thickNum = dialog.querySelector("#rotatorThicknessNum");
const poweredInput = dialog.querySelector("#rotatorPowered");
const apply = dialog.querySelector("#rotatorApply");
const cancel = dialog.querySelector("#rotatorCancel");
const closeBtn = dialog.querySelector("#rotatorEditorClose");
const undo = dialog.querySelector("#rotatorUndo");
const clear = dialog.querySelector("#rotatorClear");
if (!canvas || !ctx) return false;
let mode = "line";
let segments = cloneSegmentsForRotator(item);
let draft = null;
let drawing = false;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const deg = Math.round((Number(item.rotatorSpeed || 0) || 0) * 180 / Math.PI);
const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 12) || 12));
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 = item.rotatorPowered !== false;
const toLocal = (e) => {
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
};
const cleanSegments = () => {
const out = [];
for (const seg of segments) {
const x1 = Math.max(-420, Math.min(420, Number(seg[0]) || 0));
const y1 = Math.max(-420, Math.min(420, Number(seg[1]) || 0));
const x2 = Math.max(-420, Math.min(420, Number(seg[2]) || 0));
const y2 = Math.max(-420, Math.min(420, Number(seg[3]) || 0));
if (Math.hypot(x2 - x1, y2 - y1) >= 4) out.push([x1, y1, x2, y2]);
if (out.length >= 96) break;
}
if (!out.length) out.push([-78, 0, 78, 0]);
segments = out;
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.fillStyle = "#f7f3ea";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.translate(cx, cy);
ctx.strokeStyle = "rgba(100,80,60,0.16)";
ctx.lineWidth = 1;
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
ctx.strokeStyle = "rgba(70,60,50,0.38)";
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(127,92,190,0.92)";
ctx.lineWidth = lw;
ctx.beginPath();
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,0.58)";
ctx.lineWidth = Math.max(1.3, lw * 0.20);
ctx.beginPath();
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
if (draft) {
ctx.setLineDash([7, 5]);
ctx.strokeStyle = "rgba(60,130,210,0.85)";
ctx.lineWidth = Math.max(2, lw * 0.45);
ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke();
ctx.setLineDash([]);
}
ctx.fillStyle = "#f5edff";
ctx.strokeStyle = "#6d4ca0";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.restore();
};
const setMode = (next) => {
mode = next === "free" || next === "erase" ? next : "line";
for (const btn of dialog.querySelectorAll("[data-rotator-mode]")) btn.classList.toggle("active", btn.dataset.rotatorMode === mode);
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
};
const pointSegmentDistance = (p, seg) => {
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
};
const eraseAt = (p) => {
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
const radius = Math.max(24, lw * 1.35);
const before = segments.length;
segments = segments.filter(seg => pointSegmentDistance(p, seg) > radius);
if (segments.length !== before) { draft = null; draw(); return true; }
draw();
ctx.save();
ctx.translate(cx, cy);
ctx.globalAlpha = 0.42;
ctx.strokeStyle = "rgba(210,70,60,0.86)";
ctx.lineWidth = 1.6;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
return false;
};
const setTemplate = (name) => {
if (name === "bar") segments = [[-130, 0, 130, 0]];
else if (name === "cross") segments = [[-118, 0, 118, 0], [0, -90, 0, 90]];
else if (name === "circle") {
segments = [];
const n = 18, r = 108;
for (let i = 0; i < n; i += 1) {
const a = i / n * Math.PI * 2;
const b = (i + 1) / n * Math.PI * 2;
segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]);
}
}
cleanSegments(); draw();
};
const syncPair = (a, b) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; draw(); }; b.oninput = () => { a.value = b.value; draw(); }; };
syncPair(speed, speedNum);
syncPair(thick, thickNum);
dialog.querySelectorAll("[data-rotator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.rotatorMode); });
dialog.querySelectorAll("[data-rotator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.rotatorTemplate); });
canvas.onpointerdown = (e) => {
canvas.setPointerCapture?.(e.pointerId);
const p = toLocal(e);
drawing = true;
if (mode === "erase") {
draft = null;
eraseAt(p);
return;
}
draft = [p.x, p.y, p.x, p.y];
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
draw();
};
canvas.onpointermove = (e) => {
if (!drawing) return;
const p = toLocal(e);
if (mode === "erase") { eraseAt(p); return; }
if (!draft) return;
if (mode === "free") {
const last = segments[segments.length - 1];
if (!last) return;
const lx = last[2], ly = last[3];
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
last[2] = p.x; last[3] = p.y;
segments.push([p.x, p.y, p.x, p.y]);
}
} else {
draft[2] = p.x; draft[3] = p.y;
}
draw();
};
canvas.onpointerup = (e) => {
if (!drawing) return;
drawing = false;
const p = toLocal(e);
if (mode === "erase") { draft = null; draw(); return; }
if (!draft) return;
if (mode === "line") {
const seg = [draft[0], draft[1], p.x, p.y];
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
}
draft = null;
cleanSegments();
draw();
};
if (undo) undo.onclick = () => { segments.pop(); cleanSegments(); draw(); };
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
const close = () => {
dialog.classList.add("hidden");
canvas.style.cursor = "";
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
};
if (apply) apply.onclick = () => {
global.TarinaiHistory?.capture?.(worldRef, "rotator-edit");
cleanSegments();
item.rotatorSegments = segments.map(seg => seg.slice(0, 4));
item.rotatorSpeed = (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180;
item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
item.rotatorPowered = poweredInput ? Boolean(poweredInput.checked) : item.rotatorPowered !== false;
if (item.rotatorPowered) item.rotatorAngularVelocity = 0;
const extent = worldRef?.rotatorExtent?.(item);
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
item.rotatorEditorOpenAt = worldRef?.time || 0;
global.TarinaiLinkRuntime?.remapLinksForEditedMechanicalItem?.(worldRef, item, "rotator-link-remap");
worldRef?.markSpatialDirty?.("rotator-edited");
worldRef.drawListDirty = true;
worldRef?.log?.("回転体の設計を変更した。", "observe");
global.render?.();
close();
};
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
setMode(mode);
draw();
dialog.classList.remove("hidden");
return true;
}
function ensureReciprocatorEditor() {
let dialog = document.getElementById("reciprocatorEditor");
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.id = "reciprocatorEditor";
dialog.className = "rotator-editor hidden";
dialog.innerHTML = `
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="reciprocatorEditorTitle">
<div class="rotator-editor-titlebar">
<h2 id="reciprocatorEditorTitle">往復体</h2>
<button id="reciprocatorEditorClose" class="rotator-editor-close" type="button" aria-label="閉じる">×</button>
</div>
<div class="rotator-editor-controls">
<label>方向 <input id="reciprocatorAngle" type="range" min="0" max="355" step="5"><input id="reciprocatorAngleNum" type="number" min="0" max="355" step="5"> 度</label>
<label>速度 <input id="reciprocatorSpeed" type="range" min="0" max="240" step="5"><input id="reciprocatorSpeedNum" type="number" min="0" max="360" step="5"> px/秒</label>
<label>往復幅 <input id="reciprocatorTravel" type="range" min="48" max="360" step="4"><input id="reciprocatorTravelNum" type="number" min="24" max="520" step="4"> px</label>
<label>太さ <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="reciprocatorPowered" type="checkbox"> 動力ON</label>
</div>
<div class="rotator-editor-tools" role="toolbar" aria-label="描画方法">
<button type="button" data-reciprocator-mode="line" class="active">直線</button>
<button type="button" data-reciprocator-mode="free">自由描画</button>
<button type="button" data-reciprocator-mode="erase">消しゴム</button>
<button type="button" data-reciprocator-template="bar">棒</button>
<button type="button" data-reciprocator-template="cross">十字</button>
<button type="button" data-reciprocator-template="circle">円っぽい</button>
<button id="reciprocatorUndo" type="button">一つ戻す</button>
<button id="reciprocatorClear" type="button">全消し</button>
</div>
<canvas id="reciprocatorCanvas" width="520" height="360" aria-label="往復体の輪郭編集"></canvas>
<p class="rotator-editor-hint">中央の輪郭全体が、設定した方向へ往復移動します。動力OFFでは、たりない・ずんち・他の機構から押されたときだけ動きます。</p>
<div class="rotator-editor-actions">
<button id="reciprocatorCancel" class="btn" type="button">キャンセル</button>
<button id="reciprocatorApply" class="btn primary" type="button">反映</button>
</div>
</div>`;
document.body.appendChild(dialog);
return dialog;
}
function openReciprocatorEditor(item, worldRef) {
if (!item || item.type !== "reciprocator") return false;
const dialog = ensureReciprocatorEditor();
const canvas = dialog.querySelector("#reciprocatorCanvas");
const ctx = canvas?.getContext?.("2d");
const angle = dialog.querySelector("#reciprocatorAngle");
const angleNum = dialog.querySelector("#reciprocatorAngleNum");
const speed = dialog.querySelector("#reciprocatorSpeed");
const speedNum = dialog.querySelector("#reciprocatorSpeedNum");
const travel = dialog.querySelector("#reciprocatorTravel");
const travelNum = dialog.querySelector("#reciprocatorTravelNum");
const thick = dialog.querySelector("#reciprocatorThickness");
const thickNum = dialog.querySelector("#reciprocatorThicknessNum");
const powered = dialog.querySelector("#reciprocatorPowered");
const apply = dialog.querySelector("#reciprocatorApply");
const cancel = dialog.querySelector("#reciprocatorCancel");
const closeBtn = dialog.querySelector("#reciprocatorEditorClose");
const undo = dialog.querySelector("#reciprocatorUndo");
const clear = dialog.querySelector("#reciprocatorClear");
if (!canvas || !ctx) return false;
let mode = "line";
let segments = cloneSegmentsForRotator(item);
let draft = null;
let drawing = false;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const axisRad = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0)));
const deg = Math.round((axisRad * 180 / Math.PI) % 360 + 360) % 360;
const speedVal = Math.round(Number(item.reciprocatorSpeed || 92) || 0);
const travelVal = Math.round(Number(item.reciprocatorTravel || 150) || 150);
const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 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 = item.reciprocatorPowered !== false;
const toLocal = (e) => {
const rect = canvas.getBoundingClientRect();
const sx = canvas.width / Math.max(1, rect.width);
const sy = canvas.height / Math.max(1, rect.height);
return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy };
};
const cleanSegments = () => {
const out = [];
for (const seg of segments) {
const x1 = Math.max(-420, Math.min(420, Number(seg[0]) || 0));
const y1 = Math.max(-420, Math.min(420, Number(seg[1]) || 0));
const x2 = Math.max(-420, Math.min(420, Number(seg[2]) || 0));
const y2 = Math.max(-420, Math.min(420, Number(seg[3]) || 0));
if (Math.hypot(x2 - x1, y2 - y1) >= 4) out.push([x1, y1, x2, y2]);
if (out.length >= 96) break;
}
if (!out.length) out.push([-78, 0, 78, 0]);
segments = out;
};
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.fillStyle = "#f7f3ea";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.translate(cx, cy);
ctx.strokeStyle = "rgba(100,80,60,0.16)";
ctx.lineWidth = 1;
for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); }
for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); }
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();
ctx.strokeStyle = "rgba(70,60,50,0.38)";
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke();
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(80,150,205,0.92)";
ctx.lineWidth = lw;
ctx.beginPath();
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
ctx.strokeStyle = "rgba(255,255,255,0.58)";
ctx.lineWidth = Math.max(1.3, lw * 0.20);
ctx.beginPath();
for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); }
ctx.stroke();
if (draft) {
ctx.setLineDash([7, 5]);
ctx.strokeStyle = "rgba(60,130,210,0.85)";
ctx.lineWidth = Math.max(2, lw * 0.45);
ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke();
ctx.setLineDash([]);
}
ctx.fillStyle = "#eaf6ff";
ctx.strokeStyle = "#4d8cba";
ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.restore();
};
const setMode = (next) => {
mode = next === "free" || next === "erase" ? next : "line";
for (const btn of dialog.querySelectorAll("[data-reciprocator-mode]")) btn.classList.toggle("active", btn.dataset.reciprocatorMode === mode);
canvas.style.cursor = mode === "erase" ? "cell" : "crosshair";
};
const pointSegmentDistance = (p, seg) => {
if (!p || !Array.isArray(seg) || seg.length < 4) return Infinity;
const x1 = Number(seg[0]) || 0, y1 = Number(seg[1]) || 0;
const x2 = Number(seg[2]) || 0, y2 = Number(seg[3]) || 0;
const dx = x2 - x1, dy = y2 - y1;
const lenSq = dx * dx + dy * dy;
if (lenSq <= 0.0001) return Math.hypot(p.x - x1, p.y - y1);
const u = Math.max(0, Math.min(1, ((p.x - x1) * dx + (p.y - y1) * dy) / lenSq));
return Math.hypot(p.x - (x1 + dx * u), p.y - (y1 + dy * u));
};
const eraseAt = (p) => {
const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
const radius = Math.max(28, lw * 1.55);
const before = segments.length;
segments = segments.filter(seg => pointSegmentDistance(p, seg) > radius);
if (segments.length !== before) { draft = null; draw(); return true; }
draw();
ctx.save();
ctx.translate(cx, cy);
ctx.globalAlpha = 0.42;
ctx.strokeStyle = "rgba(210,70,60,0.86)";
ctx.lineWidth = 1.6;
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.arc(p.x, p.y, radius, 0, Math.PI * 2); ctx.stroke();
ctx.restore();
return false;
};
const setTemplate = (name) => {
if (name === "bar") segments = [[-130, 0, 130, 0]];
else if (name === "cross") segments = [[-118, 0, 118, 0], [0, -90, 0, 90]];
else if (name === "circle") {
segments = [];
const n = 18, r = 108;
for (let i = 0; i < n; i += 1) {
const a = i / n * Math.PI * 2;
const b = (i + 1) / n * Math.PI * 2;
segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]);
}
}
cleanSegments(); draw();
};
const syncPair = (a, b, redraw = false) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; if (redraw) draw(); }; b.oninput = () => { a.value = b.value; if (redraw) draw(); }; };
syncPair(angle, angleNum, false);
syncPair(speed, speedNum, false);
syncPair(travel, travelNum, true);
syncPair(thick, thickNum, true);
dialog.querySelectorAll("[data-reciprocator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.reciprocatorMode); });
dialog.querySelectorAll("[data-reciprocator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.reciprocatorTemplate); });
canvas.onpointerdown = (e) => {
canvas.setPointerCapture?.(e.pointerId);
const p = toLocal(e);
drawing = true;
if (mode === "erase") { draft = null; eraseAt(p); return; }
draft = [p.x, p.y, p.x, p.y];
if (mode === "free") segments.push([p.x, p.y, p.x, p.y]);
draw();
};
canvas.onpointermove = (e) => {
if (!drawing) return;
const p = toLocal(e);
if (mode === "erase") { eraseAt(p); return; }
if (!draft) return;
if (mode === "free") {
const last = segments[segments.length - 1];
if (!last) return;
const lx = last[2], ly = last[3];
if (Math.hypot(p.x - lx, p.y - ly) >= 5) {
last[2] = p.x; last[3] = p.y;
segments.push([p.x, p.y, p.x, p.y]);
}
} else {
draft[2] = p.x; draft[3] = p.y;
}
draw();
};
canvas.onpointerup = (e) => {
if (!drawing) return;
drawing = false;
const p = toLocal(e);
if (mode === "erase") { draft = null; draw(); return; }
if (!draft) return;
if (mode === "line") {
const seg = [draft[0], draft[1], p.x, p.y];
if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg);
}
draft = null;
cleanSegments();
draw();
};
if (undo) undo.onclick = () => { segments.pop(); cleanSegments(); draw(); };
if (clear) clear.onclick = () => { segments = []; draft = null; draw(); };
const close = () => {
dialog.classList.add("hidden");
canvas.style.cursor = "";
canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null;
};
if (apply) apply.onclick = () => {
global.TarinaiHistory?.capture?.(worldRef, "reciprocator-edit");
cleanSegments();
const angleDeg = Math.max(0, Math.min(355, Number(angleNum?.value || angle?.value || 0) || 0));
item.reciprocatorAxisAngle = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angleDeg * Math.PI / 180, 0) : angleDeg * Math.PI / 180;
item.reciprocatorSpeed = Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0));
item.reciprocatorTravel = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150));
item.reciprocatorPowered = powered ? Boolean(powered.checked) : item.reciprocatorPowered !== false;
if (item.reciprocatorPowered) item.reciprocatorVelocity = 0;
item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12));
item.rotatorSegments = segments.map(seg => seg.slice(0, 4));
const sa = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0)));
const halfTravel = Math.max(12, item.reciprocatorTravel * 0.5);
item.reciprocatorAnchorX = item.x - Math.cos(sa) * halfTravel * (Number(item.reciprocatorPhase || 0) || 0);
item.reciprocatorAnchorY = item.y - Math.sin(sa) * halfTravel * (Number(item.reciprocatorPhase || 0) || 0);
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?.("往復体の設計を変更した。", "observe");
global.render?.();
close();
};
if (cancel) cancel.onclick = close;
if (closeBtn) closeBtn.onclick = close;
dialog.onclick = (e) => { if (e.target === dialog) close(); };
setMode(mode);
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 (type === "rope" || type === "rod") {
const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(it.linkA, worldRef);
const b = runtime?.endpointWorld?.(it.linkB, worldRef);
const d = runtime?.pointSegmentDistance?.(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y) ?? Infinity;
const tolerance = worldRef?.screenSizeToWorld ? worldRef.screenSizeToWorld(12) : 12;
if (d <= tolerance && !blockedReason) blockedReason = "link";
continue;
}
if (typeof isPinType === "function" && 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");
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 copyableItemAt(worldRef, x, y) {
return copyTargetAt(worldRef, x, y).item;
}
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 === "rotator") {
data.rotatorPowered = item.rotatorPowered !== false;
data.rotatorSpeed = Number(item.rotatorSpeed || 0) || 0;
data.rotatorThickness = Number(item.rotatorThickness || 12) || 12;
data.rotatorSegments = cloneSegmentsForRotator(item);
}
if (item.type === "reciprocator") {
data.reciprocatorPowered = item.reciprocatorPowered !== false;
data.reciprocatorSpeed = Number(item.reciprocatorSpeed || 92) || 92;
data.reciprocatorTravel = Number(item.reciprocatorTravel || 150) || 150;
data.reciprocatorPhase = Number(item.reciprocatorPhase || 0) || 0;
data.reciprocatorDirection = Number(item.reciprocatorDirection || 1) || 1;
data.reciprocatorAxisAngle = Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : Number(item.angle || 0) || 0;
data.rotatorThickness = Number(item.rotatorThickness || 12) || 12;
data.rotatorSegments = 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);
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 (typeof isRotatableItemType === "function" && isRotatableItemType(item.type)) item.angle = Number(data.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 === "rotator") {
item.rotatorPowered = data.rotatorPowered !== false;
item.rotatorSpeed = Number(data.rotatorSpeed || 0) || 0;
item.rotatorAngularVelocity = 0;
item.rotatorThickness = Math.max(4, Math.min(34, Number(data.rotatorThickness || 12) || 12));
item.rotatorSegments = Array.isArray(data.rotatorSegments) ? data.rotatorSegments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item);
item.r = Math.max(item.r || 64, Math.min(460, Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8));
}
if (item.type === "reciprocator") {
item.reciprocatorPowered = data.reciprocatorPowered !== false;
item.reciprocatorSpeed = Math.max(0, Number(data.reciprocatorSpeed || 92) || 92);
item.reciprocatorTravel = Math.max(24, Number(data.reciprocatorTravel || 150) || 150);
item.reciprocatorPhase = Math.max(-1, Math.min(1, Number(data.reciprocatorPhase || 0) || 0));
item.reciprocatorDirection = Number(data.reciprocatorDirection || 1) || 1;
item.reciprocatorAxisAngle = Number.isFinite(Number(data.reciprocatorAxisAngle)) ? Number(data.reciprocatorAxisAngle) : (Number(item.reciprocatorAxisAngle || item.angle || 0) || 0);
item.reciprocatorVelocity = 0;
item.rotatorThickness = Math.max(4, Math.min(34, Number(data.rotatorThickness || 12) || 12));
item.rotatorSegments = Array.isArray(data.rotatorSegments) ? data.rotatorSegments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item);
const sa = window.TarinaiMechanicalSystem?.reciprocatorAxisAngle?.(item) ?? (Number.isFinite(Number(item.reciprocatorAxisAngle)) ? Number(item.reciprocatorAxisAngle) : (typeof itemAngleFor === "function" ? itemAngleFor(item) : (Number(item.angle) || 0)));
const halfTravel = Math.max(12, item.reciprocatorTravel * 0.5);
item.reciprocatorAnchorX = item.x - Math.cos(sa) * halfTravel * item.reciprocatorPhase;
item.reciprocatorAnchorY = item.y - Math.sin(sa) * halfTravel * item.reciprocatorPhase;
const extent = Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8;
item.r = Math.max(item.r || 64, Math.min(460, extent));
}
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) {
item.toolSize = data.toolSize || item.toolSize || "medium";
item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1;
item.foodServingsMax = Math.max(1, Number(data.foodServingsMax || item.foodServingsMax || item.amount || 1));
item.foodServingsRemaining = item.foodServingsMax;
item.amount = item.foodServingsRemaining;
}
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 (typeof isPinType === "function" && isPinType(item.type)) {
item.pinState = "loose";
item.pinTargetId = "";
item.pinAttachAngle = 0;
item.pinAttachDistance = 0;
item.pinOffsetY = 0;
item.pinDamageTick = 0;
item.pinFallCheckTimer = 0;
item.pinLogAt = -999;
}
return item;
}
function isEditableItem(item) {
return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator" || item.type === "reciprocator");
}
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 === "rotator") return openRotatorEditor(item, worldRef);
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
if (item.type === "gate_fence") {
item.gateOpen = !item.gateOpen;
item.gateLastToggleAt = worldRef?.time || 0;
worldRef?.markSpatialDirty?.("gate-toggle");
worldRef.drawListDirty = true;
worldRef?.log?.(item.gateOpen ? "ゲート柵を開けた。" : "ゲート柵を閉めた。", "observe");
if (typeof showToast === "function") showToast(item.gateOpen ? "ゲート柵: 開" : "ゲート柵: 閉");
global.render?.();
return true;
}
return false;
}
function isPlacementTool(tool) {
const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null;
return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(tool));
}
function findDuplicatorAt(worldRef, x, y) {
let best = null;
let bestD = Infinity;
const runtime = global.TarinaiDuplicatorRuntime;
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?.loadPoint?.(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.TarinaiDuplicatorRuntime;
const loadType = runtime?.loadTypeForItem?.({ type, amount: 1, foodServingsRemaining: 1, dead: false }) || "";
if (!loadType) return false;
const duplicator = findDuplicatorAt(worldRef, x, y);
if (!duplicator) return false;
const ok = runtime?.setStoredType?.(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(`複製機: ${duplicator.storedFoodLabel || toolLabel(loadType)}をセット`);
worldRef.drawListDirty = true;
global.render?.();
return true;
}
function isPhysicsObstacleItemType(type = "") {
const key = String(type || "");
return Boolean(
key === "rope" || key === "rod"
|| global.TarinaiMechanicalSystem?.isMechanicalType?.(key)
|| (typeof isFenceItemType === "function" && isFenceItemType(key))
);
}
function isSoftPhysicsIgnoredItemType(type = "") {
const key = String(type || "");
return key === "water" || key === "grass_bed";
}
function softItemRadius(item) {
if (!item) return 12;
return Math.max(8, Number(item.r || item.radius || itemRadiusFor?.(item.type, 12) || 12) || 12);
}
function physicsItemOverlapsSoftItem(worldRef, physicsItem, softItem) {
if (!worldRef || !physicsItem || !softItem || softItem.dead) return false;
const r = softItemRadius(softItem);
if (physicsItem.type === "rope" || physicsItem.type === "rod") {
const runtime = global.TarinaiLinkRuntime;
const a = runtime?.endpointWorld?.(physicsItem.linkA, worldRef);
const b = runtime?.endpointWorld?.(physicsItem.linkB, worldRef);
if (a && b) {
const d = runtime?.pointSegmentDistance?.(softItem.x, softItem.y, a.x, a.y, b.x, b.y);
return Number.isFinite(d) && d <= r + (physicsItem.type === "rod" ? 9 : 7);
}
}
const rects = worldRef.solidObstacleRects?.(physicsItem) || [];
if (rects.length) {
return rects.some(rect => global.TarinaiCollisionFootprints?.rectCircleOverlap?.(rect, softItem.x, softItem.y, r, 0.5));
}
const pr = Math.max(10, Number(physicsItem.r || physicsItem.radius || itemRadiusFor?.(physicsItem.type, 12) || 12) || 12);
return distXY(physicsItem.x, physicsItem.y, softItem.x, softItem.y) <= pr + r;
}
function clearSoftItemsUnderPhysicsItem(worldRef, item, reason = "physics-overlap-clear") {
if (!worldRef || !item || !isPhysicsObstacleItemType(item.type)) return 0;
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 (item.type === "rope" || item.type === "rod") {
search = Math.max(search, Number(item.linkLength || (item.r || 40) * 2 || 80) * 0.55 + 96);
}
let removed = 0;
for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) {
if (!soft || soft === item || soft.dead || !isSoftPhysicsIgnoredItemType(soft.type)) continue;
if (!physicsItemOverlapsSoftItem(worldRef, item, soft)) continue;
const ok = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, soft, {
reason,
userReason: "物理アイテムに重なった",
wake: true,
});
if (ok) removed += 1;
}
if (removed) {
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.drawListDirty = true;
}
return removed;
}
global.TarinaiPhysicsSoftClear = Object.freeze({
isPhysicsType: isPhysicsObstacleItemType,
isSoftType: isSoftPhysicsIgnoredItemType,
overlaps: physicsItemOverlapsSoftItem,
clearForItem: clearSoftItemsUnderPhysicsItem,
});
function clearOverlappingGrassBedsForPlacement(worldRef, item) {
if (!worldRef || !item || item.type === "grass_bed" || item.type === "trace" || item.type === "splat") return 0;
if (isPhysicsObstacleItemType(item.type)) return clearSoftItemsUnderPhysicsItem(worldRef, item, "physics-placement-soft-clear");
const radius = Math.max(16, item.r || item.radius || 12);
let removed = 0;
for (const bed of worldRef.nearbyItems?.(item.x, item.y, radius + 72) || worldRef.items || []) {
if (!bed || bed.dead || bed.type !== "grass_bed") continue;
const hit = radius * 0.95 + Math.max(14, (bed.r || 18) * 0.95);
if (distXY(item.x, item.y, bed.x, bed.y) > hit) continue;
const ok = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, bed, {
reason: "grass-bed-ignored-by-placement",
userReason: "置きものが置かれた",
wake: true,
});
if (ok) removed += 1;
}
return removed;
}
function forceImmediateToolVisualRefresh(worldRef, reason = "tool-change") {
if (!worldRef) return;
worldRef.drawListDirty = true;
if (worldRef._renderStack) worldRef._renderStack.signature = "";
if (worldRef._visibleRenderStack) {
worldRef._visibleRenderStack.backItems = [];
worldRef._visibleRenderStack.layered = [];
worldRef._visibleRenderStack.carriedPlushies = [];
worldRef._visibleRenderStack.lodgedPins = [];
}
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.ensureSpatial?.(reason);
if (typeof global.render === "function") {
global.render();
global.requestAnimationFrame?.(() => global.render?.());
}
}
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
findDuplicatorAt(x, y) {
return findDuplicatorAt(this, x, y);
},
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;
item.isCopyPreview = true;
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) {
item.isCopyPreview = false;
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");
forceImmediateToolVisualRefresh(this, "copy-paste");
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 margin = Math.max(6, (item.r || 42) * 0.24);
if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true;
const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72;
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
if (!it || it.dead) continue;
for (const r of this.solidObstacleRects(it)) {
if (global.TarinaiCollisionFootprints?.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 pad = CONFIG.worldPadding || 30;
const rects = this.solidObstacleRects(item);
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.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) 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;
}
}
}
}
if (item.type === "grass_bed") {
const spacing = Math.max(46, (item.r || 18) * 3.0);
for (const it of this.nearbyItems(item.x, item.y, spacing + 70) || []) {
if (!it || it === item || it.dead || it.type !== "grass_bed") continue;
const required = spacing + Math.max(0, ((it.r || 18) - (item.r || 18)) * 0.5);
if (distXY(item.x, item.y, it.x, it.y) < required) return true;
}
}
return false;
},
placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) {
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 === "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") 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" ? 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 === "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 && lodged.detachPushpin) 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 (item.type === "grass" && !this.canAddGrass?.(1)) {
showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`);
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" || 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;
}
clearOverlappingGrassBedsForPlacement(this, item);
const placed = this.addItem?.(item, `place:${item.type}`);
if (!placed) {
if (item.type === "grass") showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`);
return null;
}
clearSoftItemsUnderPhysicsItem(this, item, `place:${item.type}:soft-overlap-clear`);
this.emit("tool:place", { item, type: item.type, dropped });
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", "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;
}
}
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" ? "回転体" : "往復体";
this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color: mechanismTarget.type === "rotator" ? "rgba(184,132,230,0.40)" : "rgba(90,150,214,0.40)" }));
this.drawListDirty = true;
audio.poke?.();
this.log(`動力OFFの${label}をつついて動かした。`, "observe", { eventType: "mechanism_poke", hiddenFromObservation: true });
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") 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 => 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 });
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 (typeof isRotatableItemType === "function" && isRotatableItemType(itemType)) rawItem.angle = this.toolAngleFor ? this.toolAngleFor(itemType) : (typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0);
if (itemType === "reciprocator") {
rawItem.reciprocatorAxisAngle = rawItem.angle;
rawItem.angle = typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0;
}
// Placement uses the same footprint that the preview validates.
const placed = this.placeItem(rawItem, true);
if (!placed) return;
forceImmediateToolVisualRefresh(this, "tool-place");
const label = toolLabel(itemType);
this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
}
},
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" ? "" : (entry.eventType || "");
return type === "ball_poke" || type === "avoid_relationship";
},
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);