1221 lines
69 KiB
JavaScript
1221 lines
69 KiB
JavaScript
"use strict";
|
|
|
|
|
|
// Need planning runtime and Tarinai prototype extensions.
|
|
// Targeting, ActionSpec definitions, consumables, social ticks, and building are split into dedicated modules.
|
|
// This file keeps its local needs-loop helpers private; cross-file helpers come
|
|
// from their owning modules loaded earlier in index.html.
|
|
(function (global) {
|
|
|
|
function decayNeedSatisfaction(tarinai, dt = 0) {
|
|
if (!tarinai) return;
|
|
tarinai.needSatisfaction = tarinai.needSatisfaction || createDefaultNeeds();
|
|
const step = Math.max(0, Number(dt) || 0);
|
|
if (step <= 0) return;
|
|
for (const key of TARINAI_NEED_KEYS) {
|
|
const decay = (TARINAI_NEED_SATISFACTION_DECAY[key] || 1) * step;
|
|
tarinai.needSatisfaction[key] = Math.max(0, (Number(tarinai.needSatisfaction[key] || 0) || 0) - decay);
|
|
}
|
|
}
|
|
|
|
function decayNeedShock(tarinai, dt = 0) {
|
|
if (!tarinai?.needShock) return;
|
|
const step = Math.max(0, Number(dt) || 0);
|
|
const decay = step > 0 ? step * 18 : 0;
|
|
if (decay <= 0) return;
|
|
for (const key of TARINAI_NEED_KEYS) {
|
|
const shock = Number(tarinai.needShock[key] || 0) || 0;
|
|
tarinai.needShock[key] = shock > 0 ? Math.max(0, shock - decay) : Math.min(0, shock + decay);
|
|
}
|
|
}
|
|
|
|
function circadianSleepPhase(world) {
|
|
const p = world?.dayProgress?.() || 0;
|
|
// Sleep pressure should visibly rise before dusk and fade around dawn.
|
|
// Keep the behavior controlled by need deltas rather than forcing sleep/wake
|
|
// at a fixed clock time. The steep increase begins before the evening
|
|
// phase, and sleeping reduces the pressure slowly enough to settle near dawn.
|
|
const sunsetHour = 17;
|
|
const sunriseHour = 5;
|
|
const highGrowthStart = Math.max(0, (sunsetHour - 2) / 24);
|
|
const decayStart = sunriseHour / 24;
|
|
const night = p >= highGrowthStart || p < decayStart;
|
|
const eveningRamp = p >= Math.max(0, highGrowthStart - 2 / 24) && p < highGrowthStart;
|
|
const afternoon = p >= 0.46 && p < Math.max(0.46, highGrowthStart - 2 / 24);
|
|
return { p, night, eveningRamp, afternoon, highGrowthStart, decayStart };
|
|
}
|
|
|
|
function updateCircadianSleepPressure(tarinai, world, dt = 0) {
|
|
const phase = circadianSleepPhase(world);
|
|
const sleeping = Boolean(tarinai.state === "sleep" || tarinai.sleeping);
|
|
if (!Number.isFinite(tarinai.circadianSleepPressure)) {
|
|
tarinai.circadianSleepPressure = phase.night ? rand(46, 64) : (phase.afternoon || phase.eveningRamp ? rand(8, 18) : rand(0, 5));
|
|
}
|
|
const step = Math.max(0, Number(dt) || 0);
|
|
if (sleeping) {
|
|
const target = phase.night ? 18 : 0;
|
|
const current = Number(tarinai.circadianSleepPressure || 0) || 0;
|
|
const extra = Math.max(0, current - target) * (phase.night ? 0.002 : 0.018);
|
|
const bedLike = tarinai.target && typeof tarinai.isSleepFurniture === "function" && tarinai.isSleepFurniture(tarinai.target);
|
|
const sleepDecay = (phase.night ? (bedLike ? 0.45 : 0.36) : (bedLike ? 2.15 : 1.45)) + extra;
|
|
tarinai.circadianSleepPressure = clamp(current - step * sleepDecay, 0, 94);
|
|
} else if (phase.night) {
|
|
const duskBoost = phase.p >= phase.highGrowthStart && phase.p < 0.66 ? 1.0 : 0;
|
|
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * (2.35 + duskBoost), 0, 94);
|
|
} else if (phase.eveningRamp) {
|
|
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.82, 0, 60);
|
|
} else if (phase.afternoon) {
|
|
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.10, 0, 22);
|
|
} else {
|
|
tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * 0.74, 0, 94);
|
|
}
|
|
return { ...phase, pressure: tarinai.circadianSleepPressure };
|
|
}
|
|
|
|
function updateNeeds(tarinai, world, dt = 0) {
|
|
const p = tarinai.currentPersonality || {};
|
|
const aggression = Number(p.aggression) || 0;
|
|
const openness = Number(p.openness) || 0;
|
|
const sociability = Number(p.sociability) || 0;
|
|
const neuroticism = Number(p.neuroticism) || 0;
|
|
const raw = createDefaultNeeds();
|
|
raw.food = (tarinai.hunger || 0) * 0.88;
|
|
const sleepPhase = updateCircadianSleepPressure(tarinai, world, dt);
|
|
const energyMax = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100));
|
|
tarinai.maxEnergy = energyMax;
|
|
const energyPct = clamp((Number(tarinai.energy) || 0) / energyMax, 0, 1);
|
|
const energyLack = (1 - energyPct) * 100;
|
|
const energySleepNeed = energyLack * 0.22;
|
|
raw.sleep = energySleepNeed + sleepPhase.pressure;
|
|
if (tarinai.sleepDisease) raw.sleep += 34;
|
|
raw.health = Math.max(0, energyLack * 0.18 + (tarinai.zunchiStain || 0) * 0.32);
|
|
if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) raw.health += 42;
|
|
const lodgedBehavior = tarinai.currentLodgedPinBehavior?.() || null;
|
|
if ((tarinai.hurtTimer || 0) > 0.04 || ((tarinai.stuckPushpinId || "") && (lodgedBehavior?.panicOnAttach || (lodgedBehavior?.damagePerTick || 0) > 0 || (lodgedBehavior?.damageOnAttach || 0) > 0))) raw.health += 36;
|
|
const danger = findNearbyDanger(world, tarinai, 170);
|
|
if (danger) raw.safety += danger.kind ? 78 : 48;
|
|
const feltTemperature = world?.feltTemperatureFor?.(tarinai) ?? world?.temperatureAt?.(tarinai.x, tarinai.y, tarinai);
|
|
if (Number.isFinite(Number(feltTemperature))) {
|
|
const tempStatus = world.temperatureStatusFor?.(feltTemperature, tarinai) || null;
|
|
tarinai.feltTemperature = Number(feltTemperature);
|
|
tarinai.tempComfort = Number(feltTemperature);
|
|
tarinai.temperatureStatus = tempStatus;
|
|
if (tempStatus && (tempStatus.discomfort || 0) > 0) {
|
|
raw.health += Math.max(0, (tempStatus.discomfort || 0) - 2) * 6.4;
|
|
raw.safety += Math.max(0, (tempStatus.stressExcess || 0)) * 5.6;
|
|
if ((tempStatus.harmExcess || 0) > 0) raw.health += (tempStatus.harmExcess || 0) * 7.2;
|
|
}
|
|
}
|
|
if ((tarinai.fearTimer || 0) > 0.12 || (tarinai.lastDamageAt || -999) + 5 > (world?.time || 0)) raw.safety += 34;
|
|
const socialParts = { bond: 0, family: 0, mate: 0, conflict: 0, fearSocial: 0 };
|
|
socialParts.bond = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16);
|
|
raw.social = socialParts.bond;
|
|
if (tarinai.parentToFollow?.()) { socialParts.family += 18; raw.social += 18; }
|
|
const championMate = tarinai.isTarinaiChampion && (tarinai.reproductionTimer || 0) <= 8 && (world?.canAddTarinai?.(1) ?? true)
|
|
? (world?.nearestOther?.(tarinai, 780, other => other && other !== tarinai && !other.dead && other.isTarinaiChampion && !world.areParentChild?.(tarinai, other) && !!tarinai.isZunchiSlave === !!other.isZunchiSlave && !other.sleepDisease && !other.fightDisease && (other.reproductionTimer || 0) <= 10) || null)
|
|
: null;
|
|
if (championMate) { socialParts.mate += 48; socialParts.championMate = 1; raw.social += 48; }
|
|
else if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) { socialParts.mate += 16 + ((tarinai.loveMochiTimer || 0) > 0.04 ? 42 : 0); raw.social += socialParts.mate; }
|
|
const enemy = tarinai.strongestRelation?.("fear");
|
|
if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; }
|
|
const conflict = evaluateConflictUrge(tarinai, world, 112);
|
|
if (conflict?.target && (conflict.forced || conflict.score >= 14)) {
|
|
tarinai.conflictTargetId = conflict.target.id;
|
|
tarinai.conflictUrge = Math.max(Number(tarinai.conflictUrge || 0) || 0, conflict.score);
|
|
const conflictPart = conflict.score;
|
|
socialParts.conflict += conflictPart;
|
|
raw.social += conflictPart;
|
|
if (conflict.defensive) raw.safety += conflict.score * 0.24;
|
|
} else if (tarinai.conflictUrge) {
|
|
tarinai.conflictUrge = Math.max(0, (Number(tarinai.conflictUrge || 0) || 0) - Math.max(0, Number(dt) || 0) * 12);
|
|
const carriedConflict = Math.min(18, tarinai.conflictUrge * 0.22);
|
|
socialParts.conflict += carriedConflict;
|
|
raw.social += carriedConflict;
|
|
}
|
|
if ((tarinai.fightMochiTimer || 0) > 0.04) {
|
|
socialParts.conflict += 34;
|
|
raw.social += 34;
|
|
raw.safety += 8;
|
|
}
|
|
tarinai.socialReasonParts = socialParts;
|
|
if ((tarinai.defeatedTimer || 0) > 0.04 || (tarinai.intimidatedTimer || 0) > 0.04) raw.safety += 46;
|
|
const ownBed = findOwnedStructure(world, tarinai, "grass_bed");
|
|
const ownPlushie = findOwnedStructure(world, tarinai, "plushie");
|
|
const needDt = Math.max(0, Number(dt) || 0);
|
|
const boredomDelta = needDt * ((tarinai.state === "idle" || tarinai.state === "wander") ? 0.55 : -0.30);
|
|
const boredom = clamp((tarinai.boredom || 0) + boredomDelta, 0, 36);
|
|
tarinai.boredom = boredom;
|
|
raw.fulfill = 18 + boredom + (openness > 0 ? openness * 16 : 0);
|
|
if (!ownBed) raw.fulfill += 18;
|
|
if (ownBed) {
|
|
raw.sleep -= dist(tarinai, ownBed) < 80 ? 16 : 6;
|
|
raw.safety -= dist(tarinai, ownBed) < 100 ? 14 : 4;
|
|
raw.fulfill -= dist(tarinai, ownBed) < 120 ? 20 : 7;
|
|
}
|
|
const plushiePart = !ownPlushie ? 14 : 0;
|
|
const plushieComfortPart = ownPlushie ? -(ownPlushie.carriedById === tarinai.id ? 28 : 13) : 0;
|
|
if (!ownPlushie) raw.fulfill += plushiePart;
|
|
else raw.fulfill += plushieComfortPart;
|
|
const materialPart = (!ownBed || !ownPlushie) && findNearestItemWithRole(world, tarinai, "grassMaterial", 420) ? 8 : 0;
|
|
if (materialPart) raw.fulfill += materialPart;
|
|
tarinai.fulfillReasonParts = {
|
|
boredom,
|
|
bed: ownBed ? 0 : 18,
|
|
bedComfort: ownBed ? (dist(tarinai, ownBed) < 120 ? -20 : -7) : 0,
|
|
plushie: plushiePart,
|
|
plushieComfort: plushieComfortPart,
|
|
material: materialPart,
|
|
openness: openness > 0 ? openness * 16 : 0,
|
|
};
|
|
|
|
for (const key of TARINAI_NEED_KEYS) {
|
|
const shock = Number(tarinai.needShock?.[key] || 0) || 0;
|
|
if (shock !== 0) raw[key] += shock;
|
|
}
|
|
raw.safety *= clamp(1 - Math.max(0, aggression) * 0.12 + Math.max(0, neuroticism) * 0.16, 0.75, 1.25);
|
|
raw.health *= clamp(1 + Math.max(0, neuroticism) * 0.14, 0.9, 1.22);
|
|
raw.fulfill *= clamp(1 + Math.max(0, openness) * 0.14, 0.9, 1.24);
|
|
|
|
decayNeedSatisfaction(tarinai, dt);
|
|
const effective = createDefaultNeeds();
|
|
const display = createDefaultNeeds();
|
|
tarinai.needSatisfaction = tarinai.needSatisfaction || createDefaultNeeds();
|
|
for (const key of TARINAI_NEED_KEYS) {
|
|
const base = clamp(Number(raw[key] || 0) || 0, 0, 100);
|
|
const satisfaction = Math.max(0, Number(tarinai.needSatisfaction[key] || 0) || 0);
|
|
effective[key] = clamp(base - satisfaction, 0, 100);
|
|
display[key] = quantizeNeed(effective[key]);
|
|
}
|
|
tarinai.previousNeeds = tarinai.needs ? { ...tarinai.needs } : createDefaultNeeds();
|
|
tarinai.needRawBase = raw;
|
|
tarinai.needRaw = effective;
|
|
tarinai.needDisplay = display;
|
|
tarinai.needs = display;
|
|
tarinai.stress = applyGroundStressModifier(tarinai, calculateStressFromNeeds(effective));
|
|
decayNeedShock(tarinai, dt);
|
|
return effective;
|
|
}
|
|
|
|
function tarinaiNeedCalcInterval(tarinai, world) {
|
|
const jitter = stableUnit(tarinai?.familyKey || tarinai?.id || "tarinai", "need-calc-interval") * 0.35;
|
|
const urgent = (tarinai?.fightTimer || 0) > 0.04 || (tarinai?.birthRitualTimer || 0) > 0.04 || (tarinai?.eatTimer || 0) > 0.04 || (tarinai?.sunbathTimer || 0) > 0.04 || forcedBehaviorQueueOf(tarinai).length > 0;
|
|
return urgent ? (0.55 + jitter * 0.25) : (1.0 + jitter);
|
|
}
|
|
|
|
function shouldForceNeedRecalc(tarinai, world) {
|
|
if (!tarinai?.needRaw) return true;
|
|
if (forcedBehaviorQueueOf(tarinai).length > 0) return true;
|
|
if ((tarinai?.lastNeedShockBreaker || null)) return true;
|
|
if (tarinai?.stuckPushpinId) {
|
|
const behavior = tarinai.currentLodgedPinBehavior?.() || null;
|
|
if (behavior?.panicOnAttach || (behavior?.damagePerTick || 0) > 0 || (behavior?.damageOnAttach || 0) > 0) return true;
|
|
}
|
|
if ((tarinai?.hurtTimer || 0) > 0.04 || (tarinai?.defeatedTimer || 0) > 0.04 || (tarinai?.fightTimer || 0) > 0.04) return true;
|
|
return false;
|
|
}
|
|
|
|
function updateNeedsCached(tarinai, world, dt = 0, options = {}) {
|
|
if (!tarinai) return createDefaultNeeds();
|
|
const now = Number(world?.time || 0) || 0;
|
|
if (!Number.isFinite(tarinai.nextNeedCalcAt)) {
|
|
const firstInterval = tarinaiNeedCalcInterval(tarinai, world);
|
|
const offset = stableUnit(tarinai.familyKey || tarinai.id || "tarinai", "need-calc-offset") * firstInterval;
|
|
tarinai.nextNeedCalcAt = now + offset;
|
|
}
|
|
const force = Boolean(options.force || shouldForceNeedRecalc(tarinai, world));
|
|
if (force || now >= (tarinai.nextNeedCalcAt || 0)) {
|
|
// Low-frequency AI uses a fixed needs step. This intentionally avoids
|
|
// carrying accumulated elapsed time into need arithmetic; the player only
|
|
// perceives the resulting trend, not sub-second precision.
|
|
const urgent = (tarinai?.fightTimer || 0) > 0.04 || (tarinai?.birthRitualTimer || 0) > 0.04 || (tarinai?.eatTimer || 0) > 0.04 || (tarinai?.sunbathTimer || 0) > 0.04 || forcedBehaviorQueueOf(tarinai).length > 0;
|
|
const calcDt = Number(options.fixedNeedDt) || (urgent ? 0.50 : 1.00);
|
|
const result = updateNeeds(tarinai, world, calcDt);
|
|
tarinai.nextNeedCalcAt = now + tarinaiNeedCalcInterval(tarinai, world);
|
|
return result;
|
|
}
|
|
return tarinai.needRaw || tarinai.needs || createDefaultNeeds();
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function weightedPickAction(actions) {
|
|
const total = actions.reduce((sum, action) => sum + Math.max(0.01, Number(action.weight) || 1), 0);
|
|
let roll = Math.random() * total;
|
|
for (const action of actions) {
|
|
roll -= Math.max(0.01, Number(action.weight) || 1);
|
|
if (roll <= 0) return action;
|
|
}
|
|
return actions[0] || null;
|
|
}
|
|
|
|
function actionSelectionBonus(action, need, tarinai, options = {}) {
|
|
if (!action) return 0;
|
|
let bonus = 0;
|
|
if (need === "social") {
|
|
const p = tarinai?.socialReasonParts || {};
|
|
const prof = tarinai?.personalityProfile?.() || {};
|
|
const cur = tarinai?.currentPersonality || {};
|
|
const aggression = Number(cur.aggression ?? prof.fight ?? 0) || 0;
|
|
const sociability = Number(cur.sociability ?? prof.social ?? 0) || 0;
|
|
const openness = Number(cur.openness ?? prof.play ?? 0) || 0;
|
|
if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 2.2 + Math.max(0, openness) * 22;
|
|
else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 1.10 + Math.max(0, aggression) * 14;
|
|
else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.6 + Math.max(0, sociability) * 10;
|
|
else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 1.15 + Math.max(0, sociability) * 24;
|
|
} else if (need === "fulfill") {
|
|
const p = tarinai?.fulfillReasonParts || {};
|
|
if (action.id === "build_grass_bed") bonus += Number(p.bed || 0) * 1.6 + Number(p.material || 0);
|
|
else if (action.id === "build_plushie") bonus += Number(p.plushie || 0) * 1.8 + Number(p.material || 0);
|
|
else if (action.id === "play" || action.id === "play_seesaw" || action.id === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2;
|
|
else if (options.includeComfortActions && (action.id === "return_owned_structure" || action.id === "use_plushie")) {
|
|
bonus += Math.max(0, -Number(p.bedComfort || 0) - Number(p.plushieComfort || 0)) * 0.5;
|
|
}
|
|
}
|
|
return bonus;
|
|
}
|
|
|
|
|
|
function socialFightMateActionWeights(tarinai) {
|
|
const parts = tarinai?.socialReasonParts || {};
|
|
const prof = tarinai?.personalityProfile?.() || {};
|
|
const cur = tarinai?.currentPersonality || {};
|
|
const aggression = Number(cur.aggression ?? prof.fight ?? 0) || 0;
|
|
const openness = Number(cur.openness ?? prof.play ?? 0) || 0;
|
|
const sociability = Number(cur.sociability ?? prof.social ?? 0) || 0;
|
|
let fightWeight = 4;
|
|
let mateWeight = 6;
|
|
fightWeight *= clamp(1 + aggression * 0.55, 0.25, 2.8);
|
|
mateWeight *= clamp(1 + openness * 0.34 + sociability * 0.20, 0.34, 2.4);
|
|
fightWeight *= 1 + Math.min(1.9, Math.max(0, Number(parts.conflict || 0) || 0) / 34);
|
|
mateWeight *= 1 + Math.min(1.9, Math.max(0, Number(parts.mate || 0) || 0) / 34);
|
|
fightWeight *= clamp(1 - Math.max(0, Number(parts.bond || 0) || 0) / 180, 0.55, 1.04);
|
|
if ((tarinai?.fightMochiTimer || 0) > 0.04) { fightWeight *= 3.4; mateWeight *= 0.55; }
|
|
if ((tarinai?.loveMochiTimer || 0) > 0.04) { mateWeight *= 3.15; fightWeight *= 0.58; }
|
|
if (parts.championMate) { mateWeight *= 4.0; fightWeight *= 0.35; }
|
|
const total = Math.max(0, fightWeight) + Math.max(0, mateWeight);
|
|
return { fightWeight, mateWeight, fightProbability: total > 0 ? fightWeight / total : 0.4 };
|
|
}
|
|
|
|
|
|
// regression guard baseline: return ({ conflict: 34, mate: 24, family: 12, bond: 14
|
|
// regression guard baseline: subValue >= threshold && subValue >= socialPull * 0.82
|
|
function socialSubNeedStartThreshold(subNeed = "") {
|
|
return ({ conflict: 18, mate: 24, family: 12, bond: 14, fearSocial: 18 })[subNeed] ?? 16;
|
|
}
|
|
|
|
function shouldStartActionBySubNeed(tarinai, action, needs) {
|
|
const need = action?.need || "fulfill";
|
|
if (action?.id === "seek_comfort_temperature") {
|
|
const status = tarinai?.temperatureStatus || tarinai?.world?.temperatureStatusFor?.(tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15), tarinai);
|
|
return Boolean(status && (status.discomfort || 0) >= 2.5);
|
|
}
|
|
if (action?.id === "play_seesaw") {
|
|
return Boolean(globalThis.TarinaiSeesawSystem?.shouldSeek?.(tarinai?.world, tarinai, needs));
|
|
}
|
|
if (need !== "social" || !action?.subNeed) return shouldStartNeedAction(tarinai, need, needs);
|
|
const parts = tarinai?.socialReasonParts || {};
|
|
const subNeed = action.subNeed;
|
|
const subValue = Number(parts[subNeed] || 0) || 0;
|
|
const total = Number(needs?.social || 0) || 0;
|
|
const prof = tarinai?.personalityProfile?.() || {};
|
|
const cur = tarinai?.currentPersonality || {};
|
|
const modifier = subNeed === "conflict" ? Math.max(0, Number(cur.aggression ?? prof.fight ?? 0) || 0) * 8
|
|
: subNeed === "bond" || subNeed === "family" ? Math.max(0, Number(cur.sociability ?? prof.social ?? 0) || 0) * 7
|
|
: subNeed === "mate" ? Math.max(0, Number(cur.openness ?? prof.play ?? 0) || 0) * 7
|
|
: 0;
|
|
const threshold = Math.max(6, socialSubNeedStartThreshold(subNeed) - modifier);
|
|
const isContinuingSameAction = (getTarinaiBehaviorId(tarinai)) === action.id;
|
|
if (subNeed === "mate" || subNeed === "conflict") {
|
|
const relationDrive = Math.max(Number(parts.mate || 0) || 0, Number(parts.conflict || 0) || 0, total * 0.36);
|
|
const drugBoosted = (tarinai?.loveMochiTimer || 0) > 0.04 || (tarinai?.fightMochiTimer || 0) > 0.04;
|
|
return total >= needThreshold("social", "start") && (drugBoosted || relationDrive >= Math.min(socialSubNeedStartThreshold("mate"), socialSubNeedStartThreshold("conflict")) * 0.48);
|
|
}
|
|
if (subValue >= threshold) return true;
|
|
if (isContinuingSameAction && subValue >= threshold * 0.55) return true;
|
|
return total >= needThreshold("social", "start") && subValue >= threshold * 0.36;
|
|
}
|
|
|
|
function conflictNeedsForChoice(selectedAction, candidates, needs) {
|
|
const chosenNeed = selectedAction?.need || "fulfill";
|
|
const chosenPriority = actionPriority(selectedAction);
|
|
const chosenNeedValue = Number(needs?.[chosenNeed] || 0) || 0;
|
|
const result = [];
|
|
const add = key => { if (key && key !== chosenNeed && !result.includes(key)) result.push(key); };
|
|
for (const c of candidates || []) {
|
|
const need = c.action?.need || "fulfill";
|
|
const value = Number(needs?.[need] || 0) || 0;
|
|
if (need === chosenNeed || value <= 0.5) continue;
|
|
if (c.priority > chosenPriority && value >= needThreshold(need, "start")) add(need);
|
|
else if (value >= 82 && value >= chosenNeedValue - 8) add(need);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function eligiblePriorityActions(tarinai, world, needs) {
|
|
const result = [];
|
|
for (const action of TARINAI_ACTIONS) {
|
|
if (!action || action.id === "birth_ritual") continue;
|
|
if ((Number(tarinai?.actionIdCooldowns?.[action.id] || 0) || 0) > 0.01) continue;
|
|
const need = action.need || "fulfill";
|
|
if (!shouldStartActionBySubNeed(tarinai, action, needs)) continue;
|
|
if (!canStartTarinaiAction(action, tarinai, world)) continue;
|
|
result.push(action);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function choosePriorityNeedAction(needs, tarinai, world) {
|
|
const actions = eligiblePriorityActions(tarinai, world, needs);
|
|
if (!actions.length) return null;
|
|
const weighted = actions.map(action => {
|
|
const need = action.need || "fulfill";
|
|
const bonus = actionSelectionBonus(action, need, tarinai);
|
|
let priority = actionPriority(action);
|
|
const needValue = Number(needs?.[need] || 0) || 0;
|
|
if (action.id === "seek_comfort_temperature") {
|
|
const felt = tarinai?.world?.feltTemperatureFor?.(tarinai) ?? tarinai?.world?.temperatureAt?.(tarinai?.x, tarinai?.y, tarinai) ?? tarinai?.feltTemperature ?? tarinai?.tempComfort ?? (CONFIG.standardTemperature ?? 15);
|
|
const status = tarinai?.world?.temperatureStatusFor?.(felt, tarinai) || tarinai?.temperatureStatus || null;
|
|
priority = (status?.harmful || (status?.harmExcess || 0) > 0) ? 92 : 53;
|
|
}
|
|
if (action.id === "play_seesaw") {
|
|
priority = Math.max(priority, Number(globalThis.TarinaiSeesawSystem?.actionPriority?.(world, tarinai)) || 0);
|
|
}
|
|
if (action.id === "eat_food") {
|
|
const hunger = Number(tarinai?.hunger || 0) || 0;
|
|
if (hunger >= 98 || needValue >= 88) priority = Math.max(priority, 120);
|
|
else if (hunger >= 88 || needValue >= 78) priority = Math.max(priority, 98);
|
|
else if (hunger >= 78 || needValue >= 70) priority = Math.max(priority, 82);
|
|
}
|
|
const urgency = Math.max(0, needValue - needThreshold(need, "start"));
|
|
return { action, priority, need, needValue, urgency, bonus, score: priority * 1000 + needValue * 3 + (Number(action.weight || 1) + bonus) };
|
|
});
|
|
weighted.sort((a, b) => (b.priority - a.priority) || (b.score - a.score));
|
|
let selectedEntry = weighted[0];
|
|
|
|
// \u539f\u5247\u306f\u884c\u52d5\u512a\u5148\u9806\u4f4d\u3002\u305f\u3060\u3057\u3001\u4f4e\u4f4d\u6b32\u6c42\u304c\u5371\u6a5f\u7684\u306b\u9ad8\u3044\u5834\u5408\u3060\u3051\u3001\u305d\u306e\u6b32\u6c42\u884c\u52d5\u3092\u9078\u3073\u3001
|
|
// \u8868\u793a\u6587\u306f\u300c\u7720\u305f\u3044\u3051\u3069\u3001\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b\u3002\u300d\u306e\u3088\u3046\u306b\u5bfe\u7acb\u3092\u660e\u793a\u3059\u308b\u3002
|
|
const crisis = weighted
|
|
.filter(e => e.needValue >= 88 && e.urgency >= 24 && ((selectedEntry.priority - e.priority) <= 36 || e.action?.id === "eat_food"))
|
|
.sort((a, b) => (b.needValue - a.needValue) || (b.urgency - a.urgency) || (b.score - a.score))[0] || null;
|
|
if (crisis && crisis.needValue >= selectedEntry.needValue + 16) selectedEntry = crisis;
|
|
|
|
const samePriority = weighted.filter(e => e.priority === selectedEntry.priority);
|
|
let action = selectedEntry.action;
|
|
const sameSocial = samePriority.filter(e => e.need === "social");
|
|
const normalFightEntry = sameSocial.find(e => e.action?.id === "fight_rival");
|
|
const normalMateEntry = sameSocial.find(e => e.action?.id === "approach_mate");
|
|
if (!isCurrentBehaviorForced(tarinai) && normalFightEntry && normalMateEntry && samePriority.includes(selectedEntry)) {
|
|
const weights = socialFightMateActionWeights(tarinai);
|
|
const bucket = Math.floor((Number(world?.time || 0) || 0) / 3);
|
|
const pickFight = deterministicChance(world, "social-fight-vs-mate-weighted", weights.fightProbability, tarinai, normalFightEntry.action.id, normalMateEntry.action.id, bucket);
|
|
selectedEntry = pickFight ? normalFightEntry : normalMateEntry;
|
|
action = selectedEntry.action;
|
|
} else if (samePriority.length > 1 && samePriority.includes(selectedEntry)) {
|
|
const relationSameBand = samePriority.some(e => e.need === "social")
|
|
? samePriority.filter(e => e.need === "social")
|
|
: samePriority;
|
|
const pool = relationSameBand.length > 1 ? relationSameBand : samePriority;
|
|
const top = pool.map(e => ({
|
|
...e.action,
|
|
weight: Math.max(0.01, (Number(e.action.weight) || 1) + (e.score - e.priority * 1000) * 0.02),
|
|
}));
|
|
const picked = weightedPickAction(top) || selectedEntry.action;
|
|
action = TARINAI_ACTIONS.find(a => a.id === picked.id) || picked;
|
|
selectedEntry = weighted.find(e => e.action.id === action.id) || selectedEntry;
|
|
}
|
|
const need = action.need || "fulfill";
|
|
const conflictNeeds = conflictNeedsForChoice(action, weighted, needs);
|
|
return { action, choice: { need, tiedNeeds: [need, ...conflictNeeds], max: Number(needs?.[need] || 0) || 0, priority: selectedEntry.priority } };
|
|
}
|
|
|
|
function shouldStartNeedAction(tarinai, need, needs) {
|
|
const value = Number(needs?.[need]) || 0;
|
|
if (value <= 0.5) return false;
|
|
if (need === "food") {
|
|
const now = tarinai?.world?.time || 0;
|
|
const inMealCooldown = (tarinai?.mealCooldownUntil || 0) > now;
|
|
const hunger = Number(tarinai?.hunger || 0) || 0;
|
|
if (inMealCooldown && hunger < 82 && value < 78 && !(isCurrentBehaviorForced(tarinai))) return false;
|
|
}
|
|
if (need === "sleep" && circadianSleepPhase(tarinai?.world).night && value >= Math.max(44, needThreshold("sleep", "start") - 16)) return true;
|
|
if (value >= needThreshold(need, "start")) return true;
|
|
if ((getTarinaiBehaviorNeed(tarinai)) === need && value >= needThreshold(need, "continue")) return true;
|
|
return false;
|
|
}
|
|
|
|
function startNeedAction(tarinai, world, choice, action, reasonText, options = {}) {
|
|
const lockSeconds = Number(options.lockSeconds || actionLockSeconds(action, tarinai)) || 1.0;
|
|
setBehaviorFromAction(tarinai, action, choice, reasonText, {
|
|
...options,
|
|
lockSeconds,
|
|
minDuration: options.minDuration || Math.max(0.45, lockSeconds * 0.72),
|
|
phase: "start",
|
|
});
|
|
tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, lockSeconds);
|
|
const ran = startTarinaiAction(action, tarinai, world, options);
|
|
if (!ran) {
|
|
failTarinaiAction(action, tarinai, world, { phase: "start_failed" });
|
|
if (isCurrentBehaviorForced(tarinai)) retryForcedBehavior(tarinai, (currentTarinaiBehavior(tarinai)), "start_failed");
|
|
tarinai.actionCooldowns = tarinai.actionCooldowns || {};
|
|
tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {};
|
|
tarinai.actionCooldowns[action?.need || choice?.need || "fulfill"] = Math.max(tarinai.actionCooldowns[action?.need || choice?.need || "fulfill"] || 0, 1.4);
|
|
tarinai.actionIdCooldowns[action?.id || "unknown"] = Math.max(tarinai.actionIdCooldowns[action?.id || "unknown"] || 0, 2.2);
|
|
tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55);
|
|
clearBehavior(tarinai, getTarinaiBehaviorText(tarinai) || action?.label || "\u5f85\u3063\u3066\u3044\u308b");
|
|
tarinai.goIdle?.(action?.label || "\u5f85\u3063\u3066\u3044\u308b");
|
|
} else {
|
|
const target = tarinai.target || options.target;
|
|
setBehaviorFromAction(tarinai, action, choice, reasonText, {
|
|
...options,
|
|
target,
|
|
state: tarinai.state || action?.state || "idle",
|
|
lockSeconds,
|
|
minDuration: (currentTarinaiBehavior(tarinai))?.minDuration || Math.max(0.45, lockSeconds * 0.72),
|
|
phase: "active",
|
|
});
|
|
}
|
|
tarinai.thought = reasonText;
|
|
return Boolean(ran);
|
|
}
|
|
|
|
|
|
function continueCurrentBehavior(tarinai, world, needs, dt) {
|
|
const behavior = currentTarinaiBehavior(tarinai);
|
|
const actionId = behavior?.actionId;
|
|
if (!actionId) return false;
|
|
const action = actionById(actionId);
|
|
if (!action) return false;
|
|
if (tarinai.target && tarinai.target.dead) return false;
|
|
const timerBacked = Boolean(action.timerBacked || ["eat_food", "drink_water", "use_medicine", "fight_rival", "intimidate_enemy", "approach_mate", "birth_ritual", "panic_escape", "sunbath"].includes(action.id));
|
|
if (!timerBacked && !canStartTarinaiAction(action, tarinai, world)) return false;
|
|
const need = action.need || behavior?.need;
|
|
const value = Number(needs?.[need]) || 0;
|
|
const now = world?.time || 0;
|
|
const startedAt = Number(behavior?.startedAt ?? now) || now;
|
|
const minDuration = Number(behavior?.minDuration || actionLockSeconds(action, tarinai) * 0.72) || 0.8;
|
|
const elapsed = Math.max(0, now - startedAt);
|
|
const mustContinueByTimer = (action.id === "fight_rival" && (tarinai.fightTimer || 0) > 0.04)
|
|
|| (action.id === "birth_ritual" && (tarinai.birthRitualTimer || 0) > 0.04)
|
|
|| (action.id === "panic_escape" && (tarinai.fearTimer || 0) > 0.04 && tarinai.state === "panic")
|
|
|| (action.id === "intimidate_enemy" && (tarinai.intimidateTimer || 0) > 0.04)
|
|
|| (action.id === "sunbath" && (tarinai.sunbathTimer || 0) > 0.04)
|
|
|| (action.id === "play_seesaw" && Boolean(globalThis.TarinaiSeesawSystem?.validReservation?.(tarinai, world)))
|
|
|| ((action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04 && elapsed < 1.15);
|
|
const foodSuppressed = need === "food" && !isCurrentBehaviorForced(tarinai) && (tarinai.mealCooldownUntil || 0) > now && (tarinai.hunger || 0) < 82 && value < 78 && elapsed >= Math.min(minDuration, 1.15);
|
|
if (!foodSuppressed && (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || isCurrentBehaviorForced(tarinai))) {
|
|
tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, Math.min(0.9, Math.max(0.35, minDuration - elapsed)));
|
|
const refreshedReason = refreshBehaviorReasonText(tarinai, action);
|
|
const ranUpdate = tickTarinaiAction(action, tarinai, world, dt, needs, { phase: "continue" });
|
|
if (ranUpdate === "finished") {
|
|
finishTarinaiAction(action, tarinai, world, { phase: "finished" });
|
|
const finishedBehavior = behavior;
|
|
if (behaviorIsForced(finishedBehavior)) completeForcedBehavior(tarinai, finishedBehavior, "completed");
|
|
tarinai.actionCooldowns = tarinai.actionCooldowns || {};
|
|
tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6);
|
|
clearBehavior(tarinai, refreshedReason || action.label || "");
|
|
return false;
|
|
}
|
|
if (ranUpdate === false) {
|
|
failTarinaiAction(action, tarinai, world, { phase: "update_failed" });
|
|
if (behaviorIsForced(behavior)) retryForcedBehavior(tarinai, behavior, "update_failed");
|
|
tarinai.actionCooldowns = tarinai.actionCooldowns || {};
|
|
tarinai.actionIdCooldowns = tarinai.actionIdCooldowns || {};
|
|
const consumableAction = action.id === "eat_food" || action.id === "drink_water" || action.id === "use_medicine";
|
|
if (consumableAction) {
|
|
// \u98DF\u6599\u30FB\u6C34\u30FB\u85AC\u306F\u74B0\u5883\u5909\u5316\u304C\u901F\u3044\u306E\u3067\u3001\u5931\u6557\u6642\u3082\u77ED\u3044\u518D\u8A55\u4FA1\u3060\u3051\u306B\u3059\u308B\u3002
|
|
tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 0.35);
|
|
tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 0.45);
|
|
tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.18);
|
|
tarinai.target = null;
|
|
tarinai.targetKey = "";
|
|
} else {
|
|
tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6);
|
|
tarinai.actionIdCooldowns[action.id] = Math.max(tarinai.actionIdCooldowns[action.id] || 0, 2.4);
|
|
tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.55);
|
|
}
|
|
clearBehavior(tarinai);
|
|
return false;
|
|
}
|
|
setBehaviorFromAction(tarinai, action, { need, tiedNeeds: behavior?.tiedNeeds || [need] }, refreshedReason || behavior?.reason || action.label, {
|
|
target: tarinai.target,
|
|
state: tarinai.state || action.state || "idle",
|
|
source: behavior?.source || "need",
|
|
forced: behaviorIsForced(behavior),
|
|
priority: behavior?.priority || 0,
|
|
causeText: (behaviorIsForced(behavior) || !/\u306E\u52B9\u679C$/.test(String(behavior?.causeText || "").trim().replace(/[\u3002\uFF01\uFF1F]+$/, ""))) ? (behavior?.causeText || "") : "",
|
|
sourceReasonText: behavior?.sourceReasonText || "",
|
|
forcedReasonText: behavior?.sourceReasonText || "",
|
|
forcedRequest: behavior?.forcedRequest || null,
|
|
startedAt,
|
|
lockSeconds: behavior?.lockSeconds,
|
|
minDuration,
|
|
phase: mustContinueByTimer ? "perform" : "active",
|
|
});
|
|
tarinai.thought = behaviorText(tarinai) || refreshedReason || behavior?.label || tarinai.thought;
|
|
const needsRefresh = !tarinai.target || tarinai.target.dead || ["rest_to_recover", "use_plushie", "intimidate_enemy"].includes(action.id);
|
|
if (needsRefresh && elapsed >= Math.min(0.7, minDuration)) {
|
|
startTarinaiAction(action, tarinai, world, { phase: "refresh" });
|
|
}
|
|
return true;
|
|
}
|
|
tarinai.actionCooldowns = tarinai.actionCooldowns || {};
|
|
tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 2.8);
|
|
tarinai.behaviorSwitchCooldownUntil = Math.max(tarinai.behaviorSwitchCooldownUntil || 0, (world?.time || 0) + 0.35);
|
|
clearBehavior(tarinai);
|
|
return false;
|
|
}
|
|
|
|
function decayActionCooldowns(tarinai, dt = 0) {
|
|
if (!tarinai?.actionCooldowns) return;
|
|
const step = Math.max(0, Number(dt) || 0);
|
|
if (step <= 0) return;
|
|
for (const key of Object.keys(tarinai.actionCooldowns)) tarinai.actionCooldowns[key] = Math.max(0, (Number(tarinai.actionCooldowns[key]) || 0) - step);
|
|
if (tarinai.actionIdCooldowns) {
|
|
for (const key of Object.keys(tarinai.actionIdCooldowns)) tarinai.actionIdCooldowns[key] = Math.max(0, (Number(tarinai.actionIdCooldowns[key]) || 0) - step);
|
|
}
|
|
}
|
|
|
|
function shouldWakeFromSleep(tarinai, world, needs) {
|
|
if (!tarinai || tarinai.dead) return true;
|
|
if (tarinai.sleepDisease) return false;
|
|
const now = world?.time || 0;
|
|
const session = tarinai.sleepSession || { startedAt: now, minDuration: 8, targetEnergy: 74, maxDuration: 28 };
|
|
const started = Number(session.startedAt);
|
|
const elapsed = Math.max(0, now - (Number.isFinite(started) ? started : now));
|
|
const minSleepDuration = Math.max(2, Number(session.minDuration) || 8);
|
|
|
|
// \u660e\u78ba\u306a\u59a8\u5bb3\u3060\u3051\u306f\u6700\u4f4e\u7761\u7720\u6642\u9593\u3088\u308a\u512a\u5148\u3057\u3066\u8d77\u5e8a\u3055\u305b\u308b\u3002
|
|
if ((tarinai.hurtTimer || 0) > 0.06 || (tarinai.pokeFlashTimer || 0) > 0.08) return true;
|
|
if (findNearbyDanger(world, tarinai, 160)) return true;
|
|
|
|
// \u7a7a\u8179\u30fb\u7761\u7720\u6b32\u306e\u56de\u5fa9\u30fb\u4f53\u529b\u56de\u5fa9\u306a\u3069\u306e\u901a\u5e38\u8981\u56e0\u3067\u306f\u3001\u6700\u4f4e2\u6642\u9593\u306f\u5bdd\u7d9a\u3051\u308b\u3002
|
|
if (elapsed < minSleepDuration) return false;
|
|
|
|
const foodNeed = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
|
|
if ((tarinai.hunger || 0) >= 84 || foodNeed >= needThreshold("food", "start")) return true;
|
|
const sleepNeed = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0;
|
|
const sleepContinue = (typeof needThreshold === "function" ? needThreshold("sleep", "continue") : 34);
|
|
const restedEnough = sleepNeed <= sleepContinue + 4;
|
|
if (restedEnough) return true;
|
|
// Energy can recover faster than the explicit sleep need, especially inside
|
|
// nest containers. Do not wake and immediately re-enter while the individual
|
|
// is still classified as sleepy.
|
|
if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 74) && sleepNeed <= sleepContinue + 14) return true;
|
|
if (elapsed >= (Number(session.maxDuration) || 28) && sleepNeed <= sleepContinue + 18) return true;
|
|
return false;
|
|
}
|
|
|
|
|
|
function protectSleepSession(tarinai, world, needs) {
|
|
if (!(tarinai?.state === "sleep" || tarinai?.sleeping)) return false;
|
|
tarinai.sleeping = true;
|
|
if (!tarinai.sleepSession) {
|
|
const now = world?.time || 0;
|
|
const bedLike = tarinai.target && typeof tarinai.isSleepFurniture === "function" && tarinai.isSleepFurniture(tarinai.target);
|
|
tarinai.sleepSession = { startedAt: now, minDuration: rand(bedLike ? 8 : 7, bedLike ? 14 : 12), targetEnergy: rand(bedLike ? 70 : 64, bedLike ? 82 : 76), maxDuration: rand(bedLike ? 22 : 18, bedLike ? 34 : 28), targetId: tarinai.target?.id || null, consumesGrassBedOnWake: tarinai.target?.type === "grass_bed" };
|
|
if (tarinai.target?.type === "grass_bed") tarinai.pendingGrassBedWakeId = tarinai.target.id || tarinai.pendingGrassBedWakeId || "";
|
|
}
|
|
if (!shouldWakeFromSleep(tarinai, world, needs)) {
|
|
const step = Math.max(0.10, Math.min(1.0, Number(tarinai.aiTimer || 0.42) || 0.42));
|
|
const bedLike = tarinai.target && typeof tarinai.isSleepFurniture === "function" && tarinai.isSleepFurniture(tarinai.target);
|
|
const comfort = bedLike ? Math.max(0.5, Math.min(1.4, Number(world?.bedComfort?.(tarinai.target) || tarinai.target?.comfort || 1) || 1)) : 0.55;
|
|
const energyMax = Math.max(1, typeof tarinaiMaxEnergy === "function" ? tarinaiMaxEnergy(tarinai) : (Number(tarinai.maxEnergy) || 100));
|
|
tarinai.energy = Math.min(energyMax, (Number(tarinai.energy || 0) || 0) + step * (bedLike ? (1.2 + comfort * 1.35) : 0.65));
|
|
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { sleep: -step * (bedLike ? (0.16 + comfort * 0.18) : 0.18), safety: bedLike ? -step * 0.9 : 0 });
|
|
tarinai.thought = tarinai.thought || "\u7720\u3063\u3066\u3044\u308b";
|
|
setBehavior(tarinai, { id: "sleep", need: "sleep", label: "\u7720\u3063\u3066\u3044\u308b", reason: "\u306d\u3080\u3044\u306e\u3067\u3001\u7720\u3063\u3066\u3044\u308b\u3002", text: "\u7720\u3063\u3066\u3044\u308b", state: "sleep", tiedNeeds: ["sleep"], startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration, phase: "perform" });
|
|
tarinai.behaviorLockTimer = Math.max(tarinai.behaviorLockTimer || 0, 0.8);
|
|
const now = world?.time || 0;
|
|
if (now >= (tarinai.nextSleepBubbleAt || 0)) {
|
|
tarinai.nextSleepBubbleAt = now + rand(3.6, 5.6);
|
|
world?.spawnBubble?.(tarinai.x, tarinai.y - (tarinai.radius || 20) * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
|
|
}
|
|
return true;
|
|
}
|
|
const sleptLongEnough = (world?.time || 0) - (Number(tarinai.sleepSession?.startedAt) || 0) >= 2;
|
|
if (sleptLongEnough) tarinai.consumePendingGrassBedOnWake?.("\u76ee\u304c\u899a\u3081\u305f");
|
|
tarinai.sleeping = false;
|
|
tarinai.sleepSession = null;
|
|
clearBehavior(tarinai);
|
|
if (sleptLongEnough) applyNeedSatisfaction(tarinai, { sleep: 28, safety: 6 }, "sleep");
|
|
tarinai.goIdle?.("\u76ee\u304c\u899a\u3081\u305f");
|
|
return false;
|
|
}
|
|
|
|
function resolveNeedsCore(dt) {
|
|
decayActionCooldowns(this, dt);
|
|
const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds();
|
|
const needs = updateNeedsCached(this, this.world, dt);
|
|
synchronizeActionTextFromState(this);
|
|
if (this.state === "seek_toilet" || this.toiletUrgeTargetId) {
|
|
this.toiletUrgeTargetId = "";
|
|
this.toiletUrgeStartedAt = 0;
|
|
this.toiletUrgeDeadline = 0;
|
|
if (this.state === "seek_toilet") {
|
|
this.target = null;
|
|
this.behaviorLockTimer = 0;
|
|
this.goIdle?.("\u5f85\u3063\u3066\u3044\u308b");
|
|
}
|
|
}
|
|
if (processForcedBehaviorQueue(this, this.world, needs)) return;
|
|
if (protectSleepSession(this, this.world, needs)) return;
|
|
if (continueBuildPlan(this, this.world, dt)) return;
|
|
|
|
const lockedTargetInvalid = this.target && this.target.dead;
|
|
const lockedBehavior = currentTarinaiBehavior(this);
|
|
if ((this.behaviorLockTimer || 0) > 0 && lockedBehavior && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) {
|
|
if (continueCurrentBehavior(this, this.world, needs, dt)) return;
|
|
this.thought = lockedBehavior.reason || lockedBehavior.label || this.thought;
|
|
return;
|
|
}
|
|
|
|
if (this.birthRitualTimer > 0.04) {
|
|
const partner = this.world.liveTarinaiById?.(this.birthPartnerId) || null;
|
|
this.setActionState?.("birth_ritual", { target: partner, reason: "\u3078\u3053\u3078\u3053\u3057\u3066\u3044\u308b" });
|
|
synchronizeActionTextFromState(this);
|
|
return;
|
|
}
|
|
|
|
if (this.intimidateTimer > 0.04) {
|
|
const target = this.world.liveTarinaiById?.(this.intimidateTargetId) || null;
|
|
this.setActionState?.("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
|
|
setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reason: target?.name ? `${target.name}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", target, phase: "perform", source: "behavior" });
|
|
this.bubble("!", 1.2);
|
|
return;
|
|
}
|
|
|
|
if (this.fightTimer > 0.04) {
|
|
this.fightTargetIds = (this.fightTargetIds || []).filter(id => Boolean(this.world.liveTarinaiById?.(id)));
|
|
this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId;
|
|
let best = null, bestD = Infinity;
|
|
for (const id of this.fightTargetIds) {
|
|
const t = this.world.liveTarinaiById?.(id);
|
|
if (!t) continue;
|
|
const d = dist(this, t);
|
|
if (d < bestD) { best = t; bestD = d; }
|
|
}
|
|
const maxFightContinueDistance = Math.max(150, (this.radius || 20) + (best?.radius || 20) + 116);
|
|
if (!best || bestD > maxFightContinueDistance) {
|
|
const rival = best || (this.fightTargetId ? this.world.liveTarinaiById?.(this.fightTargetId) : null);
|
|
this.fightTimer = 0;
|
|
this.fightTargetIds = [];
|
|
this.fightTargetId = null;
|
|
this.counterAttackFromId = null;
|
|
this.fightCooldown = Math.max(this.fightCooldown || 0, CONFIG.fightCooldown + deterministicRange(this.world, "fight-lost-target-cooldown", 4, 8, this, rival));
|
|
if (rival) this.world?.markFightPairCooldown?.(this, rival, CONFIG.fightCooldown + 6);
|
|
clearForcedBehaviorQueue(this, e => e && e.id === "fight_rival");
|
|
this.setActionState?.("idle", { target: null, reason: "\u76f8\u624b\u3092\u898b\u5931\u3063\u305f\u3002", sleeping: false, clearTarget: true });
|
|
return;
|
|
}
|
|
const reasonText = best ? `${best.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002` : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b\u306e\u3067\u3001\u55a7\u5629\u3057\u3066\u3044\u308b\u3002";
|
|
this.setActionState?.("fight", { target: best, reason: reasonText });
|
|
setBehaviorText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText, target: best, phase: "perform", source: "behavior" });
|
|
this.bubble("!", 1.6);
|
|
return;
|
|
}
|
|
|
|
if (this.defeatedTimer > 0.04) {
|
|
const opponent = this.world.liveTarinaiById?.(this.defeatedById);
|
|
applyNeedShock(this, { safety: 54, health: 16 }, opponent || null);
|
|
startNeedDrivenEmergencyReaction(this, opponent || null, "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b\u3002");
|
|
if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null;
|
|
return;
|
|
} else if (this.defeatedById && this.fightTimer <= 0.04) {
|
|
this.defeatedById = null;
|
|
this.fightWinnerId = null;
|
|
}
|
|
|
|
if (this.stuckPushpinId) {
|
|
const pin = this.currentLodgedPin?.() || null;
|
|
const pinBehavior = this.currentLodgedPinBehavior?.() || null;
|
|
if (pin && pinBehavior?.panicOnAttach) {
|
|
this.needShock = this.needShock || {};
|
|
this.needShock.safety = Math.max(this.needShock.safety || 0, 42);
|
|
this.needShock.health = Math.max(this.needShock.health || 0, 46);
|
|
this.lastNeedShockBreaker = pin;
|
|
this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24);
|
|
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8);
|
|
const now = this.world?.time || 0;
|
|
if (now >= (this.nextPinPanicBubbleAt || 0)) {
|
|
this.bubble("!!", 0.9, "rgba(168,72,72,0.82)");
|
|
this.nextPinPanicBubbleAt = now + 1.6;
|
|
}
|
|
this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
|
|
}
|
|
if (!pin || pin.pinState !== "lodged") this.stuckPushpinId = null;
|
|
}
|
|
|
|
const breaker = this.lastNeedShockBreaker || null;
|
|
const safetyDelta = (this.needs?.safety || 0) - (beforeNeeds.safety || 0);
|
|
const healthDelta = (this.needs?.health || 0) - (beforeNeeds.health || 0);
|
|
const fulfillDelta = (this.needs?.fulfill || 0) - (beforeNeeds.fulfill || 0);
|
|
const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50;
|
|
if (breaker && shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) {
|
|
if (startNeedDrivenEmergencyReaction(this, breaker, "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002")) return;
|
|
clearTarinaiBehavior(this, { reason: this.thought });
|
|
this.lastNeedShockBreaker = null;
|
|
return;
|
|
}
|
|
|
|
if (continueCurrentBehavior(this, this.world, needs, dt)) return;
|
|
|
|
if ((this.behaviorSwitchCooldownUntil || 0) > (this.world?.time || 0) && !this.hasForcedBehavior?.()) {
|
|
this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 0.25);
|
|
if (!(currentTarinaiBehavior(this)) && !["panic", "fight", "birth_ritual", "intimidate", "sleep"].includes(this.state)) this.setActionState?.("idle", { target: null, reason: this.thought || "\u5c11\u3057\u69d8\u5b50\u3092\u898b\u3066\u3044\u308b", sleeping: false, clearTarget: true });
|
|
return;
|
|
}
|
|
|
|
const foodNeedForLeisure = Number(needs?.food || 0) || 0;
|
|
const safetyNeedForLeisure = Number(needs?.safety || 0) || 0;
|
|
const sleepNeedForLeisure = Number(needs?.sleep || 0) || 0;
|
|
const sunbathActionForLeisure = actionById("sunbath");
|
|
if (sunbathActionForLeisure
|
|
&& this.canStartSunbath?.({ leisure: true })
|
|
&& foodNeedForLeisure < 58
|
|
&& safetyNeedForLeisure < 50
|
|
&& sleepNeedForLeisure < 68
|
|
&& Math.random() < Math.max(0.010, dt * 0.055)) {
|
|
const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathActionForLeisure) };
|
|
startNeedAction(this, this.world, choice, sunbathActionForLeisure, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathActionForLeisure), lockSeconds: 6.0 });
|
|
return;
|
|
}
|
|
|
|
const picked = choosePriorityNeedAction(needs, this, this.world);
|
|
if (!picked?.action) {
|
|
const sunbathAction = actionById("sunbath");
|
|
if (sunbathAction && this.canStartSunbath?.({ leisure: true }) && Math.random() < Math.max(0.006, dt * 0.08)) {
|
|
const choice = { need: "fulfill", tiedNeeds: ["fulfill"], max: Number(needs?.fulfill || 0) || 0, priority: actionPriority(sunbathAction) };
|
|
startNeedAction(this, this.world, choice, sunbathAction, "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", { leisure: true, priority: actionPriority(sunbathAction), lockSeconds: 6.0 });
|
|
return;
|
|
}
|
|
const choice = chooseTopNeedRandom(needs, this, this.world);
|
|
setBehavior(this, { id: "idle", need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], label: "\u5f85\u3063\u3066\u3044\u308b", reason: "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b\u3002", state: "idle", phase: "idle", startedAt: this.world?.time || 0 });
|
|
clearBehavior(this);
|
|
this.behaviorLockTimer = Math.max(this.behaviorLockTimer || 0, 3.0);
|
|
this.goIdle?.("\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u3044\u308b");
|
|
return;
|
|
}
|
|
const action = picked.action;
|
|
const choice = picked.choice;
|
|
const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action, this, this.world);
|
|
startNeedAction(this, this.world, choice, action, reasonText, { priority: choice.priority ?? actionPriority(action) });
|
|
return;
|
|
}
|
|
|
|
|
|
(function (global) {
|
|
const Tarinai = global.Tarinai;
|
|
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js");
|
|
if (!global.TarinaiNeedPlannerCoreSystem) {
|
|
global.TarinaiNeedPlannerCoreSystem = Object.freeze({
|
|
updateOne(tarinai, dt = 0) {
|
|
if (!tarinai || tarinai.dead) return false;
|
|
resolveNeedsCore.call(tarinai, dt);
|
|
return true;
|
|
},
|
|
});
|
|
}
|
|
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
|
|
forceBehavior(actionId, options = {}) {
|
|
return queueForcedTarinaiBehavior(this, actionId, options);
|
|
},
|
|
hasForcedBehavior(actionId = "") {
|
|
const key = String(actionId || "");
|
|
const active = typeof currentForcedBehaviorRequest === "function" ? currentForcedBehaviorRequest(this) : null;
|
|
if (active && (!key || active.id === key)) return true;
|
|
return forcedBehaviorQueueOf(this).some(entry => entry && (!key || entry.id === key));
|
|
},
|
|
|
|
hasLoveMochiEffect() {
|
|
return (this.loveMochiTimer || 0) > 0.04;
|
|
},
|
|
|
|
hasFightMochiEffect() {
|
|
return (this.fightMochiTimer || 0) > 0.04;
|
|
},
|
|
|
|
zunchiSlaveSpriteId() {
|
|
if (!this.zunchiSlaveSpriteVariant) this.zunchiSlaveSpriteVariant = Math.random() < 0.5 ? "zunchi_slave" : "zunchi_slave_alt";
|
|
return this.zunchiSlaveSpriteVariant;
|
|
},
|
|
|
|
becomeZunchiSlave(options = {}) {
|
|
if (this.isZunchiSlave) {
|
|
this.zunchiSlaveLocked = true;
|
|
applyZunchiSlavePersonality(this, { record: false, birth: !!options.birth });
|
|
this.visibleSpriteId = this.zunchiSlaveSpriteId();
|
|
return false;
|
|
}
|
|
this.formerName = this.formerName || this.name;
|
|
this.isZunchiSlave = true;
|
|
this.zunchiSlaveLocked = true;
|
|
this.name = "\u305a\u3093\u3061\u3069\u308c\u3044";
|
|
this.birthRitualTimer = 0;
|
|
this.birthPartnerId = null;
|
|
this.birthRitualLeader = false;
|
|
this.birthRitualRole = 0;
|
|
this.target = null;
|
|
this.reproductionTimer = options.birth ? Math.max(0, this.reproductionTimer || 0) : Math.max(this.reproductionTimer || 0, CONFIG.reproductionCooldown + rand(8, 18));
|
|
if (!options.birth) this.loveMochiTimer = 0;
|
|
this.addStress(options.birth ? 0 : 14, { threshold: 8 });
|
|
this.hunger = Math.max(this.hunger, 40);
|
|
applyZunchiSlavePersonality(this, { birth: !!options.birth });
|
|
this.visibleSpriteId = this.zunchiSlaveSpriteId();
|
|
this.spriteLockUntil = 0;
|
|
this.recordChangeCause?.("\u305a\u3093\u3061\u3069\u308c\u3044\u5316", "\u72b6\u614b");
|
|
return true;
|
|
},
|
|
|
|
rawSpriteId() {
|
|
if (this.dead) return this.variantSprite("fear");
|
|
if (this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04) return this.sunbathSpriteId ? this.sunbathSpriteId() : "sunbath_1";
|
|
if (this.sleepDisease) return this.variantSprite("sleep");
|
|
if (this.isZunchiSlave) return this.zunchiSlaveSpriteId();
|
|
const low = this.lowHealthSprite();
|
|
if (low) return low;
|
|
if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back";
|
|
if (this.explosionDisease && Math.sin((this.world?.time || 0) * 8) > 0.15) return "pokan";
|
|
if (this.fightDisease) return "stress_dizzy";
|
|
if ((this.oshiriByoZunchiStock || 0) >= 6) return this.variantSprite ? this.variantSprite("hurt") : "weak";
|
|
const foodNeedForSprite = Number(this.needRaw?.food ?? this.needs?.food ?? this.hunger) || 0;
|
|
const socialNeedForSprite = Number(this.needRaw?.social ?? this.needs?.social ?? 0) || 0;
|
|
const fulfillNeedForSprite = Number(this.needRaw?.fulfill ?? this.needs?.fulfill ?? 0) || 0;
|
|
if (this.type === "cry" && (this.stress > 78 || foodNeedForSprite > 92 || this.energy < 12 || (this.needs?.safety || 0) >= 90)) return "cry";
|
|
if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat";
|
|
if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat";
|
|
if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt");
|
|
if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual";
|
|
if (foodNeedForSprite >= 72) return "hungry_70";
|
|
if (this.defeatedTimer > 0.04) return this.variantSprite("flee");
|
|
if (this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate");
|
|
if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear");
|
|
if (this.state === "fight" || this.state === "seek_enemy" || this.fightTimer > 0.04) return "angry";
|
|
if (this.stress > 62) return this.variantSprite("stress");
|
|
if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep");
|
|
if (this.surpriseTimer > 0.08) return "pokan";
|
|
if (this.state === "cursor_friend") return this.applyTemperatureSprite?.("smile") || "smile";
|
|
if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode;
|
|
if (this.state === "seek_food") return "drool";
|
|
const seekTempSprite = (this.state === "seek_temperature") ? (this.temperatureDiscomfortSprite?.(this.goodMode || this.variantSprite("normal")) || "") : "";
|
|
if (seekTempSprite) return seekTempSprite;
|
|
if (this.stress < 24 && foodNeedForSprite < 60 && socialNeedForSprite < 60 && fulfillNeedForSprite < 60) return this.applyTemperatureSprite?.(this.variantSprite("low_stress")) || this.variantSprite("low_stress");
|
|
if (this.stress < 44 && foodNeedForSprite < 60) return this.applyTemperatureSprite?.(this.goodMode) || this.goodMode;
|
|
if (foodNeedForSprite > 78 || socialNeedForSprite > 84) return "angry";
|
|
return this.applyTemperatureSprite?.(this.goodMode || this.variantSprite("normal")) || this.goodMode || this.variantSprite("normal");
|
|
},
|
|
|
|
spriteId() {
|
|
const now = this.world?.time || 0;
|
|
const sunbathActive = this.state === "sunbath" || (this.sunbathTimer || 0) > 0.04;
|
|
if (sunbathActive) {
|
|
this.preloadSunbathSprites?.();
|
|
const sunbathId = this.sunbathSpriteId ? this.sunbathSpriteId() : (String(this.sunbathSpriteVariant || "").startsWith("sunbath") ? this.sunbathSpriteVariant : "sunbath_1");
|
|
this.visibleSpriteId = sunbathId;
|
|
this.spriteLockUntil = 0;
|
|
return sunbathId;
|
|
}
|
|
if (String(this.visibleSpriteId || "").startsWith("sunbath")) {
|
|
this.visibleSpriteId = "";
|
|
this.spriteLockUntil = 0;
|
|
}
|
|
const next = this.rawSpriteId();
|
|
const isCurrentIntimidator = this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04;
|
|
const isCurrentIntimidated = !isCurrentIntimidator && this.intimidatedTimer > 0.04;
|
|
const forceImmediate = String(next || "").startsWith("zunchi_slave") || next === "birth_ritual" || next === "intimidate" || isCurrentIntimidator || isCurrentIntimidated || (this.visibleSpriteId === "intimidate" && next !== "intimidate");
|
|
if (!this.visibleSpriteId || forceImmediate) {
|
|
this.visibleSpriteId = next;
|
|
this.spriteLockUntil = forceImmediate ? now : now + 1.0;
|
|
return next;
|
|
}
|
|
if (next !== this.visibleSpriteId && now >= this.spriteLockUntil) {
|
|
this.visibleSpriteId = next;
|
|
this.spriteLockUntil = now + 1.0;
|
|
}
|
|
return this.visibleSpriteId;
|
|
},
|
|
|
|
isAwakeCursorFriendly() {
|
|
if (this.dead || this.sleepDisease || this.sleeping || this.state === "sleep" || this.state === "seek_bed") return false;
|
|
if (["fight", "panic", "cursor_enemy", "hurt", "defeated", "fight_sick"].includes(this.state)) return false;
|
|
if ((this.fearTimer || 0) > 0.18 || (this.intimidatedTimer || 0) > 0.04 || (this.intimidateTimer || 0) > 0.04) return false;
|
|
if (this.stress > 64 || (this.needs?.safety || 0) >= 70) return false;
|
|
return (this.needs?.social || 0) < 70 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1);
|
|
},
|
|
|
|
sunbathSpriteCandidates() {
|
|
const ids = SPRITES.map(s => s?.id).filter(id => typeof id === "string" && id.startsWith("sunbath"));
|
|
const ordered = ids.length ? ids : ["sunbath_1", "sunbath_2", "sunbath_3"];
|
|
return ordered.slice().sort((a, b) => {
|
|
const an = Number(String(a).match(/(\d+)$/)?.[1] || 0);
|
|
const bn = Number(String(b).match(/(\d+)$/)?.[1] || 0);
|
|
return an - bn || String(a).localeCompare(String(b));
|
|
});
|
|
},
|
|
|
|
|
|
preloadSunbathSprites() {
|
|
const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"];
|
|
for (const id of ids) ensureImage(id);
|
|
},
|
|
|
|
normalizeSunbathSpriteId(value = "") {
|
|
if (typeof value === "number" && Number.isFinite(value)) return `sunbath_${Math.max(1, Math.floor(value))}`;
|
|
const str = String(value || "");
|
|
if (/^sunbath_\d+$/.test(str) || str.startsWith("sunbath")) return str;
|
|
return "";
|
|
},
|
|
|
|
isSunbathSpriteReady(id = "") {
|
|
if (!id) return false;
|
|
return isImageReady(id);
|
|
},
|
|
|
|
randomSunbathSpriteId(exclude = "", options = {}) {
|
|
const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"];
|
|
if (!ids.length) return "sunbath_1";
|
|
const normalizedExclude = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(exclude) : String(exclude || "");
|
|
let candidates = ids;
|
|
if (options.readyOnly) {
|
|
const ready = ids.filter(id => this.isSunbathSpriteReady ? this.isSunbathSpriteReady(id) : true);
|
|
if (ready.length) candidates = ready;
|
|
else return (this._lastRenderableSunbathSpriteId && ids.includes(this._lastRenderableSunbathSpriteId))
|
|
? this._lastRenderableSunbathSpriteId
|
|
: ((normalizedExclude && ids.includes(normalizedExclude)) ? normalizedExclude : (ids[0] || "sunbath_1"));
|
|
}
|
|
const choices = candidates.length > 1 ? candidates.filter(id => id !== normalizedExclude) : candidates;
|
|
return choices[Math.floor(Math.random() * choices.length)] || candidates[0] || ids[0] || "sunbath_1";
|
|
},
|
|
|
|
sunbathSpriteId() {
|
|
const ids = this.sunbathSpriteCandidates ? this.sunbathSpriteCandidates() : ["sunbath_1", "sunbath_2", "sunbath_3"];
|
|
let current = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(this.sunbathSpriteVariant) : String(this.sunbathSpriteVariant || "");
|
|
if (!current || !ids.includes(current)) {
|
|
this.sunbathSpriteVariant = this.randomSunbathSpriteId ? this.randomSunbathSpriteId("", { readyOnly: true }) : "sunbath_1";
|
|
current = this.normalizeSunbathSpriteId ? this.normalizeSunbathSpriteId(this.sunbathSpriteVariant) : String(this.sunbathSpriteVariant || "sunbath_1");
|
|
}
|
|
if (!this.isSunbathSpriteReady?.(current)) {
|
|
const last = this._lastRenderableSunbathSpriteId;
|
|
if (last && ids.includes(last) && this.isSunbathSpriteReady?.(last)) {
|
|
this.sunbathSpriteVariant = last;
|
|
current = last;
|
|
} else {
|
|
const ready = ids.find(id => this.isSunbathSpriteReady?.(id));
|
|
if (ready) {
|
|
this.sunbathSpriteVariant = ready;
|
|
current = ready;
|
|
}
|
|
}
|
|
}
|
|
return current || "sunbath_1";
|
|
},
|
|
|
|
isSunbathTime() {
|
|
const weather = this.world?.weather || "";
|
|
const weatherOk = weather === "sunny" || weather === "cloudy";
|
|
const hour = this.world?.hourOfDay ? this.world.hourOfDay() : ((((this.world?.time || 0) / Math.max(1, CONFIG.dayLength || 120)) * 24) % 24);
|
|
return weatherOk && hour >= 6 && hour <= 13.5;
|
|
},
|
|
|
|
canStartSunbath(options = {}) {
|
|
if (this.dead || this.sleeping) return false;
|
|
if (!Number.isFinite(this.x) || !Number.isFinite(this.y)) return false;
|
|
if (!Number.isFinite(this.world?.w) || !Number.isFinite(this.world?.h)) return false;
|
|
if ((this.entryTimer || 0) > 0.04) return false;
|
|
if (!this.isSunbathTime()) return false;
|
|
const parasolSystem = globalThis.TarinaiParasolSystem || globalThis.TarinaiRainShelterSystem;
|
|
if (parasolSystem?.blocksWeatherAt?.(this.world, this.x, this.y)) return false;
|
|
const currentFelt = this.world?.feltTemperatureFor?.(this) ?? this.world?.temperatureAt?.(this.x, this.y, this) ?? this.feltTemperature ?? this.tempComfort ?? (CONFIG.standardTemperature ?? 15);
|
|
if (Number.isFinite(Number(currentFelt)) && Number(currentFelt) >= 22) return false;
|
|
if ((this.sunbathCooldown || 0) > 0) return false;
|
|
if (this.fightDisease) return false;
|
|
if (this.stuckPushpinId || this.currentLodgedPin?.()) return false;
|
|
if (["fight", "panic", "cursor_enemy", "eat", "seek_food", "seek_water", "seek_bed", "sleep", "birth_ritual", "ant_attack", "intimidate", "ant_intimidate"].includes(this.state)) return false;
|
|
if (this.hunger > (options.leisure ? 62 : 72) || this.energy < (options.leisure ? 24 : 16)) return false;
|
|
const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0;
|
|
const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease;
|
|
if (sick && healthNeed >= 34) return true;
|
|
return options.leisure ? healthNeed < needThreshold("health", "start") : healthNeed >= Math.max(38, needThreshold("health", "start") - 12);
|
|
},
|
|
|
|
tryRecoverBySunbath() {
|
|
if (this.sunbathRecoveryChecked) return false;
|
|
this.sunbathRecoveryChecked = true;
|
|
const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease;
|
|
if (!sick || Math.random() >= 0.25) return false;
|
|
let healed = false;
|
|
if (this.zunchiDisease && this.recoverZunchiDisease) healed = this.recoverZunchiDisease("\u65e5\u5149\u6d74\u3067\u305a\u3093\u3061\u75c5\u304c\u6cbb\u3063\u305f") || healed;
|
|
if (this.sleepDisease && this.recoverSleepDisease) healed = this.recoverSleepDisease("\u65e5\u5149\u6d74\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f") || healed;
|
|
if (this.explosionDisease && this.recoverExplosionDisease) healed = this.recoverExplosionDisease("\u65e5\u5149\u6d74\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f") || healed;
|
|
if (healed) this.world?.log?.(`${this.name}\u306f\u65e5\u5149\u6d74\u3067\u75c5\u6c17\u304c\u8efd\u304f\u306a\u3063\u305f\u3002`, "event", { participants: [this] });
|
|
return healed;
|
|
},
|
|
|
|
finishSunbath() {
|
|
if (this.sunbathFinishedAt === this.world?.time) return false;
|
|
this.sunbathFinishedAt = this.world?.time || 0;
|
|
applyNeedSatisfaction(this, { health: 56, safety: 6, fulfill: 8 }, "sunbath");
|
|
this.zunchiStain = 0;
|
|
this.tryRecoverBySunbath();
|
|
this.sunbathCooldown = Math.max(this.sunbathCooldown || 0, rand(20, 38));
|
|
if ((this.world?.time || 0) >= (this.nextBubbleAt || 0)) this.bubble("\u307d\u304b\u3063", 3.4, "rgba(112,86,36,0.78)");
|
|
this.sunbathLeisure = false;
|
|
return true;
|
|
},
|
|
|
|
startSunbath(options = {}) {
|
|
if (!this.canStartSunbath(options)) return false;
|
|
this.sunbathLeisure = Boolean(options.leisure);
|
|
const pad = Math.max(28, CONFIG.worldPadding || 30);
|
|
const safeW = Math.max(pad * 2 + 1, Number(this.world?.w || 1000) || 1000);
|
|
const safeH = Math.max(pad * 2 + 1, Number(this.world?.h || 720) || 720);
|
|
const startX = Number.isFinite(this.x) ? this.x : (Number.isFinite(this._lastValidX) ? this._lastValidX : pad);
|
|
const startY = Number.isFinite(this.y) ? this.y : (Number.isFinite(this._lastValidY) ? this._lastValidY : pad);
|
|
this.x = clamp(startX, pad, safeW - pad);
|
|
this.y = clamp(startY, pad, safeH - pad);
|
|
this.sunbathAnchorX = this.x;
|
|
this.sunbathAnchorY = this.y;
|
|
this._lastSunbathDrawX = this.x;
|
|
this._lastSunbathDrawY = this.y;
|
|
this._lastValidX = this.x;
|
|
this._lastValidY = this.y;
|
|
this.entryTimer = 0;
|
|
const reason = options.leisure ? "\u5c11\u3057\u843d\u3061\u7740\u3044\u3066\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b" : "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b";
|
|
this.setActionState?.("sunbath", { target: null, reason, sleeping: false });
|
|
this.preloadSunbathSprites?.();
|
|
this.sunbathTimer = Math.max(6, (CONFIG.dayLength || 120) / 12);
|
|
this.sunbathCooldown = 0;
|
|
this.sunbathFrameTimer = 0.5;
|
|
this.sunbathSpriteVariant = this.randomSunbathSpriteId ? this.randomSunbathSpriteId("", { readyOnly: true }) : `sunbath_${1 + Math.floor(Math.random() * 3)}`;
|
|
this._activeSunbathSpriteId = this.sunbathSpriteId ? this.sunbathSpriteId() : this.sunbathSpriteVariant;
|
|
this._lastRenderableSunbathSpriteId = this._activeSunbathSpriteId;
|
|
this.visibleSpriteId = this._activeSunbathSpriteId;
|
|
this.spriteLockUntil = 0;
|
|
this.sunbathRecoveryChecked = false;
|
|
this.vx = 0;
|
|
this.vy = 0;
|
|
setBehaviorText(this, { need: options.leisure ? "fulfill" : "health", subNeed: "sunbath", actionId: "sunbath", actionLabel: "\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b", reason: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002" : "\u8abf\u5b50\u304c\u60aa\u3044\u306e\u3067\u3001\u65e5\u5149\u6d74\u3057\u3066\u3044\u308b\u3002", target: null, phase: "perform", source: "behavior", causeText: options.leisure ? "\u4f59\u88d5\u304c\u3042\u308b" : "" });
|
|
if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("\u307d\u304b\u3063", 4.0, "rgba(112,86,36,0.78)");
|
|
return true;
|
|
},
|
|
|
|
bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)", options = {}) {
|
|
if (!options.force && this.world.time < this.nextBubbleAt) return false;
|
|
this.nextBubbleAt = this.world.time + cooldown;
|
|
this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color);
|
|
return true;
|
|
},
|
|
|
|
mouthPosition(target = null) {
|
|
const dir = target ? Math.sign((target.x ?? this.x) - this.x) || this.facingDir() : this.facingDir();
|
|
return {
|
|
x: this.x + dir * this.radius * 0.40 - this.radius * 0.34,
|
|
y: this.y - this.radius * 0.28,
|
|
};
|
|
},
|
|
|
|
isSleepFurniture(target) {
|
|
return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box" || target.type === "pipe"))));
|
|
},
|
|
sleepSpotFor(bed) {
|
|
if (!bed) return { x: this.x, y: this.y };
|
|
if (bed.type === "nest_box" || bed.type === "pipe") {
|
|
if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this);
|
|
if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed);
|
|
return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) };
|
|
}
|
|
const angle = stableUnit(this.familyKey || this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2;
|
|
const baseRing = bed.r * 0.52;
|
|
const ring = baseRing + stableUnit(this.familyKey || this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95;
|
|
return {
|
|
x: clamp(bed.x + Math.cos(angle) * ring, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding),
|
|
y: clamp(bed.y + Math.sin(angle) * ring * 0.56 - this.radius * 0.10, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding),
|
|
};
|
|
},
|
|
|
|
desiredFacingDir() {
|
|
if (this.state === "sleep") return this.sleepFacing || this.facing || 1;
|
|
if (this.target && Number.isFinite(this.target.x)) return Math.sign(this.target.x - this.x) || this.facing || 1;
|
|
if (Math.abs(this.vx) > 2.2) return Math.sign(this.vx);
|
|
return this.facing || 1;
|
|
},
|
|
|
|
facingDir() {
|
|
return this.visualFacing || this.facing || 1;
|
|
},
|
|
|
|
updateFacing() {
|
|
const desired = this.desiredFacingDir();
|
|
const now = this.world?.time || 0;
|
|
this.facing = desired;
|
|
if (!this.visualFacing) {
|
|
this.visualFacing = desired;
|
|
this.facingLockUntil = now + 1.0;
|
|
return;
|
|
}
|
|
if (Math.sign(desired) !== Math.sign(this.visualFacing) && now >= (this.facingLockUntil || 0)) {
|
|
this.visualFacing = Math.sign(desired) || this.visualFacing || 1;
|
|
this.facingLockUntil = now + 1.0;
|
|
}
|
|
},
|
|
|
|
reactToFreshZunchi(dt) {
|
|
if (this.isZunchiSlave) return;
|
|
if (this.state === "panic" || this.fearTimer > 0.2) return;
|
|
const z = this.world.nearest(this, ["zunchi"], 90);
|
|
if (!z || z.stage !== "fresh") return;
|
|
const d = dist(this, z);
|
|
const push = clamp(1 - d / 90, 0, 1);
|
|
applyNeedShock(this, { safety: push * dt * 6 });
|
|
if (push > 0.2) this.adjustPersonality("neuroticism", -dt * push * 0.0035, "after repeated contact with poop.");
|
|
this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40;
|
|
this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40;
|
|
if (push > 0.35 && (this.state === "idle" || !this.thought)) this.thought = "\u65b0\u9bae\u306a\u305a\u3093\u3061\u3092\u907f\u3051\u3066\u3044\u308b";
|
|
},
|
|
|
|
resolveNeeds(dt) {
|
|
return global.TarinaiNeedPlannerSystem.updateOne(this, dt);
|
|
},
|
|
|
|
dropPoopNow(source = "normal") {
|
|
const threshold = NORMAL_POOP_MEAL_THRESHOLD || 3;
|
|
if ((Number(this.digest) || 0) + 1e-6 < threshold) return false;
|
|
this.digest = Math.max(0, (Number(this.digest) || 0) - threshold);
|
|
this.poopCount = (this.poopCount || 0) + 1;
|
|
const side = this.facingDir();
|
|
const backX = this.x - side * this.radius * rand(0.72, 0.96);
|
|
const backY = this.y + this.radius * rand(0.25, 0.48);
|
|
this.world.spawnZunchi(backX, backY, this);
|
|
this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)", { force: true });
|
|
if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`, null, { participants: [this] });
|
|
return true;
|
|
},
|
|
|
|
makePoop(force = 1) {
|
|
const add = Math.max(0, Number(force) || 0);
|
|
if (add <= 0) return false;
|
|
const threshold = NORMAL_POOP_MEAL_THRESHOLD || 3;
|
|
this.digest = Math.max(0, Number(this.digest) || 0) + add;
|
|
if (this.digest + 1e-6 < threshold) return false;
|
|
if (this.hasLodgedPinEffect?.("blocksZunchi")) {
|
|
this.digest = Math.max(0, this.digest - threshold);
|
|
this.oshiriByoZunchiStock = Math.min(18, Math.max(0, this.oshiriByoZunchiStock || 0) + 1);
|
|
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u75c5\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b";
|
|
if ((this.oshiriByoZunchiStock || 0) >= 6) applyNeedShock(this, { health: 8 });
|
|
return false;
|
|
}
|
|
return this.dropPoopNow?.("normal") || false;
|
|
},
|
|
|
|
interactWithItems(dt) {
|
|
return window.TarinaiItemInteractionSystem.updateOne(this, dt);
|
|
}
|
|
}));
|
|
})(typeof window !== "undefined" ? window : globalThis);
|
|
|
|
global.TarinaiNeedsRuntime = Object.freeze({
|
|
updateNeeds,
|
|
startNeedAction,
|
|
});
|
|
})(typeof window !== "undefined" ? window : globalThis);
|