tarinai/js/need_behavior_system.js
2026-06-23 11:27:55 +09:00

547 lines
28 KiB
JavaScript

"use strict";
(function (global) {
const NEED_KEYS = Object.freeze(["food", "sleep", "health", "safety", "social", "fulfill"]);
const NEED_LABELS = Object.freeze({
food: "\u6442\u990c",
sleep: "\u7761\u7720",
health: "\u5065\u5eb7",
safety: "\u5b89\u5168",
social: "\u95a2\u4fc2",
fulfill: "\u5145\u8db3",
});
const NEED_PHRASES = Object.freeze({
food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b",
sleep: "\u306d\u3080\u3044",
health: "\u8abf\u5b50\u304c\u60aa\u3044",
safety: "\u3042\u3076\u306a\u3044",
social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b",
fulfill: "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b",
});
const NEED_PRIORITY = Object.freeze(["safety", "health", "food", "sleep", "social", "fulfill"]);
function createId(prefix = "id") {
if (global.crypto?.randomUUID) return `${prefix}_${global.crypto.randomUUID()}`;
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function createDefaultNeeds() {
return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
}
function quantizeNeed(value) {
return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10));
}
function getNeedDisplayValue(value) {
return Math.round(quantizeNeed(value) / 10);
}
function personalityValue(t, key) {
if (typeof global.effectivePersonalityValue === "function") return Number(global.effectivePersonalityValue(t, key)) || 0;
return Number(t?.currentPersonality?.[key] ?? t?.birthPersonality?.[key] ?? 0) || 0;
}
function needPressure(value) {
const x = clamp((Number(value) || 0) / 100, 0, 1);
return x * x * 100;
}
function deriveStressFromNeeds(needs = {}) {
return clamp(
needPressure(needs.food) * 0.10 +
needPressure(needs.sleep) * 0.12 +
needPressure(needs.health) * 0.25 +
needPressure(needs.safety) * 0.30 +
needPressure(needs.social) * 0.08 +
needPressure(needs.fulfill) * 0.15,
0,
130
);
}
function itemRolesFor(item) {
if (!item) return {};
if (!item.roles) item.roles = {};
const type = String(item.type || "");
const roles = item.roles;
if (["sweet", "food", "grass", "ant_corpse", "zunchi", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(type)) roles.food = true;
if (type === "water" || type === "water_bowl") roles.drink = true;
if (["sweet", "protein", "water", "water_bowl", "zunda_juice"].includes(type)) roles.medicine = true;
if (type === "bed" || type === "nest_box") roles.sleepPlace = true;
if (["firecracker", "genkotsu", "pushpin", "oshibyo", "splat", "zunchi", "ant_nest", "cannon"].includes(type)) roles.danger = true;
if (type === "grass") roles.grassMaterial = true;
if (type === "ball") roles.playObject = true;
if (item.isStructure) roles.ownedStructure = true;
return roles;
}
function ensureItemNeedEffects(item) {
if (!item) return createDefaultNeeds();
if (!item.needEffects) item.needEffects = createDefaultNeeds();
return item.needEffects;
}
function findNearestItemWithRole(world, t, role, maxDist = 640, predicate = null) {
let best = null;
let bestD = maxDist;
const items = Number.isFinite(maxDist) && world?.nearbyItems ? world.nearbyItems(t.x, t.y, maxDist) : (world?.items || []);
for (const it of items) {
if (!it || it.dead || (it.amount != null && it.amount <= 0)) continue;
const roles = itemRolesFor(it);
if (!roles[role]) continue;
if (predicate && !predicate(it)) continue;
if (t?.shouldAvoidTarget?.(it)) continue;
const d = distXY(t.x, t.y, it.x, it.y);
if (d < bestD) { best = it; bestD = d; }
}
return best;
}
function findNearestSleepPlace(world, t, maxDist = 560) {
const own = findOwnedStructure(world, t, "grass_bed", maxDist);
return own || findNearestItemWithRole(world, t, "sleepPlace", maxDist);
}
function findNearbyDanger(world, t, maxDist = 180) {
const ant = (world?.nearbyAnts?.(t.x, t.y, maxDist, true) || []).find(a => a && !a.dead);
if (ant) return ant;
return findNearestItemWithRole(world, t, "danger", maxDist);
}
function findNearbyMaterial(world, t, materialRole, maxDist = 520) {
return findNearestItemWithRole(world, t, materialRole, maxDist);
}
function ownedStructures(world, t, type = "") {
return (world?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id && (!type || it.type === type));
}
function findOwnedStructure(world, t, type = "", maxDist = Infinity) {
let best = null;
let bestD = maxDist;
for (const it of ownedStructures(world, t, type)) {
const d = distXY(t.x, t.y, it.x, it.y);
if (d < bestD) { best = it; bestD = d; }
}
return best;
}
function hasOwnedStructure(world, t, type) {
return Boolean(findOwnedStructure(world, t, type));
}
function canActNormally(t) {
return !t.dead && t.state !== "sleep" && t.state !== "fight" && t.state !== "panic" && t.state !== "intimidate" && t.birthRitualTimer <= 0.04 && t.fightTimer <= 0.04 && t.defeatedTimer <= 0.04;
}
function updateAttachedStructures(world, t) {
for (const s of ownedStructures(world, t)) {
if (s.type !== "plushie" || s.carriedById !== t.id || !s.onHead) continue;
s.x = t.x + (t.facingDir ? t.facingDir() : 1) * t.radius * 0.12;
s.y = t.y - t.radius * 1.10;
s.amount = Math.max(1, s.hp || 1);
if (t.needs) t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 0.08) };
}
}
function updateNeeds(t, world) {
const prev = t.needsInitialized && t.needs ? { ...t.needs } : null;
const pAgg = personalityValue(t, "aggression");
const pOpen = personalityValue(t, "openness");
const pSoc = personalityValue(t, "sociability");
const pNeu = personalityValue(t, "neuroticism");
const raw = createDefaultNeeds();
raw.food = clamp((t.hunger || 0) * 0.88 + (findNearestItemWithRole(world, t, "drink", 120) ? 6 : 0), 0, 100);
const dayP = world?.dayProgress?.() ?? 0.5;
const night = dayP >= 0.62 || dayP < 0.18 ? 12 : 0;
raw.sleep = clamp((100 - (t.energy || 0)) * 0.98 + night, 0, 100);
raw.health = clamp(
(t.zunchiDisease ? 38 : 0) + (t.sleepDisease ? 22 : 0) + (t.explosionDisease ? 30 : 0) + (t.fightDisease ? 34 : 0) +
clamp((100 - (t.energy || 100)) * 0.22, 0, 26) + clamp((t.zunchiStain || 0) * 0.34, 0, 34) + Number(t.needShock?.health || 0),
0,
100
);
const danger = findNearbyDanger(world, t, 210);
const recentDamage = (world?.time || 0) - (t.lastDamageAt || -999) < 8 ? clamp((t.lastDamageAmount || 0) * 2.4, 0, 52) : 0;
const fear = clamp((t.fearTimer || 0) * 22, 0, 45);
raw.safety = clamp((danger ? 42 : 0) + recentDamage + fear + Number(t.needShock?.safety || 0), 0, 100);
raw.social = clamp((t.loneliness || 0) * (0.78 + Math.max(0, pSoc) * 0.16) + (t.reproductionTimer <= 4 ? 18 : 0), 0, 100);
const hasBed = hasOwnedStructure(world, t, "grass_bed");
const hasPlushie = hasOwnedStructure(world, t, "plushie");
const play = findNearestItemWithRole(world, t, "playObject", 280) ? 10 : 0;
raw.fulfill = clamp((hasBed ? 0 : 26) + (hasPlushie ? 0 : 20) + play + Math.max(0, pOpen) * 16 + Number(t.needShock?.fulfill || 0), 0, 100);
raw.safety *= clamp(1 - Math.max(0, pAgg) * 0.12 + Math.max(0, pNeu) * 0.16, 0.78, 1.22);
raw.health *= clamp(1 + Math.max(0, pNeu) * 0.12, 0.9, 1.16);
raw.social *= clamp(1 + Math.max(0, pSoc) * 0.12, 0.9, 1.16);
raw.fulfill *= clamp(1 + Math.max(0, pOpen) * 0.12, 0.9, 1.18);
const needs = createDefaultNeeds();
for (const key of NEED_KEYS) needs[key] = quantizeNeed(raw[key]);
t.previousNeeds = prev;
t.needs = needs;
t.needsInitialized = true;
t.stress = deriveStressFromNeeds(needs);
handleNeedShockReaction(t, prev, needs);
return needs;
}
function handleNeedShockReaction(t, prev, needs, breaker = null) {
if (!prev || !needs || t.birthRitualTimer > 0.04 || t.fightTimer > 0.04) return false;
const safetyDelta = (needs.safety || 0) - (prev.safety || 0);
const healthDelta = (needs.health || 0) - (prev.health || 0);
const fulfillDelta = (needs.fulfill || 0) - (prev.fulfill || 0);
if (safetyDelta < 40 && healthDelta < 45 && fulfillDelta < 50) return false;
const attacker = breaker || t.needShockBreaker || null;
if (attacker && personalityValue(t, "aggression") > 0.6) {
t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.6);
t.intimidateTargetId = attacker.id || null;
t.setActionState?.("intimidate", { target: attacker, reason: "\u5927\u4e8b\u306a\u3082\u306e\u3092\u58ca\u3055\u308c\u3066\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true });
} else {
t.panicTimer = Math.max(t.panicTimer || 0, 3.0);
t.enterPanic?.({ target: t.panicDestination?.(attacker, true) || null, reason: "\u304a\u3069\u308d\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.8, wake: true, cause: "need_shock" });
}
t.needShockBreaker = null;
return true;
}
function addNeedShock(t, values = {}, breaker = null) {
if (!t) return;
const shock = { ...(t.needShock || {}) };
for (const key of NEED_KEYS) shock[key] = clamp(Number(shock[key] || 0) + Number(values[key] || 0), 0, 100);
t.needShock = shock;
t.needShockBreaker = breaker || t.needShockBreaker || null;
const prev = t.needsInitialized && t.needs ? { ...t.needs } : null;
updateNeeds(t, t.world);
handleNeedShockReaction(t, prev, t.needs, breaker);
}
function decayNeedShock(t, dt) {
if (!t?.needShock) return;
for (const key of NEED_KEYS) t.needShock[key] = Math.max(0, Number(t.needShock[key] || 0) - dt * (key === "fulfill" ? 2.2 : 2.8));
}
function chooseTopNeedRandom(needs) {
const maxNeed = Math.max(...NEED_KEYS.map(key => Number(needs?.[key] || 0)));
const tiedNeeds = NEED_KEYS.filter(key => Number(needs?.[key] || 0) === maxNeed);
return tiedNeeds[Math.floor(Math.random() * tiedNeeds.length)] || "fulfill";
}
function weightedRandom(items) {
const total = items.reduce((sum, a) => sum + Math.max(0.01, Number(a.weight || 1)), 0);
let pickPoint = Math.random() * total;
for (const item of items) {
pickPoint -= Math.max(0.01, Number(item.weight || 1));
if (pickPoint <= 0) return item;
}
return items[items.length - 1] || null;
}
function buildReasonText(chosenNeed, tiedNeeds, action) {
const tied = Array.isArray(tiedNeeds) ? tiedNeeds : [chosenNeed];
const ignored = NEED_PRIORITY.find(key => key !== chosenNeed && tied.includes(key));
const phrase = action?.phrase || "\u5f85\u3063\u305f";
if (ignored) return `${NEED_PHRASES[ignored]}\u3051\u3069\u3001${phrase}\u3002`;
return `${NEED_PHRASES[chosenNeed]}\u306e\u3067\u3001${phrase}\u3002`;
}
function moveTowardOrUse(t, target, state, label) {
if (!target) return false;
if (distXY(t.x, t.y, target.x, target.y) > Math.max(34, (target.r || 12) + t.radius * 0.75)) {
t.setActionState?.(state, { target, reason: label });
return true;
}
return false;
}
function createStructure(world, owner, type, x, y) {
const def = StructureRegistry[type];
if (!def) return null;
const structure = def.create(owner, x, y, world);
structure.update = updateStructure;
structure.draw = drawStructure;
structure.damage = damageStructure;
world.items.push(structure);
world.itemCounts[structure.type] = (world.itemCounts[structure.type] || 0) + 1;
world.markTerrainDirty?.(`structure:${structure.type}`);
world.rebuildSpatial?.(true);
return structure;
}
function consumeGrassMaterial(grass) {
if (!grass || grass.dead) return false;
grass.amount = Math.max(0, Number(grass.amount || 0) - 34);
grass.growth = clamp((grass.growth || 0.5) - 0.24, 0.04, 1.2);
if (grass.amount <= 1) grass.dead = true;
return true;
}
function finishBuild(t, type, grass) {
const world = t.world;
const x = clamp(t.x + rand(-18, 18), CONFIG.worldPadding, world.w - CONFIG.worldPadding);
const y = clamp(t.y + rand(10, 30), CONFIG.worldPadding, world.h - CONFIG.worldPadding);
consumeGrassMaterial(grass);
const s = createStructure(world, t, type, x, y);
if (s) {
t.buildTimer = 0;
t.buildType = "";
t.buildMaterialId = "";
t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 26) };
StructureRegistry[type].onUse?.(t, s, world);
}
return Boolean(s);
}
function runBuildAction(t, type, label) {
const grass = findNearbyMaterial(t.world, t, "grassMaterial", 520);
if (!grass) return false;
if (moveTowardOrUse(t, grass, "seek_food", label)) {
t.buildType = type;
t.buildMaterialId = grass.id;
return true;
}
t.buildType = type;
t.buildMaterialId = grass.id;
t.buildTimer = Math.max(0, Number(t.buildTimer || 0)) + 1;
t.setActionState?.("idle", { target: grass, reason: label });
if (t.buildTimer >= (StructureRegistry[type]?.buildTime || 4)) return finishBuild(t, type, grass);
return true;
}
const TARINAI_ACTIONS = [
{ id: "eat_food", need: "food", label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u98df\u3079\u305f", weight: 50,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it))),
run(t, world) { const item = findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it)); return moveTowardOrUse(t, item, "seek_food", this.label) || t.beginEating?.(item, this.label); } },
{ id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 22,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "drink", 620)),
run(t, world) { const item = findNearestItemWithRole(world, t, "drink", 620); return moveTowardOrUse(t, item, "seek_water", this.label) || (t.setActionState?.("seek_water", { target: item, reason: this.label }), true); } },
{ id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42,
condition: (t, world) => Boolean(findNearestSleepPlace(world, t, 620)),
run(t, world) { const bed = findNearestSleepPlace(world, t, 620); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; return t.startSleeping?.(bed, this.label); } },
{ id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true,
run(t) { return t.startSleeping?.(null, this.label); } },
{ id: "use_medicine", need: "health", label: "\u4f53\u306b\u3088\u3055\u305d\u3046\u306a\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u624b\u5f53\u3066\u3057\u305f", weight: 35,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "medicine", 620)),
run(t, world) { const item = findNearestItemWithRole(world, t, "medicine", 620); return moveTowardOrUse(t, item, itemRolesFor(item).drink ? "seek_water" : "seek_food", this.label) || t.beginEating?.(item, this.label); } },
{ id: "rest_to_recover", need: "health", label: "\u4f53\u3092\u4f11\u3081\u3066\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true,
run(t) { t.setActionState?.("idle", { target: null, reason: this.label }); t.energy = clamp((t.energy || 0) + 1.0, 0, 100); return true; } },
{ id: "flee", need: "safety", label: "\u96e2\u308c\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u9003\u3052\u305f", weight: 32,
condition: (t, world) => Boolean(findNearbyDanger(world, t, 260)),
run(t, world) { const danger = findNearbyDanger(world, t, 260); t.setActionState?.("panic", { target: t.panicDestination?.(danger, true) || null, reason: this.label, wake: true }); t.panicTimer = Math.max(t.panicTimer || 0, 2.0); return true; } },
{ id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u308d\u3046\u3068\u3057\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 28,
condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)),
run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return t.startSleeping?.(bed, this.label); } },
{ id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38,
condition: (t, world) => Boolean(world.nearestOther?.(t, 560)),
run(t, world) { const other = world.nearestOther?.(t, 560); t.setActionState?.(t.parentToFollow?.() === other ? "follow_parent" : "seek_friend", { target: other, reason: this.label }); return true; } },
{ id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 8,
condition: (t, world) => personalityValue(t, "aggression") > 0.45 && Boolean(world.nearestOther?.(t, 220)),
run(t, world) { const other = world.nearestOther?.(t, 220); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.0); t.intimidateTargetId = other?.id || null; t.setActionState?.("intimidate", { target: other, reason: this.label }); return true; } },
{ id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 26,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "playObject", 460)),
run(t, world) { const ball = findNearestItemWithRole(world, t, "playObject", 460); t.setActionState?.("play_ball", { target: ball, reason: this.label }); t.goodMode = "smile"; return true; } },
{ id: "build_grass_bed", need: "fulfill", label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3092\u4f5c\u3063\u305f", weight: 24,
condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "grass_bed") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)),
run(t) { return runBuildAction(t, "grass_bed", this.label); } },
{ id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 18,
condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "plushie") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)),
run(t) { return runBuildAction(t, "plushie", this.label); } },
{ id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 18,
condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)),
run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return true; } },
{ id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u5927\u4e8b\u306b\u3057\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3067\u843d\u3061\u7740\u3044\u305f", weight: 22,
condition: (t, world) => Boolean(findOwnedStructure(world, t, "plushie", Infinity)),
run(t, world) { const plushie = findOwnedStructure(world, t, "plushie", Infinity); StructureRegistry.plushie.onUse(t, plushie, world); t.setActionState?.("idle", { target: plushie, reason: this.label }); return true; } },
{ id: "wander_lightly", need: "fulfill", label: "\u8efd\u304f\u6563\u6b69\u3057\u3066\u3044\u308b", phrase: "\u6563\u6b69\u3057\u305f", weight: 12, condition: () => true,
run(t) { t.wanderAngle += rand(-1.7, 1.7); t.setActionState?.("idle", { target: null, reason: this.label }); return true; } },
];
function chooseActionForNeed(need, t, world) {
const candidates = TARINAI_ACTIONS.filter(a => a.need === need && (!a.condition || a.condition(t, world)));
return weightedRandom(candidates) || TARINAI_ACTIONS.find(a => a.id === "wander_lightly");
}
function runNeedDecision(t, world, dt = 0.1) {
updateAttachedStructures(world, t);
decayNeedShock(t, dt);
const needs = updateNeeds(t, world);
const maxNeed = Math.max(...NEED_KEYS.map(key => needs[key]));
const tiedNeeds = NEED_KEYS.filter(key => needs[key] === maxNeed);
const chosenNeed = chooseTopNeedRandom(needs);
const action = chooseActionForNeed(chosenNeed, t, world);
if (!action || !action.run(t, world, dt)) {
t.setActionState?.("idle", { target: null, reason: "\u5f85\u3063\u3066\u3044\u308b" });
}
const reasonText = buildReasonText(chosenNeed, tiedNeeds, action);
t.intent = {
need: chosenNeed,
tiedNeeds: tiedNeeds.slice(),
actionId: action?.id || "idle",
actionLabel: action?.label || "\u5f85\u3063\u3066\u3044\u308b",
reasonText,
};
t.need = chosenNeed;
t.thought = reasonText;
return t.intent;
}
function updateStructure(dt, world) {
if (this.dead) return;
this.age = (this.age || 0) + dt;
if (this.type === "plushie" && this.carriedById) {
const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === this.carriedById);
if (owner) {
this.x = owner.x + (owner.facingDir ? owner.facingDir() : 1) * owner.radius * 0.12;
this.y = owner.y - owner.radius * 1.10;
} else {
this.carriedById = "";
this.onHead = false;
}
}
this.amount = Math.max(0, this.hp || 0);
}
function damageStructure(amount = 1, breaker = null, world = null) {
this.hp = Math.max(0, Number(this.hp || 0) - Math.max(0, Number(amount) || 0));
if (this.hp > 0) return false;
const w = world || global.world;
const def = StructureRegistry[this.type];
def?.onDestroyed?.(this, w, breaker);
this.dead = true;
this.amount = 0;
w?.markTerrainDirty?.(`structure-destroyed:${this.type}`);
return true;
}
function drawStructure(ctx, time, lighting) {
const r = this.r || (this.type === "plushie" ? 14 : 28);
ctx.save();
ctx.translate(this.x, this.y);
if (this.type === "grass_bed") {
ctx.fillStyle = "rgba(175, 196, 102, 0.88)";
ctx.strokeStyle = "rgba(90, 118, 55, 0.72)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 3, r * 1.55, r * 0.74, -0.08, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = "rgba(71, 112, 48, 0.55)";
ctx.lineWidth = 1.6;
for (let i = 0; i < 15; i++) {
const x = randSeed((this.seed || 1) + i, -r * 1.1, r * 1.1);
const y = randSeed((this.seed || 1) + i + 20, -r * 0.32, r * 0.46);
const len = randSeed((this.seed || 1) + i + 40, r * 0.30, r * 0.68);
const a = randSeed((this.seed || 1) + i + 60, -0.7, 0.7);
ctx.beginPath();
ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "plushie") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : null;
ctx.rotate(Math.sin(time * 2.2 + (this.seed || 0)) * 0.08);
if (img) ctx.drawImage(img, -r * 0.8, -r * 0.8, r * 1.6, r * 1.45);
else {
ctx.fillStyle = "#f4ead6";
ctx.strokeStyle = "rgba(92,71,49,0.48)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.ellipse(0, 0, r * 0.68, r * 0.58, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
ctx.restore();
}
const StructureRegistry = {
grass_bed: {
type: "grass_bed",
label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9",
buildNeed: "fulfill",
buildTime: 4,
maxHp: 100,
materials: { grassMaterial: 1 },
roles: { sleepPlace: true, ownedStructure: true },
needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 },
create(owner, x, y) {
return { id: createId("structure"), type: "grass_bed", isStructure: true, ownerId: owner.id, hp: 100, attachment: 20, usedCount: 0, x, y, r: 28, amount: 100, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } };
},
onUse(user, structure) {
const owner = user?.id === structure?.ownerId;
structure.usedCount = (structure.usedCount || 0) + 1;
structure.attachment = clamp((structure.attachment || 0) + (owner ? 2 : 0.4), 0, 100);
user.needShock = { ...(user.needShock || {}) };
user.needShock.sleep = Math.max(0, Number(user.needShock.sleep || 0) - (owner ? 18 : 8));
user.needShock.safety = Math.max(0, Number(user.needShock.safety || 0) - (owner ? 12 : 4));
user.needShock.fulfill = Math.max(0, Number(user.needShock.fulfill || 0) - (owner ? 18 : 6));
},
onDestroyed(structure, world, breaker) {
const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId);
if (owner) addNeedShock(owner, { safety: 45, fulfill: 52 }, breaker);
},
},
plushie: {
type: "plushie",
label: "\u306c\u3044\u3050\u308b\u307f",
buildNeed: "fulfill",
buildTime: 4,
maxHp: 1,
materials: { grassMaterial: 1 },
roles: { carriedObject: true, ownedStructure: true, fulfillObject: true },
needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 },
create(owner, x, y) {
return { id: createId("structure"), type: "plushie", isStructure: true, ownerId: owner.id, hp: 1, attachment: 30, usedCount: 0, x, y, r: 14, amount: 1, carriedById: owner.id, onHead: true, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } };
},
onUse(user, structure) {
if (!user || !structure) return;
structure.attachment = clamp((structure.attachment || 0) + 1.5, 0, 100);
user.needShock = { ...(user.needShock || {}), fulfill: Math.max(0, Number(user.needShock?.fulfill || 0) - 20), safety: Math.max(0, Number(user.needShock?.safety || 0) - 3) };
},
onDestroyed(structure, world, breaker) {
const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId);
if (owner) addNeedShock(owner, { safety: 58, fulfill: 70 }, breaker);
},
},
};
global.TarinaiNeedSystem = {
NEED_KEYS,
NEED_LABELS,
NEED_PHRASES,
TARINAI_ACTIONS,
StructureRegistry,
createDefaultNeeds,
quantizeNeed,
updateNeeds,
chooseTopNeedRandom,
chooseActionForNeed,
buildReasonText,
getNeedDisplayValue,
findNearestItemWithRole,
findNearestSleepPlace,
findNearbyDanger,
findNearbyMaterial,
findOwnedStructure,
ownedStructures,
itemRolesFor,
ensureItemNeedEffects,
deriveStressFromNeeds,
addNeedShock,
runNeedDecision,
createStructure,
damageStructure,
};
global.createDefaultNeeds = createDefaultNeeds;
global.quantizeNeed = quantizeNeed;
global.updateNeeds = updateNeeds;
global.chooseTopNeedRandom = chooseTopNeedRandom;
global.chooseActionForNeed = chooseActionForNeed;
global.buildReasonText = buildReasonText;
global.getNeedDisplayValue = getNeedDisplayValue;
global.findNearestItemWithRole = findNearestItemWithRole;
global.findNearestSleepPlace = findNearestSleepPlace;
global.findNearbyDanger = findNearbyDanger;
global.findNearbyMaterial = findNearbyMaterial;
global.StructureRegistry = StructureRegistry;
})(typeof window !== "undefined" ? window : globalThis);