tarinai/js/signal_system.js
2026-07-16 22:12:03 +09:00

431 lines
22 KiB
JavaScript

"use strict";
// Signal network, rectangular detectors, and conductive wires.
(function (global) {
const WIRE_TYPES = new Set(["wire", "insulated_wire"]);
const LINK_TYPES = new Set(["rope", "rod", "spring", "wire", "insulated_wire"]);
const SIGNAL_SOURCE_TYPES = new Set(["pressure_switch"]);
const DIRECT_SIGNAL_SINK_TYPES = new Set(["rotator", "reciprocator", "robot_cleaner", "gate_fence", "fan", "stove", "dry_ice", "duplicator"]);
const DETECTOR_METRICS = Object.freeze({
tarinai_count: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1 },
hunger_avg: { label: "\u5e73\u5747\u7a7a\u8179", defaultThreshold: 70 },
stress_avg: { label: "\u5e73\u5747\u30b9\u30c8\u30ec\u30b9", defaultThreshold: 70 },
sleep_avg: { label: "\u5e73\u5747\u7761\u7720\u6b32", defaultThreshold: 70 },
low_hp_count: { label: "\u4f4eHP\u500b\u4f53\u6570", defaultThreshold: 1 },
sick_count: { label: "\u75c5\u6c17\u500b\u4f53\u6570", defaultThreshold: 1 },
temperature: { label: "\u6c17\u6e29", defaultThreshold: 15 },
zunchi_count: { label: "\u305a\u3093\u3061\u6570", defaultThreshold: 1 },
ant_count: { label: "\u30a2\u30ea\u6570", defaultThreshold: 1 },
water_count: { label: "\u6c34\u6ef4\u6570", defaultThreshold: 1 },
});
const PRESSURE_TARGETS = Object.freeze({
tarinai: { label: "\u305f\u308a\u306a\u3044\u6570", defaultThreshold: 1, spatial: true },
item: { label: "\u30a2\u30a4\u30c6\u30e0\u6570", defaultThreshold: 1, spatial: true },
time: { label: "\u6642\u523b", defaultThreshold: 0, spatial: false },
hunger_avg: DETECTOR_METRICS.hunger_avg,
stress_avg: DETECTOR_METRICS.stress_avg,
sleep_avg: DETECTOR_METRICS.sleep_avg,
low_hp_count: DETECTOR_METRICS.low_hp_count,
sick_count: DETECTOR_METRICS.sick_count,
temperature: { ...DETECTOR_METRICS.temperature, spatial: false },
zunchi_count: DETECTOR_METRICS.zunchi_count,
ant_count: DETECTOR_METRICS.ant_count,
water_count: DETECTOR_METRICS.water_count,
});
const num = global.TarinaiCoreHelpers?.finiteOr || ((value, fallback = 0) => {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
});
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((value, min, max) => Math.max(min, Math.min(max, num(value, min))));
function normalizeMinute(value, fallback = 0) {
return Math.max(0, Math.min(1435, Math.round(num(value, fallback) / 5) * 5));
}
function currentMinuteOfDay(worldRef) {
const dayLength = Math.max(1, num(worldRef?.config?.dayLength ?? global.CONFIG?.dayLength, 120));
const progress = typeof worldRef?.dayProgress === "function"
? num(worldRef.dayProgress())
: ((num(worldRef?.time) % dayLength) + dayLength) % dayLength / dayLength;
return Math.max(0, Math.min(1439, Math.floor((((progress % 1) + 1) % 1) * 1440)));
}
function isMinuteInRange(minute, start, end) {
const now = Math.max(0, Math.min(1439, Number(minute) | 0));
const from = normalizeMinute(start, 360);
const to = normalizeMinute(end, 1080);
if (from === to) return true;
return from < to ? now >= from && now < to : now >= from || now < to;
}
function normalizePressureTarget(value) {
return PRESSURE_TARGETS[value] ? value : "tarinai";
}
function normalizeComparator(value) { return value === "lte" ? "lte" : "gte"; }
function isWire(item) { return Boolean(item && !item.dead && WIRE_TYPES.has(item.type)); }
function emitEffect(worldRef, type, x, y, options = {}) {
if (!worldRef?.effects) return false;
const EffectCtor = global.TarinaiEffect || (typeof Effect === "function" ? Effect : null);
if (typeof EffectCtor !== "function") return false;
worldRef.effects.push(new EffectCtor(type, x, y, options));
return true;
}
function endpointObject(endpoint, worldRef) {
if (!endpoint || !worldRef) return null;
const cached = endpoint._ref;
if (cached && !cached.dead && (!endpoint.id || cached.id === endpoint.id)) return cached;
let found = null;
if (endpoint.kind === "tarinai") {
found = worldRef.liveTarinaiById?.(endpoint.id, endpoint.liveToken ?? null)
|| worldRef.tarinaiById?.get?.(endpoint.id)
|| (worldRef.tarinai || []).find(t => t && !t.dead && t.id === endpoint.id)
|| null;
} else if (endpoint.kind === "ant") {
found = (worldRef.ants || []).find(ant => ant && !ant.dead && ant.id === endpoint.id) || null;
} else {
found = worldRef.itemById?.(endpoint.id)
|| (worldRef.items || []).find(item => item && !item.dead && item.id === endpoint.id)
|| null;
}
endpoint._ref = found;
return found;
}
function endpointSignalNode(endpoint, object, worldRef) {
if (!endpoint || !object) return object || null;
if (object.type !== "circuit_board") return object;
const circuit = global.TarinaiCircuitBoardSystem;
const portId = endpoint.portId || circuit?.nearestPort?.(object, endpoint.x, endpoint.y, 80)?.port?.portId || "";
return circuit?.terminal?.(object, portId) || null;
}
function isSignalConnectable(item) {
return Boolean(item && !item.dead && (SIGNAL_SOURCE_TYPES.has(item.type) || DIRECT_SIGNAL_SINK_TYPES.has(item.type) || item.type === "circuit_board"));
}
function endpointPoint(endpoint, worldRef) {
return global.TarinaiLinkRuntime?.endpointWorld?.(endpoint, worldRef)
|| (endpoint ? { x: num(endpoint.x), y: num(endpoint.y) } : null);
}
function insideRect(item, target, width, height) {
const tr = Math.max(0, num(target?.r ?? target?.radius));
return Math.abs(num(target?.x) - num(item.x)) <= width * 0.5 + tr
&& Math.abs(num(target?.y) - num(item.y)) <= height * 0.5 + tr;
}
function detectorTarinai(item, worldRef, width, height) {
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
return (worldRef.nearbyTarinai?.(item.x, item.y, radius, true) || worldRef.tarinai || [])
.filter(t => t && !t.dead && !worldRef.isTarinaiHiddenInNestBox?.(t) && insideRect(item, t, width, height));
}
function detectorItems(item, worldRef, width, height, type = "") {
const radius = Math.hypot(width * 0.5, height * 0.5) + 48;
return (worldRef.nearbyItems?.(item.x, item.y, radius, true) || worldRef.items || [])
.filter(it => it && !it.dead && it !== item && !LINK_TYPES.has(it.type)
&& (!type || it.type === type) && insideRect(item, it, width, height));
}
function average(list, valueFor) {
if (!list.length) return 0;
return list.reduce((sum, value) => sum + num(valueFor(value)), 0) / list.length;
}
function detectorMetricValue(item, worldRef) {
const width = clamp(item.pressureWidth || 220, 60, 840);
const height = clamp(item.pressureHeight || 160, 60, 840);
const target = normalizePressureTarget(item.pressureTarget);
if (target === "temperature") return num(worldRef.temperatureAt?.(item.x, item.y, null), global.CONFIG?.standardTemperature ?? 15);
if (target === "ant_count") {
const radius = Math.hypot(width * 0.5, height * 0.5) + 32;
return (worldRef.nearbyAnts?.(item.x, item.y, radius, true) || worldRef.ants || [])
.filter(a => a && !a.dead && insideRect(item, a, width, height)).length;
}
if (target === "item") return detectorItems(item, worldRef, width, height).length;
if (target === "zunchi_count") return detectorItems(item, worldRef, width, height, "zunchi").length;
if (target === "water_count") return detectorItems(item, worldRef, width, height, "water").length;
const actors = detectorTarinai(item, worldRef, width, height);
if (target === "tarinai") return actors.length;
if (target === "hunger_avg") return average(actors, t => t.hunger);
if (target === "stress_avg") return average(actors, t => t.stress);
if (target === "sleep_avg") return average(actors, t => t.needs?.sleep ?? t.needRaw?.sleep ?? t.sleepPressure);
if (target === "low_hp_count") return actors.filter(t => num(t.hp, num(t.maxHp, 100)) / Math.max(1, num(t.maxHp, 100)) <= 0.35).length;
if (target === "sick_count") return actors.filter(t => t.zunchiDisease || t.sleepDisease || t.explosionDisease || t.fightDisease).length;
return actors.length;
}
function updatePressureSwitches(worldRef) {
const minute = currentMinuteOfDay(worldRef);
for (const item of worldRef.itemsOfType?.("pressure_switch") || []) {
if (!item || item.dead) continue;
item.pressureTarget = normalizePressureTarget(item.pressureTarget);
const defaultThreshold = PRESSURE_TARGETS[item.pressureTarget]?.defaultThreshold ?? 1;
item.pressureMin = clamp(num(item.pressureMin, defaultThreshold), -999, 999);
item.pressureMax = clamp(num(item.pressureMax, 999), -999, 999);
if (item.pressureMin > item.pressureMax) { const swap = item.pressureMin; item.pressureMin = item.pressureMax; item.pressureMax = swap; }
item.pressureWidth = clamp(item.pressureWidth || 220, 60, 840);
item.pressureHeight = clamp(item.pressureHeight || 160, 60, 840);
item.pressureTimeStart = normalizeMinute(item.pressureTimeStart, 360);
item.pressureTimeEnd = normalizeMinute(item.pressureTimeEnd, 1080);
let value = 0;
let active = false;
if (item.pressureTarget === "time") {
active = isMinuteInRange(minute, item.pressureTimeStart, item.pressureTimeEnd);
value = active ? 1 : 0;
} else {
value = detectorMetricValue(item, worldRef);
active = value >= item.pressureMin && value <= item.pressureMax;
}
if (active !== Boolean(item.signalActive)) worldRef.drawListDirty = true;
item.pressureCount = value;
item.pressureValue = value;
item.pressureCurrentMinute = minute;
item.signalActive = active;
}
}
function setEffectivePower(node, directDriven, directOn, worldRef) {
const wasOn = Boolean(node?._achievementSignalWasOn);
node._directSignalDriven = Boolean(directDriven);
node._directSignalOn = Boolean(directDriven && directOn);
node._achievementSignalWasOn = node._directSignalOn;
if (!wasOn && node._directSignalOn) {
global.TarinaiAchievements?.recordSignalActivation?.({ world: worldRef, target: node, type: node.type });
if (node.type === "robot_cleaner") global.TarinaiAchievements?.evaluateCleanFreak?.(worldRef, { robot: node, source: "signal-power" });
}
node._signalDriven = node._directSignalDriven;
node._signalOn = node._directSignalOn;
if (node.type === "gate_fence" && node._signalDriven) {
const nextOpen = Boolean(node._signalOn);
if (Boolean(node.gateOpen) !== nextOpen) {
node.gateOpen = nextOpen;
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("signal-gate-toggle");
}
}
}
function buildSignalNetwork(worldRef) {
const adjacency = new Map();
const connectedNodes = new Set();
const wires = [];
const circuit = global.TarinaiCircuitBoardSystem;
let wireGeometryMoved = false;
const addEdge = (a, b) => {
if (!a || !b || a === b) return;
if (!adjacency.has(a)) adjacency.set(a, new Set());
if (!adjacency.has(b)) adjacency.set(b, new Set());
adjacency.get(a).add(b); adjacency.get(b).add(a);
connectedNodes.add(a); connectedNodes.add(b);
};
const boards = [];
for (const item of worldRef.items || []) {
if (!item || item.dead) continue;
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type)) {
item._wireSignalDriven = false; item._wireSignalOn = false;
item._directSignalDriven = false; item._directSignalOn = false;
}
if (item.type === "circuit_board") {
circuit?.ensureConfig?.(item);
item._circuitInputState = Object.create(null);
if (!item._circuitOutputState || typeof item._circuitOutputState !== "object") circuit?.evaluate?.(item, item._circuitInputState);
boards.push(item);
}
}
for (const wire of worldRef.items || []) {
if (!isWire(wire)) continue;
const pb = global.TarinaiPhysicsBodySystem;
const endpointA = pb?.endpoint?.(wire, 0);
const endpointB = pb?.endpoint?.(wire, 1);
const objectA = endpointObject(endpointA, worldRef);
const objectB = endpointObject(endpointB, worldRef);
const nodeA = endpointSignalNode(endpointA, objectA, worldRef);
const nodeB = endpointSignalNode(endpointB, objectB, worldRef);
const pointA = endpointPoint(endpointA, worldRef);
const pointB = endpointPoint(endpointB, worldRef);
if (pointA && pointB) {
const nextX = (pointA.x + pointB.x) * 0.5;
const nextY = (pointA.y + pointB.y) * 0.5;
const nextR = Math.max(18, Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y) * 0.5);
if (Math.hypot(num(wire.x) - nextX, num(wire.y) - nextY) > 0.25 || Math.abs(num(wire.r) - nextR) > 0.25) wireGeometryMoved = true;
wire.x = nextX; wire.y = nextY; wire.r = nextR;
if (endpointA) { endpointA.x = pointA.x; endpointA.y = pointA.y; }
if (endpointB) { endpointB.x = pointB.x; endpointB.y = pointB.y; }
}
wires.push({ wire, a: objectA, b: objectB, nodeA, nodeB });
if (nodeA && nodeB) addEdge(nodeA, nodeB);
}
const components = [];
const componentOf = new Map();
const visited = new Set();
for (const startNode of connectedNodes) {
if (visited.has(startNode)) continue;
const stack = [startNode], nodes = [];
while (stack.length) {
const node = stack.pop();
if (!node || visited.has(node)) continue;
visited.add(node); nodes.push(node);
for (const next of adjacency.get(node) || []) if (!visited.has(next)) stack.push(next);
}
const component = { nodes, active: false, hasSource: nodes.some(node => SIGNAL_SOURCE_TYPES.has(node.type) || (node.type === "circuit_terminal" && node.direction === "output")) };
components.push(component);
nodes.forEach(node => componentOf.set(node, component));
}
// Board outputs can depend on other boards, so resolve the graph to a
// fixed point. Unstable internal combinational cycles are forced OFF by the
// board evaluator; external feedback is still bounded to avoid hanging the simulation.
const maxCircuitPasses = Math.min(256, Math.max(16, boards.length * 2 + 4));
for (let pass = 0; pass < maxCircuitPasses; pass += 1) {
let changed = false;
for (const component of components) {
const active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
if (active !== component.active) { component.active = active; changed = true; }
}
for (const board of boards) {
const inputs = Object.create(null);
for (const port of circuit?.ports?.(board) || []) {
if (port.direction !== "input") continue;
const terminal = circuit.terminal(board, port.portId);
inputs[port.id] = Boolean(componentOf.get(terminal)?.active);
}
const before = JSON.stringify(board._circuitOutputState || {});
circuit?.evaluate?.(board, inputs);
if (before !== JSON.stringify(board._circuitOutputState || {})) changed = true;
}
if (!changed) break;
}
for (const component of components) {
component.active = component.hasSource && component.nodes.some(node => (SIGNAL_SOURCE_TYPES.has(node.type) && node.signalActive) || (node.type === "circuit_terminal" && node.direction === "output" && node.signalActive));
for (const node of component.nodes) {
if (!DIRECT_SIGNAL_SINK_TYPES.has(node.type)) continue;
node._wireSignalDriven = component.hasSource;
node._wireSignalOn = component.hasSource && component.active;
setEffectivePower(node, component.hasSource, component.active, worldRef);
}
}
for (const entry of wires) {
const component = componentOf.get(entry.nodeA) || componentOf.get(entry.nodeB);
entry.wire.signalActive = Boolean(component?.hasSource && component?.active);
if (!entry.wire.signalActive || entry.a === entry.b) continue;
let sourceNode = null, targetNode = null, sourceBoard = null, targetBoard = null;
if (entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "output"
&& entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "input") {
sourceNode = entry.nodeA; targetNode = entry.nodeB; sourceBoard = entry.a; targetBoard = entry.b;
} else if (entry.nodeB?.type === "circuit_terminal" && entry.nodeB.direction === "output"
&& entry.nodeA?.type === "circuit_terminal" && entry.nodeA.direction === "input") {
sourceNode = entry.nodeB; targetNode = entry.nodeA; sourceBoard = entry.b; targetBoard = entry.a;
}
if (!sourceNode || !targetNode || sourceBoard === targetBoard) continue;
const sourcePortId = String(sourceNode.portId || "").replace(/^o:/, "");
const targetPortId = String(targetNode.portId || "").replace(/^i:/, "");
if (!sourceBoard?._circuitOutputState?.[sourcePortId] || !targetBoard?._circuitInputState?.[targetPortId]) continue;
global.TarinaiAchievements?.recordCircuitBoardSignalTransfer?.(sourceBoard, targetBoard, entry.wire, { world: worldRef });
}
for (const item of worldRef.items || []) {
if (!item || item.dead) continue;
if (DIRECT_SIGNAL_SINK_TYPES.has(item.type) && !componentOf.has(item)) {
item._wireSignalDriven = false; item._wireSignalOn = false;
setEffectivePower(item, false, false, worldRef);
}
if (isWire(item) && !wires.some(entry => entry.wire === item)) item.signalActive = false;
}
if (wireGeometryMoved) {
worldRef.drawListDirty = true;
worldRef.markSpatialDirty?.("signal-wire-motion");
}
return wires;
}
const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
function shockTarget(worldRef, wire, target, now, { connected = false } = {}) {
if (!target || target.dead) return false;
if (!wire._shockCooldowns || typeof wire._shockCooldowns.get !== "function") wire._shockCooldowns = new WeakMap();
if (now < num(wire._shockCooldowns.get(target), -Infinity)) return false;
wire._shockCooldowns.set(target, now + 0.38);
const isAnt = target.kind === "worker" || target.kind === "queen" || (target.r && !target.radius && Number.isFinite(Number(target.hp)));
if (isAnt) {
globalThis.TarinaiAchievements?.recordAntDamageSource?.(target, wire, { world: worldRef, reason: "electric_wire" });
target.hp = Math.max(0, num(target.hp, target.maxHp || 32) - 2.4);
target.hpBarTimer = Math.max(num(target.hpBarTimer), 0.85);
target.state = "panic";
target.targetId = "";
target.targetToken = 0;
target.targetRef = null;
target.electricShockUntil = Math.max(num(target.electricShockUntil), now + 0.55);
} else {
target.damage?.(2.4, connected ? "\u901a\u96fb\u4e2d\u306e\u96fb\u7dda\u306b\u63a5\u7d9a\u3055\u308c\u305f" : "\u901a\u96fb\u4e2d\u306e\u96fb\u7dda\u306b\u89e6\u308c\u305f");
target.panicTimer = Math.max(num(target.panicTimer), 0.8);
target.fearTimer = Math.max(num(target.fearTimer), 0.8);
target.electricShockUntil = Math.max(num(target.electricShockUntil), now + 0.62);
target.setActionState?.("panic", { target: null, reason: "\u611f\u96fb\u3057\u3066\u3044\u308b", wake: true, sleeping: false });
}
emitEffect(worldRef, "electric_shock", target.x, target.y - Math.max(3, num(target.radius ?? target.r, 12) * 0.12), {
size: Math.max(12, num(target.radius ?? target.r, 12) * 1.15),
life: 0.28,
color: "rgba(118,224,255,0.96)",
});
global.TarinaiAchievements?.recordWireShock?.(wire, target, { world: worldRef, connected });
return true;
}
function damageFromWires(worldRef, wires) {
const now = num(worldRef.time);
for (const { wire, a, b } of wires) {
global.TarinaiAchievements?.recordWirePowerState?.(wire, Boolean(wire.signalActive));
if (!wire.signalActive) continue;
// Living endpoints are directly energized even through insulated wire.
if (a && (a.radius || a.kind === "worker" || a.kind === "queen")) shockTarget(worldRef, wire, a, now, { connected: true });
if (b && b !== a && (b.radius || b.kind === "worker" || b.kind === "queen")) shockTarget(worldRef, wire, b, now, { connected: true });
// Only bare wire shocks creatures merely touching the span.
if (wire.type !== "wire") continue;
const pb = global.TarinaiPhysicsBodySystem;
const pointA = endpointPoint(pb?.endpoint?.(wire, 0), worldRef);
const pointB = endpointPoint(pb?.endpoint?.(wire, 1), worldRef);
if (!pointA || !pointB) continue;
const cx = (pointA.x + pointB.x) * 0.5, cy = (pointA.y + pointB.y) * 0.5;
const radius = Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y) * 0.5 + 42;
const near = worldRef.nearbyTarinai?.(cx, cy, radius, true) || worldRef.tarinai || [];
for (const t of near) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (distanceToSegment(t.x, t.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(8, num(t.radius, 20) * 0.48)) continue;
shockTarget(worldRef, wire, t, now, { connected: false });
}
const ants = worldRef.nearbyAnts?.(cx, cy, radius, true) || worldRef.ants || [];
for (const ant of ants) {
if (!ant || ant.dead) continue;
if (distanceToSegment(ant.x, ant.y, pointA.x, pointA.y, pointB.x, pointB.y) > Math.max(5, num(ant.r, 6) * 0.9)) continue;
shockTarget(worldRef, wire, ant, now, { connected: false });
}
}
}
function powerOverride(item) {
if (!item || !item._signalDriven) return null;
return Boolean(item._signalOn);
}
function updateWorld(worldRef, dt = 0.016) {
if (!worldRef?.items) return false;
updatePressureSwitches(worldRef);
const wires = buildSignalNetwork(worldRef);
damageFromWires(worldRef, wires);
return true;
}
global.TarinaiSignalSystem = Object.freeze({
updateWorld,
powerOverride,
isWire,
isMinuteInRange,
currentMinuteOfDay,
isSignalConnectable,
DIRECT_SIGNAL_SINK_TYPES,
SIGNAL_SOURCE_TYPES,
SENSOR_METRICS: DETECTOR_METRICS,
DETECTOR_METRICS,
PRESSURE_TARGETS,
detectorMetricValue,
});
})(typeof window !== "undefined" ? window : globalThis);