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

366 lines
19 KiB
JavaScript

"use strict";
// Food/item/structure targeting helpers for Tarinai need actions.
function effectiveFoodTypeForItem(item) {
if (!item) return "";
return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || "");
}
function isDuplicatorFoodSource(item) {
return !!item && item.type === "duplicator";
}
const NORMAL_POOP_MEAL_THRESHOLD = 4;
function normalPoopUnitsFor(foodType = "", amount = 0, options = {}) {
const type = String(foodType || "");
if (type === "laxative" || type === "first_aid" || options.laxative === true) return 0;
if (isServingFoodType(type)) return 1;
const value = Math.max(0, Number(amount) || 0);
return value / 18;
}
function effectiveFoodRoleMatches(item, role) {
const type = effectiveFoodTypeForItem(item);
if (!type) return false;
if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(type) || (isServingFoodType(type));
if (role === "drink") return type === "water" || type === "zunda_juice";
if (role === "medicine") return type === "sweet" || type === "water" || type === "zunda_juice" || ["sleep_drug", "slave_chain", "golden_crown"].includes(type) || (isParamEffectItemType(type));
return false;
}
function roleMatches(item, role) {
if (!item || item.dead) return false;
if (item.amount != null && item.amount <= 0 && item.type !== "duplicator") return false;
if (item.type === "duplicator") return globalThis.TarinaiSignalSystem?.powerOverride?.(item) !== false && effectiveFoodRoleMatches(item, role);
if (item.roles?.[role]) return true;
if (role === "food" || role === "drink" || role === "medicine") return effectiveFoodRoleMatches(item, role);
if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box" || item.type === "pipe" || item.type === "grass_bed";
if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "splat"].includes(item.type);
if (role === "grassMaterial") return item.type === "grass";
return false;
}
function foodInteractionPoint(item) {
if (!item) return { x: 0, y: 0 };
if (item.type === "duplicator") return { x: item.x + (item.r || 34) * 0.44, y: item.y + (item.r || 34) * 0.18 };
return { x: item.x, y: item.y };
}
function foodInteractionDistance(tarinai, item) {
const p = foodInteractionPoint(item);
const mouth = tarinai?.mouthPosition?.(item) || { x: tarinai?.x || 0, y: tarinai?.y || 0 };
return distXY(mouth.x, mouth.y, p.x, p.y);
}
function foodInteractionReach(tarinai, item, role = "food") {
if (item?.type === "slave_chain" || item?.type === "golden_crown") {
// \u738B\u51A0\u30FB\u9396\u306F\u7DDA\u753B/\u6A2A\u9577\u306E\u5916\u89B3\u306A\u306E\u3067\u3001\u901A\u5E38\u306E\u98DF\u7269\u5224\u5B9A\u3088\u308A\u898B\u305F\u76EE\u57FA\u6E96\u3067\u5E83\u304F\u53D6\u308B\u3002
// \u4E2D\u5FC3\u70B9\u304C\u5C11\u3057\u96E2\u308C\u3066\u3044\u3066\u3082\u3001\u305F\u308A\u306A\u3044\u306E\u4F53\u8868\u3068\u30A2\u30A4\u30C6\u30E0\u5916\u89B3\u304C\u89E6\u308C\u305F\u6642\u70B9\u3067\u53D6\u5F97\u3067\u304D\u308B\u3002
const bodyR = Math.max(16, Number(tarinai?.radius || 20) || 20);
const itemR = Math.max(item.type === "slave_chain" ? 15 : 16, Number(item?.r || 0) || 0);
const visualPad = item.type === "slave_chain" ? 14 : 12;
return Math.max(46, bodyR * 0.94 + itemR * 1.28 + visualPad);
}
if (item?.type === "duplicator") return Math.max(30, (tarinai?.radius || 20) * 0.72 + (item?.r || 34) * 0.36);
if (role === "drink") return Math.max(16, (tarinai?.radius || 20) * 0.58 + (item?.r || 12) * 0.40);
return Math.max(13, (tarinai?.radius || 20) * 0.50 + (item?.r || 12) * 0.28);
}
function foodPriorityRank(tarinai, item) {
const type = effectiveFoodTypeForItem(item);
if (!type) return -1;
const need = getTarinaiBehaviorNeed(tarinai);
const socialNeed = Number(tarinai?.needRaw?.social ?? tarinai?.needs?.social ?? 0) || 0;
const healthNeed = Number(tarinai?.needRaw?.health ?? tarinai?.needs?.health ?? 0) || 0;
if (tarinai?.isZunchiSlave && type === "sweet") return -1;
if (tarinai?.isZunchiSlave && type === "zunchi") return 6200;
if (type === "sweet") return 4300 + Math.min(120, Math.max(0, healthNeed - 40) * 3);
if (type === "water") return 3650 + Math.max(0, healthNeed - 42) * 7;
if (type === "zunda_juice") return 3720 + Math.max(0, healthNeed - 42) * 6;
if (type === "love_mochi") return 3340 + Math.min(120, Math.max(0, socialNeed - 45) * 3);
if (type === "fight_mochi") return 3280 + Math.min(90, Math.max(0, Number(tarinai?.fightMochiTimer || 0)) * 3);
if (type === "sleep_drug") return need === "sleep" ? 3380 : 3220;
if (isParamEffectItemType(type)) return 3260 + Math.min(120, Math.max(0, healthNeed - 45) * 3);
if (type === "food" || type === "ant_corpse") return 3300;
if (isServingFoodType(type)) return 3300;
if (type === "grass") {
const hunger = Number(tarinai?.hunger || 0) || 0;
const foodNeed = Number(tarinai?.needRaw?.food ?? tarinai?.needs?.food ?? 0) || 0;
return 2200 + Math.min(260, Math.max(0, hunger - 64) * 3 + Math.max(0, foodNeed - 58) * 2);
}
if (type === "zunchi") return 900 + (tarinai?.isZunchiSlave ? 180 : 0);
return -1;
}
function foodPriorityScore(tarinai, item, distance = 0) {
let rank = foodPriorityRank(tarinai, item);
if (rank < 0) return -Infinity;
const hunger = Number(tarinai?.hunger || 0) || 0;
if (item?.type === "zunchi") {
if (zunchiEatingBlocked(tarinai, item, tarinai?.world)) return -Infinity;
if (!tarinai?.isZunchiSlave) rank -= 1400;
if (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 104) rank -= 1200;
}
if (item?.type === "duplicator") rank += 420;
return rank - Math.max(0, Number(distance) || 0) * 0.65;
}
function hasBetterFoodThanZunchiNearby(tarinai, world, maxDist = 180) {
if (!tarinai || !world) return false;
if (tarinai.isZunchiSlave) return false;
const source = world.nearbyFood?.(tarinai.x, tarinai.y, maxDist, true) || world.nearbyItems?.(tarinai.x, tarinai.y, maxDist, true) || world.items || [];
for (const it of source) {
if (!it || it.dead) continue;
const type = effectiveFoodTypeForItem(it);
if (!type || type === "zunchi") continue;
if (!roleMatches(it, "food")) continue;
if (tarinai.shouldAvoidTarget?.(it)) continue;
if (tarinaiTargetBlockedByObstacle(world, tarinai, it, "food")) continue;
if (it.type === "grass" && (it.amount || 0) <= 0) continue;
if (it.type === "duplicator" || isServingFoodType(type) || ["sweet", "food", "zunda_juice"].includes(type)) {
if (typeof tarinai.foodItemHasServingLeft === "function" && !tarinai.foodItemHasServingLeft(it)) continue;
}
return true;
}
return false;
}
function zunchiEatingBlocked(tarinai, item, world) {
if (!tarinai || !item || item.type !== "zunchi") return false;
if ((Number(item.amount || 0) || 0) <= 0) return true;
const protectedUntil = Number(item.eatProtectedUntil || 0) || 0;
// \u305A\u3093\u3061\u306F\u98DF\u7269\u306E\u6700\u4E0B\u4F4D\u5019\u88DC\u3002\u307B\u304B\u306B\u98DF\u3079\u7269\u304C\u3042\u308B\u9593\u3060\u3051\u4FDD\u8B77\u3057\u3001
// \u307B\u304B\u306B\u98DF\u3079\u7269\u304C\u7121\u3044\u5834\u5408\u306F\u512A\u5148\u9806\u3069\u304A\u308A\u6700\u5F8C\u306E\u98DF\u6599\u3068\u3057\u3066\u98DF\u3079\u3089\u308C\u308B\u3002
if (protectedUntil > 0 && (world?.time || 0) < protectedUntil && hasBetterFoodThanZunchiNearby(tarinai, world, 900)) return true;
return false;
}
function tarinaiTargetBlockedByObstacle(world, tarinai, item, role = "") {
if (!world || !tarinai || !item) return false;
// \u305A\u3093\u3060\u9905\u3060\u3051\u306F\u969C\u5BB3\u7269\u8D8A\u3057\u3067\u3082\u98DF\u3079\u306B\u884C\u304F\u3002
if ((role === "food" || role === "medicine") && effectiveFoodTypeForItem(item) === "sweet") return false;
return Boolean(world.targetBlockedByObstacle?.(tarinai, item, Math.max(18, (tarinai.radius || 20) * 0.65)));
}
function foodRoleSearchTypes(role = "") {
const set = new Set();
if (role === "food") {
for (const type of ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "grass", "zunchi", "ant_corpse", "zunda_juice", "duplicator"]) set.add(type);
if (typeof FOOD_SERVING_TYPES !== "undefined") for (const type of FOOD_SERVING_TYPES) set.add(type);
} else if (role === "drink") {
for (const type of ["water", "zunda_juice", "duplicator"]) set.add(type);
} else if (role === "medicine") {
for (const type of ["sweet", "water", "zunda_juice", "sleep_drug", "love_mochi", "fight_mochi", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "slave_chain", "golden_crown", "duplicator"]) set.add(type);
if (typeof PARAM_EFFECT_ITEM_TYPES !== "undefined") for (const type of PARAM_EFFECT_ITEM_TYPES) set.add(type);
}
return set;
}
function findNearestItemWithRole(world, tarinai, role, maxDist = 520) {
let best = null, bestScore = Infinity;
const searchDist = Math.max(1, Number(maxDist) || 520);
const foodLikeRole = role === "food" || role === "drink" || role === "medicine";
const primary = (foodLikeRole && world?.nearbyFood)
? world.nearbyFood(tarinai.x, tarinai.y, searchDist, true)
: (world?.nearbyItems?.(tarinai.x, tarinai.y, searchDist, true) || world?.items || []);
const candidates = [];
const seen = new Set();
const addCandidate = (it) => {
if (it && !it.dead && !seen.has(it)) {
seen.add(it);
candidates.push(it);
}
};
if (Array.isArray(primary)) for (const it of primary) addCandidate(it);
// \u98df\u6599\u30fb\u6c34\u30fb\u85ac\u306f\u3001\u751f\u6210\u76f4\u5f8c/\u79fb\u52d5\u76f4\u5f8c\u306e spatial cache \u6f0f\u308c\u306b\u3082\u8010\u3048\u308b\u3002
// item type buckets \u304c\u4f7f\u3048\u308b\u5834\u5408\u306f role \u3054\u3068\u306e\u578b\u3060\u3051\u3092\u898b\u308b\u3053\u3068\u3067\u3001
// crowded world \u3067\u6bce\u56de world.items \u5168\u4f53\u3092\u8db3\u3059\u7d4c\u8def\u3092\u907f\u3051\u308b\u3002
if (foodLikeRole) {
if (world?.itemsOfType) {
for (const type of foodRoleSearchTypes(role)) {
// Grass is static and already represented by nearbyFood. Re-adding the
// entire global grass bucket here made every food-target search O(all
// grass), which was especially costly around 100 grass plants.
if (type === "grass" && world?.nearbyFood) continue;
for (const it of world.itemsOfType(type) || []) addCandidate(it);
}
} else if (Array.isArray(world?.items)) {
for (const it of world.items) addCandidate(it);
}
}
for (const item of candidates) {
if (!roleMatches(item, role)) continue;
if (role === "food" && typeof tarinai?.foodItemHasServingLeft === "function") {
const type = effectiveFoodTypeForItem(item);
if ((item.type === "duplicator" || (isServingFoodType(type))) && !tarinai.foodItemHasServingLeft(item)) continue;
if (item.type === "grass" && (Number(item.amount || 0) || 0) <= 0) continue;
if (item.type === "zunchi" && (Number(item.amount || 0) || 0) <= 0) continue;
}
if (tarinai?.shouldAvoidTarget?.(item)) continue;
const d = item.type === "duplicator" ? foodInteractionDistance(tarinai, item) : dist(tarinai, item);
if (d > searchDist) continue;
const blocked = tarinaiTargetBlockedByObstacle(world, tarinai, item, role);
let score = d;
if (role === "food" || role === "medicine") {
const priority = foodPriorityScore(tarinai, item, d);
if (!Number.isFinite(priority)) continue;
score = -priority + (blocked ? 260 : 0);
} else if (blocked && !foodLikeRole) {
continue;
} else if (blocked) {
score += 180;
}
if (score < bestScore) { best = item; bestScore = score; }
}
return best;
}
function sleepPlaceAvailableFor(world, tarinai, item) {
return globalThis.TarinaiNestSleepSystem.sleepPlaceAvailableFor(world, tarinai, item) || false;
}
function nearestSleepPlaceOfType(world, tarinai, type, maxDist = 520) {
return globalThis.TarinaiNestSleepSystem.nearestSleepPlaceOfType(world, tarinai, type, maxDist) || null;
}
function findPrioritySleepPlaceBeforeGrassBedBuild(world, tarinai, maxDist = 520) {
return globalThis.TarinaiNestSleepSystem.findPrioritySleepPlaceBeforeGrassBedBuild(world, tarinai, maxDist) || null;
}
function findNearestSleepPlace(world, tarinai, maxDist = 520) {
return globalThis.TarinaiNestSleepSystem.findNearestSleepPlace(world, tarinai, maxDist) || null;
}
function findNearbyDanger(world, tarinai, maxDist = 150) {
const ant = (world?.nearbyAnts?.(tarinai.x, tarinai.y, maxDist, true) || [])[0] || null;
const item = findNearestItemWithRole(world, tarinai, "danger", maxDist);
if (ant && item) return distXY(tarinai.x, tarinai.y, ant.x, ant.y) <= dist(tarinai, item) ? ant : item;
return ant || item;
}
function activePanicBreaker(tarinai, world, maxDist = 260) {
const breaker = tarinai?.lastNeedShockBreaker || null;
if (!breaker || !tarinai) return null;
const cause = String(tarinai.lastPanicCause || "");
// Relation/death shocks are intentionally not active dangers. Keeping them as
// breakers made old panic spread and persist after the original event ended.
if (cause === "relation_death" || cause === "pending_relation_death") {
tarinai.lastNeedShockBreaker = null;
return null;
}
if (breaker.dead) {
tarinai.lastNeedShockBreaker = null;
return null;
}
const now = Number(world?.time || 0) || 0;
if (Number.isFinite(tarinai.lastPanicAt) && now - tarinai.lastPanicAt > 8.0) {
tarinai.lastNeedShockBreaker = null;
return null;
}
if (!Number.isFinite(breaker.x) || !Number.isFinite(breaker.y)) return null;
const d = distXY(tarinai.x, tarinai.y, breaker.x, breaker.y);
const isDangerItem = roleMatches(breaker, "danger") || breaker.kind === "ant" || breaker.type === "ant";
const isThreatTarinai = breaker !== tarinai && !breaker.dead && breaker.id && (breaker.fightTimer > 0.04 || breaker.intimidateTimer > 0.04 || breaker.fightMochiTimer > 0.04);
const allowedRange = isDangerItem ? maxDist : (isThreatTarinai ? maxDist * 0.75 : maxDist * 0.45);
if (d <= allowedRange) return breaker;
tarinai.lastNeedShockBreaker = null;
return null;
}
function survivalNeedShouldCancelPanic(tarinai, needs) {
if (!tarinai) return false;
const food = Number(needs?.food ?? tarinai.needRaw?.food ?? tarinai.needs?.food ?? 0) || 0;
const sleep = Number(needs?.sleep ?? tarinai.needRaw?.sleep ?? tarinai.needs?.sleep ?? 0) || 0;
const health = Number(needs?.health ?? tarinai.needRaw?.health ?? tarinai.needs?.health ?? 0) || 0;
return food >= 72 || (tarinai.hunger || 0) >= 82 || sleep >= 82 || (tarinai.energy || 100) <= 14 || health >= 88;
}
function evaluateConflictUrge(tarinai, world, maxDist = 96) {
if (!tarinai || tarinai.dead || !world) return null;
if ((tarinai.fightCooldown || 0) > 0.04 || (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.postBirthPeaceTimer || 0) > 0.04) return null;
const p = tarinai.personalityProfile?.() || tarinai.currentPersonality || {};
const aggression = Number(tarinai.currentPersonality?.aggression ?? p.fight ?? 0) || 0;
const battleDrug = (tarinai.fightMochiTimer || 0) > 0.04 || tarinai.hasFightMochiEffect?.();
const near = typeof world.nearbyTarinai === "function" ? world.nearbyTarinai(tarinai.x, tarinai.y, maxDist) : (world.tarinai || []);
let best = null;
for (const other of near || []) {
if (!other || other === tarinai || other.dead) continue;
if (!world.canFightPair?.(tarinai, other)) continue;
if ((other.fightCooldown || 0) > 0.04 || (other.postBirthPeaceTimer || 0) > 0.04) continue;
if (world.areParentChild?.(tarinai, other) || world.areCoParents?.(tarinai, other)) continue;
const d = Math.max(1, dist(tarinai, other));
if (d > maxDist) continue;
const rel = tarinai.relationTo?.(other.id) || {};
const relOther = other.relationTo?.(tarinai.id) || {};
const mixedZunchi = !!tarinai.isZunchiSlave !== !!other.isZunchiSlave;
const otherBattleDrug = (other.fightMochiTimer || 0) > 0.04 || other.hasFightMochiEffect?.();
const closePressure = clamp(1 - d / maxDist, 0, 1) * 18;
const stressPressure = clamp(((tarinai.stress || 0) - 52) / 42, 0, 1) * 18;
const angerPressure = (tarinai.type === "angry" ? 12 : 0) + ((other.type === "angry" || otherBattleDrug) ? 8 : 0);
const fearPressure = clamp(((rel.fear || 0) + (relOther.fear || 0) - 22) / 90, 0, 1) * 14;
const affinityBrake = battleDrug ? 0 : clamp(Math.max(rel.affinity || 0, relOther.affinity || 0) / 80, 0, 1) * 26;
const calmBrake = tarinai.shouldApplyPersonalityBehavior?.("aggression", -1) ? 22 : 0;
const drugBonus = battleDrug ? 56 : 0;
const zunchiBonus = mixedZunchi ? 14 : 0;
const personalityBonus = clamp(aggression, -1, 1) * 10;
const score = closePressure + stressPressure + angerPressure + fearPressure + drugBonus + zunchiBonus + personalityBonus - affinityBrake - calmBrake;
if (!best || score > best.score) {
best = {
target: other,
score,
forced: !!battleDrug || !!otherBattleDrug,
defensive: mixedZunchi || (rel.fear || 0) > 35,
reason: mixedZunchi ? "\u8fd1\u304f\u306e\u76f8\u624b\u304c\u843d\u3061\u7740\u304b\u306a\u3044" : battleDrug ? "\u304d\u305a\u3064\u304d\u9905\u3067\u6c17\u304c\u7acb\u3063\u3066\u3044\u308b" : "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u8fd1\u3044",
};
}
}
return best && best.score >= (best.forced ? 4 : 7) ? best : null;
}
function conflictTargetFor(tarinai, world, minScore = 34) {
const evaluated = evaluateConflictUrge(tarinai, world, 118);
if (evaluated && evaluated.score >= minScore) return evaluated;
const id = tarinai?.conflictTargetId || "";
const target = id ? world?.liveTarinaiById?.(id) : null;
if (!target || !world?.canFightPair?.(tarinai, target)) return null;
const d = dist(tarinai, target);
const carried = Math.max(0, Number(tarinai.conflictUrge || 0) || 0) - Math.max(0, d - 70) * 0.28;
return carried >= minScore ? { target, score: carried, forced: (tarinai.fightMochiTimer || 0) > 0.04, defensive: false, reason: "\u6c17\u306b\u5165\u3089\u306a\u3044\u76f8\u624b\u304c\u3044\u308b" } : null;
}
function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) {
return findNearestItemWithRole(world, tarinai, materialRole, maxDist);
}
function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) {
return globalThis.TarinaiNestSleepSystem.findOwnedStructure(world, tarinai, type, maxDist, {
blocked: (worldRef, actor, item) => tarinaiTargetBlockedByObstacle(worldRef, actor, item, "structure"),
}) || null;
}
function findUnownedStructure(world, tarinai, type = "", maxDist = Infinity) {
return globalThis.TarinaiNestSleepSystem.findUnownedStructure(world, tarinai, type, maxDist, {
blocked: (worldRef, actor, item) => tarinaiTargetBlockedByObstacle(worldRef, actor, item, "structure"),
}) || null;
}
function consumeGrassMaterial(material, stages = 1) {
if (!material) return false;
const steps = Math.max(1, Math.floor(Number(stages) || 1));
const consumed = window.TarinaiGrass.regress(material, steps) ?? 0;
if (!(consumed > 0)) return false;
material.eatenAmount = (material.eatenAmount || 0) + consumed;
material.world?.markTerrainDirty?.("grass-material-consumed");
return true;
}
function moveToOrUse(t, target, state = "seek_food", reason = "") {
if (!target) return false;
t.setActionState?.(state, { target, reason, sleeping: false });
return true;
}