tarinai/js/placement_preview_system.js
2026-07-29 20:52:11 +09:00

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);