This commit is contained in:
33333-33333 2026-06-22 02:03:19 +09:00
commit e8f1d1db1e
60 changed files with 2417 additions and 612 deletions

341
js/freeze_system.js Normal file
View file

@ -0,0 +1,341 @@
"use strict";
(function (global) {
const STORAGE_KEY = "tarinai_frozen_storage_v1";
const MAX_PENDING_LOSSES = 16;
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for freeze_system.js");
const clonePlain = Snapshot.clonePlain;
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
function frozenList(worldRef = global.world) {
if (!worldRef) return [];
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
return worldRef.frozenTarinai;
}
function saveFrozenStore(worldRef = global.world) {
try {
global.localStorage?.setItem(STORAGE_KEY, JSON.stringify(frozenList(worldRef)));
} catch (_) {}
}
function loadFrozenStore(worldRef = global.world) {
if (!worldRef) return [];
try {
const raw = global.localStorage?.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed) && !worldRef.frozenTarinai?.length) worldRef.frozenTarinai = clonePlain(parsed) || [];
} catch (_) {
if (!Array.isArray(worldRef.frozenTarinai)) worldRef.frozenTarinai = [];
}
return frozenList(worldRef);
}
function spritePathFor(type = "smile") {
const sprites = (typeof SPRITES !== "undefined" ? SPRITES : (global.SPRITES || []));
const meta = sprites.find(s => s.id === type) || sprites[0];
return meta?.path || "assets/sprites/tarinai_01_smile.webp";
}
function ensureStyles() {
if (document.getElementById("freezeSystemStyles")) return;
const style = document.createElement("style");
style.id = "freezeSystemStyles";
style.textContent = `
.frozen-panel { margin-top: 10px; padding: 10px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:700; font-size:13px; color:#4f5f70; margin-bottom:8px; }
.frozen-panel-count { font-size:12px; opacity:.72; }
.frozen-list { display:grid; grid-template-columns: 1fr; gap:7px; }
.frozen-card { display:flex; align-items:center; gap:8px; width:100%; border:1px solid rgba(72, 98, 124, .20); border-radius:12px; padding:7px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); }
.frozen-card:active { cursor:grabbing; }
.frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); }
.frozen-card img { width:34px; height:34px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); }
.frozen-card-main { min-width:0; flex:1; }
.frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.frozen-empty { font-size:12px; color:rgba(58,70,82,.68); padding:4px 2px; }
canvas.freeze-drop-ready { outline: 3px solid rgba(70, 142, 210, .45); outline-offset: -3px; }
`;
document.head.appendChild(style);
}
function ensurePanel() {
ensureStyles();
let panel = document.getElementById("frozenPanel");
if (panel) return panel;
panel = document.createElement("section");
panel.id = "frozenPanel";
panel.className = "frozen-panel";
panel.innerHTML = `
<div class="frozen-panel-title"><span>冷凍個体</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></div>
<div id="frozenList" class="frozen-list"></div>
`;
const anchor = global.ui?.toolPalette || document.getElementById("toolPalette");
if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
else document.body.appendChild(panel);
return panel;
}
function renderFrozenPanel(worldRef = global.world) {
const panel = ensurePanel();
const listEl = panel.querySelector("#frozenList");
const countEl = panel.querySelector("#frozenPanelCount");
const list = frozenList(worldRef);
if (countEl) countEl.textContent = `${list.length}`;
if (!listEl) return;
if (!list.length) {
listEl.innerHTML = `<div class="frozen-empty">冷凍中のたりないはいません。</div>`;
return;
}
listEl.innerHTML = list.map(entry => {
const data = entry.data || entry;
const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / 訃報${entry.pendingLosses.length}` : "";
const gen = data.generation ? `第${data.generation}世代` : "";
const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || "");
return `<button class="frozen-card" type="button" draggable="true" data-frozen-id="${id}" title="ドラッグしてフィールドに戻す">
<img src="${htmlEscape(spritePathFor(data.type || "smile"))}" alt="">
<span class="frozen-card-main"><span class="frozen-card-name">${htmlEscape(data.name || "たりない")}</span><span class="frozen-card-meta">${htmlEscape(gen)}${htmlEscape(losses)}</span></span>
</button>`;
}).join("");
}
function makeFrozenEntry(t, worldRef = global.world) {
const data = Snapshot.snapshotEntity(worldRef, t, new Set(["world", "target", "panicTarget", "targetRef"]));
data.sleeping = false;
data.insideNestBoxId = "";
data.state = data.state === "sleep" || data.state === "seek_bed" ? "idle" : (data.state || "idle");
data.targetKey = "";
const id = (global.crypto?.randomUUID ? global.crypto.randomUUID() : `frozen-${Date.now()}-${Math.random()}`);
return {
freezeId: id,
frozenAt: Date.now(),
worldTime: worldRef?.time || 0,
name: t.name || "たりない",
familyKey: t.familyKey || "",
liveId: t.id || "",
liveToken: t.liveToken || 0,
type: t.type || "smile",
data,
pendingLosses: [],
};
}
function findTarinaiAt(worldRef, x, y) {
const arr = worldRef?.tarinai || [];
for (let i = arr.length - 1; i >= 0; i--) {
const t = arr[i];
if (!t || t.dead) continue;
if (worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.35) return t;
}
return null;
}
function freezeTarinai(t, worldRef = global.world) {
if (!t || t.dead || !worldRef) return false;
const entry = makeFrozenEntry(t, worldRef);
worldRef.clearLiveReferencesTo?.(t);
if (t.id && worldRef.liveTarinai?.has?.(t.id)) worldRef.liveTarinai.delete(t.id);
worldRef.tarinai = (worldRef.tarinai || []).filter(o => o !== t);
if (worldRef.selected === t) worldRef.selected = null;
frozenList(worldRef).push(entry);
worldRef.drawListDirty = true;
worldRef.familyTreeDirty = true;
worldRef.updateItemCounts?.();
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
global.showToast?.(`${t.name}を冷凍保存しました。`);
worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] });
global.renderStats?.();
global.render?.();
return true;
}
function updateLiveIdAfterThaw(worldRef, t) {
if (!worldRef.liveTarinai) worldRef.liveTarinai = new Map();
const id = t.id || "";
const conflict = id && worldRef.liveTarinai.has(id);
if (!id || conflict) {
worldRef.assignTarinaiLiveId?.(t);
return;
}
worldRef.liveTarinai.set(id, { token: t.liveToken || 0, target: t });
const m = /^t(\d+)$/.exec(id);
if (m) worldRef.liveIdNext = Math.max(worldRef.liveIdNext || 1, Number(m[1]) + 1);
worldRef.liveIdSerial = Math.max(worldRef.liveIdSerial || 0, t.liveToken || 0);
}
function applyPendingLossesOnThaw(t, entry, worldRef) {
const losses = Array.isArray(entry.pendingLosses) ? entry.pendingLosses : [];
if (!losses.length) return;
const unique = [];
const seen = new Set();
for (const loss of losses) {
const key = loss.familyKey || loss.liveId || loss.name || JSON.stringify(loss);
if (seen.has(key)) continue;
seen.add(key);
unique.push(loss);
}
const relPriority = unique.some(l => l.relation === "親" || l.relation === "子") ? 1 : 0;
const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8);
if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" });
else {
t.stress = clamp((t.stress || 0) + stress, 0, 130);
t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35));
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true });
}
t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)");
t.recordChangeCause?.("冷凍中の訃報", "ストレス", { value: stress });
t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death");
worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] });
}
function thawFrozen(freezeId, x, y, worldRef = global.world) {
const list = frozenList(worldRef);
const index = list.findIndex(e => (e.freezeId || e.id) === freezeId);
if (index < 0 || !worldRef) return false;
const entry = list[index];
const data = clonePlain(entry.data || entry) || {};
let t = null;
try {
t = Snapshot.restoreTarinaiData(worldRef, data, { clearTransient: true });
} catch (_) {
return false;
}
t.dead = false;
t.deathReason = "";
t.x = clamp(Number(x) || worldRef.w / 2, CONFIG.worldPadding, worldRef.w - CONFIG.worldPadding);
t.y = clamp(Number(y) || worldRef.h / 2, CONFIG.worldPadding, worldRef.h - CONFIG.worldPadding);
t.vx = 0;
t.vy = 0;
t.target = null;
t.panicTarget = null;
t.insideNestBoxId = "";
t.sleeping = false;
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("解凍されて少し驚いている");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
t.thought = "解凍されて少し驚いている";
t.frozenAt = 0;
updateLiveIdAfterThaw(worldRef, t);
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
worldRef.tarinai.push(t);
worldRef.recordFamily?.(t);
applyPendingLossesOnThaw(t, entry, worldRef);
list.splice(index, 1);
worldRef.selected = t;
worldRef.drawListDirty = true;
worldRef.familyTreeDirty = true;
worldRef.updateItemCounts?.();
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
global.showToast?.(`${t.name}を解凍しました。`);
worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] });
global.renderStats?.();
global.render?.();
return true;
}
function bindFreezeSystem() {
const worldRef = global.world;
loadFrozenStore(worldRef);
renderFrozenPanel(worldRef);
const panel = ensurePanel();
if (panel.dataset.freezeBound !== "1") {
panel.dataset.freezeBound = "1";
panel.addEventListener("dragstart", (e) => {
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
const id = card.dataset.frozenId || "";
e.dataTransfer?.setData("text/plain", `tarinai-frozen:${id}`);
e.dataTransfer?.setData("application/x-tarinai-frozen", id);
e.dataTransfer.effectAllowed = "move";
global.document?.querySelector("canvas")?.classList.add("freeze-drop-ready");
});
panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready"));
panel.addEventListener("click", (e) => {
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;
pendingThawId = card.dataset.frozenId || "";
for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card);
global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。");
});
}
const canvas = global.canvas || document.getElementById("gameCanvas");
if (canvas && canvas.dataset.freezeDropBound !== "1") {
canvas.dataset.freezeDropBound = "1";
canvas.addEventListener("dragover", (e) => {
const text = e.dataTransfer?.types?.includes?.("application/x-tarinai-frozen") || [...(e.dataTransfer?.types || [])].includes("text/plain");
if (!text) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
canvas.classList.add("freeze-drop-ready");
});
canvas.addEventListener("dragleave", () => canvas.classList.remove("freeze-drop-ready"));
canvas.addEventListener("drop", (e) => {
const id = e.dataTransfer?.getData("application/x-tarinai-frozen") || String(e.dataTransfer?.getData("text/plain") || "").replace(/^tarinai-frozen:/, "");
if (!id) return;
e.preventDefault();
canvas.classList.remove("freeze-drop-ready");
const p = typeof global.screenToWorld === "function" ? global.screenToWorld(e) : (() => {
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
return worldRef.screenToWorld ? worldRef.screenToWorld(sx, sy) : { x: sx, y: sy, inside: true };
})();
thawFrozen(id, p.x, p.y, worldRef);
});
}
}
let pendingThawId = "";
function tryPendingThawAt(x, y, worldRef = global.world) {
if (!pendingThawId) return false;
const id = pendingThawId;
pendingThawId = "";
document.querySelectorAll(".frozen-card.pending-thaw").forEach(el => el.classList.remove("pending-thaw"));
return thawFrozen(id, x, y, worldRef);
}
function afterSnapshotRestored(worldRef = global.world) {
if (!Array.isArray(worldRef?.frozenTarinai)) worldRef.frozenTarinai = [];
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
}
function patchWorldPrototype() {
const apply = (proto) => {
const originalHandleClick = proto.handleClick;
proto.handleClick = function (x, y) {
if (tryPendingThawAt(x, y, this)) return;
if (this.tool === "freeze") {
global.showToast?.("冷凍は『つまむ』でたりないを冷凍個体UIへ運んでください。");
return;
}
return originalHandleClick.call(this, x, y);
};
};
if (global.TarinaiPatcher?.patchWorld?.("freezeSystem", apply)) return;
const World = global.World;
if (!World || World.prototype.__freezeSystemPatched) return;
World.prototype.__freezeSystemPatched = true;
apply(World.prototype);
}
patchWorldPrototype();
global.TarinaiFreezeSystem = {
bindFreezeSystem,
renderFrozenPanel,
freezeTarinai,
thawFrozen,
frozenList,
loadFrozenStore,
saveFrozenStore,
afterSnapshotRestored,
};
})(typeof window !== "undefined" ? window : globalThis);