tarinai/js/tarinai_direct_feeding_system.js
2026-07-29 20:52:11 +09:00

170 lines
7.7 KiB
JavaScript

"use strict";
// Direct player-to-Tarinai feeding. Placement clicks and pinched-item drops
// share this path so item effects, feedback, and consumption stay consistent.
(function (global) {
const FOOD_TOOL_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "grass", "water", "zunda_juice"]);
const MEDICINE_TOOL_TYPES = new Set(["first_aid", "sedative", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"]);
const HEART_FOOD_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "grass", "zunda_juice"]);
function itemType(itemOrType = "") {
return String(typeof itemOrType === "object" ? itemOrType?.type || "" : itemOrType || "");
}
function isDirectFeedType(itemOrType = "") {
const type = itemType(itemOrType);
return FOOD_TOOL_TYPES.has(type) || MEDICINE_TOOL_TYPES.has(type);
}
function isFoodGift(itemOrType = "") {
return HEART_FOOD_TYPES.has(itemType(itemOrType));
}
function hasConsumableAmount(item = null) {
if (!item || item.dead || !isDirectFeedType(item)) return false;
const type = itemType(item);
if (typeof global.isServingFoodType === "function" && global.isServingFoodType(type)) {
if (Number.isFinite(Number(item.foodServingsRemaining))) return Number(item.foodServingsRemaining) > 0;
return Number(item.amount ?? 1) > 0;
}
return Number(item.amount ?? 1) > 0;
}
function findTargetAt(worldRef, x, y) {
if (!worldRef || !Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) return null;
const px = Number(x);
const py = Number(y);
const list = worldRef.tarinai || [];
for (let i = list.length - 1; i >= 0; i -= 1) {
const t = list[i];
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const hit = typeof t.contains === "function"
? t.contains(px, py)
: (typeof global.distXY === "function" && global.distXY(px, py, t.x, t.y) <= Math.max(20, Number(t.radius || 22) * 1.35));
if (hit) return t;
}
return null;
}
function roleFor(item = null) {
const type = itemType(item);
if (type === "water") return "drink";
if (MEDICINE_TOOL_TYPES.has(type) || type === "zunda_juice") return "medicine";
return "food";
}
function randomRange(min, max) {
return min + Math.random() * (max - min);
}
function spawnFoodGiftHearts(tarinai, nutrition = 0) {
const worldRef = tarinai?.world;
if (!worldRef?.effects || typeof global.TarinaiEffect !== "function") return false;
const count = 10 + Math.min(4, Math.max(0, Math.round((Number(nutrition) || 0) / 7)));
const radius = Math.max(18, Number(tarinai.radius || 22) || 22);
for (let i = 0; i < count; i += 1) {
const side = i % 2 ? 1 : -1;
worldRef.queueEffect?.("heart",
tarinai.x + side * randomRange(radius * 0.06, radius * 0.82),
tarinai.y - randomRange(radius * 0.38, radius * 1.72), {
vx: side * randomRange(9, 30) + randomRange(-9, 9),
vy: randomRange(-76, -30),
size: randomRange(9, 17),
life: randomRange(0.82, 1.36),
color: "rgba(240,91,135,0.94)",
});
}
worldRef.queueEffect?.("ring", tarinai.x, tarinai.y, {
size: Math.max(24, radius * 1.35), life: 0.28, color: "rgba(240,91,135,0.42)",
});
return true;
}
function applyFoodGiftPersonality(tarinai, nutrition = 0) {
if (!tarinai || typeof tarinai.adjustPersonality !== "function") return false;
const amount = Math.max(0, Number(nutrition) || 0);
// A single gift intentionally outweighs ordinary pointer contact, which
// changes openness gradually per second while the cursor remains nearby.
const openness = 0.018 + Math.min(0.010, amount * 0.0004);
const sociability = 0.010 + Math.min(0.006, amount * 0.00024);
const changedOpen = tarinai.adjustPersonality("openness", openness, "\u30ab\u30fc\u30bd\u30eb\u304b\u3089\u76f4\u63a5\u98df\u3079\u7269\u3092\u98df\u3079\u305f") || 0;
const changedSocial = tarinai.adjustPersonality("sociability", sociability, "\u30ab\u30fc\u30bd\u30eb\u304b\u3089\u76f4\u63a5\u98df\u3079\u7269\u3092\u98df\u3079\u305f") || 0;
return Math.abs(changedOpen) + Math.abs(changedSocial) > 0;
}
function removeGivenWorldItem(worldRef, item, label) {
if (!worldRef || !item || !(worldRef.items || []).includes(item)) return false;
if (global.TarinaiStructureLifecycle?.deleteItem) {
return Boolean(global.TarinaiStructureLifecycle.deleteItem(worldRef, item, {
reason: "direct-feed-consumed",
userReason: `${label}\u304c\u76f4\u63a5\u4e0e\u3048\u3089\u308c\u305f`,
wake: false,
panicOwner: false,
}));
}
item.amount = 0;
item.dead = true;
worldRef.markItemBucketsDirty?.("direct-feed-consumed");
worldRef.markSpatialDirty?.("direct-feed-consumed");
return true;
}
function give(worldRef, tarinai, item, options = {}) {
if (!worldRef || !tarinai || tarinai.dead || !hasConsumableAmount(item)) return null;
if (typeof global.consumeBehaviorTarget !== "function") return null;
const type = itemType(item);
if (tarinai.isZunchiSlave && type === "sweet") return null;
const label = typeof global.toolLabel === "function" ? global.toolLabel(type) : type;
const healthNeedBefore = Number(tarinai.needs?.health);
const consumed = global.consumeBehaviorTarget(tarinai, worldRef, item, roleFor(item), 0.18);
if (!consumed) return null;
const nutrition = global.TarinaiItemRegistry?.food?.nutrition?.(type, 0) ?? 0;
tarinai.target = null;
tarinai.targetKey = "";
tarinai.sleeping = false;
tarinai.goodMode = "smile";
tarinai.moodLiftTimer = Math.max(Number(tarinai.moodLiftTimer || 0) || 0, 1.2);
tarinai.foodReactTimer = Math.max(Number(tarinai.foodReactTimer || 0) || 0, 0.55);
tarinai.surpriseTimer = Math.max(Number(tarinai.surpriseTimer || 0) || 0, 0.28);
tarinai.thought = `${label}\u3092\u3082\u3089\u3063\u305f`;
const foodGift = isFoodGift(type);
if (foodGift) {
tarinai.affection = Number(tarinai.affection || 0) + 1.8 + Math.max(0, Number(nutrition) || 0) * 0.035;
applyFoodGiftPersonality(tarinai, nutrition);
// Cosmetic feedback must never interrupt consumption, removal, or achievement recording.
try { spawnFoodGiftHearts(tarinai, nutrition); } catch (_) {}
try { tarinai.bubble?.("\u2661", 2.2, "rgba(190,82,132,0.80)"); } catch (_) {}
global.TarinaiAchievements?.recordDirectFeed?.({ world: worldRef, tarinai, item, type, source: options.source || "direct" });
} else {
tarinai.focusPulseTimer = Math.max(Number(tarinai.focusPulseTimer || 0) || 0, 0.72);
const healthNeedAfter = Number(tarinai.needs?.health);
const beneficialRecovery = Number.isFinite(healthNeedBefore) && Number.isFinite(healthNeedAfter) && healthNeedAfter < healthNeedBefore - 0.0001;
global.TarinaiAchievements?.recordDirectCare?.({ world: worldRef, tarinai, item, type, beneficialRecovery, source: options.source || "direct" });
}
// Direct feeding is a player operation and is intentionally not logged.
const removed = options.removeItem !== false ? removeGivenWorldItem(worldRef, item, label) : false;
if (!removed && (worldRef.items || []).includes(item)) {
item.vx = 0;
item.vy = 0;
item.prevX = item.x;
item.prevY = item.y;
}
worldRef.hoverGiveTarget = null;
worldRef.drawListDirty = true;
worldRef.recordFamily?.(tarinai);
if (global.uiCache) global.uiCache.selectedSnapshot = "";
return { tarinai, item, type, label, foodGift, removed };
}
global.TarinaiDirectFeedingSystem = Object.freeze({
isDirectFeedType,
isFoodGift,
hasConsumableAmount,
findTargetAt,
give,
});
})(typeof window !== "undefined" ? window : globalThis);