tarinai/js/placement_preview_system.js
2026-07-01 16:55:49 +09:00

185 lines
9.7 KiB
JavaScript

"use strict";
// Layer: physics/placement-preview
// Single source of truth for placement preview geometry and the boundary part
// of placement blocking. Render and actual placement both delegate here so a
// green preview means the same footprint the click will try to place.
(function (global) {
function num(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function itemRadius(type, fallback = 12) {
return typeof global.itemRadiusFor === "function" ? global.itemRadiusFor(type, fallback) : fallback;
}
function toolTypeFor(worldRef) {
return typeof global.toolItemType === "function" ? global.toolItemType(worldRef?.tool || "") : "";
}
function toolSizeScale(worldRef, type) {
return worldRef?.toolSizeScale ? worldRef.toolSizeScale(type) : 1;
}
function toolSizeName(worldRef, type) {
return worldRef?.toolSizeFor ? worldRef.toolSizeFor(type) : (worldRef?.toolSize || "medium");
}
function isRotatable(type) {
return typeof global.isRotatableItemType === "function" && global.isRotatableItemType(type);
}
function defaultAngle(type) {
return typeof global.defaultItemAngle === "function" ? global.defaultItemAngle(type) : 0;
}
function rectOutside(worldRef, rect) {
if (!worldRef || !rect) return true;
const pad = global.CONFIG?.worldPadding || 30;
return num(rect.left) < pad || num(rect.top) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.bottom) > (worldRef.h || 0) - pad;
}
function circleOutside(worldRef, x, y, radius) {
if (!worldRef) return true;
const pad = global.CONFIG?.worldPadding || 30;
const r = Math.max(0, num(radius));
return num(x) - r < pad || num(y) - r < pad || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0) - pad;
}
function orientedAabb(rect) {
if (!rect) return null;
if (!rect.oriented) return rect;
const reach = Math.max(num(rect.halfW), num(rect.halfH));
return { left: num(rect.cx) - reach, right: num(rect.cx) + reach, top: num(rect.cy) - reach, bottom: num(rect.cy) + reach };
}
function rectsFor(worldRef, item) {
return global.TarinaiCollisionFootprints.placementRectsFor(worldRef, item);
}
function footprintRadius(worldRef, item, rects = null) {
return global.TarinaiCollisionFootprints.itemFootprintRadius(worldRef, item, rects);
}
function ensureMechanicalPreview(item, worldRef) {
if (!item || !global.TarinaiMechanicalSystem.isMechanicalType(item.type)) return item;
const pb = global.TarinaiPhysicsBodySystem;
pb?.ensureBody?.(item, worldRef || null, { syncFromLegacy: false });
if (item.type === "rotator") {
pb?.setScalar?.(item, "motorSpeed", Math.PI * 0.65, "preview");
pb?.setScalar?.(item, "motorOn", true, "preview");
pb?.setScalar?.(item, "thickness", 12, "preview");
pb?.setSegments?.(item, [[-78, 0, 78, 0], [0, -52, 0, 52]], "preview");
} else if (item.type === "poison_block") {
pb?.setScalar?.(item, "thickness", 14, "preview");
item.world = worldRef || null;
} else if (item.type === "reciprocator") {
pb?.setScalar?.(item, "railOn", true, "preview");
pb?.setScalar?.(item, "railMotorSpeed", 92, "preview");
pb?.setScalar?.(item, "railTravel", 150, "preview");
pb?.setScalar?.(item, "railPhase", 0, "preview");
pb?.setScalar?.(item, "railAnchorX", item.x, "preview");
pb?.setScalar?.(item, "railAnchorY", item.y, "preview");
pb?.setScalar?.(item, "thickness", 12, "preview");
pb?.setSegments?.(item, [[-78, 0, 78, 0]], "preview");
}
return item;
}
function makePreviewItem(worldRef, type, x, y, opts = {}) {
if (!worldRef || !type) return null;
const scale = opts.sizeScale ?? toolSizeScale(worldRef, type);
const sizeName = opts.toolSize ?? toolSizeName(worldRef, type);
const r = (opts.r ?? itemRadius(type, 12)) * scale;
const item = { type, x: num(x), y: num(y), r, toolSize: sizeName, foodServingScale: scale, dead: false, isPlacementPreview: true };
if (isServingFoodType(type)) {
item.foodServingsMax = foodServingsForSize(sizeName);
item.foodServingsRemaining = item.foodServingsMax;
item.amount = item.foodServingsRemaining;
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
}
if (isRotatable(type)) item.angle = opts.angle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type));
if (type === "reciprocator") {
const axis = item.angle ?? defaultAngle(type);
global.TarinaiPhysicsBodySystem.setScalar(item, "railAxis", axis, "preview-axis");
item.angle = defaultAngle(type);
}
ensureMechanicalPreview(item, worldRef);
return item;
}
function boundsBlocked(worldRef, item, opts = {}) {
if (!worldRef || !item || item.dead) return true;
const rects = opts.rects || rectsFor(worldRef, item);
if (rects.length) return rects.some(rect => rectOutside(worldRef, orientedAabb(rect)));
const type = item.type || "";
const radius = opts.radius ?? Math.max(14, (num(item.r, itemRadius(type, 12)) || itemRadius(type, 12)) * (type === "bed" ? 1.55 : 1.25));
return circleOutside(worldRef, item.x, item.y, radius);
}
function clampPointFor(worldRef, item, x = item?.x || 0, y = item?.y || 0) {
if (!worldRef || !item) return { x, y };
const pad = global.CONFIG?.worldPadding || 30;
const rects = rectsFor(worldRef, item);
if (rects.length) {
let left = 0, right = 0, top = 0, bottom = 0;
for (const rect of rects) {
const aabb = orientedAabb(rect);
if (!aabb) continue;
left = Math.max(left, num(item.x) - num(aabb.left));
right = Math.max(right, num(aabb.right) - num(item.x));
top = Math.max(top, num(item.y) - num(aabb.top));
bottom = Math.max(bottom, num(aabb.bottom) - num(item.y));
}
return { x: global.clamp ? global.clamp(x, pad + left, (worldRef.w || 0) - pad - right) : Math.max(pad + left, Math.min((worldRef.w || 0) - pad - right, x)), y: global.clamp ? global.clamp(y, pad + top, (worldRef.h || 0) - pad - bottom) : Math.max(pad + top, Math.min((worldRef.h || 0) - pad - bottom, y)) };
}
const radius = Math.max(14, (num(item.r, itemRadius(item.type, 12)) || itemRadius(item.type, 12)) * (item.type === "bed" ? 1.55 : 1.25));
return { x: global.clamp ? global.clamp(x, pad + radius, (worldRef.w || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.w || 0) - pad - radius, x)), y: global.clamp ? global.clamp(y, pad + radius, (worldRef.h || 0) - pad - radius) : Math.max(pad + radius, Math.min((worldRef.h || 0) - pad - radius, y)) };
}
function geometryFor(worldRef, item) {
if (!worldRef || !item) return null;
ensureMechanicalPreview(item, worldRef);
if ((item.type === "fence_v" || item.type === "fence_h" || item.type === "bounce_fence" || item.type === "bounce_fence_v" || item.type === "gate_fence") && worldRef.fenceRect) {
return { rect: worldRef.fenceRect(item), rects: [] };
}
if (item.type === "nest_box" && worldRef.nestBoxBaseRect) return { rect: worldRef.nestBoxBaseRect(item), rects: [] };
const rects = rectsFor(worldRef, item);
return { rect: null, rects };
}
function previewForItem(worldRef, item, opts = {}) {
if (!worldRef || !item) return null;
const geometry = geometryFor(worldRef, item) || {};
const rects = geometry.rects || [];
const radius = footprintRadius(worldRef, item, rects);
const bounds = boundsBlocked(worldRef, item, { rects: rects.length ? rects : (geometry.rect ? [geometry.rect] : null) });
return {
type: item.type,
x: item.x,
y: item.y,
r: item.r || radius,
rect: geometry.rect || null,
rects,
boundsBlocked: bounds,
blocked: opts.skipBlocked ? bounds : (bounds || Boolean(worldRef.placementBlocked?.(item) || worldRef.fencePlacementBlocked?.(item))),
};
}
function previewForWorld(worldRef) {
const p = worldRef?.pointer;
const type = toolTypeFor(worldRef);
if (!p?.inside || !type) return null;
const x = num(p.x), y = num(p.y);
const tmp = makePreviewItem(worldRef, type, x, y);
if (!tmp) return null;
const r = tmp.r;
let extra = {};
if (type === "dry_ice" || type === "stove") {
const range = Math.max(24, global.CONFIG?.temperatureItemRange ?? 190);
extra.influence = { kind: "temperatureEffectRange", shape: "circle", x, y, radius: range, type };
}
if (type === "grass") {
const overlaps = global.TarinaiPhysicsSoftClear.collectForPlacement(worldRef, tmp) || [];
const replacingGrass = overlaps.some(it => it?.type === "grass");
const limitBlocked = !(worldRef.canAddGrass?.(1) ?? true) && !replacingGrass;
const softBlocked = Boolean(worldRef.grassBlockedAt?.(x, y, tmp, { ignoreSoftPlacement: true }) || worldRef.grassOnTarinaiAt?.(x, y));
const b = limitBlocked || softBlocked || circleOutside(worldRef, x, y, Math.max(14, r * 1.55));
return { type, x, y, r, blocked: b, item: tmp };
}
const geometry = geometryFor(worldRef, tmp) || {};
const rectsForBounds = geometry.rects && geometry.rects.length ? geometry.rects : (geometry.rect ? [geometry.rect] : null);
const ownBounds = extra.boundsRadius ? circleOutside(worldRef, x, y, extra.boundsRadius) : boundsBlocked(worldRef, tmp, { rects: rectsForBounds });
const blocked = ownBounds || Boolean(worldRef.placementBlocked?.(tmp));
return { type, x, y, r, rect: geometry.rect || null, rects: geometry.rects || [], influence: extra.influence || null, blocked, item: tmp };
}
global.TarinaiPlacementPreviewSystem = Object.freeze({
boundsBlocked,
clampPointFor,
forItem: previewForItem,
forWorld: previewForWorld,
});
})(typeof window !== "undefined" ? window : globalThis);