1708 lines
100 KiB
JavaScript
1708 lines
100 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;
|
|
const CONNECTION_ITEM_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
|
|
|
|
function ensureEditorDialog(id, className, html) {
|
|
let dialog = document.getElementById(id);
|
|
if (dialog) return dialog;
|
|
dialog = document.createElement("div");
|
|
dialog.id = id;
|
|
dialog.className = className;
|
|
dialog.innerHTML = html;
|
|
document.body.appendChild(dialog);
|
|
return dialog;
|
|
}
|
|
|
|
function bindEditorClose(dialog, close, selectors = []) {
|
|
if (!dialog || typeof close !== "function") return;
|
|
for (const selector of selectors) {
|
|
const button = dialog.querySelector(selector);
|
|
if (button) button.onclick = close;
|
|
}
|
|
dialog.onclick = (event) => { if (event.target === dialog) close(); };
|
|
}
|
|
|
|
function ensureSignboardEditor() {
|
|
return ensureEditorDialog("signboardEditor", "signboard-editor hidden", `
|
|
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
|
|
<div class="signboard-editor-titlebar">
|
|
<h2 id="signboardEditorTitle">\u770b\u677f</h2>
|
|
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
|
|
</div>
|
|
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT + SIGNBOARD_LINES - 1}" rows="3" cols="6" wrap="off" spellcheck="false" inputmode="text"></textarea>
|
|
<div class="signboard-editor-meta"><span>6\u6587\u5b57\u00d73\u884c\u307e\u3067</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
|
|
<div class="signboard-editor-actions">
|
|
<button id="signboardEditorClear" class="btn" type="button">\u6d88\u3059</button>
|
|
<button id="signboardEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
|
<button id="signboardEditorApply" class="btn primary" type="button">\u66f8\u304d\u8fbc\u3080</button>
|
|
</div>
|
|
</div>`);
|
|
}
|
|
|
|
|
|
function sliceGraphemes(text, limit) {
|
|
const chars = Array.from(String(text || ""));
|
|
return chars.slice(0, Math.max(0, limit)).join("");
|
|
}
|
|
|
|
function sanitizeSignboardText(value = "", opts = {}) {
|
|
const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " ");
|
|
const rawLines = normalized.split("\n").slice(0, SIGNBOARD_LINES);
|
|
const out = [];
|
|
for (let i = 0; i < SIGNBOARD_LINES; i += 1) {
|
|
const rawLine = rawLines[i];
|
|
if (rawLine == null) break;
|
|
out.push(sliceGraphemes(String(rawLine), SIGNBOARD_CHARS_PER_LINE));
|
|
}
|
|
const next = out.join("\n");
|
|
return opts.trimTrailing === false ? next : next.replace(/\n+$/g, "");
|
|
}
|
|
|
|
function signboardVisibleLength(text = "") {
|
|
return Array.from(String(text || "").replace(/\n/g, "")).length;
|
|
}
|
|
|
|
function replaceSignboardEditorSelection(area, text = "") {
|
|
if (!area) return;
|
|
const value = String(area.value || "");
|
|
const start = Number(area.selectionStart || 0);
|
|
const end = Number(area.selectionEnd || start);
|
|
const next = sanitizeSignboardText(value.slice(0, start) + text + value.slice(end), { trimTrailing: false });
|
|
area.value = next;
|
|
const caret = Math.min(next.length, start + String(text || "").length);
|
|
area.selectionStart = caret;
|
|
area.selectionEnd = caret;
|
|
}
|
|
|
|
function openSignboardEditor(sign, worldRef) {
|
|
if (!sign) return false;
|
|
const dialog = ensureSignboardEditor();
|
|
const area = dialog.querySelector("#signboardEditorText");
|
|
const count = dialog.querySelector("#signboardEditorCount");
|
|
const apply = dialog.querySelector("#signboardEditorApply");
|
|
const cancel = dialog.querySelector("#signboardEditorCancel");
|
|
const clear = dialog.querySelector("#signboardEditorClear");
|
|
const closeBtn = dialog.querySelector("#signboardEditorClose");
|
|
const close = () => {
|
|
dialog.classList.add("hidden");
|
|
};
|
|
const syncCount = () => {
|
|
if (!area || !count) return;
|
|
count.textContent = `${signboardVisibleLength(area.value || "")}/${SIGNBOARD_TEXT_LIMIT}`;
|
|
};
|
|
const commit = (value) => {
|
|
const next = sanitizeSignboardText(value);
|
|
sign.text = next;
|
|
worldRef?.log?.(sign.text ? `\u770b\u677f\u3092\u66f8\u304d\u63db\u3048\u305f\u3002` : `\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f\u3002`, "observe");
|
|
worldRef.drawListDirty = true;
|
|
global.render?.();
|
|
close();
|
|
};
|
|
if (area) {
|
|
area.value = sanitizeSignboardText(sign.text || "");
|
|
area.onkeydown = (e) => {
|
|
const value = String(area.value || "");
|
|
const start = Number(area.selectionStart || 0);
|
|
const end = Number(area.selectionEnd || start);
|
|
const before = value.slice(0, start);
|
|
const selected = value.slice(start, end);
|
|
const lineIndex = before.split("\n").length - 1;
|
|
const lines = value.split("\n");
|
|
const currentLine = lines[lineIndex] || "";
|
|
const replacing = selected.length > 0;
|
|
if (e.key === "Enter") {
|
|
const selectedLineBreaks = (selected.match(/\n/g) || []).length;
|
|
e.preventDefault();
|
|
if (lines.length - selectedLineBreaks < SIGNBOARD_LINES) {
|
|
replaceSignboardEditorSelection(area, "\n");
|
|
syncCount();
|
|
}
|
|
return;
|
|
}
|
|
if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
const selectedSameLine = !selected.includes("\n");
|
|
const selectedCount = selectedSameLine ? Array.from(selected).length : 0;
|
|
if (!replacing && Array.from(currentLine).length >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
|
|
else if (replacing && selectedSameLine && Array.from(currentLine).length - selectedCount >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
|
|
}
|
|
};
|
|
area.onpaste = (e) => {
|
|
e.preventDefault();
|
|
const text = e.clipboardData?.getData?.("text") || "";
|
|
replaceSignboardEditorSelection(area, text);
|
|
syncCount();
|
|
};
|
|
area.oninput = () => {
|
|
const clean = sanitizeSignboardText(area.value || "", { trimTrailing: false });
|
|
if (area.value !== clean) area.value = clean;
|
|
syncCount();
|
|
};
|
|
setTimeout(() => { area.focus(); area.select(); syncCount(); }, 0);
|
|
}
|
|
if (apply) apply.onclick = () => commit(area?.value || "");
|
|
if (clear) clear.onclick = () => commit("");
|
|
bindEditorClose(dialog, close, ["#signboardEditorCancel", "#signboardEditorClose"]);
|
|
dialog.classList.remove("hidden");
|
|
syncCount();
|
|
return true;
|
|
}
|
|
|
|
|
|
|
|
function ensureRobotCleanerEditor() {
|
|
return ensureEditorDialog("robotCleanerEditor", "signboard-editor robot-cleaner-editor hidden", `
|
|
<div class="signboard-editor-panel robot-cleaner-editor-panel" role="dialog" aria-modal="true" aria-labelledby="robotCleanerEditorTitle">
|
|
<div class="signboard-editor-titlebar">
|
|
<h2 id="robotCleanerEditorTitle">\u30ed\u30dc\u6383\u9664\u6a5f</h2>
|
|
<button id="robotCleanerEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
|
|
</div>
|
|
<div class="robot-cleaner-editor-help">\u7247\u4ed8\u3051\u308b\u5bfe\u8c61\u3092\u9078\u629e\u3057\u3066\u304f\u3060\u3055\u3044\u3002</div>
|
|
<div class="robot-cleaner-targets">
|
|
<label><input type="checkbox" data-robot-target="zunchi"> \u305a\u3093\u3061</label>
|
|
<label><input type="checkbox" data-robot-target="ant_corpse"> \u6b7b\u9ab8</label>
|
|
<label><input type="checkbox" data-robot-target="tarinai"> \u305f\u308a\u306a\u3044</label>
|
|
<label><input type="checkbox" data-robot-target="grass"> \u8349</label>
|
|
<label><input type="checkbox" data-robot-target="grass_bed"> \u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9</label>
|
|
<label><input type="checkbox" data-robot-target="water"> \u6c34\u6ef4</label>
|
|
</div>
|
|
<div class="robot-cleaner-targets robot-cleaner-target-scan">
|
|
<label><input id="robotCleanerHighSpeed" type="checkbox"> \u9ad8\u901f\u30e2\u30fc\u30c9</label>
|
|
</div>
|
|
<div class="signboard-editor-actions">
|
|
<button id="robotCleanerDefault" class="btn" type="button">\u65e2\u5b9a\u306b\u623b\u3059</button>
|
|
<button id="robotCleanerCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
|
<button id="robotCleanerApply" class="btn primary" type="button">\u4fdd\u5b58</button>
|
|
</div>
|
|
</div>`);
|
|
}
|
|
|
|
function openRobotCleanerEditor(robot, worldRef) {
|
|
if (!robot || robot.type !== "robot_cleaner") return false;
|
|
const api = global.TarinaiItemDynamicToolSystem;
|
|
const dialog = ensureRobotCleanerEditor();
|
|
const inputs = [...dialog.querySelectorAll("[data-robot-target]")];
|
|
const closeBtn = dialog.querySelector("#robotCleanerEditorClose");
|
|
const cancel = dialog.querySelector("#robotCleanerCancel");
|
|
const apply = dialog.querySelector("#robotCleanerApply");
|
|
const def = dialog.querySelector("#robotCleanerDefault");
|
|
const highSpeed = dialog.querySelector("#robotCleanerHighSpeed");
|
|
const bits = api?.robotCleanerTargetBits || {};
|
|
const defaultMask = api?.robotCleanerDefaultMask?.() ?? 3;
|
|
const readMask = () => inputs.reduce((mask, input) => input.checked ? (mask | (bits[input.dataset.robotTarget] || 0)) : mask, 0);
|
|
const writeMask = (mask) => {
|
|
for (const input of inputs) input.checked = Boolean((Number(mask) | 0) & (bits[input.dataset.robotTarget] || 0));
|
|
};
|
|
const close = () => {
|
|
dialog.classList.add("hidden");
|
|
};
|
|
const commit = (mask) => {
|
|
api?.setRobotCleanerMask?.(robot, mask);
|
|
api?.setRobotCleanerHighSpeed?.(robot, Boolean(highSpeed?.checked));
|
|
robot.robotCleanerTargetId = "";
|
|
robot.robotCleanerTargetType = "";
|
|
robot.robotCleanerScanTimer = 0;
|
|
worldRef?.markItemBucketsDirty?.("robot-cleaner-settings");
|
|
if (worldRef) worldRef.drawListDirty = true;
|
|
worldRef?.log?.("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u5bfe\u8c61\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002", "observe");
|
|
global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot, source: "robot-cleaner-settings" });
|
|
if (typeof showToast === "function") showToast("\u30ed\u30dc\u6383\u9664\u6a5f\u306e\u8a2d\u5b9a\u3092\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002");
|
|
global.render?.();
|
|
close();
|
|
};
|
|
writeMask(api?.robotCleanerMask?.(robot) ?? robot.robotCleanerMask ?? defaultMask);
|
|
if (highSpeed) highSpeed.checked = Boolean(api?.robotCleanerHighSpeed?.(robot) ?? robot.robotCleanerHighSpeed);
|
|
bindEditorClose(dialog, close, ["#robotCleanerEditorClose", "#robotCleanerCancel"]);
|
|
if (def) def.onclick = () => { writeMask(defaultMask); if (highSpeed) highSpeed.checked = false; };
|
|
if (apply) apply.onclick = () => commit(readMask());
|
|
dialog.classList.remove("hidden");
|
|
return true;
|
|
}
|
|
|
|
function ensureFanEditor() {
|
|
return ensureEditorDialog("fanEditor", "rotator-editor fan-editor hidden", `
|
|
<div class="rotator-editor-panel fan-editor-panel" role="dialog" aria-modal="true" aria-labelledby="fanEditorTitle">
|
|
<div class="rotator-editor-titlebar">
|
|
<h2 id="fanEditorTitle">\u6247\u98a8\u6a5f</h2>
|
|
<button id="fanEditorClose" class="rotator-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
|
|
</div>
|
|
<div class="fan-editor-help">\u30af\u30ea\u30c3\u30af\u3057\u305f\u6247\u98a8\u6a5f\u306e\u98a8\u5411\u304d\u3068\u9996\u632f\u308a\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\u4fdd\u5b58\u3057\u3066\u3082\u672c\u4f53\u306e\u898b\u305f\u76ee\u306e\u5411\u304d\u306f\u5909\u3048\u305a\u3001\u98a8\u5411\u304d\u77e2\u5370\u3060\u3051\u304c\u5909\u308f\u308a\u307e\u3059\u3002</div>
|
|
<div class="rotator-editor-controls fan-editor-controls">
|
|
<label><span>\u5411\u304d</span><input id="fanCenterAngle" type="range" min="0" max="355" step="5"><input id="fanCenterAngleNum" type="number" min="0" max="355" step="5"><span>\u5ea6</span></label>
|
|
<label><span>\u632f\u308a\u5e45</span><input id="fanSwingRange" type="range" min="0" max="120" step="5"><input id="fanSwingRangeNum" type="number" min="0" max="150" step="5"><span>\u5ea6</span></label>
|
|
<label><span>\u901f\u5ea6</span><input id="fanSwingSpeed" type="range" min="0" max="180" step="5"><input id="fanSwingSpeedNum" type="number" min="0" max="360" step="5"><span>\u5ea6/\u79d2</span></label>
|
|
<label class="fan-editor-check"><input id="fanSwingEnabled" type="checkbox"> \u9996\u632f\u308aON</label>
|
|
</div>
|
|
<p class="rotator-editor-hint">\u632f\u308a\u5e450\u5ea6\u306a\u3089\u56fa\u5b9a\u5411\u304d\u3067\u3059\u3002\u914d\u7f6e\u6e08\u307f\u6247\u98a8\u6a5f\u306e\u672c\u4f53\u89d2\u5ea6\u306f\u56fa\u5b9a\u3057\u305f\u307e\u307e\u3001\u98a8\u5411\u304d\u3060\u3051\u3092\u8abf\u6574\u3057\u307e\u3059\u3002</p>
|
|
<div class="rotator-editor-actions">
|
|
<button id="fanEditorStop" class="btn" type="button">\u56fa\u5b9a\u306b\u3059\u308b</button>
|
|
<button id="fanEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
|
<button id="fanEditorApply" class="btn primary" type="button">\u4fdd\u5b58</button>
|
|
</div>
|
|
</div>`);
|
|
}
|
|
|
|
function degForAngle(angle = 0) {
|
|
const rad = typeof normalizedItemAngle === "function" ? normalizedItemAngle(angle, 0) : (((Number(angle) || 0) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
|
|
return Math.round((rad * 180 / Math.PI) % 360 + 360) % 360;
|
|
}
|
|
|
|
function syncEditorInputPair(rangeInput, numberInput, onChange = null) {
|
|
const sync = (source, target) => {
|
|
if (!source || !target) return;
|
|
target.value = source.value;
|
|
if (typeof onChange === "function") onChange();
|
|
};
|
|
if (rangeInput) rangeInput.oninput = () => sync(rangeInput, numberInput);
|
|
if (numberInput) numberInput.oninput = () => sync(numberInput, rangeInput);
|
|
}
|
|
|
|
function openFanEditor(item, worldRef) {
|
|
if (!item || item.type !== "fan") return false;
|
|
const dialog = ensureFanEditor();
|
|
const center = dialog.querySelector("#fanCenterAngle");
|
|
const centerNum = dialog.querySelector("#fanCenterAngleNum");
|
|
const swingRange = dialog.querySelector("#fanSwingRange");
|
|
const swingRangeNum = dialog.querySelector("#fanSwingRangeNum");
|
|
const swingSpeed = dialog.querySelector("#fanSwingSpeed");
|
|
const swingSpeedNum = dialog.querySelector("#fanSwingSpeedNum");
|
|
const enabled = dialog.querySelector("#fanSwingEnabled");
|
|
const stop = dialog.querySelector("#fanEditorStop");
|
|
const apply = dialog.querySelector("#fanEditorApply");
|
|
const cancel = dialog.querySelector("#fanEditorCancel");
|
|
const closeBtn = dialog.querySelector("#fanEditorClose");
|
|
if (!Number.isFinite(Number(item.fanBodyAngle))) item.fanBodyAngle = itemAngleFor(item);
|
|
const currentAngle = item.fanSwingOn ? (Number(item.fanSwingCenter) || itemAngleFor(item)) : itemAngleFor(item);
|
|
const centerDeg = degForAngle(currentAngle);
|
|
const rangeDeg = Math.round(Math.max(0, Math.min(150, Number(item.fanSwingRange ?? (35 * Math.PI / 180)) * 180 / Math.PI || 35)));
|
|
const speedDeg = Math.round(Math.max(0, Math.min(360, Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) * 180 / Math.PI || 48)));
|
|
if (center) center.value = String(Math.max(0, Math.min(355, centerDeg)));
|
|
if (centerNum) centerNum.value = String(centerDeg);
|
|
if (swingRange) swingRange.value = String(Math.max(0, Math.min(120, rangeDeg)));
|
|
if (swingRangeNum) swingRangeNum.value = String(rangeDeg);
|
|
if (swingSpeed) swingSpeed.value = String(Math.max(0, Math.min(180, speedDeg)));
|
|
if (swingSpeedNum) swingSpeedNum.value = String(speedDeg);
|
|
if (enabled) enabled.checked = Boolean(item.fanSwingOn);
|
|
syncEditorInputPair(center, centerNum);
|
|
syncEditorInputPair(swingRange, swingRangeNum);
|
|
syncEditorInputPair(swingSpeed, swingSpeedNum);
|
|
const close = () => dialog.classList.add("hidden");
|
|
const commit = (forceStop = false) => {
|
|
global.TarinaiHistory.capture(worldRef, "fan-swing-edit");
|
|
const cDeg = Math.max(0, Math.min(355, Number(centerNum?.value || center?.value || 0) || 0));
|
|
const rDeg = Math.max(0, Math.min(150, Number(swingRangeNum?.value || swingRange?.value || 0) || 0));
|
|
const sDeg = Math.max(0, Math.min(360, Number(swingSpeedNum?.value || swingSpeed?.value || 0) || 0));
|
|
const previousBodyAngle = Number.isFinite(Number(item.fanBodyAngle)) ? Number(item.fanBodyAngle) : itemAngleFor(item);
|
|
item.fanSwingCenter = (typeof normalizedItemAngle === "function" ? normalizedItemAngle(cDeg * Math.PI / 180, 0) : cDeg * Math.PI / 180);
|
|
item.fanSwingRange = rDeg * Math.PI / 180;
|
|
item.fanSwingSpeed = sDeg * Math.PI / 180;
|
|
item.fanSwingOn = forceStop ? false : Boolean(enabled?.checked && rDeg > 0 && sDeg > 0);
|
|
item.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0;
|
|
item.fanBodyAngle = previousBodyAngle;
|
|
item.angle = item.fanSwingCenter;
|
|
worldRef?.markSpatialDirty?.("fan-swing-settings");
|
|
if (worldRef) worldRef.drawListDirty = true;
|
|
worldRef?.log?.(item.fanSwingOn ? "\u6247\u98a8\u6a5f\u306e\u9996\u632f\u308a\u8a2d\u5b9a\u3092\u5909\u66f4\u3057\u305f\u3002" : "\u6247\u98a8\u6a5f\u3092\u56fa\u5b9a\u5411\u304d\u306b\u3057\u305f\u3002", "observe");
|
|
if (typeof showToast === "function") showToast(item.fanSwingOn ? "\u6247\u98a8\u6a5f: \u9996\u632f\u308aON" : "\u6247\u98a8\u6a5f: \u56fa\u5b9a\u5411\u304d");
|
|
global.render?.();
|
|
close();
|
|
};
|
|
if (stop) stop.onclick = () => commit(true);
|
|
if (apply) apply.onclick = () => commit(false);
|
|
bindEditorClose(dialog, close, ["#fanEditorCancel", "#fanEditorClose"]);
|
|
dialog.classList.remove("hidden");
|
|
return true;
|
|
}
|
|
|
|
function ensureRotatorEditor() {
|
|
const dialog = ensureEditorDialog("rotatorEditor", "rotator-editor hidden", `
|
|
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="rotatorEditorTitle">
|
|
<div class="rotator-editor-titlebar">
|
|
<h2 id="rotatorEditorTitle">\u56de\u8ee2\u4f53</h2>
|
|
<button id="rotatorEditorClose" class="rotator-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
|
|
</div>
|
|
<div class="rotator-editor-controls">
|
|
<label>\u901f\u5ea6 <input id="mechMotorSpeed" type="range" min="-360" max="360" step="5"><input id="mechMotorSpeedNum" type="number" min="-720" max="720" step="5"> \u5ea6/\u79d2</label>
|
|
<label>\u592a\u3055 <input id="mechShapeThickness" type="range" min="4" max="30" step="1"><input id="mechShapeThicknessNum" type="number" min="4" max="34" step="1"></label>
|
|
<label><input id="mechMotorPowered" type="checkbox"> \u52d5\u529bON</label>
|
|
<label id="poisonCollisionLabel"><input id="poisonSolidEnabled" type="checkbox"> \u5f53\u305f\u308a\u5224\u5b9aON</label>
|
|
</div>
|
|
<div class="rotator-editor-tools" role="toolbar" aria-label="\u63cf\u753b\u65b9\u6cd5">
|
|
<button type="button" data-rotator-mode="line" class="active">\u2501 \u76f4\u7dda</button>
|
|
<button type="button" data-rotator-mode="free">\u3030 \u81ea\u7531\u63cf\u753b</button>
|
|
<button type="button" data-rotator-mode="erase">\u232b \u6d88\u3057\u30b4\u30e0</button>
|
|
<button type="button" data-rotator-template="bar">\u2503 \u68d2</button>
|
|
<button type="button" data-rotator-template="cross">\uff0b \u5341\u5b57</button>
|
|
<button type="button" data-rotator-template="circle">\u25cb \u5186</button>
|
|
<button id="rotatorUndo" type="button">\u21b6 \u4e00\u3064\u623b\u3059</button>
|
|
<button id="rotatorClear" type="button">\ud83d\uddd1 \u5168\u6d88\u3057</button>
|
|
</div>
|
|
<canvas id="rotatorCanvas" width="520" height="360" aria-label="\u56de\u8ee2\u4f53\u306e\u8f2a\u90ed\u7de8\u96c6"></canvas>
|
|
<p class="rotator-editor-hint">\u4e2d\u592e\u304c\u56de\u8ee2\u8ef8\u3002\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002</p>
|
|
<div class="rotator-editor-actions">
|
|
<button id="rotatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
|
<button id="rotatorApply" class="btn primary" type="button">\u53cd\u6620</button>
|
|
</div>
|
|
</div>`);
|
|
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "rotator");
|
|
return dialog;
|
|
}
|
|
|
|
function physicsApi() { return global.TarinaiPhysicsBodySystem || null; }
|
|
function phyScalar(item, key, fallback = 0) { return physicsApi()?.scalar?.(item, key, fallback) ?? fallback; }
|
|
function phySet(item, key, value, reason = "editor") { return physicsApi()?.setScalar?.(item, key, value, reason) || false; }
|
|
function cloneSegmentsForRotator(item) {
|
|
const fallback = item?.type === "poison_block" ? [[-52, -20, 52, -20], [52, -20, 52, 20], [52, 20, -52, 20], [-52, 20, -52, -20]] : [[-78, 0, 78, 0], [0, -52, 0, 52]];
|
|
const src = physicsApi()?.segments?.(item) || fallback;
|
|
return src.filter(seg => Array.isArray(seg) && seg.length >= 4).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]);
|
|
}
|
|
function physicsShapeExtent(item, segments = cloneSegmentsForRotator(item), thick = phyScalar(item, "thickness", item?.type === "poison_block" ? 11 : 12)) {
|
|
return Math.max(...segments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + Math.max(4, thick) + 8;
|
|
}
|
|
|
|
function openRotatorEditor(item, worldRef) {
|
|
if (!item || (item.type !== "rotator" && item.type !== "poison_block")) return false;
|
|
const isPoisonBlock = item.type === "poison_block";
|
|
const dialog = ensureRotatorEditor();
|
|
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "rotator");
|
|
const canvas = dialog.querySelector("#rotatorCanvas");
|
|
const ctx = canvas?.getContext?.("2d");
|
|
const speed = dialog.querySelector("#mechMotorSpeed");
|
|
const speedNum = dialog.querySelector("#mechMotorSpeedNum");
|
|
const thick = dialog.querySelector("#mechShapeThickness");
|
|
const thickNum = dialog.querySelector("#mechShapeThicknessNum");
|
|
const poweredInput = dialog.querySelector("#mechMotorPowered");
|
|
const poisonCollisionInput = dialog.querySelector("#poisonSolidEnabled");
|
|
const poisonCollisionLabel = dialog.querySelector("#poisonCollisionLabel");
|
|
const titleEl = dialog.querySelector("#rotatorEditorTitle");
|
|
const apply = dialog.querySelector("#rotatorApply");
|
|
const cancel = dialog.querySelector("#rotatorCancel");
|
|
const closeBtn = dialog.querySelector("#rotatorEditorClose");
|
|
if (!canvas || !ctx) return false;
|
|
const editor = global.TarinaiPhysicsShapeEditorSystem;
|
|
const controller = editor.createSegmentEditorController({
|
|
dialog,
|
|
canvas,
|
|
ctx,
|
|
prefix: "rotator",
|
|
segments: cloneSegmentsForRotator(item),
|
|
fallback: [-78, 0, 78, 0],
|
|
lineWidth: () => Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)),
|
|
eraseRadius: (lw) => Math.max(24, lw * 1.35),
|
|
strokeStyle: () => isPoisonBlock ? "rgba(92,172,76,0.92)" : "rgba(127,92,190,0.92)",
|
|
drawCenter: () => {
|
|
ctx.fillStyle = isPoisonBlock ? "#eefee7" : "#f5edff";
|
|
ctx.strokeStyle = isPoisonBlock ? "#3c8f32" : "#6d4ca0";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
|
},
|
|
});
|
|
if (!controller) return false;
|
|
const deg = Math.round((Number(phyScalar(item, "motorSpeed", 0) || 0) || 0) * 180 / Math.PI);
|
|
const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12)));
|
|
if (titleEl) titleEl.textContent = isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u56de\u8ee2\u4f53";
|
|
const speedLabel = speed?.closest?.("label");
|
|
const poweredLabel = poweredInput?.closest?.("label");
|
|
if (speedLabel) speedLabel.style.display = isPoisonBlock ? "none" : "";
|
|
if (poweredLabel) poweredLabel.style.display = isPoisonBlock ? "none" : "";
|
|
if (poisonCollisionLabel) poisonCollisionLabel.style.display = "";
|
|
if (poisonCollisionInput) poisonCollisionInput.checked = phyScalar(item, "solid", true) !== false;
|
|
if (speed) speed.value = String(Math.max(-360, Math.min(360, deg)));
|
|
if (speedNum) speedNum.value = String(deg);
|
|
if (thick) thick.value = String(initialThickness);
|
|
if (thickNum) thickNum.value = String(initialThickness);
|
|
if (poweredInput) { poweredInput.checked = phyScalar(item, "motorOn", true) !== false; poweredInput.disabled = Boolean(item._signalDriven); poweredInput.title = item._signalDriven ? "\u96fb\u7dda\u4fe1\u53f7\u3067\u5236\u5fa1\u4e2d" : ""; }
|
|
editor.syncInputPair(speed, speedNum, controller.draw);
|
|
editor.syncInputPair(thick, thickNum, controller.draw);
|
|
const close = () => {
|
|
dialog.classList.add("hidden");
|
|
controller.close();
|
|
};
|
|
if (apply) apply.onclick = () => {
|
|
global.TarinaiHistory.capture(worldRef, isPoisonBlock ? "poison-block-edit" : "rotator-edit");
|
|
controller.cleanSegments();
|
|
const editedSegments = controller.getSegments();
|
|
physicsApi()?.setSegments?.(item, editedSegments, isPoisonBlock ? "poison-shape-edit" : "rotator-shape-edit");
|
|
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || (isPoisonBlock ? 11 : 12)) || (isPoisonBlock ? 11 : 12))), "editor-thickness");
|
|
phySet(item, "solid", poisonCollisionInput ? Boolean(poisonCollisionInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
|
|
if (isPoisonBlock) {
|
|
phySet(item, "damage", Math.max(1, Number(phyScalar(item, "damage", 7) || 7) || 7), "editor-damage");
|
|
} else {
|
|
phySet(item, "motorSpeed", (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180, "editor-speed");
|
|
if (!item._signalDriven) phySet(item, "motorOn", poweredInput ? Boolean(poweredInput.checked) : phyScalar(item, "motorOn", true) !== false, "editor-power");
|
|
if (phyScalar(item, "motorOn", true)) phySet(item, "spin", 0, "editor-spin-reset");
|
|
}
|
|
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, isPoisonBlock ? "poison-block-edited" : "rotator-edited")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
|
const extent = worldRef?.rotatorExtent?.(item);
|
|
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
|
|
global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, isPoisonBlock ? "poison-block-link-remap" : "rotator-link-remap");
|
|
worldRef?.markSpatialDirty?.(isPoisonBlock ? "poison-block-edited" : "rotator-edited");
|
|
worldRef.drawListDirty = true;
|
|
worldRef?.log?.(isPoisonBlock ? "\u6bd2\u30d6\u30ed\u30c3\u30af\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002" : "\u56de\u8ee2\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe");
|
|
global.render?.();
|
|
close();
|
|
};
|
|
bindEditorClose(dialog, close, ["#rotatorCancel", "#rotatorEditorClose"]);
|
|
controller.draw();
|
|
dialog.classList.remove("hidden");
|
|
return true;
|
|
}
|
|
|
|
|
|
function ensureReciprocatorEditor() {
|
|
const dialog = ensureEditorDialog("reciprocatorEditor", "rotator-editor hidden", `
|
|
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="reciprocatorEditorTitle">
|
|
<div class="rotator-editor-titlebar">
|
|
<h2 id="reciprocatorEditorTitle">\u5f80\u5fa9\u4f53</h2>
|
|
<button id="reciprocatorEditorClose" class="rotator-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
|
|
</div>
|
|
<div class="rotator-editor-controls">
|
|
<label>\u65b9\u5411 <input id="reciprocatorAngle" type="range" min="0" max="355" step="5"><input id="reciprocatorAngleNum" type="number" min="0" max="355" step="5"> \u5ea6</label>
|
|
<label>\u901f\u5ea6 <input id="railMotorSpeedControl" type="range" min="0" max="240" step="5"><input id="railMotorSpeedControlNum" type="number" min="0" max="360" step="5"> px/\u79d2</label>
|
|
<label>\u5f80\u5fa9\u5e45 <input id="railTravelControl" type="range" min="48" max="360" step="4"><input id="railTravelControlNum" type="number" min="24" max="520" step="4"> px</label>
|
|
<label>\u592a\u3055 <input id="reciprocatorThickness" type="range" min="4" max="30" step="1"><input id="reciprocatorThicknessNum" type="number" min="4" max="34" step="1"></label>
|
|
<label><input id="railMotorPowered" type="checkbox"> \u52d5\u529bON</label>
|
|
<label><input id="reciprocatorSolidEnabled" type="checkbox"> \u5f53\u305f\u308a\u5224\u5b9aON</label>
|
|
</div>
|
|
<div class="rotator-editor-tools" role="toolbar" aria-label="\u63cf\u753b\u65b9\u6cd5">
|
|
<button type="button" data-reciprocator-mode="line" class="active">\u2501 \u76f4\u7dda</button>
|
|
<button type="button" data-reciprocator-mode="free">\u3030 \u81ea\u7531\u63cf\u753b</button>
|
|
<button type="button" data-reciprocator-mode="erase">\u232b \u6d88\u3057\u30b4\u30e0</button>
|
|
<button type="button" data-reciprocator-template="bar">\u2503 \u68d2</button>
|
|
<button type="button" data-reciprocator-template="cross">\uff0b \u5341\u5b57</button>
|
|
<button type="button" data-reciprocator-template="circle">\u25cb \u5186</button>
|
|
<button id="reciprocatorUndo" type="button">\u21b6 \u4e00\u3064\u623b\u3059</button>
|
|
<button id="reciprocatorClear" type="button">\ud83d\uddd1 \u5168\u6d88\u3057</button>
|
|
</div>
|
|
<canvas id="reciprocatorCanvas" width="520" height="360" aria-label="\u5f80\u5fa9\u4f53\u306e\u8f2a\u90ed\u7de8\u96c6"></canvas>
|
|
<p class="rotator-editor-hint">\u8f2a\u90ed\u306f20px\u9593\u9694\u306e\u30b0\u30ea\u30c3\u30c9\u683c\u5b50\u70b9\u306b\u30b9\u30ca\u30c3\u30d7\u3057\u307e\u3059\u3002\u52d5\u529bOFF\u3067\u306f\u3001\u305f\u308a\u306a\u3044\u30fb\u305a\u3093\u3061\u30fb\u4ed6\u306e\u6a5f\u69cb\u304b\u3089\u62bc\u3055\u308c\u305f\u3068\u304d\u3060\u3051\u52d5\u304d\u307e\u3059\u3002</p>
|
|
<div class="rotator-editor-actions">
|
|
<button id="reciprocatorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
|
|
<button id="reciprocatorApply" class="btn primary" type="button">\u53cd\u6620</button>
|
|
</div>
|
|
</div>`);
|
|
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "reciprocator");
|
|
return dialog;
|
|
}
|
|
|
|
function openReciprocatorEditor(item, worldRef) {
|
|
if (!item || item.type !== "reciprocator") return false;
|
|
const dialog = ensureReciprocatorEditor();
|
|
global.TarinaiPhysicsShapeEditorSystem.decorateToolbar(dialog, "reciprocator");
|
|
const canvas = dialog.querySelector("#reciprocatorCanvas");
|
|
const ctx = canvas?.getContext?.("2d");
|
|
const angle = dialog.querySelector("#reciprocatorAngle");
|
|
const angleNum = dialog.querySelector("#reciprocatorAngleNum");
|
|
const speed = dialog.querySelector("#railMotorSpeedControl");
|
|
const speedNum = dialog.querySelector("#railMotorSpeedControlNum");
|
|
const travel = dialog.querySelector("#railTravelControl");
|
|
const travelNum = dialog.querySelector("#railTravelControlNum");
|
|
const thick = dialog.querySelector("#reciprocatorThickness");
|
|
const thickNum = dialog.querySelector("#reciprocatorThicknessNum");
|
|
const powered = dialog.querySelector("#railMotorPowered");
|
|
const solidInput = dialog.querySelector("#reciprocatorSolidEnabled");
|
|
const apply = dialog.querySelector("#reciprocatorApply");
|
|
const cancel = dialog.querySelector("#reciprocatorCancel");
|
|
const closeBtn = dialog.querySelector("#reciprocatorEditorClose");
|
|
if (!canvas || !ctx) return false;
|
|
const editor = global.TarinaiPhysicsShapeEditorSystem;
|
|
const controller = editor.createSegmentEditorController({
|
|
dialog,
|
|
canvas,
|
|
ctx,
|
|
prefix: "reciprocator",
|
|
segments: cloneSegmentsForRotator(item),
|
|
fallback: [-78, 0, 78, 0],
|
|
lineWidth: () => Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)),
|
|
eraseRadius: (lw) => Math.max(28, lw * 1.55),
|
|
strokeStyle: "rgba(80,150,205,0.92)",
|
|
drawBeforeSegments: () => {
|
|
const travelNow = Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150));
|
|
ctx.save();
|
|
ctx.strokeStyle = "rgba(70,120,170,0.42)";
|
|
ctx.lineWidth = 2;
|
|
ctx.setLineDash([7, 6]);
|
|
ctx.beginPath(); ctx.moveTo(-travelNow * 0.5, 22); ctx.lineTo(travelNow * 0.5, 22); ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
},
|
|
drawCenter: () => {
|
|
ctx.fillStyle = "#eaf6ff";
|
|
ctx.strokeStyle = "#4d8cba";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
|
|
},
|
|
});
|
|
if (!controller) return false;
|
|
const axisRad = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", (itemAngleFor(item)));
|
|
const deg = Math.round((axisRad * 180 / Math.PI) % 360 + 360) % 360;
|
|
const speedVal = Math.round(Number(phyScalar(item, "railMotorSpeed", 92)) || 0);
|
|
const travelVal = Math.round(Number(phyScalar(item, "railTravel", 150)) || 150);
|
|
const initialThickness = Math.max(4, Math.min(34, Number(phyScalar(item, "thickness", 12) || 12) || 12));
|
|
if (angle) angle.value = String(Math.max(0, Math.min(355, deg)));
|
|
if (angleNum) angleNum.value = String(deg);
|
|
if (speed) speed.value = String(Math.max(0, Math.min(240, speedVal)));
|
|
if (speedNum) speedNum.value = String(speedVal);
|
|
if (travel) travel.value = String(Math.max(48, Math.min(360, travelVal)));
|
|
if (travelNum) travelNum.value = String(travelVal);
|
|
if (thick) thick.value = String(initialThickness);
|
|
if (thickNum) thickNum.value = String(initialThickness);
|
|
if (powered) { powered.checked = phyScalar(item, "railOn", true) !== false; powered.disabled = Boolean(item._signalDriven); powered.title = item._signalDriven ? "\u96fb\u7dda\u4fe1\u53f7\u3067\u5236\u5fa1\u4e2d" : ""; }
|
|
if (solidInput) solidInput.checked = phyScalar(item, "solid", true) !== false;
|
|
editor.syncInputPair(angle, angleNum);
|
|
editor.syncInputPair(speed, speedNum);
|
|
editor.syncInputPair(travel, travelNum, controller.draw);
|
|
editor.syncInputPair(thick, thickNum, controller.draw);
|
|
const close = () => {
|
|
dialog.classList.add("hidden");
|
|
controller.close();
|
|
};
|
|
if (apply) apply.onclick = () => {
|
|
global.TarinaiHistory.capture(worldRef, "reciprocator-edit");
|
|
controller.cleanSegments();
|
|
const editedSegments = controller.getSegments();
|
|
const angleDeg = Math.max(0, Math.min(355, Number(angleNum?.value || angle?.value || 0) || 0));
|
|
const railAxis = normalizedItemAngle(angleDeg * Math.PI / 180, 0);
|
|
phySet(item, "railAxis", railAxis, "editor-rail-axis");
|
|
phySet(item, "railMotorSpeed", Math.max(0, Math.min(360, Number(speedNum?.value || speed?.value || 92) || 0)), "editor-rail-speed");
|
|
phySet(item, "railTravel", Math.max(24, Math.min(520, Number(travelNum?.value || travel?.value || 150) || 150)), "editor-rail-travel");
|
|
if (!item._signalDriven) phySet(item, "railOn", powered ? Boolean(powered.checked) : phyScalar(item, "railOn", true) !== false, "editor-rail-power");
|
|
phySet(item, "solid", solidInput ? Boolean(solidInput.checked) : phyScalar(item, "solid", true) !== false, "editor-solid");
|
|
if (phyScalar(item, "railOn", true)) phySet(item, "slideSpeed", 0, "editor-slide-reset");
|
|
phySet(item, "thickness", Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)), "editor-thickness");
|
|
physicsApi()?.setSegments?.(item, editedSegments, "editor-segments");
|
|
const sa = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", itemAngleFor(item));
|
|
const halfTravel = Math.max(12, phyScalar(item, "railTravel", 150) * 0.5);
|
|
const phase = Number(phyScalar(item, "railPhase", 0) || 0) || 0;
|
|
phySet(item, "railAnchorX", item.x - Math.cos(sa) * halfTravel * phase, "editor-rail-anchor");
|
|
phySet(item, "railAnchorY", item.y - Math.sin(sa) * halfTravel * phase, "editor-rail-anchor");
|
|
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, "reciprocator-edited")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
|
const extent = worldRef?.rotatorExtent?.(item);
|
|
if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent));
|
|
global.TarinaiLinkRuntime.remapLinksForEditedMechanicalItem(worldRef, item, "reciprocator-link-remap");
|
|
worldRef?.markSpatialDirty?.("reciprocator-edited");
|
|
worldRef.drawListDirty = true;
|
|
worldRef?.log?.("\u5f80\u5fa9\u4f53\u306e\u8a2d\u8a08\u3092\u5909\u66f4\u3057\u305f\u3002", "observe");
|
|
global.render?.();
|
|
close();
|
|
};
|
|
bindEditorClose(dialog, close, ["#reciprocatorCancel", "#reciprocatorEditorClose"]);
|
|
controller.draw();
|
|
dialog.classList.remove("hidden");
|
|
return true;
|
|
}
|
|
|
|
function copyTargetAt(worldRef, x, y) {
|
|
let blockedReason = "";
|
|
const collision = global.TarinaiCollisionFootprints;
|
|
const items = worldRef?.items || [];
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const it = items[i];
|
|
if (!it || it.dead) continue;
|
|
const type = String(it.type || "");
|
|
if (type === "trace" || type === "splat" || type === "ant_corpse") continue;
|
|
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(type)) {
|
|
const runtime = global.TarinaiLinkRuntime;
|
|
const pb = physicsApi();
|
|
const a = runtime?.endpointWorld?.(pb?.endpoint?.(it, 0), worldRef);
|
|
const b = runtime?.endpointWorld?.(pb?.endpoint?.(it, 1), worldRef);
|
|
const d = global.TarinaiGeometry.pointSegmentDistance(x, y, a?.x ?? it.x, a?.y ?? it.y, b?.x ?? it.x, b?.y ?? it.y);
|
|
const tolerance = worldRef?.screenSizeToWorld ? worldRef.screenSizeToWorld(12) : 12;
|
|
if (d <= tolerance && !blockedReason) blockedReason = "link";
|
|
continue;
|
|
}
|
|
if (isPinType(type) && it.pinState === "lodged") {
|
|
const d = distXY(x, y, it.x, it.y);
|
|
if (d <= Math.max(24, (it.r || 12) * 1.8) && !blockedReason) blockedReason = "lodged_pin";
|
|
continue;
|
|
}
|
|
const d = distXY(x, y, it.x, it.y);
|
|
let result = null;
|
|
if (collision?.hitTestItem) {
|
|
const isPrecise = Boolean(global.TarinaiMechanicalSystem.isMechanicalType(type) || worldRef?.isFenceType?.(type) || type === "nest_box" || type === "pipe");
|
|
result = collision.hitTestItem(worldRef, it, x, y, {
|
|
padding: isPrecise ? 10 : 4,
|
|
radiusMultiplier: isPrecise ? 1.12 : 1.18,
|
|
});
|
|
}
|
|
const hit = result ? Boolean(result.hit) : d <= Math.max(16, (it.r || 16) * 1.18);
|
|
if (!hit) continue;
|
|
return { item: it, reason: "" };
|
|
}
|
|
return { item: null, reason: blockedReason };
|
|
}
|
|
|
|
|
|
function makeCopyBufferForItem(item) {
|
|
if (!item) return null;
|
|
const data = {
|
|
type: item.type,
|
|
r: Number(item.r || itemRadiusFor?.(item.type, 12) || 12),
|
|
amount: Math.max(1, Number(item.amount || itemAmountFor?.(item.type, 80) || 80)),
|
|
angle: Number(item.angle || 0) || 0,
|
|
toolSize: item.toolSize || "medium",
|
|
foodServingScale: Number(item.foodServingScale || 1) || 1,
|
|
foodServingsMax: Number(item.foodServingsMax || 0) || 0,
|
|
seed: Number.isFinite(Number(item.seed)) ? Number(item.seed) : null,
|
|
spin: Number.isFinite(Number(item.spin)) ? Number(item.spin) : 0,
|
|
zunchiVariant: item.zunchiVariant || "",
|
|
grassStage: Number.isFinite(Number(item.grassStage)) ? Number(item.grassStage) : null,
|
|
growth: Number.isFinite(Number(item.growth)) ? Number(item.growth) : null,
|
|
health: Number.isFinite(Number(item.health)) ? Number(item.health) : null,
|
|
fuseMax: Number.isFinite(Number(item.fuseMax)) ? Number(item.fuseMax) : null,
|
|
};
|
|
if (item.type === "signboard") data.text = item.text || "";
|
|
if (item.type === "circuit_board") data.circuitConfig = global.TarinaiCircuitBoardSystem?.serializeConfig?.(item) || null;
|
|
if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen);
|
|
if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; }
|
|
if (item.type === "robot_cleaner") { data.robotCleanerMask = Number(item.robotCleanerMask ?? 3) | 0; data.robotCleanerHighSpeed = Boolean(item.robotCleanerHighSpeed); }
|
|
if (item.type === "pressure_switch") {
|
|
data.pressureTarget = item.pressureTarget || "tarinai";
|
|
data.pressureMin = Math.max(-999, Math.min(999, Number(item.pressureMin ?? 1) || 0));
|
|
data.pressureMax = Math.max(-999, Math.min(999, Number(item.pressureMax ?? 300) || 0));
|
|
data.pressureWidth = Math.max(60, Math.min(840, Number(item.pressureWidth || 220) | 0));
|
|
data.pressureHeight = Math.max(60, Math.min(840, Number(item.pressureHeight || 160) | 0));
|
|
data.pressureTimeStart = Math.max(0, Math.min(1435, Number(item.pressureTimeStart ?? 360) | 0));
|
|
data.pressureTimeEnd = Math.max(0, Math.min(1435, Number(item.pressureTimeEnd ?? 1080) | 0));
|
|
}
|
|
if (item.type === "fan") {
|
|
data.fanSwingOn = Boolean(item.fanSwingOn);
|
|
data.fanSwingCenter = Number(item.fanSwingCenter ?? item.angle ?? 0) || 0;
|
|
data.fanSwingRange = Number(item.fanSwingRange ?? (35 * Math.PI / 180)) || 0;
|
|
data.fanSwingSpeed = Number(item.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0;
|
|
data.fanSwingPhase = Number(item.fanSwingPhase || 0) || 0;
|
|
data.fanBodyAngle = Number(item.fanBodyAngle ?? item.angle ?? 0) || 0;
|
|
}
|
|
if (item.type === "rotator") {
|
|
data.physics = { solid: phyScalar(item, "solid", true) !== false, motorOn: phyScalar(item, "motorOn", true) !== false, motorSpeed: Number(phyScalar(item, "motorSpeed", 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
|
|
}
|
|
if (item.type === "poison_block") {
|
|
data.physics = { solid: phyScalar(item, "solid", true) !== false, damage: Number(phyScalar(item, "damage", 7) || 7), thickness: Number(phyScalar(item, "thickness", 11) || 11), segments: cloneSegmentsForRotator(item) };
|
|
}
|
|
if (item.type === "reciprocator") {
|
|
data.physics = { solid: phyScalar(item, "solid", true) !== false, railOn: phyScalar(item, "railOn", true) !== false, railMotorSpeed: Number(phyScalar(item, "railMotorSpeed", 92) || 92), railTravel: Number(phyScalar(item, "railTravel", 150) || 150), railPhase: Number(phyScalar(item, "railPhase", 0) || 0), railDir: Number(phyScalar(item, "railDir", 1) || 1), railAxis: Number(phyScalar(item, "railAxis", item.angle || 0) || 0), thickness: Number(phyScalar(item, "thickness", 12) || 12), segments: cloneSegmentsForRotator(item) };
|
|
}
|
|
return data;
|
|
}
|
|
|
|
function applyCopyBufferToItem(item, data) {
|
|
if (!item || !data) return item;
|
|
item.r = Number(data.r || item.r || itemRadiusFor?.(item.type, 12) || 12);
|
|
if (item.type === "duplicator") item.r = Math.min(24, Math.max(14, Number(item.r || 24) || 24));
|
|
item.amount = Math.max(1, Number(data.amount || item.amount || 1));
|
|
if (Number.isFinite(Number(data.seed))) item.seed = Number(data.seed);
|
|
if (Number.isFinite(Number(data.spin))) item.spin = Number(data.spin);
|
|
if (item.type === "zunchi") {
|
|
if (data.zunchiVariant) item.zunchiVariant = String(data.zunchiVariant);
|
|
item.stage = "fresh";
|
|
item.stageTimer = 0;
|
|
item.freshness = 1;
|
|
item.fertility = 0;
|
|
item.spawnGrace = Math.max(Number(item.spawnGrace || 0) || 0, 10);
|
|
}
|
|
if (item.type === "grass") {
|
|
if (Number.isFinite(Number(data.grassStage))) item.grassStage = Number(data.grassStage);
|
|
if (Number.isFinite(Number(data.growth))) item.growth = Number(data.growth);
|
|
if (Number.isFinite(Number(data.health))) item.health = Number(data.health);
|
|
}
|
|
if (item.type === "firecracker") {
|
|
if (Number.isFinite(Number(data.fuseMax))) item.fuseMax = Number(data.fuseMax);
|
|
item.fuseTimer = Math.max(0.1, Number(item.fuseMax || 5) || 5);
|
|
}
|
|
if (isRotatableItemType(item.type)) item.angle = Number(data.angle || 0) || 0;
|
|
if (item.type === "fan") {
|
|
item.fanSwingOn = Boolean(data.fanSwingOn);
|
|
item.fanSwingCenter = Number(data.fanSwingCenter ?? item.angle ?? 0) || 0;
|
|
item.fanSwingRange = Number(data.fanSwingRange ?? (35 * Math.PI / 180)) || 0;
|
|
item.fanSwingSpeed = Number(data.fanSwingSpeed ?? (48 * Math.PI / 180)) || 0;
|
|
item.fanSwingPhase = Number(data.fanSwingPhase || 0) || 0;
|
|
item.fanBodyAngle = Number(data.fanBodyAngle ?? item.fanBodyAngle ?? item.angle ?? 0) || 0;
|
|
}
|
|
if (item.type === "signboard") item.text = String(data.text || "");
|
|
if (item.type === "circuit_board") global.TarinaiCircuitBoardSystem?.applySerializedConfig?.(item, data.circuitConfig || {});
|
|
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 === "pressure_switch") {
|
|
const targets = new Set(["tarinai", "item", "time", "hunger_avg", "stress_avg", "sleep_avg", "low_hp_count", "sick_count", "temperature", "zunchi_count", "ant_count", "water_count"]);
|
|
item.pressureTarget = targets.has(data.pressureTarget) ? data.pressureTarget : "tarinai";
|
|
item.pressureMin = Math.max(-999, Math.min(999, Number(data.pressureMin ?? 1) || 0));
|
|
item.pressureMax = Math.max(-999, Math.min(999, Number(data.pressureMax ?? 300) || 0));
|
|
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
|
|
item.pressureWidth = Math.max(60, Math.min(840, Number(data.pressureWidth || 220) | 0));
|
|
item.pressureHeight = Math.max(60, Math.min(840, Number(data.pressureHeight || 160) | 0));
|
|
item.pressureTimeStart = Math.max(0, Math.min(1435, Number(data.pressureTimeStart ?? 360) | 0));
|
|
item.pressureTimeEnd = Math.max(0, Math.min(1435, Number(data.pressureTimeEnd ?? 1080) | 0));
|
|
item.signalActive = false;
|
|
}
|
|
if (item.type === "robot_cleaner") {
|
|
const mask = Number(data.robotCleanerMask);
|
|
item.robotCleanerMask = Number.isFinite(mask) ? Math.max(0, Math.min(0x3f, mask | 0)) : (global.TarinaiItemDynamicToolSystem?.robotCleanerDefaultMask?.() ?? 3);
|
|
item.robotCleanerHighSpeed = Boolean(data.robotCleanerHighSpeed);
|
|
item.robotCleanerSpeed = global.TarinaiItemDynamicToolSystem?.robotCleanerEffectiveSpeed?.(item) ?? (item.robotCleanerHighSpeed ? 120 : 60);
|
|
item.robotCleanerTargetId = "";
|
|
item.robotCleanerTargetType = "";
|
|
}
|
|
if (item.type === "rotator") {
|
|
const ph = data.physics || {};
|
|
phySet(item, "solid", ph.solid !== false, "copy");
|
|
phySet(item, "motorOn", ph.motorOn !== false, "copy");
|
|
phySet(item, "motorSpeed", Number(ph.motorSpeed || 0) || 0, "copy");
|
|
phySet(item, "spin", 0, "copy");
|
|
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 12) || 12)), "copy");
|
|
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
|
|
item.r = Math.max(item.r || 64, Math.min(460, physicsShapeExtent(item)));
|
|
}
|
|
if (item.type === "poison_block") {
|
|
const ph = data.physics || {};
|
|
phySet(item, "solid", ph.solid !== false, "copy");
|
|
phySet(item, "damage", Math.max(1, Number(ph.damage || 7) || 7), "copy");
|
|
phySet(item, "spin", 0, "copy");
|
|
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 11) || 11)), "copy");
|
|
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
|
|
const extent = physicsShapeExtent(item);
|
|
item.r = Math.max(item.r || 64, Math.min(460, extent));
|
|
}
|
|
if (item.type === "reciprocator") {
|
|
const ph = data.physics || {};
|
|
phySet(item, "solid", ph.solid !== false, "copy");
|
|
phySet(item, "railOn", ph.railOn !== false, "copy");
|
|
phySet(item, "railMotorSpeed", Math.max(0, Number(ph.railMotorSpeed || 92) || 92), "copy");
|
|
phySet(item, "railTravel", Math.max(24, Number(ph.railTravel || 150) || 150), "copy");
|
|
phySet(item, "railPhase", Math.max(-1, Math.min(1, Number(ph.railPhase || 0) || 0)), "copy");
|
|
phySet(item, "railDir", Number(ph.railDir || 1) || 1, "copy");
|
|
phySet(item, "railAxis", Number.isFinite(Number(ph.railAxis)) ? Number(ph.railAxis) : (Number(item.angle || 0) || 0), "copy");
|
|
phySet(item, "slideSpeed", 0, "copy");
|
|
phySet(item, "thickness", Math.max(4, Math.min(34, Number(ph.thickness || 12) || 12)), "copy");
|
|
physicsApi()?.setSegments?.(item, Array.isArray(ph.segments) ? ph.segments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item), "copy");
|
|
const sa = window.TarinaiMechanicalSystem.railAxisAngle(item) ?? phyScalar(item, "railAxis", itemAngleFor(item));
|
|
const halfTravel = Math.max(12, phyScalar(item, "railTravel", 150) * 0.5);
|
|
const phase = Number(phyScalar(item, "railPhase", 0) || 0) || 0;
|
|
phySet(item, "railAnchorX", item.x - Math.cos(sa) * halfTravel * phase, "copy");
|
|
phySet(item, "railAnchorY", item.y - Math.sin(sa) * halfTravel * phase, "copy");
|
|
const extent = physicsShapeExtent(item);
|
|
item.r = Math.max(item.r || 64, Math.min(460, extent));
|
|
}
|
|
if (global.TarinaiMechanicalSystem.isMechanicalType(item.type)) {
|
|
if (!global.TarinaiPhysicsBodySystem.invalidateItem(item, "item-data-restore")) global.TarinaiMechanicalSystem.invalidateGeometry(item);
|
|
}
|
|
if (isServingFoodType(item.type)) {
|
|
item.toolSize = data.toolSize || item.toolSize || "medium";
|
|
item.foodServingsMax = Math.max(1, Number(data.foodServingsMax || item.foodServingsMax || item.amount || 1));
|
|
item.foodServingsRemaining = clamp(Number(data.foodServingsRemaining ?? item.foodServingsMax) || item.foodServingsMax, 0, item.foodServingsMax);
|
|
item.amount = item.foodServingsRemaining;
|
|
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
|
|
}
|
|
if (Number.isFinite(Number(item.x))) item.prevX = item.x;
|
|
if (Number.isFinite(Number(item.y))) item.prevY = item.y;
|
|
item.vx = 0;
|
|
item.vy = 0;
|
|
item.spinVelocity = 0;
|
|
if (isPinType(item.type)) {
|
|
global.TarinaiPinAttachmentSystem?.resetLooseState?.(item);
|
|
}
|
|
return item;
|
|
}
|
|
|
|
|
|
|
|
const DETECTOR_TARGETS = Object.freeze({
|
|
tarinai: { label: "\u305f\u308a\u306a\u3044\u6570", threshold: 1, min: 0, max: 300, step: 1, spatial: true },
|
|
item: { label: "\u30a2\u30a4\u30c6\u30e0\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
|
time: { label: "\u6642\u523b", threshold: 0, spatial: false, time: true },
|
|
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", threshold: 70, min: 0, max: 100, step: 1, spatial: true },
|
|
stress_avg: { label: "\u5e73\u5747\u30b9\u30c8\u30ec\u30b9", threshold: 70, min: 0, max: 100, step: 1, spatial: true },
|
|
sleep_avg: { label: "\u5e73\u5747\u7761\u7720\u6b32", threshold: 70, min: 0, max: 100, step: 1, spatial: true },
|
|
low_hp_count: { label: "\u4f4eHP\u500b\u4f53\u6570", threshold: 1, min: 0, max: 300, step: 1, spatial: true },
|
|
sick_count: { label: "\u75c5\u6c17\u500b\u4f53\u6570", threshold: 1, min: 0, max: 300, step: 1, spatial: true },
|
|
temperature: { label: "\u6c17\u6e29", threshold: 15, min: -100, max: 120, step: 1, unit: "\u2103", spatial: false },
|
|
zunchi_count: { label: "\u305a\u3093\u3061\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
|
ant_count: { label: "\u30a2\u30ea\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
|
water_count: { label: "\u6c34\u6ef4\u6570", threshold: 1, min: 0, max: 500, step: 1, spatial: true },
|
|
});
|
|
|
|
function ensureSignalItemEditor() {
|
|
const options = `<option value="tarinai">\u305f\u308a\u306a\u3044\u6570</option><option value="item">\u30a2\u30a4\u30c6\u30e0\u6570</option><option value="time">\u6642\u523b</option><option value="hunger_avg">\u5e73\u5747\u7a7a\u8179</option><option value="stress_avg">\u5e73\u5747\u30b9\u30c8\u30ec\u30b9</option><option value="sleep_avg">\u5e73\u5747\u7761\u7720\u6b32</option><option value="low_hp_count">\u4f4eHP\u500b\u4f53\u6570</option><option value="sick_count">\u75c5\u6c17\u500b\u4f53\u6570</option><option value="temperature">\u6c17\u6e29</option><option value="zunchi_count">\u305a\u3093\u3061\u6570</option><option value="ant_count">\u30a2\u30ea\u6570</option><option value="water_count">\u6c34\u6ef4\u6570</option>`;
|
|
return ensureEditorDialog("signalItemEditor", "rotator-editor hidden", `
|
|
<div class="rotator-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signalItemEditorTitle">
|
|
<div class="rotator-editor-titlebar"><h2 id="signalItemEditorTitle">\u691c\u77e5\u5668\u8a2d\u5b9a</h2><button id="signalItemEditorClose" class="rotator-editor-close" type="button">\u00d7</button></div>
|
|
<div class="rotator-editor-controls">
|
|
<label>\u691c\u77e5\u5bfe\u8c61 <select id="pressureSwitchTarget">${options}</select></label>
|
|
<div id="pressureSwitchCountRow" class="pressure-time-control">
|
|
<div class="pressure-time-summary"><span>\u4f5c\u52d5\u7bc4\u56f2</span><span><output id="pressureSwitchValueMinLabel">1</output><span aria-hidden="true"> \u301c </span><output id="pressureSwitchValueMaxLabel">300</output></span></div>
|
|
<div id="pressureSwitchValueRange" class="pressure-time-range">
|
|
<div id="pressureSwitchValueTrack" class="pressure-time-track" aria-hidden="true"></div>
|
|
<input id="pressureSwitchValueMin" class="pressure-time-thumb pressure-time-thumb-start" type="range" min="0" max="300" step="1" aria-label="\u4f5c\u52d5\u7bc4\u56f2\u306e\u4e0b\u9650">
|
|
<input id="pressureSwitchValueMax" class="pressure-time-thumb pressure-time-thumb-end" type="range" min="0" max="300" step="1" aria-label="\u4f5c\u52d5\u7bc4\u56f2\u306e\u4e0a\u9650">
|
|
</div>
|
|
<div class="pressure-number-inputs">
|
|
<label>\u4e0b\u9650 <input id="pressureSwitchValueMinNumber" type="number" inputmode="decimal"></label>
|
|
<label>\u4e0a\u9650 <input id="pressureSwitchValueMaxNumber" type="number" inputmode="decimal"></label>
|
|
</div>
|
|
</div>
|
|
<div id="pressureSwitchWidthRow" class="pressure-numeric-control pressure-spatial-control">
|
|
<div class="pressure-time-summary"><span>\u6a2a\u5e45</span><output id="pressureSwitchWidthLabel">220px</output></div>
|
|
<input id="pressureSwitchWidth" class="pressure-value-slider" type="range" min="60" max="840" step="20">
|
|
</div>
|
|
<div id="pressureSwitchHeightRow" class="pressure-numeric-control pressure-spatial-control">
|
|
<div class="pressure-time-summary"><span>\u7e26\u5e45</span><output id="pressureSwitchHeightLabel">160px</output></div>
|
|
<input id="pressureSwitchHeight" class="pressure-value-slider" type="range" min="60" max="840" step="20">
|
|
</div>
|
|
<div id="pressureSwitchTimeRow" class="pressure-time-control" hidden>
|
|
<div class="pressure-time-summary"><span>\u4f5c\u52d5\u6642\u9593\u5e2f</span><span><output id="pressureSwitchTimeStartLabel">06:00</output><span aria-hidden="true"> \u301c </span><output id="pressureSwitchTimeEndLabel">18:00</output></span></div>
|
|
<div id="pressureSwitchTimeRange" class="pressure-time-range">
|
|
<div id="pressureSwitchTimeTrack" class="pressure-time-track" aria-hidden="true"></div>
|
|
<input id="pressureSwitchTimeStart" class="pressure-time-thumb pressure-time-thumb-start" type="range" min="0" max="1435" step="5" aria-label="\u4f5c\u52d5\u958b\u59cb\u6642\u523b">
|
|
<input id="pressureSwitchTimeEnd" class="pressure-time-thumb pressure-time-thumb-end" type="range" min="0" max="1435" step="5" aria-label="\u4f5c\u52d5\u7d42\u4e86\u6642\u523b">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="rotator-editor-actions"><button id="signalItemEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button><button id="signalItemEditorApply" class="btn primary" type="button">\u4fdd\u5b58</button></div>
|
|
</div>`);
|
|
}
|
|
|
|
function openSignalItemEditor(item, worldRef) {
|
|
if (!item || item.type !== "pressure_switch") return false;
|
|
const dialog = ensureSignalItemEditor();
|
|
const target = dialog.querySelector("#pressureSwitchTarget");
|
|
const valueRow = dialog.querySelector("#pressureSwitchCountRow");
|
|
const valueRange = dialog.querySelector("#pressureSwitchValueRange");
|
|
const valueTrack = dialog.querySelector("#pressureSwitchValueTrack");
|
|
const valueMin = dialog.querySelector("#pressureSwitchValueMin");
|
|
const valueMax = dialog.querySelector("#pressureSwitchValueMax");
|
|
const valueMinNumber = dialog.querySelector("#pressureSwitchValueMinNumber");
|
|
const valueMaxNumber = dialog.querySelector("#pressureSwitchValueMaxNumber");
|
|
const valueMinLabel = dialog.querySelector("#pressureSwitchValueMinLabel");
|
|
const valueMaxLabel = dialog.querySelector("#pressureSwitchValueMaxLabel");
|
|
const width = dialog.querySelector("#pressureSwitchWidth");
|
|
const widthLabel = dialog.querySelector("#pressureSwitchWidthLabel");
|
|
const height = dialog.querySelector("#pressureSwitchHeight");
|
|
const heightLabel = dialog.querySelector("#pressureSwitchHeightLabel");
|
|
const widthRow = dialog.querySelector("#pressureSwitchWidthRow");
|
|
const heightRow = dialog.querySelector("#pressureSwitchHeightRow");
|
|
const timeRow = dialog.querySelector("#pressureSwitchTimeRow");
|
|
const timeRange = dialog.querySelector("#pressureSwitchTimeRange");
|
|
const timeTrack = dialog.querySelector("#pressureSwitchTimeTrack");
|
|
const timeStart = dialog.querySelector("#pressureSwitchTimeStart");
|
|
const timeEnd = dialog.querySelector("#pressureSwitchTimeEnd");
|
|
const timeStartLabel = dialog.querySelector("#pressureSwitchTimeStartLabel");
|
|
const timeEndLabel = dialog.querySelector("#pressureSwitchTimeEndLabel");
|
|
const normalizeMinute = (value, fallback) => Math.max(0, Math.min(1435, Math.round((Number.isFinite(Number(value)) ? Number(value) : fallback) / 5) * 5));
|
|
const formatMinute = value => { const minute = normalizeMinute(value, 0); return `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`; };
|
|
const targetDef = () => DETECTOR_TARGETS[target.value] || DETECTOR_TARGETS.tarinai;
|
|
const normalizeValue = (value, fallback, def = targetDef()) => {
|
|
const min = Number(def.min ?? 0), max = Number(def.max ?? 100), step = Math.max(0.0001, Number(def.step ?? 1));
|
|
const numeric = Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
const rounded = Math.round((numeric - min) / step) * step + min;
|
|
return Math.max(min, Math.min(max, Number(rounded.toFixed(6))));
|
|
};
|
|
const formatDetectorValue = (value, def) => `${Number(value) || 0}${def?.unit || ""}`;
|
|
|
|
target.value = DETECTOR_TARGETS[item.pressureTarget] ? item.pressureTarget : "tarinai";
|
|
const initialDef = targetDef();
|
|
valueMin.value = String(normalizeValue(item.pressureMin, initialDef.threshold, initialDef));
|
|
valueMax.value = String(normalizeValue(item.pressureMax, initialDef.max, initialDef));
|
|
width.value = String(item.pressureWidth || 220);
|
|
height.value = String(item.pressureHeight || 160);
|
|
timeStart.value = String(normalizeMinute(item.pressureTimeStart, 360));
|
|
timeEnd.value = String(normalizeMinute(item.pressureTimeEnd, 1080));
|
|
|
|
const updateTimeRange = () => {
|
|
const start = normalizeMinute(timeStart.value, 360), end = normalizeMinute(timeEnd.value, 1080);
|
|
timeStart.value = String(start); timeEnd.value = String(end);
|
|
timeStartLabel.textContent = formatMinute(start); timeEndLabel.textContent = formatMinute(end);
|
|
const startPct = start / 1435 * 100, endPct = end / 1435 * 100;
|
|
const idle = "rgba(94,112,128,0.22)", active = "rgba(67,158,218,0.78)";
|
|
timeTrack.style.background = start <= end
|
|
? `linear-gradient(to right, ${idle} 0%, ${idle} ${startPct}%, ${active} ${startPct}%, ${active} ${endPct}%, ${idle} ${endPct}%, ${idle} 100%)`
|
|
: `linear-gradient(to right, ${active} 0%, ${active} ${endPct}%, ${idle} ${endPct}%, ${idle} ${startPct}%, ${active} ${startPct}%, ${active} 100%)`;
|
|
if (timeRange) timeRange.dataset.wraps = start > end ? "1" : "0";
|
|
};
|
|
const updateValueRange = source => {
|
|
const def = targetDef();
|
|
let low = normalizeValue(source === "min-number" ? valueMinNumber.value : valueMin.value, def.threshold, def);
|
|
let high = normalizeValue(source === "max-number" ? valueMaxNumber.value : valueMax.value, def.max, def);
|
|
if (low > high) {
|
|
if (source === "min" || source === "min-number") high = low;
|
|
else low = high;
|
|
}
|
|
valueMin.value = String(low); valueMax.value = String(high);
|
|
valueMinNumber.value = String(low); valueMaxNumber.value = String(high);
|
|
valueMinLabel.textContent = formatDetectorValue(low, def); valueMaxLabel.textContent = formatDetectorValue(high, def);
|
|
const span = Math.max(0.0001, Number(def.max) - Number(def.min));
|
|
const lowPct = (low - Number(def.min)) / span * 100, highPct = (high - Number(def.min)) / span * 100;
|
|
const idle = "rgba(94,112,128,0.22)", active = "rgba(67,158,218,0.78)";
|
|
valueTrack.style.background = `linear-gradient(to right, ${idle} 0%, ${idle} ${lowPct}%, ${active} ${lowPct}%, ${active} ${highPct}%, ${idle} ${highPct}%, ${idle} 100%)`;
|
|
valueRange.dataset.wraps = "0";
|
|
};
|
|
const updateOverlay = () => {
|
|
const def = targetDef();
|
|
if (!def.spatial) {
|
|
if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null;
|
|
} else {
|
|
worldRef._pressureSwitchOverlay = { item, width: Math.max(60, Math.min(840, Number(width.value || 220) | 0)), height: Math.max(60, Math.min(840, Number(height.value || 160) | 0)) };
|
|
}
|
|
global.render?.();
|
|
};
|
|
const updateNumericDisplays = () => {
|
|
widthLabel.textContent = `${Number(width.value) || 0}px`;
|
|
heightLabel.textContent = `${Number(height.value) || 0}px`;
|
|
width.setAttribute("aria-valuetext", widthLabel.textContent);
|
|
height.setAttribute("aria-valuetext", heightLabel.textContent);
|
|
};
|
|
let previousTarget = target.value;
|
|
const updateMode = (targetChanged = false) => {
|
|
const def = targetDef();
|
|
for (const control of [valueMin, valueMax, valueMinNumber, valueMaxNumber]) {
|
|
control.min = String(def.min ?? 0); control.max = String(def.max ?? 100); control.step = String(def.step ?? 1);
|
|
}
|
|
if (targetChanged && previousTarget !== target.value) {
|
|
valueMin.value = String(def.threshold);
|
|
valueMax.value = String(def.max);
|
|
}
|
|
previousTarget = target.value;
|
|
valueRow.hidden = Boolean(def.time);
|
|
widthRow.hidden = !def.spatial;
|
|
heightRow.hidden = !def.spatial;
|
|
timeRow.hidden = !def.time;
|
|
updateValueRange(); updateNumericDisplays(); updateTimeRange(); updateOverlay();
|
|
};
|
|
valueMin.oninput = () => updateValueRange("min");
|
|
valueMax.oninput = () => updateValueRange("max");
|
|
valueMinNumber.oninput = () => updateValueRange("min-number");
|
|
valueMaxNumber.oninput = () => updateValueRange("max-number");
|
|
width.oninput = () => { updateNumericDisplays(); updateOverlay(); };
|
|
height.oninput = () => { updateNumericDisplays(); updateOverlay(); };
|
|
timeStart.oninput = updateTimeRange; timeEnd.oninput = updateTimeRange;
|
|
|
|
const bindNearestThumbTeleport = (range, first, second, normalize, update) => {
|
|
if (!range || !first || !second) return;
|
|
range.onpointerdown = event => {
|
|
if (event.button !== 0 || event.target === first || event.target === second) return;
|
|
const rect = range.getBoundingClientRect();
|
|
if (!rect.width) return;
|
|
event.preventDefault();
|
|
const min = Number(first.min || 0), max = Number(first.max || 100);
|
|
const ratio = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
|
const raw = min + (max - min) * ratio;
|
|
const next = normalize(raw);
|
|
const firstValue = Number(first.value), secondValue = Number(second.value);
|
|
const targetThumb = Math.abs(next - firstValue) <= Math.abs(next - secondValue) ? first : second;
|
|
targetThumb.value = String(next);
|
|
update(targetThumb === first ? "min" : "max");
|
|
targetThumb.focus({ preventScroll: true });
|
|
};
|
|
};
|
|
bindNearestThumbTeleport(valueRange, valueMin, valueMax, value => normalizeValue(value, targetDef().threshold, targetDef()), source => updateValueRange(source));
|
|
bindNearestThumbTeleport(timeRange, timeStart, timeEnd, value => normalizeMinute(value, 0), updateTimeRange);
|
|
target.onchange = () => updateMode(true);
|
|
updateMode(false);
|
|
const close = () => { dialog.classList.add("hidden"); if (worldRef._pressureSwitchOverlay?.item === item) worldRef._pressureSwitchOverlay = null; global.render?.(); };
|
|
dialog.querySelector("#signalItemEditorApply").onclick = () => {
|
|
global.TarinaiHistory.capture(worldRef, "detector-edit");
|
|
item.pressureTarget = DETECTOR_TARGETS[target.value] ? target.value : "tarinai";
|
|
const def = targetDef();
|
|
item.pressureMin = normalizeValue(valueMin.value, def.threshold, def);
|
|
item.pressureMax = normalizeValue(valueMax.value, def.max, def);
|
|
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
|
|
item.pressureWidth = Math.max(60, Math.min(840, Number(width.value || 220) | 0));
|
|
item.pressureHeight = Math.max(60, Math.min(840, Number(height.value || 160) | 0));
|
|
item.pressureTimeStart = normalizeMinute(timeStart.value, 360);
|
|
item.pressureTimeEnd = normalizeMinute(timeEnd.value, 1080);
|
|
worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("detector-edit"); global.render?.(); close();
|
|
};
|
|
dialog.querySelector("#signalItemEditorCancel").onclick = close;
|
|
dialog.querySelector("#signalItemEditorClose").onclick = close;
|
|
dialog.onclick = e => { if (e.target === dialog) close(); };
|
|
dialog.classList.remove("hidden");
|
|
return true;
|
|
}
|
|
|
|
function isEditableItem(item) {
|
|
return item && !item.dead && (item.type === "signboard" || item.type === "robot_cleaner" || item.type === "gate_fence" || item.type === "rotator" || item.type === "poison_block" || item.type === "reciprocator" || item.type === "fan" || item.type === "pressure_switch" || item.type === "circuit_board");
|
|
}
|
|
|
|
function findEditableItemAt(worldRef, x, y) {
|
|
let best = null;
|
|
let bestD = Infinity;
|
|
for (let i = (worldRef?.items || []).length - 1; i >= 0; i--) {
|
|
const it = worldRef.items[i];
|
|
if (!isEditableItem(it)) continue;
|
|
let d = distXY(x, y, it.x, it.y);
|
|
let hit = d <= Math.max(30, (it.r || 24) * 1.35);
|
|
if (global.TarinaiMechanicalSystem.isMechanicalType(it.type) || worldRef?.isFenceType?.(it.type)) {
|
|
const result = global.TarinaiCollisionFootprints.hitTestItem(worldRef, it, x, y, { padding: 14, radiusMultiplier: 1.25 }) || { hit: false, distance: d };
|
|
hit = Boolean(result.hit);
|
|
d = Math.min(d, result.distance ?? d);
|
|
}
|
|
if (hit && d < bestD) { best = it; bestD = d; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function openEditableItemEditor(item, worldRef) {
|
|
if (!isEditableItem(item)) return false;
|
|
if (item.type === "signboard") return openSignboardEditor(item, worldRef);
|
|
if (item.type === "robot_cleaner") return openRobotCleanerEditor(item, worldRef);
|
|
if (item.type === "rotator" || item.type === "poison_block") return openRotatorEditor(item, worldRef);
|
|
if (item.type === "reciprocator") return openReciprocatorEditor(item, worldRef);
|
|
if (item.type === "fan") return openFanEditor(item, worldRef);
|
|
if (item.type === "pressure_switch") return openSignalItemEditor(item, worldRef);
|
|
if (item.type === "circuit_board") return global.TarinaiCircuitBoardSystem?.openEditor?.(item, worldRef) || false;
|
|
if (item.type === "gate_fence") {
|
|
item.gateOpen = !item.gateOpen;
|
|
worldRef?.markSpatialDirty?.("gate-toggle");
|
|
worldRef.drawListDirty = true;
|
|
worldRef?.log?.(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5\u3092\u958b\u3051\u305f\u3002" : "\u30b2\u30fc\u30c8\u67f5\u3092\u9589\u3081\u305f\u3002", "observe");
|
|
if (typeof showToast === "function") showToast(item.gateOpen ? "\u30b2\u30fc\u30c8\u67f5: \u958b" : "\u30b2\u30fc\u30c8\u67f5: \u9589");
|
|
global.render?.();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function isPlacementTool(tool) {
|
|
const def = toolDefinition(tool);
|
|
return Boolean(def?.placeable && toolItemType(tool));
|
|
}
|
|
|
|
function findDuplicatorAt(worldRef, x, y) {
|
|
let best = null;
|
|
let bestD = Infinity;
|
|
const runtime = global.TarinaiItemLifecycleRuntimeSupport;
|
|
for (let i = (worldRef?.items || []).length - 1; i >= 0; i -= 1) {
|
|
const it = worldRef.items[i];
|
|
if (!it || it.dead || it.type !== "duplicator") continue;
|
|
const r = it.r || 34;
|
|
const centerD = distXY(x, y, it.x, it.y);
|
|
const load = runtime?.duplicatorLoadPoint?.(it) || { x: it.x + r * 0.62, y: it.y + r * 0.24 };
|
|
const slotD = distXY(x, y, load.x, load.y);
|
|
const hit = Math.max(44, r * 1.45);
|
|
if (centerD <= hit || slotD <= Math.max(30, r * 0.86)) {
|
|
const d = Math.min(centerD, slotD);
|
|
if (d < bestD) { best = it; bestD = d; }
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function directSetDuplicatorAt(worldRef, x, y, itemType = "") {
|
|
const type = String(itemType || "");
|
|
if (!type || type === "duplicator") return false;
|
|
const runtime = global.TarinaiItemLifecycleRuntimeSupport;
|
|
const loadType = runtime?.duplicatorLoadTypeForItem?.({ type, amount: 1, foodServingsRemaining: 1, dead: false }) || "";
|
|
if (!loadType) return false;
|
|
const duplicator = findDuplicatorAt(worldRef, x, y);
|
|
if (!duplicator) return false;
|
|
const ok = runtime?.duplicatorSetStoredType?.(duplicator, loadType, worldRef, { direct: true, force: false });
|
|
if (!ok) return false;
|
|
duplicator.duplicatorLoadSuppressedUntil = Math.max(duplicator.duplicatorLoadSuppressedUntil || 0, (worldRef?.time || 0) + 0.45);
|
|
if (typeof showToast === "function") showToast(`\u8907\u88fd\u6a5f: ${duplicator.storedFoodLabel || toolLabel(loadType)}\u3092\u30bb\u30c3\u30c8`);
|
|
worldRef.drawListDirty = true;
|
|
global.render?.();
|
|
return true;
|
|
}
|
|
|
|
function isPhysicsObstacleItemType(type = "") {
|
|
const key = String(type || "");
|
|
return Boolean(
|
|
key === "rope" || key === "rod" || key === "spring"
|
|
|| global.TarinaiMechanicalSystem.isMechanicalType(key)
|
|
|| (typeof isFenceItemType === "function" && isFenceItemType(key))
|
|
);
|
|
}
|
|
|
|
function isSoftPlacementIgnoredItemType(type = "") {
|
|
return global.TarinaiCollisionFootprints?.isSoftPlacementIgnoredType?.(type) || false;
|
|
}
|
|
|
|
|
|
function softItemRadius(item) {
|
|
if (!item) return 12;
|
|
return Math.max(8, Number(item.r || item.radius || itemRadiusFor?.(item.type, 12) || 12) || 12);
|
|
}
|
|
|
|
function placementRectsForItem(worldRef, item) {
|
|
const solid = worldRef?.solidObstacleRects?.(item) || [];
|
|
if (solid.length) return solid;
|
|
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) return global.TarinaiMechanicalSystem.placementRects(item) || [];
|
|
return solid;
|
|
}
|
|
|
|
function placementItemOverlapsSoftItem(worldRef, placedItem, softItem) {
|
|
if (!worldRef || !placedItem || !softItem || softItem.dead) return false;
|
|
const r = softItemRadius(softItem);
|
|
if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(placedItem.type)) {
|
|
const runtime = global.TarinaiLinkRuntime;
|
|
const pb = physicsApi();
|
|
const a = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 0), worldRef);
|
|
const b = runtime?.endpointWorld?.(pb?.endpoint?.(placedItem, 1), worldRef);
|
|
if (a && b) {
|
|
const d = global.TarinaiGeometry.pointSegmentDistance(softItem.x, softItem.y, a.x, a.y, b.x, b.y);
|
|
return Number.isFinite(d) && d <= r + (placedItem.type === "rod" ? 9 : 7);
|
|
}
|
|
}
|
|
const rects = placementRectsForItem(worldRef, placedItem);
|
|
if (rects.length) return rects.some(rect => global.TarinaiCollisionFootprints.rectCircleOverlap(rect, softItem.x, softItem.y, r, 0.5));
|
|
const pr = Math.max(10, Number(placedItem.r || placedItem.radius || itemRadiusFor?.(placedItem.type, 12) || 12) || 12);
|
|
return distXY(placedItem.x, placedItem.y, softItem.x, softItem.y) <= pr + r;
|
|
}
|
|
|
|
function placementSoftSearchRadius(item) {
|
|
let search = Math.max(80, Number(item?.r || item?.radius || 36) + 90);
|
|
if (global.TarinaiMechanicalSystem.isMechanicalType(item?.type)) search = Math.max(search, (global.TarinaiMechanicalSystem.reach(item) || item.r || 80) + 96);
|
|
else if (["rope", "rod", "spring", "wire", "insulated_wire"].includes(item?.type)) search = Math.max(search, Number(physicsApi()?.linkScalar?.(item, "len", (item.r || 40) * 2 || 80)) * 0.55 + 96);
|
|
return search;
|
|
}
|
|
|
|
function collectSoftUnderPlacement(worldRef, item) {
|
|
if (!worldRef || !item || item.type === "trace" || item.type === "splat") return [];
|
|
const out = [];
|
|
const search = placementSoftSearchRadius(item);
|
|
for (const soft of worldRef.nearbyItems?.(item.x, item.y, search, true) || worldRef.items || []) {
|
|
if (!soft || soft === item || soft.dead || !isSoftPlacementIgnoredItemType(soft.type)) continue;
|
|
if (placementItemOverlapsSoftItem(worldRef, item, soft)) out.push(soft);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function clearSoftItemsUnderPlacementItem(worldRef, item, reason = "placement-soft-overlap-clear") {
|
|
const items = collectSoftUnderPlacement(worldRef, item);
|
|
let removed = 0;
|
|
for (const soft of items) {
|
|
const ok = global.TarinaiStructureLifecycle.deleteItem(worldRef, soft, {
|
|
reason,
|
|
userReason: "\u7f6e\u304d\u3082\u306e\u304c\u91cd\u306a\u3063\u305f",
|
|
wake: true,
|
|
});
|
|
if (ok) removed += 1;
|
|
}
|
|
if (removed) {
|
|
worldRef.markItemBucketsDirty?.(reason);
|
|
worldRef.markSpatialDirty?.(reason);
|
|
worldRef.drawListDirty = true;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
|
|
function clearSoftItemsUnderPhysicsItem(worldRef, item, reason = "physics-overlap-clear") {
|
|
if (!worldRef || !item || !isPhysicsObstacleItemType(item.type)) return 0;
|
|
return clearSoftItemsUnderPlacementItem(worldRef, item, reason);
|
|
}
|
|
|
|
global.TarinaiPhysicsSoftClear = Object.freeze({
|
|
isSoftPlacementType: isSoftPlacementIgnoredItemType,
|
|
collectForPlacement: collectSoftUnderPlacement,
|
|
clearForItem: clearSoftItemsUnderPhysicsItem,
|
|
});
|
|
|
|
|
|
function clearGrassBedsForPlacement(worldRef, item) {
|
|
return clearSoftItemsUnderPlacementItem(worldRef, item, "placement-soft-overlap-clear");
|
|
}
|
|
|
|
|
|
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
|
|
|
|
directSetDuplicatorAt(x, y, itemType = "") {
|
|
return directSetDuplicatorAt(this, x, y, itemType);
|
|
},
|
|
|
|
copyPreviewItemAt(x, y) {
|
|
if (!this.copyBuffer?.type) return null;
|
|
const snapped = global.TarinaiPlacementPreviewSystem?.snapPoint?.(this, x, y) || { x, y };
|
|
const item = new Item(this.copyBuffer.type, snapped.x, snapped.y);
|
|
applyCopyBufferToItem(item, this.copyBuffer);
|
|
item.x = snapped.x; item.y = snapped.y;
|
|
item.world = this;
|
|
return item;
|
|
},
|
|
|
|
useCopyToolAt(x, y) {
|
|
if (!this.copyBuffer) {
|
|
const targetInfo = copyTargetAt(this, x, y);
|
|
const target = targetInfo.item;
|
|
if (!target) {
|
|
if (targetInfo.reason === "link") showToast("\u30ed\u30fc\u30d7\u3068\u68d2\u306f\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3002");
|
|
else if (targetInfo.reason === "lodged_pin") showToast("\u523a\u3055\u3063\u305f\u30d4\u30f3\u306f\u30b3\u30d4\u30fc\u3067\u304d\u307e\u305b\u3093\u3002");
|
|
else showToast("\u30b3\u30d4\u30fc\u3067\u304d\u308b\u3082\u306e\u304c\u3042\u308a\u307e\u305b\u3093\u3002");
|
|
return true;
|
|
}
|
|
this.copyBuffer = makeCopyBufferForItem(target);
|
|
showToast(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u307e\u3057\u305f\u3002`);
|
|
this.log?.(`${toolLabel(target.type)}\u3092\u30b3\u30d4\u30fc\u3057\u305f\u3002`, "observe");
|
|
return true;
|
|
}
|
|
const item = this.copyPreviewItemAt?.(x, y);
|
|
if (item) {
|
|
const placed = this.placeItem(item, false);
|
|
if (placed) {
|
|
placed.world = this;
|
|
if (placed.type === "grass" || placed.type === "trace" || placed.type === "splat") this.markTerrainDirtyAt?.(placed.x, placed.y, Math.max(placed.r || 24, 36), "copy-paste");
|
|
this._forceImmediateToolVisualRefresh?.("tool-change");
|
|
const label = toolLabel(item.type);
|
|
this.log?.(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u305f\u3002`, "observe");
|
|
showToast(`${label}\u3092\u8cbc\u308a\u4ed8\u3051\u307e\u3057\u305f\u3002`);
|
|
return true;
|
|
}
|
|
}
|
|
showToast("\u305d\u3053\u306b\u306f\u8cbc\u308a\u4ed8\u3051\u3067\u304d\u307e\u305b\u3093\u3002");
|
|
return true;
|
|
},
|
|
|
|
fencePlacementBlocked(item) {
|
|
if (!item || !this.isFenceType(item.type)) return false;
|
|
const rect = this.fenceRect(item);
|
|
if (!rect) return false;
|
|
const baseMargin = item.type === "gate_fence" ? 1.5 : Math.max(3, (item.r || 42) * 0.12);
|
|
if (rect.left < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.top < 0 || rect.bottom > this.h) return true;
|
|
const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 56;
|
|
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
|
|
if (!it || it === item || it.dead) continue;
|
|
const otherIsGate = it.type === "gate_fence";
|
|
const margin = (item.type === "gate_fence" || otherIsGate) ? 1.5 : baseMargin;
|
|
for (const r of this.solidObstacleRects(it)) {
|
|
if (global.TarinaiGeometry.rectsOverlap(rect, r, margin)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
importantPlacementOverlapBlocked(item) {
|
|
return global.TarinaiCollisionFootprints.itemPlacementOverlapBlocked(this, item) ?? false;
|
|
},
|
|
|
|
placementBlocked(item) {
|
|
if (!item || item.dead) return true;
|
|
if (item.type === "genkotsu") return false;
|
|
const rects = this.solidObstacleRects(item);
|
|
const sharedBoundsBlocked = global.TarinaiPlacementPreviewSystem.boundsBlocked(this, item, { rects });
|
|
if (sharedBoundsBlocked === true) return true;
|
|
if (sharedBoundsBlocked !== false) {
|
|
const pad = CONFIG.worldPadding || 30;
|
|
if (rects.length) {
|
|
for (const rect of rects) {
|
|
if (rect.left < pad || rect.top < 0 || 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 < 0 || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true;
|
|
}
|
|
}
|
|
if (item.type === "grass" && (this.grassBlockedAt?.(item.x, item.y, item, { ignoreSoftPlacement: true }) || this.grassOnTarinaiAt?.(item.x, item.y))) return true;
|
|
if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true;
|
|
if (this.importantPlacementOverlapBlocked(item)) return true;
|
|
if (item.type === "nest_box") {
|
|
const margin = Math.max(8, (item.r || 42) * 0.18);
|
|
for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) {
|
|
if (!it || it === item || it.dead) continue;
|
|
for (const rect of this.solidObstacleRects(it)) {
|
|
for (const own of rects) {
|
|
const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom;
|
|
if (!separated) return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
},
|
|
|
|
placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) {
|
|
const shared = global.TarinaiPlacementPreviewSystem.clampPointFor(this, item, x, y);
|
|
if (shared) return shared;
|
|
const pad = CONFIG.worldPadding || 30;
|
|
const rects = this.solidObstacleRects(item);
|
|
if (rects.length) {
|
|
let left = 0, right = 0, top = 0, bottom = 0;
|
|
for (const rect of rects) {
|
|
left = Math.max(left, (item.x || 0) - rect.left);
|
|
right = Math.max(right, rect.right - (item.x || 0));
|
|
top = Math.max(top, (item.y || 0) - rect.top);
|
|
bottom = Math.max(bottom, rect.bottom - (item.y || 0));
|
|
}
|
|
return {
|
|
x: clamp(x, pad + left, this.w - pad - right),
|
|
y: clamp(y, top, this.h - pad - bottom),
|
|
};
|
|
}
|
|
const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25));
|
|
return {
|
|
x: clamp(x, pad + radius, this.w - pad - radius),
|
|
y: clamp(y, pad + radius, this.h - pad - radius),
|
|
};
|
|
},
|
|
|
|
placementProbeItem(item, x, y) {
|
|
return { ...item, x, y, dead: false };
|
|
},
|
|
|
|
findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) {
|
|
if (!item || item.dead) return null;
|
|
if (item.type === "grass") {
|
|
const spot = this.findGrassPlantingSpot
|
|
? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true })
|
|
: null;
|
|
if (!spot) return null;
|
|
const probe = this.placementProbeItem(item, spot.x, spot.y);
|
|
return this.placementBlocked(probe) ? null : spot;
|
|
}
|
|
|
|
const start = this.placementClampPointFor(item, item.x, item.y);
|
|
const original = this.placementProbeItem(item, start.x, start.y);
|
|
if (allowOriginal && !this.placementBlocked(original)) return start;
|
|
|
|
const solid = this.isFenceType(item.type) || (item.type === "nest_box" || item.type === "pipe") || item.type === "rotator" || item.type === "reciprocator";
|
|
const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2));
|
|
const count = attempts || (solid ? 72 : 36);
|
|
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
let best = null;
|
|
let bestD = Infinity;
|
|
for (let i = 0; i < count; i++) {
|
|
const t = count <= 1 ? 1 : i / (count - 1);
|
|
const radius = Math.max(8, searchRadius * Math.sqrt(t));
|
|
const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2;
|
|
const rawX = item.x + Math.cos(angle) * radius;
|
|
const rawY = item.y + Math.sin(angle) * radius * 0.74;
|
|
const p = this.placementClampPointFor(item, rawX, rawY);
|
|
const probe = this.placementProbeItem(item, p.x, p.y);
|
|
if (this.placementBlocked(probe)) continue;
|
|
const d = distXY(item.x, item.y, p.x, p.y);
|
|
if (d < bestD) { best = p; bestD = d; }
|
|
if (d < Math.max(18, (item.r || 12) * 0.75)) break;
|
|
}
|
|
return best;
|
|
},
|
|
|
|
findGrabTargetAt(x, y) {
|
|
let foundT = null;
|
|
for (let i = this.tarinai.length - 1; i >= 0; i--) {
|
|
const t = this.tarinai[i];
|
|
if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue;
|
|
if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; }
|
|
}
|
|
let foundItem = null, foundItemD = Infinity;
|
|
for (let i = this.items.length - 1; i >= 0; i--) {
|
|
const it = this.items[i];
|
|
if (!it || it.dead || it.type === "trace" || it.type === "splat" || it.type === "plushie" || CONNECTION_ITEM_TYPES.has(it.type)) continue;
|
|
let hit = false;
|
|
let d = distXY(x, y, it.x, it.y);
|
|
if (global.TarinaiMechanicalSystem.isMechanicalType(it.type)) {
|
|
const result = global.TarinaiMechanicalSystem.hitTest(this, it, x, y, { padding: 16 });
|
|
hit = Boolean(result.hit);
|
|
d = Math.min(d, result.distance ?? d);
|
|
} else {
|
|
const result = global.TarinaiCollisionFootprints.hitTestItem(this, it, x, y, { radiusMultiplier: (it.type === "ball" || it.type === "balloon") ? 4.0 : 2.2 }) || { hit: false, distance: d };
|
|
hit = Boolean(result.hit);
|
|
d = Math.min(d, result.distance ?? d);
|
|
}
|
|
if (hit && d < foundItemD) { foundItem = it; foundItemD = d; }
|
|
}
|
|
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "ball_lodged") {
|
|
const carrier = (this.items || []).find(it => it && !it.dead && it.type === "ball" && it.id === foundItem.pinBallId);
|
|
if (carrier) return carrier;
|
|
}
|
|
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem;
|
|
if (foundT) {
|
|
const lodged = foundT.currentLodgedPin?.() || (this.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === foundT.id);
|
|
if (lodged) return lodged;
|
|
}
|
|
if (foundT && foundItem) {
|
|
const td = distXY(x, y, foundT.x, foundT.y);
|
|
return td <= foundItemD + 8 ? foundT : foundItem;
|
|
}
|
|
return foundT || foundItem;
|
|
},
|
|
|
|
placeItem(item, dropped = true) {
|
|
if (!item) return null;
|
|
const directFeeding = global.TarinaiDirectFeedingSystem;
|
|
if (directFeeding?.isDirectFeedType?.(item)) {
|
|
const directTarget = directFeeding.findTargetAt?.(this, item.x, item.y) || null;
|
|
const given = directTarget ? directFeeding.give?.(this, directTarget, item, { source: "placement" }) : null;
|
|
if (given) {
|
|
global.TarinaiAchievements?.recordIntervention?.({ world: this, item, type: item.type, source: "direct-feed-placement" });
|
|
this.drawListDirty = true;
|
|
if (typeof global.render === "function") global.render();
|
|
return item;
|
|
}
|
|
}
|
|
if (!(this.canAddObjects?.(1) ?? true)) {
|
|
showToast(`\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u7dcf\u6570\u306f${this.objectLimit}\u500b\u307e\u3067\u3067\u3059\u3002`);
|
|
return null;
|
|
}
|
|
if (item.type === "grass" && !this.canAddGrass?.(1)) {
|
|
showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
|
|
return null;
|
|
}
|
|
if (this.placementBlocked(item)) {
|
|
showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002");
|
|
return null;
|
|
}
|
|
if (dropped && (item.type === "genkotsu" || item.type === "stone" || item.type === "ball" || item.type === "balloon" || isPinType(item.type))) {
|
|
item.dropMax = item.type === "genkotsu" ? 0.22 : (item.type === "stone" ? 0.72 : 0.48);
|
|
item.dropTimer = item.dropMax;
|
|
item.dropImpactDone = false;
|
|
} else {
|
|
item.dropMax = 0;
|
|
item.dropTimer = 0;
|
|
item.dropImpactDone = true;
|
|
}
|
|
const removedSoft = clearGrassBedsForPlacement(this, item);
|
|
if (removedSoft) this.updateItemCounts?.("placement-soft-overlap-clear");
|
|
const placed = this.addItem?.(item, `place:${item.type}`);
|
|
if (!placed) {
|
|
if (!(this.canAddObjects?.(1) ?? true)) showToast(`\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u7dcf\u6570\u306f${this.objectLimit}\u500b\u307e\u3067\u3067\u3059\u3002`);
|
|
else if (item.type === "grass") showToast(`\u8349\u306f\u3053\u306e\u30d5\u30a3\u30fc\u30eb\u30c9\u3067\u306f${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}\u672c\u307e\u3067\u3067\u3059\u3002`);
|
|
return null;
|
|
}
|
|
global.TarinaiAchievements?.recordPlayerPlacement?.({ world: this, item, type: item.type, dropped, tool: item.type });
|
|
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;
|
|
},
|
|
|
|
findPokeTargetAt(x, y) {
|
|
const items = this.items || [];
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const item = items[i];
|
|
if (!item || item.dead || item.type !== "plushie") continue;
|
|
const owner = item.carriedById ? (this.liveTarinaiById?.(item.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === item.carriedById)) : null;
|
|
if (owner && this.isTarinaiHiddenInNestBox?.(owner)) continue;
|
|
const cx = Number(item.x ?? owner?.x ?? 0) || 0;
|
|
const cy = Number(item.y ?? (owner ? owner.y - Math.max(22, (owner.radius || 24) * 1.02) : 0)) || 0;
|
|
const rx = Math.max(30, (item.r || 7) * 4.8, (owner?.radius || 0) * 1.05);
|
|
const ry = Math.max(34, (item.r || 7) * 5.6, (owner?.radius || 0) * 1.18);
|
|
const dx = (x - cx) / rx;
|
|
const dy = (y - cy) / ry;
|
|
if (dx * dx + dy * dy <= 1) return item;
|
|
}
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const item = items[i];
|
|
if (!item || item.dead || (item.type !== "ball" && item.type !== "balloon")) continue;
|
|
if (distXY(x, y, item.x, item.y) <= Math.max(30, (item.r || 18) * 1.8)) return item;
|
|
}
|
|
let mechanical = null;
|
|
let mechanicalDistance = Infinity;
|
|
for (let i = items.length - 1; i >= 0; i--) {
|
|
const item = items[i];
|
|
if (!item || item.dead || !["rotator", "reciprocator", "poison_block"].includes(item.type)) continue;
|
|
const hit = global.TarinaiMechanicalSystem.hitTest(this, item, x, y, { padding: 12 });
|
|
if (hit?.hit && Number(hit.distance || 0) < mechanicalDistance) {
|
|
mechanical = item;
|
|
mechanicalDistance = Number(hit.distance || 0);
|
|
}
|
|
}
|
|
if (mechanical) return mechanical;
|
|
for (let i = (this.tarinai || []).length - 1; i >= 0; i--) {
|
|
const tarinai = this.tarinai[i];
|
|
if (!tarinai || tarinai.dead || this.isTarinaiHiddenInNestBox?.(tarinai)) continue;
|
|
const r = Math.max(18, Number(tarinai.radius || 22) || 22);
|
|
const dx = (x - tarinai.x) / (r * 1.12);
|
|
const dy = (y - tarinai.y) / (r * 0.94);
|
|
if (tarinai.contains?.(x, y) || dx * dx + dy * dy <= 1) return tarinai;
|
|
}
|
|
return null;
|
|
},
|
|
|
|
handleClick(x, y) {
|
|
const tool = this.tool;
|
|
if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "rope", "rod", "spring", "wire", "insulated_wire", "new"].includes(tool)) {
|
|
const editable = findEditableItemAt(this, x, y);
|
|
if (editable && openEditableItemEditor(editable, this)) {
|
|
global.TarinaiCommands.setSelection(this, null) || (this.selected = null);
|
|
return;
|
|
}
|
|
}
|
|
if (tool === "observe") {
|
|
let found = null;
|
|
for (let i = this.tarinai.length - 1; i >= 0; i--) {
|
|
if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; }
|
|
}
|
|
if (!found) {
|
|
const editable = findEditableItemAt(this, x, y);
|
|
if (editable && openEditableItemEditor(editable, this)) {
|
|
global.TarinaiCommands.setSelection(this, null) || (this.selected = null);
|
|
return;
|
|
}
|
|
}
|
|
if (!found && !this.findDeleteToolTargetAt?.(x, y)) {
|
|
global.TarinaiAchievements?.recordEmptyClick?.({ world: this, x, y });
|
|
}
|
|
const wasSelectionEmpty = !this.selected;
|
|
if (found && wasSelectionEmpty) this._scrollSelectedDataTopOnNextSelection = true;
|
|
else this._scrollSelectedDataTopOnNextSelection = false;
|
|
global.TarinaiCommands.setSelection(this, found) || (this.selected = found);
|
|
return;
|
|
}
|
|
|
|
if (tool === "poke") {
|
|
global.TarinaiAchievements?.recordIntervention?.({ world: this, tool: "poke", x, y });
|
|
const target = this.findPokeTargetAt?.(x, y) || null;
|
|
if (!target) return;
|
|
if (target.type === "plushie" && typeof target.fling === "function") {
|
|
const owner = target.carriedById ? (this.liveTarinaiById?.(target.carriedById) || (this.tarinai || []).find(t => t && !t.dead && t.id === target.carriedById)) : null;
|
|
let dx = (Number(target.x || owner?.x || 0) || 0) - (Number(x) || 0);
|
|
let dy = (Number(target.y || owner?.y || 0) || 0) - (Number(y) || 0);
|
|
if (Math.hypot(dx, dy) < 8) {
|
|
dx = Number(owner?.facing || owner?.faceDir || 1) || 1;
|
|
dy = -0.46;
|
|
}
|
|
const length = Math.max(1, Math.hypot(dx, dy));
|
|
const speed = 520;
|
|
const vx = dx / length * speed;
|
|
const vy = Math.min(dy / length * speed - 95, -145);
|
|
if (target.fling(this, x, y, { source: "poke", vx, vy, speed, life: 5.2, destroyOffscreen: true })) {
|
|
this.effects?.push(new Effect("ring", target.x, target.y, { size: 26, life: 0.20, color: "rgba(190,82,92,0.40)" }));
|
|
this.drawListDirty = true;
|
|
audio.poke?.();
|
|
global.TarinaiAchievements?.recordPlushiePokeFling?.({ world: this, item: target, owner });
|
|
}
|
|
return;
|
|
}
|
|
if (target.type === "ball" || target.type === "balloon") {
|
|
this.pokeBall(target, x, y);
|
|
return;
|
|
}
|
|
if (["rotator", "reciprocator", "poison_block"].includes(target.type)) {
|
|
if (global.TarinaiMechanicalSystem.applyPokeImpulse(target, x, y, this)) {
|
|
const label = target.type === "rotator" ? "\u56de\u8ee2\u4f53" : (target.type === "poison_block" ? "\u6bd2\u30d6\u30ed\u30c3\u30af" : "\u5f80\u5fa9\u4f53");
|
|
const color = target.type === "rotator" ? "rgba(184,132,230,0.40)" : (target.type === "poison_block" ? "rgba(92,188,68,0.40)" : "rgba(90,150,214,0.40)");
|
|
this.effects?.push(new Effect("ring", x, y, { size: 22, life: 0.18, color }));
|
|
this.drawListDirty = true;
|
|
audio.poke?.();
|
|
this.log(`${label}\u3092\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f\u3002`, "observe", { eventType: "mechanism_poke" });
|
|
}
|
|
return;
|
|
}
|
|
if (typeof target.poke === "function") {
|
|
target.poke();
|
|
if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (tool === "new") {
|
|
const spot = this.edgeSpawnPoint(x, y, 42, true);
|
|
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 });
|
|
if (!t) { showToast(`\u305f\u308a\u306a\u3044\u500b\u4f53\u6570\u306f${this.tarinaiPopulationLimit}\u5339\u307e\u3067\u3067\u3059\u3002`); return; }
|
|
t.wanderAngle = spot.angle;
|
|
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
|
|
return;
|
|
}
|
|
|
|
if (tool === "copy") { this.useCopyToolAt?.(x, y); return; }
|
|
if (this.useToolAt?.(x, y)) return;
|
|
|
|
const itemType = toolItemType(tool);
|
|
// In placement mode, clicks always mean placement.
|
|
// Editing rotators / reciprocators is reserved for observe or non-placement tools.
|
|
if (itemType) {
|
|
const snapped = global.TarinaiPlacementPreviewSystem?.snapPoint?.(this, x, y) || { x, y };
|
|
let px = snapped.x, py = snapped.y;
|
|
const rawItem = this.applyToolSize(new Item(itemType, px, py));
|
|
if (isRotatableItemType(itemType)) {
|
|
const requestedAngle = this.toolAngleFor ? this.toolAngleFor(itemType) : defaultItemAngle(itemType);
|
|
global.TarinaiPlacementPreviewSystem.applyToolOrientation(this, rawItem, requestedAngle);
|
|
}
|
|
const directFeeding = global.TarinaiDirectFeedingSystem;
|
|
const directTarget = directFeeding?.isDirectFeedType?.(rawItem)
|
|
? directFeeding.findTargetAt?.(this, x, y)
|
|
: null;
|
|
// A Tarinai under the pointer takes precedence over a duplicator load slot.
|
|
if (directTarget) {
|
|
rawItem.x = x;
|
|
rawItem.y = y;
|
|
const given = this.placeItem(rawItem, true);
|
|
if (given) this._forceImmediateToolVisualRefresh?.("tool-change");
|
|
return;
|
|
}
|
|
if (this.directSetDuplicatorAt?.(x, y, itemType)) return;
|
|
// Placement uses the same footprint that the preview validates.
|
|
const placed = this.placeItem(rawItem, true);
|
|
if (!placed) return;
|
|
this._forceImmediateToolVisualRefresh?.("tool-change");
|
|
// Item placement intentionally no longer writes observation-log entries.
|
|
// Placement audio is still emitted through tool:placed.
|
|
}
|
|
},
|
|
|
|
inferLogKind(text) {
|
|
const s = String(text || "").toLowerCase();
|
|
if (s.includes("\u914d\u7f6e") || s.includes("\u8a2d\u7f6e") || s.includes("placed") || s.includes("selected")) return "observe";
|
|
if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident";
|
|
if (s.includes("fight") || s.includes("lost to") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051")) return "fight";
|
|
if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth";
|
|
if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:") || s.includes("\u6b7b\u56e0:")) return "death";
|
|
if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather";
|
|
if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food";
|
|
if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass";
|
|
if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe";
|
|
if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation";
|
|
return "note";
|
|
},
|
|
|
|
shouldHideFromObservationRecords(entry = {}) {
|
|
const type = typeof entry === "string" ? "" : String(entry.eventType || "");
|
|
const text = typeof entry === "string" ? String(entry || "") : String(entry.text || "");
|
|
if (type === "ball_poke" || type === "avoid_relationship" || type === "mechanism_poke") return true;
|
|
if (/^(player_|tool_|tool:)/.test(type) || type.includes("tool") || type.includes("player")) return true;
|
|
if (type.includes("robot")) return true;
|
|
if (/\u30ed\u30dc\u6383\u9664\u6a5f|\u6383\u9664\u6a5f/.test(text)) return true;
|
|
if (/\u7bc4\u56f2\u524a\u9664|\u30b3\u30d4\u30fc\u3057\u305f|\u8cbc\u308a\u4ed8\u3051\u305f|\u3064\u306a\u3044\u3060|\u8a2d\u5b9a\u3092\u5909\u66f4|\u770b\u677f\u3092\u66f8\u304d\u63db\u3048|\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f|\u3092\u6d88\u3057\u305f\u3002|\u3064\u3064\u3044\u3066\u52d5\u304b\u3057\u305f/.test(text)) return true;
|
|
return false;
|
|
},
|
|
|
|
log(text, kind = null, options = {}) {
|
|
const entry = {
|
|
time: this.time,
|
|
text,
|
|
kind: kind || this.inferLogKind(text),
|
|
eventType: options.eventType || "",
|
|
hiddenFromObservation: Boolean(options.hiddenFromObservation),
|
|
sound: options.sound === false ? false : true,
|
|
participants: (options.participants || []).filter(Boolean).map(t => ({
|
|
id: t.id || t.releasedLiveId || "",
|
|
liveId: t.id || t.releasedLiveId || "",
|
|
liveToken: t.liveToken || t.releasedLiveToken || 0,
|
|
familyKey: this.tarinaiFamilyKey(t),
|
|
name: t.name || "",
|
|
type: t.type || "",
|
|
generation: t.generation || 1,
|
|
})),
|
|
participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id),
|
|
participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({
|
|
liveId: t.id || t.releasedLiveId || "",
|
|
liveToken: t.liveToken || t.releasedLiveToken || 0,
|
|
familyKey: this.tarinaiFamilyKey(t),
|
|
name: t.name || "",
|
|
type: t.type || "",
|
|
generation: t.generation || 1,
|
|
})),
|
|
};
|
|
if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true;
|
|
const countInStats = options.countInStats ?? !entry.hiddenFromObservation;
|
|
entry.countInStats = Boolean(countInStats);
|
|
if (!this.eventCounters) this.eventCounters = {};
|
|
if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
|
|
for (const t of options.participants || []) {
|
|
if (!t) continue;
|
|
if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind);
|
|
}
|
|
this.logs.unshift(entry);
|
|
this.logs = this.logs.slice(0, 100);
|
|
this.emit("log:entry", { world: this, entry });
|
|
}
|
|
}));
|
|
})(typeof window !== "undefined" ? window : globalThis);
|