tarinai/js/memorial_bell_system.js
2026-07-20 14:36:59 +09:00

629 lines
28 KiB
JavaScript

"use strict";
(function (global) {
const BUILD_TEXT = "親しいたりないの墓を作っている。";
const MOURN_TEXT = "親しいたりないの墓が壊されて泣いている。";
const BELL_TEXT = "呼び鈴の鳴った場所に集まっている。";
const GRAVE_VISIT_APPROACH_TEXT = "親しいたりないの墓参りに向かっている。";
const GRAVE_VISIT_TEXT = "親しいたりないの墓参りをしている。";
const BELL_RANGES = Object.freeze({ small: 120, medium: 210, large: 350 });
const BELL_DURATIONS = Object.freeze({ small: 12, medium: 18, large: 26 });
function familyKeyOf(t) {
return String(t?.familyKey || t?.id || "");
}
function ensureRequests(worldRef) {
if (!worldRef) return [];
if (!Array.isArray(worldRef.memorialRequests)) worldRef.memorialRequests = [];
return worldRef.memorialRequests;
}
function requestById(worldRef, id) {
return ensureRequests(worldRef).find(req => req && req.id === id) || null;
}
function liveByFamilyKey(worldRef, key) {
if (!worldRef || !key) return null;
return (worldRef.tarinai || []).find(t => t && !t.dead && familyKeyOf(t) === key) || null;
}
function activeGraveFor(worldRef, deceasedFamilyKey) {
if (!worldRef || !deceasedFamilyKey) return null;
return (worldRef.items || []).find(item => item && !item.dead && item.type === "grave" && item.memorialFamilyKey === deceasedFamilyKey) || null;
}
function relatedGravesFor(worldRef, tarinai) {
if (!worldRef || !tarinai) return [];
const key = familyKeyOf(tarinai);
if (!key) return [];
return (worldRef.items || []).filter(item => item && !item.dead && item.type === "grave"
&& Array.isArray(item.memorialEligibleFamilyKeys) && item.memorialEligibleFamilyKeys.includes(key));
}
function graveById(worldRef, id) {
if (!worldRef || !id) return null;
const item = worldRef.itemById?.(id) || (worldRef.items || []).find(entry => entry?.id === id) || null;
return item && !item.dead && item.type === "grave" ? item : null;
}
function calmEnoughForGraveVisit(t) {
if (!t || t.dead || isCritical(t)) return false;
if (!["idle", "wander"].includes(String(t.state || "idle"))) return false;
if ((t.hunger || 0) >= 62 || (t.energy || 100) <= 38 || (t.stress || 0) >= 72) return false;
const needValues = ["food", "sleep", "health", "safety", "social", "fulfill"]
.map(key => Number(t.needRaw?.[key] ?? t.needs?.[key] ?? 0) || 0);
return Math.max(0, ...needValues) < 58;
}
function clearGraveVisit(t, reason = "") {
if (!t) return;
const wasVisiting = t.state === "visit_grave";
t.graveVisitTargetId = "";
t.graveVisitUntil = 0;
t.graveVisitPhase = "";
t.graveVisitReliefApplied = false;
t.nextGraveVisitCheckAt = Math.max(Number(t.nextGraveVisitCheckAt || 0), Number(t.world?.time || 0) + 28);
if (wasVisiting) {
t.target = null;
t.goIdle?.(reason || "墓参りを終えた。");
}
}
function graveVisitHasUrgentNeed(t) {
if (!t) return true;
if ((t.hunger || 0) >= 78 || (t.energy || 100) <= 24 || (t.stress || 0) >= 84) return true;
const needValues = ["food", "sleep", "health", "safety"]
.map(key => Number(t.needRaw?.[key] ?? t.needs?.[key] ?? 0) || 0);
return Math.max(0, ...needValues) >= 76;
}
function maintainGraveVisit(t) {
if (!t || !t.graveVisitTargetId) return false;
if (t.state !== "visit_grave") { clearGraveVisit(t); return false; }
const worldRef = t.world;
const grave = graveById(worldRef, t.graveVisitTargetId);
if (!grave || isCritical(t) || graveVisitHasUrgentNeed(t)) { clearGraveVisit(t, grave ? "別の用事を優先した。" : "墓がなくなった。"); return false; }
const now = Number(worldRef?.time || 0) || 0;
if (t.graveVisitPhase === "visiting") {
if (now >= Number(t.graveVisitUntil || 0)) { clearGraveVisit(t, "墓参りを終えた。"); return false; }
const mournText = `${grave.memorialName || "親しいたりない"}を悼んでいる。`;
setSpecialBehavior(t, "visit_grave", mournText, grave);
return true;
}
const approachText = `${grave.memorialName || "親しいたりない"}の墓参りに向かっている。`;
setSpecialBehavior(t, "visit_grave", approachText, grave);
return true;
}
function maybeStartGraveVisit(t) {
const worldRef = t?.world;
if (!worldRef || !calmEnoughForGraveVisit(t)) return false;
const now = Number(worldRef.time || 0) || 0;
if (now < Number(t.nextGraveVisitCheckAt || 0)) return false;
t.nextGraveVisitCheckAt = now + 10;
const chance = 0.045; // checked at most once per 10 seconds while otherwise idle
const hit = typeof global.deterministicChance === "function"
? global.deterministicChance(worldRef, "idle-grave-visit", chance, t, Math.floor(now / 10))
: Math.random() < chance;
if (!hit) return false;
const graves = relatedGravesFor(worldRef, t);
if (!graves.length) return false;
graves.sort((a, b) => Math.hypot(a.x - t.x, a.y - t.y) - Math.hypot(b.x - t.x, b.y - t.y));
const shortlist = graves.slice(0, Math.min(3, graves.length));
const unit = typeof global.stableUnit === "function" ? global.stableUnit(familyKeyOf(t), `grave-visit-${Math.floor(now / 10)}`) : Math.random();
const grave = shortlist[Math.min(shortlist.length - 1, Math.floor(unit * shortlist.length))] || shortlist[0];
if (!grave) return false;
t.graveVisitTargetId = grave.id || "";
t.graveVisitPhase = "approach";
t.graveVisitUntil = 0;
t.graveVisitReliefApplied = false;
setSpecialBehavior(t, "visit_grave", `${grave.memorialName || "親しいたりない"}の墓参りに向かっている。`, grave);
return true;
}
function relationAffinity(a, b) {
if (!a || !b) return -Infinity;
const aToB = Number(a.relationships?.[b.id]?.affinity);
const bToA = Number(b.relationships?.[a.id]?.affinity);
return Math.max(Number.isFinite(aToB) ? aToB : -Infinity, Number.isFinite(bToA) ? bToA : -Infinity);
}
function eligibleFamilyKeysAtDeath(worldRef, dead) {
const related = new Set([...(dead?.parents || []), ...(dead?.children || [])].filter(Boolean));
const threshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14;
for (const other of worldRef?.tarinai || []) {
if (!other || other.dead || other === dead) continue;
const key = familyKeyOf(other);
if (!key) continue;
if (related.has(key) || relationAffinity(dead, other) >= threshold) related.add(key);
}
related.delete(familyKeyOf(dead));
return Array.from(related).filter(key => liveByFamilyKey(worldRef, key));
}
const MEMORIAL_BUILD_CHANCE = 0.35;
const GRAVE_BREAK_FORCE = 720;
function createGraveFromRequest(worldRef, req, builderFamilyKey = "", reason = "memorial-built") {
if (!worldRef || !req || activeGraveFor(worldRef, req.deceasedFamilyKey)) return activeGraveFor(worldRef, req.deceasedFamilyKey);
const grave = global.StructureRegistry?.create?.("grave", null, req.x, req.y, worldRef) || null;
if (!grave) return null;
grave.memorialFamilyKey = req.deceasedFamilyKey;
grave.memorialName = req.deceasedName;
grave.memorialType = req.deceasedType;
grave.memorialDeathReason = req.deathReason;
grave.memorialDeathTime = req.deathTime;
grave.memorialBuilderFamilyKey = String(builderFamilyKey || "");
grave.memorialEligibleFamilyKeys = Array.from(new Set(req.eligibleFamilyKeys || []));
grave.memorialFavorite = Boolean(req.deceasedFavorite);
grave.memorialTarinaiKing = Boolean(req.deceasedTarinaiKing);
grave.memorialZunchiSlave = Boolean(req.deceasedZunchiSlave);
grave.createdAt = Number(worldRef.time || 0) || 0;
grave._naturalDecayLastWorldTime = grave.createdAt;
const added = worldRef.addItem?.(grave, reason) || null;
if (!added) return null;
worldRef.markItemBucketsDirty?.(reason);
worldRef.markSpatialDirty?.(reason);
worldRef.markTerrainDirtyAt?.(grave.x, grave.y, 52, reason);
worldRef.drawListDirty = true;
return grave;
}
function captureDeath(worldRef, dead, reason = "") {
if (!worldRef || !dead) return null;
const deceasedFamilyKey = familyKeyOf(dead);
if (!deceasedFamilyKey) return null;
const requests = ensureRequests(worldRef);
const existing = requests.find(req => req?.deceasedFamilyKey === deceasedFamilyKey && req.status !== "cancelled");
if (existing) return existing;
const eligibleFamilyKeys = eligibleFamilyKeysAtDeath(worldRef, dead);
const deceasedFavorite = Boolean(dead.favorite);
if (!deceasedFavorite && !eligibleFamilyKeys.length) return null;
if (!deceasedFavorite) {
const chanceHit = typeof global.deterministicChance === "function"
? global.deterministicChance(worldRef, "memorial-build-at-death", MEMORIAL_BUILD_CHANCE, dead, deceasedFamilyKey)
: Math.random() < MEMORIAL_BUILD_CHANCE;
if (!chanceHit) return null;
}
const req = {
id: `memorial-${deceasedFamilyKey}-${Math.round(Number(worldRef.time || 0) * 10)}`,
deceasedFamilyKey,
deceasedName: String(dead.name || "たりない"),
deceasedType: String(dead.type || "smile"),
deathReason: String(reason || dead.deathReason || ""),
deathTime: dead.deathTime != null && Number.isFinite(Number(dead.deathTime)) ? Number(dead.deathTime) : Number(worldRef.time || 0),
deceasedFavorite,
deceasedTarinaiKing: Boolean(dead.isTarinaiChampion),
deceasedZunchiSlave: Boolean(dead.isZunchiSlave),
x: Number(dead.x || 0) || 0,
y: Number(dead.y || 0) || 0,
eligibleFamilyKeys,
builderFamilyKey: "",
splatId: "",
status: deceasedFavorite ? "building_direct" : "pending",
createdAt: Number(worldRef.time || 0) || 0,
};
requests.push(req);
if (deceasedFavorite) {
const grave = createGraveFromRequest(worldRef, req, "", "favorite-memorial-direct");
if (grave) {
req.status = "complete";
worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 44, life: 0.48, color: "rgba(171,154,118,0.44)" });
} else req.status = "cancelled";
}
return req;
}
function liveSplatForRequest(worldRef, req) {
if (!worldRef || !req) return null;
let best = null;
let bestD = Infinity;
for (const item of worldRef.residuesOfType?.("splat") || []) {
if (!item || item.dead || Number(item.amount || 0) <= 0) continue;
if (item._memorialReservedBy && item._memorialReservedBy !== req.id) continue;
const d = Math.hypot((Number(item.x || 0) || 0) - req.x, (Number(item.y || 0) || 0) - req.y);
if (d <= 190 && d < bestD) { best = item; bestD = d; }
}
return best;
}
function isCritical(t) {
if (!t || t.dead) return true;
if (t.burning || (t.burnTimer || 0) > 0.02 || t.sleepDisease || t.fightDisease) return true;
if ((t.fightTimer || 0) > 0.04 || (t.birthRitualTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04) return true;
return ["panic", "fight", "birth_ritual", "intimidate", "ant_attack", "frozen"].includes(String(t.state || ""));
}
function setSpecialBehavior(t, state, text, target = null) {
t.setActionState?.(state, { target, reason: text, wake: true, sleeping: false });
t.thought = text;
t.target = target;
if (typeof global.setBehaviorText === "function") {
global.setBehaviorText(t, {
need: (state === "mourn_grave" || state === "visit_grave") ? "social" : "fulfill",
actionId: state,
actionLabel: text,
reasonText: text,
target,
phase: "perform",
source: "behavior",
});
}
}
function clearBuilderAssignment(t, req = null) {
if (req && req.builderFamilyKey === familyKeyOf(t)) req.builderFamilyKey = "";
const worldRef = t?.world;
const splat = req?.splatId ? worldRef?.residueByRuntimeId?.(req.splatId) : null;
if (splat && splat._memorialReservedBy === req?.id) splat._memorialReservedBy = "";
if (t) {
t.memorialRequestId = "";
t.memorialBuildProgress = 0;
if (t.state === "build_memorial") t.goIdle?.("墓作りをやめた");
}
}
function assignMemorial(t) {
const worldRef = t?.world;
if (!worldRef || !t || isCritical(t)) return false;
const key = familyKeyOf(t);
if (!key) return false;
for (const req of ensureRequests(worldRef)) {
if (!req || req.status !== "pending" || !req.eligibleFamilyKeys?.includes?.(key)) continue;
if (activeGraveFor(worldRef, req.deceasedFamilyKey)) { req.status = "complete"; continue; }
if (req.builderFamilyKey) {
const builder = liveByFamilyKey(worldRef, req.builderFamilyKey);
if (builder && builder.memorialRequestId === req.id) continue;
req.builderFamilyKey = "";
}
const splat = liveSplatForRequest(worldRef, req);
if (!splat) continue;
req.builderFamilyKey = key;
req.splatId = splat.id || "";
splat._memorialReservedBy = req.id;
t.memorialRequestId = req.id;
t.memorialBuildProgress = 0;
setSpecialBehavior(t, "build_memorial", BUILD_TEXT, splat);
return true;
}
return false;
}
function completeMemorial(t, req, splat) {
const worldRef = t?.world;
if (!worldRef || !req || !splat) return false;
const grave = createGraveFromRequest(worldRef, req, familyKeyOf(t), "memorial-built");
if (!grave) return false;
splat.amount = 0;
splat.hp = 0;
splat._memorialReservedBy = "";
worldRef.removeResidue?.(splat, "memorial-material-consumed");
req.status = "complete";
req.builderFamilyKey = "";
req.splatId = "";
t.memorialRequestId = "";
t.memorialBuildProgress = 0;
t.target = null;
if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -48, fulfill: -8 });
t.goIdle?.("親しいたりないの墓を作った。");
worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 38, life: 0.42, color: "rgba(128,120,112,0.42)" });
return true;
}
function updateMemorialMotion(t, dt) {
const worldRef = t?.world;
const req = requestById(worldRef, t?.memorialRequestId);
if (!req || req.status !== "pending" || req.builderFamilyKey !== familyKeyOf(t)) {
if (t?.memorialRequestId) clearBuilderAssignment(t, req);
return false;
}
if (isCritical(t) && t.state !== "build_memorial") return false;
let splat = req.splatId ? worldRef.residueByRuntimeId?.(req.splatId) : null;
if (!splat || splat.dead || splat.type !== "splat" || Number(splat.amount || 0) <= 0) {
splat = liveSplatForRequest(worldRef, req);
if (!splat) { clearBuilderAssignment(t, req); return false; }
req.splatId = splat.id || "";
splat._memorialReservedBy = req.id;
}
setSpecialBehavior(t, "build_memorial", BUILD_TEXT, splat);
const d = Math.hypot((splat.x || 0) - t.x, (splat.y || 0) - t.y);
const reach = Math.max(28, (t.radius || 22) + (splat.r || 20) * 0.55);
if (d > reach) return false;
t.vx *= Math.pow(0.18, Math.max(0.016, dt) * 60);
t.vy *= Math.pow(0.18, Math.max(0.016, dt) * 60);
t.memorialBuildProgress = Math.max(0, Number(t.memorialBuildProgress || 0)) + dt;
if (t.memorialBuildProgress >= 9.0) completeMemorial(t, req, splat);
return true;
}
function maintainMourning(t, dt = 0) {
const now = Number(t?.world?.time || 0) || 0;
if (!t || !Number.isFinite(Number(t.graveMourningUntil)) || now >= Number(t.graveMourningUntil)) {
if (t?.state === "mourn_grave") t.goIdle?.("泣き止んだ");
return false;
}
setSpecialBehavior(t, "mourn_grave", MOURN_TEXT, null);
if (dt > 0) {
t.vx *= Math.pow(0.15, dt * 60);
t.vy *= Math.pow(0.15, dt * 60);
}
return true;
}
function maintainBellCall(t) {
const now = Number(t?.world?.time || 0) || 0;
if (!t || !t.bellCallTarget || now >= Number(t.bellCallUntil || 0)) {
if (t?.state === "bell_call") t.goIdle?.("呼び鈴の音が止んだ");
t && (t.bellCallTarget = null);
return false;
}
if (isCritical(t) && t.state !== "bell_call") return false;
const target = t.bellCallTarget;
const d = Math.hypot(target.x - t.x, target.y - t.y);
if (d <= Math.max(32, (t.radius || 22) * 1.15)) {
t.bellCallTarget = null;
t.target = null;
t.goIdle?.("呼び鈴の鳴った場所に集まった。");
return false;
}
setSpecialBehavior(t, "bell_call", BELL_TEXT, target);
return true;
}
function updateAi(t) {
if (!t || t.dead) return false;
if (maintainMourning(t, 0)) return true;
if (t.memorialRequestId) {
const req = requestById(t.world, t.memorialRequestId);
if (req?.status === "pending") {
setSpecialBehavior(t, "build_memorial", BUILD_TEXT, req.splatId ? t.world?.residueByRuntimeId?.(req.splatId) : t.target);
return true;
}
clearBuilderAssignment(t, req);
}
if (maintainBellCall(t)) return true;
if (assignMemorial(t)) return true;
if (maintainGraveVisit(t)) return true;
return maybeStartGraveVisit(t);
}
function updateMotion(t, dt) {
if (!t || t.dead) return { handled: false };
if (maintainMourning(t, dt)) return { handled: true };
if (t.memorialRequestId || t.state === "build_memorial") {
const handled = updateMemorialMotion(t, dt);
return { handled };
}
if (maintainBellCall(t)) return { handled: false };
if (t.graveVisitTargetId || t.state === "visit_grave") {
const grave = graveById(t.world, t.graveVisitTargetId);
if (!grave) { clearGraveVisit(t, "墓がなくなった。"); return { handled: false }; }
const d = Math.hypot((grave.x || 0) - t.x, (grave.y || 0) - t.y);
const reach = Math.max(38, (t.radius || 22) + (grave.r || 18) * 0.82);
if (t.graveVisitPhase === "visiting" || d <= reach) {
if (t.graveVisitPhase !== "visiting") {
const now = Number(t.world?.time || 0) || 0;
const unit = typeof global.stableUnit === "function" ? global.stableUnit(familyKeyOf(t), `grave-visit-duration-${grave.id || "grave"}-${Math.floor(now)}`) : Math.random();
t.graveVisitPhase = "visiting";
t.graveVisitUntil = now + 4.5 + unit * 4.0;
if (!t.graveVisitReliefApplied && typeof applyNeedRelief === "function") {
applyNeedRelief(t, { social: -42, fulfill: -6 });
t.graveVisitReliefApplied = true;
}
setSpecialBehavior(t, "visit_grave", `${grave.memorialName || "親しいたりない"}を悼んでいる。`, grave);
}
t.vx *= Math.pow(0.12, Math.max(0.016, dt) * 60);
t.vy *= Math.pow(0.12, Math.max(0.016, dt) * 60);
maintainGraveVisit(t);
return { handled: true };
}
maintainGraveVisit(t);
return { handled: false };
}
return { handled: false };
}
function ringAt(worldRef, x, y, requestedSize = "medium") {
if (!worldRef) return 0;
const size = ["small", "medium", "large"].includes(requestedSize) ? requestedSize : "medium";
const range = BELL_RANGES[size];
const duration = BELL_DURATIONS[size];
const now = Number(worldRef.time || 0) || 0;
global.TarinaiAudio?.bell?.();
const px = Number(x || 0) || 0;
const py = Number(y || 0) || 0;
const target = { id: `bell-call-${Math.round(now * 1000)}-${Math.round(px)}-${Math.round(py)}`, type: "bell_call", x: px, y: py, dead: false };
let affected = 0;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead) continue;
if (Math.hypot(t.x - px, t.y - py) > range) continue;
t.bellCallTarget = target;
t.bellCallUntil = Math.max(Number(t.bellCallUntil || 0), now + duration);
if (t.graveVisitTargetId) {
t.graveVisitTargetId = "";
t.graveVisitUntil = 0;
t.graveVisitPhase = "";
}
if (!isCritical(t)) setSpecialBehavior(t, "bell_call", BELL_TEXT, target);
affected += 1;
}
worldRef.spawnEffect?.("ring", px, py, { size: range, life: 0.62, color: "rgba(219,164,70,0.52)" });
worldRef.spawnEffect?.("ring", px, py, { size: Math.min(92, 34 + range * 0.08), life: 0.48, color: "rgba(244,197,100,0.58)" });
worldRef.drawListDirty = true;
return affected;
}
const NATURAL_ASCENSION_SPRITES = Object.freeze(["sleep", "normal_happy"]);
const REMOVAL_ASCENSION_SPRITES = Object.freeze(["hurt", "flee", "flee_fear2", "cold_1"]);
function ascensionSpriteFor(worldRef, grave, sprites, salt = "grave-ascension") {
const list = Array.isArray(sprites) && sprites.length ? sprites : ["sleep"];
const unit = typeof global.stableUnit === "function"
? global.stableUnit(grave?.memorialFamilyKey || grave?.id || "grave", `${salt}-${Math.floor(Number(worldRef?.time || 0) || 0)}`)
: Math.random();
return list[Math.min(list.length - 1, Math.floor(Math.max(0, Math.min(0.999999, unit)) * list.length))] || list[0];
}
function spawnGraveAscension(worldRef, grave, options = {}) {
if (!worldRef || !grave) return false;
const natural = options.natural === true;
const spriteId = ascensionSpriteFor(worldRef, grave, natural ? NATURAL_ASCENSION_SPRITES : REMOVAL_ASCENSION_SPRITES, natural ? "grave-natural-ascension" : "grave-removal-ascension");
worldRef.spawnEffect?.("ascend_soul", grave.x, grave.y - Math.max(2, (grave.r || 14) * 0.25), {
vx: 0,
vy: -34,
size: Math.max(18, (grave.r || 14) * 1.45),
life: 2.4,
importance: 3,
allowOffscreen: true,
forceVisual: true,
spriteId,
});
return true;
}
function applyExternalForce(worldRef, grave, magnitude = 0, breaker = null, reason = "external-force") {
if (!worldRef || !grave || grave.dead || grave.type !== "grave") return false;
const force = Math.max(0, Number(magnitude || 0) || 0);
if (force < GRAVE_BREAK_FORCE) return false;
const removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, grave, {
reason: `grave-${reason}`,
userReason: "強い衝撃で墓が壊れた",
wake: true,
breaker,
}));
if (removed) {
worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 42, life: 0.28, color: "rgba(130,124,116,0.46)" });
worldRef.spawnEffect?.("fight", grave.x, grave.y, { size: 18, life: 0.24, color: "rgba(118,108,98,0.52)" });
}
return removed;
}
function onGraveDestroyed(worldRef, grave, breaker = null) {
if (!worldRef || !grave || grave.type !== "grave" || grave._memorialDestroyedHandled) return false;
grave._memorialDestroyedHandled = true;
if (grave._naturalDecaySilent) return false;
spawnGraveAscension(worldRef, grave, { natural: false });
const eligible = new Set(Array.isArray(grave.memorialEligibleFamilyKeys) ? grave.memorialEligibleFamilyKeys : []);
const now = Number(worldRef.time || 0) || 0;
let count = 0;
for (const t of worldRef.tarinai || []) {
if (!t || t.dead || !eligible.has(familyKeyOf(t))) continue;
if (t.graveVisitTargetId === grave.id) {
t.graveVisitTargetId = "";
t.graveVisitUntil = 0;
t.graveVisitPhase = "";
}
t.graveMourningUntil = Math.max(Number(t.graveMourningUntil || 0), now + 5.5);
t.bellCallTarget = null;
t.memorialRequestId = "";
setSpecialBehavior(t, "mourn_grave", MOURN_TEXT, null);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.75);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.5);
t.stress = Math.min(100, (Number(t.stress || 0) || 0) + 12);
worldRef.spawnBubble?.(t.x, t.y - (t.radius || 20) * 1.15, "……", "rgba(84,112,166,0.82)");
count += 1;
}
if (count) worldRef.log?.(`${grave.memorialName || "親しいたりない"}の墓が壊され、親しいたりないたちが泣いている。`, "death");
void breaker;
return count > 0;
}
function removeGraveNaturally(worldRef, grave, day) {
if (!worldRef || !grave || grave.dead || grave.type !== "grave" || grave.memorialFavorite) return false;
const disappears = typeof global.deterministicChance === "function"
? global.deterministicChance(worldRef, "grave-natural-decay-0444", 0.25, grave.memorialFamilyKey || grave.id, day)
: Math.random() < 0.25;
if (!disappears) return false;
grave._naturalDecaySilent = true;
spawnGraveAscension(worldRef, grave, { natural: true });
const removed = Boolean(global.TarinaiStructureLifecycle?.deleteItem?.(worldRef, grave, {
reason: "grave-natural-decay",
userReason: "いつの間にか消えた",
wake: false,
panicOwner: false,
}));
if (removed) worldRef.spawnEffect?.("ring", grave.x, grave.y, { size: 30, life: 0.36, color: "rgba(164,156,143,0.26)", importance: 2 });
return removed;
}
function updateWorldNaturalDecay(worldRef) {
if (!worldRef) return 0;
const now = Math.max(0, Number(worldRef.time || 0) || 0);
const hasPrevious = Number.isFinite(Number(worldRef._graveNaturalDecayLastWorldTime));
const previous = hasPrevious ? Number(worldRef._graveNaturalDecayLastWorldTime) : now;
worldRef._graveNaturalDecayLastWorldTime = now;
if (!hasPrevious || now <= previous) return 0;
const dayLength = Math.max(1, Number((typeof CONFIG !== "undefined" ? CONFIG.dayLength : 120) || 120) || 120);
const targetFraction = (4 * 60 + 44) / (24 * 60);
const firstDay = Math.max(0, Math.floor(previous / dayLength));
const lastDay = Math.max(firstDay, Math.floor(now / dayLength));
let removedCount = 0;
for (let day = firstDay; day <= lastDay; day += 1) {
if (Number(worldRef._graveNaturalDecayLastCheckedDay) === day) continue;
const targetTime = day * dayLength + dayLength * targetFraction;
if (!(previous < targetTime && now >= targetTime)) continue;
worldRef._graveNaturalDecayLastCheckedDay = day;
const graveBucket = typeof worldRef.itemsOfType === "function" ? worldRef.itemsOfType("grave") : null;
const graves = graveBucket && typeof graveBucket.length === "number"
? Array.from(graveBucket)
: (worldRef.items || []).filter(item => item && !item.dead && item.type === "grave");
for (const grave of graves) if (removeGraveNaturally(worldRef, grave, day)) removedCount += 1;
}
return removedCount;
}
// Kept for compatibility with older direct callers. Grave decay is now
// world-scheduled, so adding more graves does not add per-grave update work.
function updateGraveNaturalDecay(grave, worldRef) {
void grave;
void worldRef;
return false;
}
function formatWorldTime(timeValue) {
const value = Number(timeValue);
if (!Number.isFinite(value)) return "";
const dayLength = Math.max(1, Number((typeof CONFIG !== "undefined" ? CONFIG.dayLength : 120) || 120) || 120);
const completedDays = Math.max(0, Math.floor(value / dayLength));
const season = ["春", "夏", "秋", "冬"][Math.floor((completedDays % 20) / 5)] || "春";
const seasonDay = (completedDays % 5) + 1;
const progress = (((value % dayLength) + dayLength) % dayLength) / dayLength;
const totalMinutes = Math.floor(progress * 24 * 60 + 1e-7) % (24 * 60);
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
const mm = String(totalMinutes % 60).padStart(2, "0");
return `${season}${seasonDay}${hh}:${mm}`;
}
function graveTooltipLines(grave) {
if (!grave) return [];
const lines = ["墓"];
if (grave.memorialName) lines.push(`故人: ${grave.memorialName}`);
const died = formatWorldTime(grave.memorialDeathTime);
if (died) lines.push(`死亡日時: ${died}`);
if (grave.memorialDeathReason) lines.push(`死因: ${grave.memorialDeathReason}`);
return lines;
}
global.TarinaiMemorialBellSystem = Object.freeze({
BUILD_TEXT,
MOURN_TEXT,
BELL_TEXT,
GRAVE_VISIT_APPROACH_TEXT,
GRAVE_VISIT_TEXT,
BELL_RANGES,
MEMORIAL_BUILD_CHANCE,
GRAVE_BREAK_FORCE,
captureDeath,
ringAt,
updateAi,
updateMotion,
applyExternalForce,
onGraveDestroyed,
updateGraveNaturalDecay,
updateWorldNaturalDecay,
graveTooltipLines,
formatWorldTime,
});
})(typeof window !== "undefined" ? window : globalThis);