"use strict"; (function (global) { const World = global.World; if (!World) throw new Error("World is not available for mixin: world_placement_log.js"); const SIGNBOARD_CHARS_PER_LINE = 6; const SIGNBOARD_LINES = 3; const SIGNBOARD_TEXT_LIMIT = SIGNBOARD_CHARS_PER_LINE * SIGNBOARD_LINES; function ensureSignboardEditor() { let dialog = document.getElementById("signboardEditor"); if (dialog) return dialog; dialog = document.createElement("div"); dialog.id = "signboardEditor"; dialog.className = "signboard-editor hidden"; dialog.innerHTML = ` `; document.body.appendChild(dialog); return dialog; } function sliceGraphemes(text, limit) { const chars = Array.from(String(text || "")); return chars.slice(0, Math.max(0, limit)).join(""); } function sanitizeSignboardText(value = "", opts = {}) { const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " "); const rawLines = normalized.split("\n").slice(0, SIGNBOARD_LINES); const out = []; for (let i = 0; i < SIGNBOARD_LINES; i += 1) { const rawLine = rawLines[i]; if (rawLine == null) break; out.push(sliceGraphemes(String(rawLine), SIGNBOARD_CHARS_PER_LINE)); } const next = out.join("\n"); return opts.trimTrailing === false ? next : next.replace(/\n+$/g, ""); } function signboardVisibleLength(text = "") { return Array.from(String(text || "").replace(/\n/g, "")).length; } function replaceSignboardEditorSelection(area, text = "") { if (!area) return; const value = String(area.value || ""); const start = Number(area.selectionStart || 0); const end = Number(area.selectionEnd || start); const next = sanitizeSignboardText(value.slice(0, start) + text + value.slice(end), { trimTrailing: false }); area.value = next; const caret = Math.min(next.length, start + String(text || "").length); area.selectionStart = caret; area.selectionEnd = caret; } function openSignboardEditor(sign, worldRef) { if (!sign) return false; const dialog = ensureSignboardEditor(); const area = dialog.querySelector("#signboardEditorText"); const count = dialog.querySelector("#signboardEditorCount"); const apply = dialog.querySelector("#signboardEditorApply"); const cancel = dialog.querySelector("#signboardEditorCancel"); const clear = dialog.querySelector("#signboardEditorClear"); const closeBtn = dialog.querySelector("#signboardEditorClose"); const close = () => { dialog.classList.add("hidden"); dialog.__sign = null; dialog.__worldRef = null; }; const syncCount = () => { if (!area || !count) return; count.textContent = `${signboardVisibleLength(area.value || "")}/${SIGNBOARD_TEXT_LIMIT}`; }; const commit = (value) => { const next = sanitizeSignboardText(value); sign.text = next; sign.textEditedAt = worldRef?.time || 0; worldRef?.log?.(sign.text ? `\u770b\u677f\u3092\u66f8\u304d\u63db\u3048\u305f\u3002` : `\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f\u3002`, "observe"); worldRef.drawListDirty = true; global.render?.(); close(); }; dialog.__sign = sign; dialog.__worldRef = worldRef; if (area) { area.value = sanitizeSignboardText(sign.text || ""); area.onkeydown = (e) => { const value = String(area.value || ""); const start = Number(area.selectionStart || 0); const end = Number(area.selectionEnd || start); const before = value.slice(0, start); const selected = value.slice(start, end); const lineIndex = before.split("\n").length - 1; const lines = value.split("\n"); const currentLine = lines[lineIndex] || ""; const replacing = selected.length > 0; if (e.key === "Enter") { const selectedLineBreaks = (selected.match(/\n/g) || []).length; e.preventDefault(); if (lines.length - selectedLineBreaks < SIGNBOARD_LINES) { replaceSignboardEditorSelection(area, "\n"); syncCount(); } return; } if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { const selectedSameLine = !selected.includes("\n"); const selectedCount = selectedSameLine ? Array.from(selected).length : 0; if (!replacing && Array.from(currentLine).length >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault(); else if (replacing && selectedSameLine && Array.from(currentLine).length - selectedCount >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault(); } }; area.onpaste = (e) => { e.preventDefault(); const text = e.clipboardData?.getData?.("text") || ""; replaceSignboardEditorSelection(area, text); syncCount(); }; area.oninput = () => { const clean = sanitizeSignboardText(area.value || "", { trimTrailing: false }); if (area.value !== clean) area.value = clean; syncCount(); }; setTimeout(() => { area.focus(); area.select(); syncCount(); }, 0); } if (apply) apply.onclick = () => commit(area?.value || ""); if (clear) clear.onclick = () => commit(""); if (cancel) cancel.onclick = close; if (closeBtn) closeBtn.onclick = close; dialog.onclick = (e) => { if (e.target === dialog) close(); }; dialog.classList.remove("hidden"); syncCount(); return true; } function ensureRotatorEditor() { let dialog = document.getElementById("rotatorEditor"); if (dialog) return dialog; dialog = document.createElement("div"); dialog.id = "rotatorEditor"; dialog.className = "rotator-editor hidden"; dialog.innerHTML = ` `; document.body.appendChild(dialog); return dialog; } function cloneSegmentsForRotator(item) { const src = Array.isArray(item?.rotatorSegments) ? item.rotatorSegments : [[-78, 0, 78, 0], [0, -52, 0, 52]]; return src.filter(seg => Array.isArray(seg) && seg.length >= 4).slice(0, 96).map(seg => [Number(seg[0]) || 0, Number(seg[1]) || 0, Number(seg[2]) || 0, Number(seg[3]) || 0]); } function openRotatorEditor(item, worldRef) { if (!item || item.type !== "rotator") return false; const dialog = ensureRotatorEditor(); const canvas = dialog.querySelector("#rotatorCanvas"); const ctx = canvas?.getContext?.("2d"); const speed = dialog.querySelector("#rotatorSpeed"); const speedNum = dialog.querySelector("#rotatorSpeedNum"); const thick = dialog.querySelector("#rotatorThickness"); const thickNum = dialog.querySelector("#rotatorThicknessNum"); const apply = dialog.querySelector("#rotatorApply"); const cancel = dialog.querySelector("#rotatorCancel"); const closeBtn = dialog.querySelector("#rotatorEditorClose"); const undo = dialog.querySelector("#rotatorUndo"); const clear = dialog.querySelector("#rotatorClear"); if (!canvas || !ctx) return false; let mode = "line"; let segments = cloneSegmentsForRotator(item); let draft = null; let drawing = false; const cx = canvas.width / 2; const cy = canvas.height / 2; const deg = Math.round((Number(item.rotatorSpeed || 0) || 0) * 180 / Math.PI); const initialThickness = Math.max(4, Math.min(34, Number(item.rotatorThickness || 12) || 12)); if (speed) speed.value = String(Math.max(-360, Math.min(360, deg))); if (speedNum) speedNum.value = String(deg); if (thick) thick.value = String(initialThickness); if (thickNum) thickNum.value = String(initialThickness); const toLocal = (e) => { const rect = canvas.getBoundingClientRect(); const sx = canvas.width / Math.max(1, rect.width); const sy = canvas.height / Math.max(1, rect.height); return { x: (e.clientX - rect.left) * sx - cx, y: (e.clientY - rect.top) * sy - cy }; }; const cleanSegments = () => { const out = []; for (const seg of segments) { const x1 = Math.max(-420, Math.min(420, Number(seg[0]) || 0)); const y1 = Math.max(-420, Math.min(420, Number(seg[1]) || 0)); const x2 = Math.max(-420, Math.min(420, Number(seg[2]) || 0)); const y2 = Math.max(-420, Math.min(420, Number(seg[3]) || 0)); if (Math.hypot(x2 - x1, y2 - y1) >= 4) out.push([x1, y1, x2, y2]); if (out.length >= 96) break; } if (!out.length) out.push([-78, 0, 78, 0]); segments = out; }; const draw = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.fillStyle = "#f7f3ea"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.translate(cx, cy); ctx.strokeStyle = "rgba(100,80,60,0.16)"; ctx.lineWidth = 1; for (let x = -cx; x <= cx; x += 40) { ctx.beginPath(); ctx.moveTo(x, -cy); ctx.lineTo(x, cy); ctx.stroke(); } for (let y = -cy; y <= cy; y += 40) { ctx.beginPath(); ctx.moveTo(-cx, y); ctx.lineTo(cx, y); ctx.stroke(); } ctx.strokeStyle = "rgba(70,60,50,0.38)"; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(-cx, 0); ctx.lineTo(cx, 0); ctx.moveTo(0, -cy); ctx.lineTo(0, cy); ctx.stroke(); const lw = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); ctx.lineCap = "round"; ctx.lineJoin = "round"; ctx.strokeStyle = "rgba(127,92,190,0.92)"; ctx.lineWidth = lw; ctx.beginPath(); for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); } ctx.stroke(); ctx.strokeStyle = "rgba(255,255,255,0.58)"; ctx.lineWidth = Math.max(1.3, lw * 0.20); ctx.beginPath(); for (const seg of segments) { ctx.moveTo(seg[0], seg[1]); ctx.lineTo(seg[2], seg[3]); } ctx.stroke(); if (draft) { ctx.setLineDash([7, 5]); ctx.strokeStyle = "rgba(60,130,210,0.85)"; ctx.lineWidth = Math.max(2, lw * 0.45); ctx.beginPath(); ctx.moveTo(draft[0], draft[1]); ctx.lineTo(draft[2], draft[3]); ctx.stroke(); ctx.setLineDash([]); } ctx.fillStyle = "#f5edff"; ctx.strokeStyle = "#6d4ca0"; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 8, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); ctx.restore(); }; const setMode = (next) => { mode = next === "free" ? "free" : "line"; for (const btn of dialog.querySelectorAll("[data-rotator-mode]")) btn.classList.toggle("active", btn.dataset.rotatorMode === mode); }; const setTemplate = (name) => { if (name === "bar") segments = [[-130, 0, 130, 0]]; else if (name === "cross") segments = [[-118, 0, 118, 0], [0, -90, 0, 90]]; else if (name === "circle") { segments = []; const n = 18, r = 108; for (let i = 0; i < n; i += 1) { const a = i / n * Math.PI * 2; const b = (i + 1) / n * Math.PI * 2; segments.push([Math.cos(a) * r, Math.sin(a) * r, Math.cos(b) * r, Math.sin(b) * r]); } } cleanSegments(); draw(); }; const syncPair = (a, b) => { if (!a || !b) return; a.oninput = () => { b.value = a.value; draw(); }; b.oninput = () => { a.value = b.value; draw(); }; }; syncPair(speed, speedNum); syncPair(thick, thickNum); dialog.querySelectorAll("[data-rotator-mode]").forEach(btn => { btn.onclick = () => setMode(btn.dataset.rotatorMode); }); dialog.querySelectorAll("[data-rotator-template]").forEach(btn => { btn.onclick = () => setTemplate(btn.dataset.rotatorTemplate); }); canvas.onpointerdown = (e) => { canvas.setPointerCapture?.(e.pointerId); const p = toLocal(e); drawing = true; draft = [p.x, p.y, p.x, p.y]; if (mode === "free") segments.push([p.x, p.y, p.x, p.y]); draw(); }; canvas.onpointermove = (e) => { if (!drawing || !draft) return; const p = toLocal(e); if (mode === "free") { const last = segments[segments.length - 1]; if (!last) return; const lx = last[2], ly = last[3]; if (Math.hypot(p.x - lx, p.y - ly) >= 5) { last[2] = p.x; last[3] = p.y; if (segments.length < 96) segments.push([p.x, p.y, p.x, p.y]); } } else { draft[2] = p.x; draft[3] = p.y; } draw(); }; canvas.onpointerup = (e) => { if (!drawing || !draft) return; drawing = false; const p = toLocal(e); if (mode === "line") { const seg = [draft[0], draft[1], p.x, p.y]; if (Math.hypot(seg[2] - seg[0], seg[3] - seg[1]) >= 4) segments.push(seg); } draft = null; cleanSegments(); draw(); }; if (undo) undo.onclick = () => { segments.pop(); cleanSegments(); draw(); }; if (clear) clear.onclick = () => { segments = []; draft = null; draw(); }; const close = () => { dialog.classList.add("hidden"); canvas.onpointerdown = canvas.onpointermove = canvas.onpointerup = null; }; if (apply) apply.onclick = () => { cleanSegments(); item.rotatorSegments = segments.map(seg => seg.slice(0, 4)); item.rotatorSpeed = (Number(speedNum?.value || speed?.value || 0) || 0) * Math.PI / 180; item.rotatorThickness = Math.max(4, Math.min(34, Number(thickNum?.value || thick?.value || 12) || 12)); const extent = worldRef?.rotatorExtent?.(item); if (Number.isFinite(extent)) item.r = Math.max(32, Math.min(460, extent)); item.rotatorEditorOpenAt = worldRef?.time || 0; worldRef?.markSpatialDirty?.("rotator-edited"); worldRef.drawListDirty = true; worldRef?.log?.("回転体の設計を変更した。", "observe"); global.render?.(); close(); }; if (cancel) cancel.onclick = close; if (closeBtn) closeBtn.onclick = close; dialog.onclick = (e) => { if (e.target === dialog) close(); }; setMode(mode); draw(); dialog.classList.remove("hidden"); return true; } function copyableItemAt(worldRef, x, y) { const it = worldRef?.findDeleteToolTargetAt?.(x, y); if (!it || it.dead || it.type === "trace" || it.type === "splat" || it.type === "ant_corpse") return null; if (typeof isPinType === "function" && isPinType(it.type) && it.pinState === "lodged") return null; return it; } 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, foodServingsRemaining: Number(item.foodServingsRemaining || 0) || 0, }; if (item.type === "signboard") data.text = item.text || ""; if (item.type === "gate_fence") data.gateOpen = Boolean(item.gateOpen); if (item.type === "duplicator") { data.storedFoodType = item.storedFoodType || ""; data.storedFoodLabel = item.storedFoodLabel || ""; } if (item.type === "rotator") { data.rotatorSpeed = Number(item.rotatorSpeed || 0) || 0; data.rotatorThickness = Number(item.rotatorThickness || 12) || 12; data.rotatorSegments = cloneSegmentsForRotator(item); } return data; } function applyCopyBufferToItem(item, data) { if (!item || !data) return item; item.r = Number(data.r || item.r || itemRadiusFor?.(item.type, 12) || 12); item.amount = Math.max(1, Number(data.amount || item.amount || 1)); if (typeof isRotatableItemType === "function" && isRotatableItemType(item.type)) item.angle = Number(data.angle || 0) || 0; if (item.type === "signboard") item.text = String(data.text || ""); if (item.type === "gate_fence") item.gateOpen = Boolean(data.gateOpen); if (item.type === "duplicator") { item.storedFoodType = String(data.storedFoodType || ""); item.storedFoodLabel = String(data.storedFoodLabel || (item.storedFoodType ? toolLabel(item.storedFoodType) : "")); if (item.roles) item.roles.food = Boolean(item.storedFoodType); } if (item.type === "rotator") { item.rotatorSpeed = Number(data.rotatorSpeed || 0) || 0; item.rotatorThickness = Math.max(4, Math.min(34, Number(data.rotatorThickness || 12) || 12)); item.rotatorSegments = Array.isArray(data.rotatorSegments) ? data.rotatorSegments.map(seg => seg.slice(0, 4)) : cloneSegmentsForRotator(item); item.r = Math.max(item.r || 64, Math.min(460, Math.max(...item.rotatorSegments.flatMap(seg => [Math.hypot(seg[0], seg[1]), Math.hypot(seg[2], seg[3])]), 64) + item.rotatorThickness + 8)); } if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) { item.toolSize = data.toolSize || item.toolSize || "medium"; item.foodServingScale = Number(data.foodServingScale || item.foodServingScale || 1) || 1; item.foodServingsMax = Math.max(1, Number(data.foodServingsMax || item.foodServingsMax || item.amount || 1)); item.foodServingsRemaining = Math.max(1, Number(data.foodServingsRemaining || item.foodServingsRemaining || item.amount || 1)); item.amount = item.foodServingsRemaining; } if (typeof isPinType === "function" && isPinType(item.type)) { item.pinState = "loose"; item.pinTargetId = ""; item.vx = 0; item.vy = 0; item.spinVelocity = 0; } return item; } function isEditableItem(item) { return item && !item.dead && (item.type === "signboard" || item.type === "gate_fence" || item.type === "rotator"); } 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; const d = distXY(x, y, it.x, it.y); if (d <= Math.max(54, (it.r || 24) * 2.4) && d < bestD) { best = it; bestD = d; } } return best; } function openEditableItemEditor(item, worldRef) { if (!isEditableItem(item)) return false; if (item.type === "signboard") return openSignboardEditor(item, worldRef); if (item.type === "rotator") return openRotatorEditor(item, worldRef); if (item.type === "gate_fence") { item.gateOpen = !item.gateOpen; item.gateLastToggleAt = worldRef?.time || 0; worldRef?.markSpatialDirty?.("gate-toggle"); worldRef.drawListDirty = true; worldRef?.log?.(item.gateOpen ? "ゲート柵を開けた。" : "ゲート柵を閉めた。", "observe"); if (typeof showToast === "function") showToast(item.gateOpen ? "ゲート柵: 開" : "ゲート柵: 閉"); global.render?.(); return true; } return false; } function isPlacementTool(tool) { const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null; return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(tool)); } function findDuplicatorAt(worldRef, x, y) { let best = null; let bestD = Infinity; const runtime = global.TarinaiDuplicatorRuntime; for (let i = (worldRef?.items || []).length - 1; i >= 0; i -= 1) { const it = worldRef.items[i]; if (!it || it.dead || it.type !== "duplicator") continue; const r = it.r || 34; const centerD = distXY(x, y, it.x, it.y); const load = runtime?.loadPoint?.(it) || { x: it.x + r * 0.62, y: it.y + r * 0.24 }; const slotD = distXY(x, y, load.x, load.y); const hit = Math.max(44, r * 1.45); if (centerD <= hit || slotD <= Math.max(30, r * 0.86)) { const d = Math.min(centerD, slotD); if (d < bestD) { best = it; bestD = d; } } } return best; } function directSetDuplicatorAt(worldRef, x, y, itemType = "") { const type = String(itemType || ""); if (!type || type === "duplicator") return false; const runtime = global.TarinaiDuplicatorRuntime; const loadType = runtime?.loadTypeForItem?.({ type, amount: 1, foodServingsRemaining: 1, dead: false }) || ""; if (!loadType) return false; const duplicator = findDuplicatorAt(worldRef, x, y); if (!duplicator) return false; const ok = runtime?.setStoredType?.(duplicator, loadType, worldRef, { direct: true, force: false }); if (!ok) return false; duplicator.duplicatorLoadSuppressedUntil = Math.max(duplicator.duplicatorLoadSuppressedUntil || 0, (worldRef?.time || 0) + 0.45); if (typeof showToast === "function") showToast(`複製機: ${duplicator.storedFoodLabel || toolLabel(loadType)}をセット`); worldRef.drawListDirty = true; global.render?.(); return true; } function clearOverlappingGrassBedsForPlacement(worldRef, item) { if (!worldRef || !item || item.type === "grass_bed" || item.type === "trace" || item.type === "splat") return 0; const radius = Math.max(16, item.r || item.radius || 12); let removed = 0; for (const bed of worldRef.nearbyItems?.(item.x, item.y, radius + 72) || worldRef.items || []) { if (!bed || bed.dead || bed.type !== "grass_bed") continue; const hit = radius * 0.95 + Math.max(14, (bed.r || 18) * 0.95); if (distXY(item.x, item.y, bed.x, bed.y) > hit) continue; const ok = global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, bed, { reason: "grass-bed-ignored-by-placement", userReason: "置きものが置かれた", wake: true, }); if (ok) removed += 1; } return removed; } Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ findDuplicatorAt(x, y) { return findDuplicatorAt(this, x, y); }, directSetDuplicatorAt(x, y, itemType = "") { return directSetDuplicatorAt(this, x, y, itemType); }, copyPreviewItemAt(x, y) { if (!this.copyBuffer?.type) return null; const item = new Item(this.copyBuffer.type, x, y); applyCopyBufferToItem(item, this.copyBuffer); item.x = x; item.y = y; return item; }, useCopyToolAt(x, y) { if (!this.copyBuffer) { const target = copyableItemAt(this, x, y); if (!target) { showToast("コピーできるものがありません。"); return true; } this.copyBuffer = makeCopyBufferForItem(target); showToast(`${toolLabel(target.type)}をコピーしました。`); this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe"); return true; } const item = this.copyPreviewItemAt?.(x, y); if (item) { const placed = this.placeItem(item, false); if (placed) { const label = toolLabel(item.type); this.log?.(`${label}を貼り付けた。`, "observe"); showToast(`${label}を貼り付けました。`); return true; } } const target = copyableItemAt(this, x, y); if (target) { this.copyBuffer = makeCopyBufferForItem(target); showToast(`${toolLabel(target.type)}をコピーしました。`); this.log?.(`${toolLabel(target.type)}をコピーした。`, "observe"); } return true; }, fencePlacementBlocked(item) { if (!item || !this.isFenceType(item.type)) return false; const rect = this.fenceRect(item); if (!rect) return false; const margin = Math.max(6, (item.r || 42) * 0.24); if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true; const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72; for (const it of this.nearbyItems(item.x, item.y, searchRadius)) { if (!it || it.dead) continue; for (const r of this.solidObstacleRects(it)) { const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom; if (!separated) return true; } } return false; }, importantPlacementOverlapBlocked(item) { if (!item || item.dead) return true; const important = new Set(["grass", "fence_v", "fence_h", "bounce_fence", "bounce_fence_v", "gate_fence", "fan", "magnet", "rotator", "nest_box", "water"]); if (!important.has(item.type)) return false; const placingGrass = item.type === "grass"; const itemRects = this.solidObstacleRects(item); const radiusFor = typeof itemRadiusFor === "function" ? itemRadiusFor : ((type, fallback = 12) => fallback); const itemRadius = Math.max(14, item.r || radiusFor(item.type, 12) || 12); const rectCircleOverlap = (rect, cx, cy, radius, margin = 0) => { if (!rect) return false; const px = clamp(cx, rect.left - margin, rect.right + margin); const py = clamp(cy, rect.top - margin, rect.bottom + margin); return distXY(cx, cy, px, py) < radius + margin; }; const rectsOverlap = (a, b, margin = 0) => Boolean(a && b && !(a.right + margin < b.left || a.left - margin > b.right || a.bottom + margin < b.top || a.top - margin > b.bottom)); for (const other of this.nearbyItems(item.x, item.y, Math.max(132, itemRadius * 4))) { if (!other || other === item || other.dead || !important.has(other.type)) continue; // Grass is a ground layer: objects may be placed on top of existing grass. // Grass placement itself still avoids objects and other grass via grassSpotOpen(). if (!placingGrass && other.type === "grass") continue; const otherRects = this.solidObstacleRects(other); const otherRadius = Math.max(14, other.r || radiusFor(other.type, 12) || 12); const margin = Math.max(4, Math.min(itemRadius, otherRadius) * 0.18); if (itemRects.length && otherRects.length) { if (itemRects.some(a => otherRects.some(b => rectsOverlap(a, b, margin)))) return true; continue; } if (itemRects.length) { if (itemRects.some(r => rectCircleOverlap(r, other.x, other.y, otherRadius, margin))) return true; continue; } if (otherRects.length) { if (otherRects.some(r => rectCircleOverlap(r, item.x, item.y, itemRadius, margin))) return true; continue; } if (distXY(item.x, item.y, other.x, other.y) < itemRadius + otherRadius + margin) return true; } return false; }, placementBlocked(item) { if (!item || item.dead) return true; if (item.type === "genkotsu") return false; const pad = CONFIG.worldPadding || 30; const rects = this.solidObstacleRects(item); if (rects.length) { for (const rect of rects) { if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true; } } else { const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true; } if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true; if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true; if (this.importantPlacementOverlapBlocked(item)) return true; if (item.type === "nest_box") { const margin = Math.max(8, (item.r || 42) * 0.18); for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) { if (!it || it === item || it.dead) continue; for (const rect of this.solidObstacleRects(it)) { for (const own of rects) { const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom; if (!separated) return true; } } } } if (item.type === "grass_bed") { const spacing = Math.max(46, (item.r || 18) * 3.0); for (const it of this.nearbyItems(item.x, item.y, spacing + 70) || []) { if (!it || it === item || it.dead || it.type !== "grass_bed") continue; const required = spacing + Math.max(0, ((it.r || 18) - (item.r || 18)) * 0.5); if (distXY(item.x, item.y, it.x, it.y) < required) return true; } } return false; }, placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) { const pad = CONFIG.worldPadding || 30; const rects = this.solidObstacleRects(item); if (rects.length) { let left = 0, right = 0, top = 0, bottom = 0; for (const rect of rects) { left = Math.max(left, (item.x || 0) - rect.left); right = Math.max(right, rect.right - (item.x || 0)); top = Math.max(top, (item.y || 0) - rect.top); bottom = Math.max(bottom, rect.bottom - (item.y || 0)); } return { x: clamp(x, pad + left, this.w - pad - right), y: clamp(y, pad + top, this.h - pad - bottom), }; } const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); return { x: clamp(x, pad + radius, this.w - pad - radius), y: clamp(y, pad + radius, this.h - pad - radius), }; }, placementProbeItem(item, x, y) { return { ...item, x, y, dead: false }; }, findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) { if (!item || item.dead) return null; if (item.type === "grass") { const spot = this.findGrassPlantingSpot ? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true }) : null; if (!spot) return null; const probe = this.placementProbeItem(item, spot.x, spot.y); return this.placementBlocked(probe) ? null : spot; } const start = this.placementClampPointFor(item, item.x, item.y); const original = this.placementProbeItem(item, start.x, start.y); if (allowOriginal && !this.placementBlocked(original)) return start; const solid = this.isFenceType(item.type) || item.type === "nest_box" || item.type === "fan" || item.type === "magnet" || item.type === "rotator"; const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2)); const count = attempts || (solid ? 72 : 36); const golden = Math.PI * (3 - Math.sqrt(5)); let best = null; let bestD = Infinity; for (let i = 0; i < count; i++) { const t = count <= 1 ? 1 : i / (count - 1); const radius = Math.max(8, searchRadius * Math.sqrt(t)); const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2; const rawX = item.x + Math.cos(angle) * radius; const rawY = item.y + Math.sin(angle) * radius * 0.74; const p = this.placementClampPointFor(item, rawX, rawY); const probe = this.placementProbeItem(item, p.x, p.y); if (this.placementBlocked(probe)) continue; const d = distXY(item.x, item.y, p.x, p.y); if (d < bestD) { best = p; bestD = d; } if (d < Math.max(18, (item.r || 12) * 0.75)) break; } return best; }, findGrabTargetAt(x, y) { let foundT = null; for (let i = this.tarinai.length - 1; i >= 0; i--) { const t = this.tarinai[i]; if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; } } let foundItem = null, foundItemD = Infinity; for (let i = this.items.length - 1; i >= 0; i--) { const it = this.items[i]; if (!it || it.dead || it.type === "trace" || it.type === "splat") continue; const hit = Math.max(24, (it.r || 12) * (it.type === "ball" ? 4.0 : 2.2)); const d = distXY(x, y, it.x, it.y); if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; } } if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem; if (foundT) { const lodged = foundT.currentLodgedPin?.() || (this.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === foundT.id); if (lodged && lodged.detachPushpin) return lodged; } if (foundT && foundItem) { const td = distXY(x, y, foundT.x, foundT.y); return td <= foundItemD + 8 ? foundT : foundItem; } return foundT || foundItem; }, placeItem(item, dropped = true) { if (!item) return null; if (item.type === "grass" && !this.canAddGrass?.(1)) { showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`); return null; } if (this.placementBlocked(item)) { showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002"); return null; } if (dropped && (item.type === "genkotsu" || item.type === "stone" || isPinType(item.type))) { item.dropMax = item.type === "genkotsu" ? 0.22 : (item.type === "stone" ? 0.72 : 0.48); item.dropTimer = item.dropMax; item.dropImpactDone = false; } else { item.dropMax = 0; item.dropTimer = 0; item.dropImpactDone = true; } clearOverlappingGrassBedsForPlacement(this, item); const placed = this.addItem?.(item, `place:${item.type}`); if (!placed) { if (item.type === "grass") showToast(`草はこのフィールドでは${this.grassLimit?.() ?? CONFIG.grassLimit ?? 99}本までです。`); return null; } this.emit("tool:place", { item, type: item.type, dropped }); this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type }); this.drawListDirty = true; this.ensureSpatial?.(`place:${item.type}`); if (typeof global.render === "function") global.render(); return item; }, handleClick(x, y) { const tool = this.tool; if (!isPlacementTool(tool) && !["delete", "area_delete", "copy", "poke", "pinch", "water_hose", "new"].includes(tool)) { const editable = findEditableItemAt(this, x, y); if (editable && openEditableItemEditor(editable, this)) { global.TarinaiCommands?.setSelection?.(this, null) || (this.selected = null); return; } } if (tool === "observe") { let found = null; for (let i = this.tarinai.length - 1; i >= 0; i--) { if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } } if (!found) { const editable = findEditableItemAt(this, x, y); if (editable && openEditableItemEditor(editable, this)) { global.TarinaiCommands?.setSelection?.(this, null) || (this.selected = null); return; } } global.TarinaiCommands?.setSelection?.(this, found) || (this.selected = found); return; } if (tool === "poke") { let ballTarget = null; let ballTargetD = Infinity; for (let i = this.items.length - 1; i >= 0; i--) { const it = this.items[i]; if (!it || it.dead || it.type !== "ball") continue; const hit = Math.max(72, (it.r || 18) * 4.0); const d = distXY(x, y, it.x, it.y); if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; } } if (ballTarget && this.pokeBall(ballTarget, x, y)) return; const target = [...this.tarinai].reverse().find(t => t.contains(x, y)); if (target) { target.poke(); if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "observe", { participants: [target] }); } return; } if (tool === "new") { const spot = this.edgeSpawnPoint(x, y, 42, true); const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, generation: 1, entryTimer: 2.0 }); t.wanderAngle = spot.angle; this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`); return; } if (tool === "copy") { this.useCopyToolAt?.(x, y); return; } if (this.useToolAt?.(x, y)) return; const itemType = toolItemType(tool); if (itemType === "rotator") { const editable = findEditableItemAt(this, x, y); if (editable?.type === "rotator" && openEditableItemEditor(editable, this)) return; } if (itemType && this.directSetDuplicatorAt?.(x, y, itemType)) return; if (itemType) { let px = x, py = y; const rawItem = this.applyToolSize(new Item(itemType, px, py)); if (typeof isRotatableItemType === "function" && isRotatableItemType(itemType)) rawItem.angle = this.toolAngleFor ? this.toolAngleFor(itemType) : (typeof defaultItemAngle === "function" ? defaultItemAngle(itemType) : 0); // Placement uses the same footprint that the preview validates. const placed = this.placeItem(rawItem, true); if (!placed) return; const label = toolLabel(itemType); this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); } }, inferLogKind(text) { const s = String(text || "").toLowerCase(); if (s.includes("\u914d\u7f6e") || s.includes("\u8a2d\u7f6e") || s.includes("placed") || s.includes("selected")) return "observe"; if (s.includes("firecracker") || s.includes("accident") || s.includes("drop") || s.includes("\u7206\u7af9") || s.includes("\u843d\u3061") || s.includes("\u4e8b\u6545") || s.includes("\u5439\u304d\u98db")) return "accident"; if (s.includes("fight") || s.includes("lost to") || s.includes("\u55a7\u5629") || s.includes("\u8ca0\u3051")) return "fight"; if (s.includes("appeared") || s.includes("wandered in") || s.includes("birth") || s.includes("\u73fe\u308c") || s.includes("\u8ff7\u3044\u8fbc")) return "birth"; if (s.includes("dead") || s.includes("trace") || s.includes("reason:") || s.includes("\u6b7b\u4ea1") || s.includes("\u75d5\u8de1") || s.includes("\u7406\u7531:") || s.includes("\u6b7b\u56e0:")) return "death"; if (s.includes("weather") || s.includes("day") || s.includes("night") || s.includes("morning") || s.includes("evening") || s.includes("\u5929\u6c17") || s.includes("\u65e5\u76ee") || s.includes("\u591c") || s.includes("\u671d") || s.includes("\u5915")) return "weather"; if (s.includes("zunda") || s.includes("food") || s.includes("eat") || s.includes("appreciated") || s.includes("\u305a\u3093\u3060") || s.includes("\u98df\u3079\u7269") || s.includes("\u5473\u308f")) return "food"; if (s.includes("grass") || s.includes("soil") || s.includes("\u8349") || s.includes("\u571f")) return "grass"; if (s.includes("selected") || s.includes("placed") || s.includes("\u9078\u629e") || s.includes("\u914d\u7f6e")) return "observe"; if (s.includes("near") || s.includes("\u8fd1\u304f") || s.includes("\u95a2\u4fc2")) return "relation"; return "note"; }, shouldHideFromObservationRecords(entry = {}) { const type = typeof entry === "string" ? "" : (entry.eventType || ""); return type === "ball_poke" || type === "avoid_relationship"; }, log(text, kind = null, options = {}) { const entry = { time: this.time, text, kind: kind || this.inferLogKind(text), eventType: options.eventType || "", hiddenFromObservation: Boolean(options.hiddenFromObservation), sound: options.sound === false ? false : true, participants: (options.participants || []).filter(Boolean).map(t => ({ id: t.id || t.releasedLiveId || "", liveId: t.id || t.releasedLiveId || "", liveToken: t.liveToken || t.releasedLiveToken || 0, familyKey: this.tarinaiFamilyKey(t), name: t.name || "", type: t.type || "", generation: t.generation || 1, })), participantLiveIds: (options.participants || []).filter(t => t?.id).map(t => t.id), participantSnapshots: (options.participants || []).filter(Boolean).map(t => ({ liveId: t.id || t.releasedLiveId || "", liveToken: t.liveToken || t.releasedLiveToken || 0, familyKey: this.tarinaiFamilyKey(t), name: t.name || "", type: t.type || "", generation: t.generation || 1, })), }; if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true; const countInStats = options.countInStats ?? !entry.hiddenFromObservation; entry.countInStats = Boolean(countInStats); if (!this.eventCounters) this.eventCounters = {}; if (countInStats) this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; for (const t of options.participants || []) { if (!t) continue; if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind); } this.logs.unshift(entry); this.logs = this.logs.slice(0, 100); this.emit("log:entry", { world: this, entry }); } })); })(typeof window !== "undefined" ? window : globalThis);