629 lines
28 KiB
JavaScript
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);
|