1453 lines
71 KiB
JavaScript
1453 lines
71 KiB
JavaScript
"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);
|