255 lines
13 KiB
JavaScript
255 lines
13 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) {
|
|
const num = global.TarinaiCoreHelpers?.finiteOr || ((v, fallback = 0) => {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
});
|
|
function itemRadius(type, fallback = 12) {
|
|
return global.itemRadiusFor(type, fallback);
|
|
}
|
|
function toolTypeFor(worldRef) {
|
|
return 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 pickMode(worldRef) {
|
|
const value = String(worldRef?.toolPickMode || "free").toLowerCase();
|
|
return new Set(["grid20", "grid40", "grid60"]).has(value) ? value : "free";
|
|
}
|
|
function gridStep(worldRef) {
|
|
const mode = pickMode(worldRef);
|
|
if (mode === "grid20") return 20;
|
|
if (mode === "grid60") return 60;
|
|
if (mode === "grid40") return 40;
|
|
return 0;
|
|
}
|
|
function snapPoint(worldRef, x = 0, y = 0) {
|
|
const step = gridStep(worldRef);
|
|
if (!step) return { x: num(x), y: num(y), snapped: false, step: 0 };
|
|
return {
|
|
x: Math.round(num(x) / step) * step,
|
|
y: Math.round(num(y) / step) * step,
|
|
snapped: true,
|
|
step,
|
|
};
|
|
}
|
|
function isRotatable(type) {
|
|
return global.isRotatableItemType(type);
|
|
}
|
|
function defaultAngle(type) {
|
|
return global.defaultItemAngle(type);
|
|
}
|
|
function rectOutside(worldRef, rect, itemType = "") {
|
|
if (!worldRef || !rect) return true;
|
|
const pad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : global.CONFIG?.worldPadding) || 30;
|
|
return num(rect.left) < pad || num(rect.right) > (worldRef.w || 0) - pad || num(rect.top) < 0 || num(rect.bottom) > (worldRef.h || 0);
|
|
}
|
|
function circleOutside(worldRef, x, y, radius) {
|
|
if (!worldRef) return true;
|
|
const pad = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : global.CONFIG?.worldPadding) || 30;
|
|
const r = Math.max(0, num(radius));
|
|
return num(x) - r < pad || num(y) - r < 0 || num(x) + r > (worldRef.w || 0) - pad || num(y) + r > (worldRef.h || 0);
|
|
}
|
|
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 applyToolOrientation(worldRef, item, requestedAngle = null) {
|
|
if (!item || !item.type) return item;
|
|
const type = String(item.type || "");
|
|
if (!isRotatable(type)) return item;
|
|
const angle = requestedAngle ?? (worldRef?.toolAngleFor ? worldRef.toolAngleFor(type) : defaultAngle(type));
|
|
if (type === "reciprocator") {
|
|
// The tool angle controls the rail axis. The body itself keeps its
|
|
// canonical placement angle. Apply both after the body exists so the
|
|
// preview and the placed Item cannot diverge by 90 degrees.
|
|
const bodyAngle = defaultAngle(type);
|
|
item.angle = bodyAngle;
|
|
const pb = global.TarinaiPhysicsBodySystem;
|
|
const body = pb?.ensureBody?.(item, worldRef || null, { syncFromLegacy: false });
|
|
if (body?.pose) body.pose.angle = bodyAngle;
|
|
pb?.setScalar?.(item, "railAxis", angle, "placement-rail-axis");
|
|
pb?.invalidateItem?.(item, "placement-orientation");
|
|
return item;
|
|
}
|
|
item.angle = angle;
|
|
if (global.TarinaiMechanicalSystem?.isMechanicalType?.(type)) {
|
|
const pb = global.TarinaiPhysicsBodySystem;
|
|
const body = pb?.ensureBody?.(item, worldRef || null, { syncFromLegacy: false });
|
|
if (body?.pose) body.pose.angle = angle;
|
|
pb?.invalidateItem?.(item, "placement-orientation");
|
|
}
|
|
return item;
|
|
}
|
|
|
|
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", 11, "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)) applyToolOrientation(worldRef, item, opts.angle);
|
|
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) {
|
|
const fence = global.isFenceItemType?.(item.type);
|
|
return rects.some(rect => rectOutside(worldRef, fence ? rect : orientedAabb(rect), item.type));
|
|
}
|
|
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 = (typeof CONFIG !== "undefined" ? CONFIG.worldPadding : 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 = global.isFenceItemType?.(item.type) ? rect : 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, top, (worldRef.h || 0) - bottom) : Math.max(top, Math.min((worldRef.h || 0) - 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, radius, (worldRef.h || 0) - radius) : Math.max(radius, Math.min((worldRef.h || 0) - radius, y)) };
|
|
}
|
|
function overlayForItem(item) {
|
|
if (!item || item.dead) return null;
|
|
if (item.type === "toilet") {
|
|
const footprint = global.TarinaiToiletSandSystem?.footprint?.(item) || null;
|
|
if (footprint) return { shape: "roundedRect", ...footprint };
|
|
}
|
|
if (item.type === "rain_shelter") {
|
|
const footprint = global.TarinaiParasolSystem?.parasolFootprint?.(item) || null;
|
|
if (footprint) return { shape: "roundedRect", centerX: footprint.centerX, centerY: footprint.centerY, halfWidth: footprint.radiusX, halfHeight: footprint.radiusY, cornerRadius: Math.max(8, Math.min(18, footprint.radiusY * 0.32)) };
|
|
}
|
|
const def = global.toolDefinition?.(item.type) || null;
|
|
if (def?.collisionShape === "circle") return { shape: "circle", centerX: num(item.x), centerY: num(item.y), radius: Math.max(8, num(item.r, itemRadius(item.type, 12))) };
|
|
return null;
|
|
}
|
|
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" || item.type === "one_way_fence") && worldRef.fenceRect) {
|
|
return { rect: worldRef.fenceRect(item), rects: [] };
|
|
}
|
|
if ((item.type === "nest_box" || item.type === "pipe") && 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,
|
|
overlay: overlayForItem(item),
|
|
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 snapped = snapPoint(worldRef, p.x, p.y);
|
|
const x = num(snapped.x), y = num(snapped.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, (typeof CONFIG !== "undefined" ? CONFIG.temperatureItemRange : global.CONFIG?.temperatureItemRange) ?? 190);
|
|
extra.influence = { kind: "temperatureEffectRange", shape: "circle", x, y, radius: range, type };
|
|
} else if (type === "leakage_device") {
|
|
const range = global.TarinaiItemEnvironmentHazardSystem?.leakageRadius?.(tmp) ?? Math.max(260, (tmp.r || 34) * 8.2);
|
|
extra.influence = { kind: "leakageDamageRange", 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 || [], overlay: overlayForItem(tmp), influence: extra.influence || null, blocked, item: tmp };
|
|
}
|
|
global.TarinaiPlacementPreviewSystem = Object.freeze({
|
|
applyToolOrientation,
|
|
boundsBlocked,
|
|
clampPointFor,
|
|
forItem: previewForItem,
|
|
forWorld: previewForWorld,
|
|
gridStep,
|
|
pickMode,
|
|
snapPoint,
|
|
});
|
|
})(typeof window !== "undefined" ? window : globalThis);
|