From 54aff02658d7d1577e15d03cde0791c2eeeca740 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 23 Jun 2026 18:11:42 +0900 Subject: [PATCH 1/2] hmm --- .tmp-edge-profile/Crashpad/metadata | 0 .tmp-edge-profile/Crashpad/settings.dat | Bin 0 -> 288 bytes .tmp-edge-profile/Crashpad/throttle_store.dat | 1 + .tmp-edge-profile/Variations | 1 + PATCH_NOTES.md | 136 +- app_manifest.json | 2 +- css/components.css | 7 + docs/prompt.txt | 1326 +++++++++++------ index.html | 133 +- js/effect_registry.js | 6 +- js/freeze_system.js | 2 +- js/health.js | 2 + js/items.js | 14 +- js/relation_event_system.js | 2 +- js/render.js | 4 + js/structures.js | 214 +++ js/tarinai.js | 7 +- js/tarinai_action_state.js | 40 +- js/tarinai_disease_nest.js | 14 +- js/tarinai_identity_social.js | 12 +- js/tarinai_needs_items.js | 727 +++++---- js/tarinai_social_move_life.js | 35 +- js/ui_selected.js | 15 +- js/version.js | 4 +- js/world_combat_effects.js | 24 +- js/world_family_social.js | 4 +- service-worker.js | 2 +- 27 files changed, 1700 insertions(+), 1034 deletions(-) create mode 100644 .tmp-edge-profile/Crashpad/metadata create mode 100644 .tmp-edge-profile/Crashpad/settings.dat create mode 100644 .tmp-edge-profile/Crashpad/throttle_store.dat create mode 100644 .tmp-edge-profile/Variations create mode 100644 js/structures.js diff --git a/.tmp-edge-profile/Crashpad/metadata b/.tmp-edge-profile/Crashpad/metadata new file mode 100644 index 0000000..e69de29 diff --git a/.tmp-edge-profile/Crashpad/settings.dat b/.tmp-edge-profile/Crashpad/settings.dat new file mode 100644 index 0000000000000000000000000000000000000000..e76f11a5de8023cd7a257067dd8fd4e58d8ae613 GIT binary patch literal 288 pcmXR;32 display 8 +internal value 100 -> display 10 + +Safety is capped at 100 like every other need. +Safety is not a hard interrupt. + +============================================================ +2. NEED CALCULATION +============================================================ + +Calculate needs from existing tarinai/world state where possible. + +Suggested mapping: + +food: +- hunger +- thirst if available + +sleep: +- low energy +- long awake time if available +- night/time-of-day bonus if available + +health: +- disease +- injury +- dirtiness +- poison or negative status if available + +safety: +- nearby ants +- nearby dangerous items +- recent damage +- fear/panic-related existing state if any +- being attacked +- owned structure destruction shock + +social: +- loneliness if available +- nearby friend / parent / child +- reproduction readiness +- enemy proximity if current relation system supports it + +fulfill: +- wants to play +- has no owned structure +- wants to return to owned grass bed +- wants to carry/use plushie +- wants to build a simple owned structure + +Use existing personality fields if available: + +aggression: +- safety rises slightly less from danger +- may choose intimidate instead of panic when there is a breaker/attacker + +openness: +- fulfill rises more easily +- play/build actions become more likely + +sociability: +- social rises more easily +- reacts less negatively when others use owned structures + +neuroticism: +- safety and health rise more easily +- owned structure damage/destruction has stronger effect + +Keep personality modifiers small and easy to tune. + +============================================================ +3. ACTION SELECTION +============================================================ + +Use this behavior flow: + +1. Calculate all six raw needs. +2. Quantize all needs. +3. Find the maximum need value. +4. Collect all needs equal to the maximum. +5. Randomly choose one need from that tied top set. +6. Choose an executable action associated with that chosen need. +7. Store the chosen need and action in tarinai.intent. +8. Highlight only the chosen need in the tarinai data UI. + +If safety and food are both 100, either may be chosen randomly. + +Add helpers: + +- createDefaultNeeds() +- quantizeNeed(value) +- updateNeeds(tarinai, world) +- chooseTopNeedRandom(needs) +- chooseActionForNeed(need, tarinai, world) +- buildReasonText(chosenNeed, tiedNeeds, action) +- getNeedDisplayValue(value) + +============================================================ +4. SIMPLE ACTION DEFINITIONS +============================================================ + +Use a simple action list, not a complex behavior tree. + +Each action should have: + +- id +- need +- label +- phrase +- weight +- condition(tarinai, world) +- run(tarinai, world) + +Example shape: + +const TARINAI_ACTIONS = [ + { + id: "eat_food", + need: "food", + label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", + phrase: "\u98df\u3079\u305f", + weight: 50, + condition(t, world) { + return !!findNearestItemWithRole(world, t, "food"); + }, + run(t, world) { + const item = findNearestItemWithRole(world, t, "food"); + moveToOrUse(t, item); + } + } +]; + +Suggested actions: + +food: +- eat_food +- drink_water + +sleep: +- sleep_in_bed +- sleep_in_owned_structure +- sleep_anywhere + +health: +- use_medicine +- wash_or_drink_water +- rest_to_recover + +safety: +- flee +- hide_in_nest +- hide_at_owned_structure + +social: +- approach_friend +- approach_parent_or_child +- approach_mate +- intimidate_enemy + +fulfill: +- play +- wander_lightly +- build_grass_bed +- build_plushie +- return_owned_structure +- use_plushie + +Use weighted random among executable actions for the chosen need. + +If no action is executable, fall back to idle, wander, or rest. + +Do not make tarinai perfectly optimal. + +============================================================ +5. TIE REASON TEXT +============================================================ + +If multiple needs are tied for highest and the chosen need is only one of them, generate: + +“[ignored need phrase]\u3051\u3069\u3001[chosen action phrase]\u3002” + +Examples: +- "\u3042\u3076\u306a\u3044\u3051\u3069\u3001\u98df\u3079\u305f\u3002" +- "\u306d\u3080\u3044\u3051\u3069\u3001\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f\u3002" +- "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b\u3051\u3069\u3001\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f\u3002" +- "\u8abf\u5b50\u304c\u60aa\u3044\u3051\u3069\u3001\u904a\u3093\u3060\u3002" + +When multiple ignored needs exist, mention the ignored need with highest priority: + +safety > health > food > sleep > social > fulfill + +Conflict phrases: + +food: +"\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b" + +sleep: +"\u306d\u3080\u3044" + +health: +"\u8abf\u5b50\u304c\u60aa\u3044" + +safety: +"\u3042\u3076\u306a\u3044" + +social: +"\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b" + +fulfill: +"\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b" + +If there is no tie, use: + +“[need phrase]\u306e\u3067\u3001[action phrase]\u3002” + +Store: + +tarinai.intent = { + need: chosenNeed, + tiedNeeds, + actionId: action.id, + actionLabel: action.label, + reasonText +}; + +============================================================ +6. NEED UI +============================================================ + +Update the tarinai data UI. + +Show six needs as 0–10 values. + +Labels must be escaped in source: + +food label: +"\u6442\u990c" + +sleep label: +"\u7761\u7720" + +health label: +"\u5065\u5eb7" + +safety label: +"\u5b89\u5168" + +social label: +"\u95a2\u4fc2" + +fulfill label: +"\u5145\u8db3" + +Example UI: + +摂餌 8 +睡眠 3 +健康 1 +安全 10 +関係 4 +充足 10 ★ + +Only the need actually chosen for the current action receives the highlight. + +Also show: + +"\u3044\u307e": +[action label] + +"\u7406\u7531": +[reasonText] + +Do not show detailed need breakdowns. +Do not add graphs or extra panels. + +============================================================ +7. ITEM ROLES AND NEED EFFECTS +============================================================ + +Use minimal item roles and needEffects. + +Do not create a broad tag system. + +Item example: + +item.roles = { + food: true, + drink: true, + medicine: true, + sleepPlace: true, + danger: true, + grassMaterial: true +}; + +item.needEffects = { + food: 0, + sleep: 0, + health: 0, + safety: 0, + social: 0, + fulfill: 0 +}; + +Only add roles that are actually needed by the new behavior. + +Useful helpers: + +- findNearestItemWithRole(world, tarinai, role) +- findNearestSleepPlace(world, tarinai) +- findNearbyDanger(world, tarinai) +- findNearbyMaterial(world, tarinai, materialRole) + +Adapt to existing item registries where possible. + +============================================================ +8. STRUCTURE REGISTRY +============================================================ + +Keep StructureRegistry because tarinai-built structures will expand in the future. + +Do not collapse structures into one-off special item cases. + +Create or adapt a small StructureRegistry for buildable tarinai structures. + +Shared structure instance shape: + +{ + id, + type, + isStructure: true, + ownerId, + hp, + attachment, + usedCount, + x, + y, + roles, + needEffects +} + +Do not implement: +- repair +- inheritance +- ownership privacy +- material-carrying inventory +- complex construction logistics + +First buildable structures: + +- grass_bed +- plushie + +============================================================ +9. STRUCTURE: GRASS BED +============================================================ + +Structure type: +"grass_bed" + +Japanese label, escaped: +"\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9" + +Concept: +A simple bed made from grass. It is an owned place. The owner prefers it for sleep, safety, and fulfill. + +Example registry entry shape: + +grass_bed: { + type: "grass_bed", + label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 100, + materials: { + grassMaterial: 1 + }, + roles: { + sleepPlace: true, + ownedStructure: true + }, + needEffects: { + food: 0, + sleep: 60, + health: 5, + safety: 25, + social: 0, + fulfill: 40 + }, + create(owner, x, y, world) { + return { + id: createId("structure"), + type: "grass_bed", + isStructure: true, + ownerId: owner.id, + hp: 100, + attachment: 20, + usedCount: 0, + x, + y, + roles: this.roles, + needEffects: this.needEffects + }; + }, + onUse(user, structure, world) { + // owner gets stronger effects + // non-owner gets weaker effects + // owner use increases attachment + }, + onDestroyed(structure, world, breaker) { + // increase owner safety and fulfill + // trigger panic or intimidate through need shock logic + } +} + +Build condition: +- chosen need is fulfill +- tarinai has no owned grass_bed +- grass material is nearby +- tarinai is not sleeping, fighting, or panicking + +Build process: +- move near grass +- spend short build time if existing state system supports it +- consume/reduce grass if easy +- create grass_bed through StructureRegistry + +Owner behavior: +- owner prefers own grass_bed for sleep, safety, and fulfill +- using own grass_bed reduces sleep, safety, and fulfill +- using own grass_bed increases attachment + +Other tarinai: +- may use it with weaker effects +- if owner sees another tarinai using it: + - aggression may cause intimidate + - neuroticism may increase safety/fulfill + - sociability reduces negative reaction + +Destruction: +- hp can be reduced by explosions, punches, weather, repeated use, or existing damage sources if easy to connect +- when hp reaches 0, call onDestroyed and remove structure + +============================================================ +10. STRUCTURE: PLUSHIE +============================================================ + +Add a new tarinai-built structure: + +type: +"plushie" + +Japanese label, escaped: +"\u306c\u3044\u3050\u308b\u307f" + +Concept: +A small plushie item that resembles a tarinai. It is made from grass. When completed, it attaches to the owner tarinai’s head. While attached, it reduces fulfill. If damaged, it breaks and disappears. The owner receives a strong need shock and enters panic or intimidate. + +Example registry entry shape: + +plushie: { + type: "plushie", + label: "\u306c\u3044\u3050\u308b\u307f", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 1, + materials: { + grassMaterial: 1 + }, + roles: { + carriedObject: true, + ownedStructure: true, + fulfillObject: true + }, + needEffects: { + food: 0, + sleep: 0, + health: 0, + safety: 5, + social: 0, + fulfill: 50 + }, + create(owner, x, y, world) { + return { + id: createId("structure"), + type: "plushie", + isStructure: true, + ownerId: owner.id, + hp: 1, + attachment: 30, + usedCount: 0, + x, + y, + carriedById: owner.id, + onHead: true, + roles: this.roles, + needEffects: this.needEffects + }; + }, + onUse(user, structure, world) { + // if owner carries/uses it, reduce fulfill + // optionally reduce safety slightly + // increase attachment slightly + }, + onDestroyed(structure, world, breaker) { + // large shock to owner safety and fulfill + // trigger panic or intimidate + } +} + +Build condition: +- chosen need is fulfill +- tarinai does not already own/carry a plushie +- grass material is nearby +- tarinai is not sleeping, fighting, or panicking + +Build process: +- move near grass +- spend short build time if existing state system supports it +- consume/reduce grass if easy +- create plushie through StructureRegistry +- attach it to owner’s head + +Behavior while attached: +- follow owner position +- render above owner’s head +- use same or similar sprite as tarinai, scaled down +- reduce fulfill over time or when used +- optionally reduce safety slightly +- do not implement inventory + +Damage/destruction: +- any meaningful damage destroys it +- remove plushie +- increase owner safety and fulfill strongly +- trigger panic or intimidate through need shock logic + +Do not implement: +- repair +- inheritance +- trading +- inventory +- multiple plushies per tarinai + +============================================================ +11. EMOTION SYSTEM SIMPLIFICATION +============================================================ + +Remove the old detailed emotion system as a behavior driver. + +Behavior must be selected only from the six needs: +food, sleep, health, safety, social, fulfill. + +Old emotions should be removed or absorbed: + +fear -> safety +loneliness -> social +happiness -> low stress / low fulfill pressure +sadness -> fulfill/social/safety pressure +comfort -> sleep/safety/fulfill satisfaction +boredom -> do not implement +anger -> intimidate state only +panic -> need shock reaction +stress -> derived pressure from needs + +Do not keep happiness, sadness, fear, loneliness, comfort, boredom, or anger as independent behavior-driving values. + +============================================================ +12. STRESS REDEFINITION +============================================================ + +Stress is not an independent emotion. + +Stress is a derived value calculated from current need pressure. + +Do not directly add to stress for shock events. +For shock events, increase relevant needs first, then recalculate stress from needs. + +Stress must not be used as a primary action-selection need. + +Behavior is selected only from the six needs. + +Stress may affect: +- expression +- minor visual intensity +- UI display if already present +- minor secondary effects if existing systems need it + +Suggested pressure function: + +function needPressure(value) { + const x = value / 100; + return x * x * 100; +} + +Suggested stress calculation: + +stress = + needPressure(food) * 0.10 + + needPressure(sleep) * 0.12 + + needPressure(health) * 0.25 + + needPressure(safety) * 0.30 + + needPressure(social) * 0.08 + + needPressure(fulfill) * 0.15; + +Adjust weights if needed, but keep stress derived from needs. + +============================================================ +13. PANIC REDEFINITION +============================================================ + +Panic is not a need. +Panic is not a persistent emotion. + +Panic is a short temporary reaction state caused by large sudden increases in safety, health, or fulfill. + +Food, sleep, and social should not normally trigger panic. + +Track previous needs or otherwise compute need delta. + +Suggested rule: + +panic triggers if: +- safety increases suddenly by 40 or more +- health increases suddenly by 45 or more +- fulfill increases suddenly by 50 or more + +Panic should last for a short timer, such as 2–4 seconds. + +After panic timer ends, return to normal need-based action selection. + +If panic would be triggered and the tarinai has high aggression and a known breaker/attacker, use intimidate instead of panic. + +Example: + +if (shockTriggered) { + if (breaker && t.personality?.aggression > 0.6) { + t.state = "intimidate"; + t.target = breaker; + } else { + t.state = "panic"; + t.panicTimer = 3.0; + } +} + +============================================================ +14. EXPRESSION / VISUAL EMOTION COMPATIBILITY +============================================================ + +If the old emotion system is deeply tied to expressions, keep only a small compatibility layer for visuals. + +Expressions should be derived from: +- current state +- chosen need +- stress level +- panic/intimidate + +Suggested mapping: + +state panic: +panic expression + +state intimidate: +angry/threat expression + +chosen need safety: +worried expression + +chosen need food: +hungry expression + +chosen need sleep: +sleepy expression + +chosen need health: +unwell expression + +chosen need social: +social/looking expression + +chosen need fulfill: +neutral/pleased expression + +Do not let old expression emotions drive behavior. + +============================================================ +15. SAVE COMPATIBILITY +============================================================ + +Backward compatibility is not required. + +Do not implement save migrations. +Do not add compatibility shims for old saves unless the current code would immediately crash during normal startup. + +It is acceptable if old saves are incompatible after this feature. + +============================================================ +16. DO NOT ADD +============================================================ + +Do not add: +- long-term memory +- boredom/habit system +- player-intervention profile +- ReactionRegistry +- WorldRules +- full behavior tree +- complex utility AI +- detailed need breakdown UI +- repair system +- inheritance system +- material-carrying inventory +- structure privacy system + +============================================================ +17. ACCEPTANCE CRITERIA +============================================================ + +After implementation: + +- Each tarinai has six needs: + - food + - sleep + - health + - safety + - social + - fulfill + +- Needs are clamped 0–100. +- Needs are quantized to 10-point steps. +- UI displays needs as 0–10. +- Only the chosen action-driving need is highlighted. +- If multiple needs are tied for highest, one is chosen randomly. +- Safety does not force interruption. +- If safety and another need are tied, non-safety behavior can happen. +- Tie reason text uses the “Xけど、Yした。” format. +- Newly added Japanese source strings are Unicode escaped. +- Actions are defined in a simple action list. +- Items expose minimal roles and needEffects. +- StructureRegistry exists and supports at least grass_bed and plushie. +- grass_bed consumes grass to build. +- grass_bed is preferred by its owner. +- grass_bed destruction increases owner safety/fulfill and can trigger panic/intimidate. +- plushie consumes grass to build. +- plushie attaches to the owner’s head. +- plushie visually resembles a small tarinai. +- plushie reduces fulfill while carried/used. +- plushie breaks and disappears when damaged. +- plushie destruction strongly increases owner safety/fulfill and triggers panic/intimidate. +- Old detailed emotion values no longer drive behavior. +- Stress is derived from needs. +- Panic is caused by sudden safety/health/fulfill increases. +- Old save compatibility is not required. \ No newline at end of file diff --git a/index.html b/index.html index 75ac774..4ea4a73 100644 --- a/index.html +++ b/index.html @@ -5,11 +5,11 @@ たりない観察 - - - - - + + + + +
@@ -183,66 +183,67 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/effect_registry.js b/js/effect_registry.js index 4d73aa4..1810a8c 100644 --- a/js/effect_registry.js +++ b/js/effect_registry.js @@ -109,7 +109,7 @@ const scale = context.scale ?? 1; tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); const nutrition = Number(context.nutrition) || 0; - tarinai.stress = clamp(tarinai.stress - (context.source === "eating" ? nutrition * 0.25 : 8), 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); return true; }, }); @@ -118,7 +118,7 @@ const scale = context.scale ?? 1; tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); const nutrition = Number(context.nutrition) || 0; - tarinai.stress = clamp(tarinai.stress + (context.source === "eating" ? nutrition * 0.22 : 10), 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); if (context.source === "eating") tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); return true; }, @@ -127,7 +127,7 @@ onApply(tarinai, context = {}) { if (context.source === "eating") { const nutrition = Number(context.nutrition) || 0; - tarinai.stress = clamp(tarinai.stress - nutrition * 0.10, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { safety: -nutrition * 0.25, sleep: -nutrition * 0.15 }); tarinai.energy = clamp(tarinai.energy - nutrition * 0.20, 0, 100); tarinai.fearTimer = Math.max(0, (tarinai.fearTimer || 0) - 0.18); tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); diff --git a/js/freeze_system.js b/js/freeze_system.js index 506a2a6..17356bd 100644 --- a/js/freeze_system.js +++ b/js/freeze_system.js @@ -183,7 +183,7 @@ const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8); if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" }); else { - t.stress = clamp((t.stress || 0) + stress, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 }); t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35)); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true }); } diff --git a/js/health.js b/js/health.js index 690a16c..75f674f 100644 --- a/js/health.js +++ b/js/health.js @@ -134,6 +134,8 @@ const HEALTH = (() => { if (actualLoss > 0.01) { cause = rememberDamage(t, actualLoss, reason); t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason }); + const plushie = (t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id); + if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null); if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); audio.damage?.(actualLoss); if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"); diff --git a/js/items.js b/js/items.js index 1989a4b..54e0cef 100644 --- a/js/items.js +++ b/js/items.js @@ -184,6 +184,14 @@ class Item { this.amount = itemAmountFor(type, 80); this.age = 0; this.seed = Math.random() * 1000; + this.roles = {}; + this.needEffects = { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + if (isServingFoodType(type) || ["grass", "ant_corpse", "zunchi"].includes(type)) this.roles.food = true; + if (type === "water" || type === "water_bowl" || type === "zunda_juice") this.roles.drink = true; + if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) this.roles.medicine = true; + if (type === "bed" || type === "nest_box") this.roles.sleepPlace = true; + if (["firecracker", "genkotsu", "pushpin", "oshibyo", "zunchi", "splat"].includes(type)) this.roles.danger = true; + if (type === "grass") this.roles.grassMaterial = true; this.dropTimer = 0; this.dropMax = 0; this.dropImpactDone = false; @@ -412,7 +420,7 @@ class Item { t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5); t.fearTimer = Math.max(t.fearTimer || 0, 0.55); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "高速ずんちにぶつかって吹き飛んでいる", wake: true }); - t.addStress ? t.addStress(7, { threshold: 8, duration: 3.2 }) : (t.stress = clamp((t.stress || 0) + 7, 0, 130)); + if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 }); } worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65); worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" })); @@ -693,7 +701,7 @@ class Item { t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2); t.fearTimer = Math.max(t.fearTimer || 0, 1.3); t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true }); - t.addStress ? t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressOnAttach ?? 18), 0, 130)); + if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }); } if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲"); if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) { @@ -730,7 +738,7 @@ class Item { this.pinDamageTick -= 0.55; if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "画鋲"); t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50); - t.addStress ? t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressPerTick ?? 2.6), 0, 130)); + if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }); if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)"); } t.sleeping = false; diff --git a/js/relation_event_system.js b/js/relation_event_system.js index fcb3249..4d7cd80 100644 --- a/js/relation_event_system.js +++ b/js/relation_event_system.js @@ -30,7 +30,7 @@ observer.sleeping = false; observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true }); observer.fearTimer = Math.max(observer.fearTimer || 0, fear); - observer.stress = clamp((observer.stress || 0) + stress, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 }); observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)"); } observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress }); diff --git a/js/render.js b/js/render.js index 5ea4cb6..38f79f8 100644 --- a/js/render.js +++ b/js/render.js @@ -743,6 +743,7 @@ function render() { for (const it of world.items) { if (isCachedTerrainItem(it)) continue; if (isPinType(it.type) && it.pinState === "lodged") continue; + if (it.isStructure && it.type === "plushie" && it.carriedById) continue; if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const it of world.items) { @@ -769,6 +770,9 @@ function render() { for (const t of sorted) { if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting); } + for (const it of world.items) { + if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + } for (const it of world.items) { if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } diff --git a/js/structures.js b/js/structures.js new file mode 100644 index 0000000..59bb11d --- /dev/null +++ b/js/structures.js @@ -0,0 +1,214 @@ +"use strict"; + +(function (global) { + function createStructureId(prefix = "structure") { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; + } + + function clonePlain(value) { + return JSON.parse(JSON.stringify(value || {})); + } + + class TarinaiStructure { + constructor(definition, owner, x, y, world) { + this.id = createStructureId("structure"); + this.type = definition.type; + this.label = definition.label; + this.isStructure = true; + this.ownerId = owner?.id || ""; + this.hp = definition.maxHp || 1; + this.maxHp = definition.maxHp || this.hp; + this.attachment = definition.type === "plushie" ? 30 : 20; + this.usedCount = 0; + this.x = x; + this.y = y; + this.r = definition.type === "plushie" ? 13 : 24; + this.amount = this.hp; + this.seed = Math.random() * 1000; + this.roles = clonePlain(definition.roles); + this.needEffects = clonePlain(definition.needEffects); + this.carriedById = definition.type === "plushie" ? this.ownerId : ""; + this.onHead = definition.type === "plushie"; + this.dead = false; + this.world = world || null; + } + + owner(worldRef = this.world) { + return (worldRef?.tarinai || []).find(t => t && !t.dead && t.id === this.ownerId) || null; + } + + update(dt, worldRef = this.world) { + this.world = worldRef || this.world; + if (this.dead || this.hp <= 0) { + this.amount = 0; + return; + } + if (this.type === "plushie") { + const owner = this.owner(worldRef); + if (!owner) { + this.damage(1, worldRef, null); + return; + } + this.x = owner.x; + this.y = owner.y - Math.max(18, (owner.radius || 24) * 0.78); + if (owner.needs) owner.needs.fulfill = Math.max(0, (owner.needs.fulfill || 0) - dt * 2.8); + } + this.amount = Math.max(0.001, this.hp); + } + + use(user, worldRef = this.world) { + const definition = global.StructureRegistry?.get?.(this.type); + return definition?.onUse?.(user, this, worldRef) || false; + } + + damage(amount = 1, worldRef = this.world, breaker = null) { + if (this.dead) return false; + this.hp = Math.max(0, (this.hp || 0) - Math.max(0, amount || 0)); + this.amount = this.hp; + if (this.hp > 0) return false; + this.dead = true; + this.amount = 0; + global.StructureRegistry?.get?.(this.type)?.onDestroyed?.(this, worldRef, breaker); + return true; + } + + draw(ctx, time = 0, lighting = {}) { + if (this.dead) return; + ctx.save(); + ctx.translate(this.x, this.y); + const night = (lighting?.light || 1) < 0.42; + if (this.type === "grass_bed") { + ctx.fillStyle = night ? "rgba(108,129,77,0.88)" : "rgba(134,174,83,0.92)"; + ctx.strokeStyle = night ? "rgba(55,73,49,0.72)" : "rgba(72,105,50,0.72)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.ellipse(0, 4, this.r * 1.55, this.r * 0.62, -0.08, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = night ? "rgba(185,203,142,0.35)" : "rgba(231,229,139,0.55)"; + for (let i = 0; i < 12; i++) { + const a = -0.8 + i * 0.145; + ctx.beginPath(); + ctx.moveTo(-this.r * 1.05 + i * this.r * 0.19, this.r * 0.38); + ctx.quadraticCurveTo(Math.sin(time + i) * 4, -this.r * 0.12, Math.cos(a) * this.r * 0.92, -this.r * 0.42 + Math.sin(i) * 4); + ctx.stroke(); + } + } else if (this.type === "plushie") { + const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : global.images?.get?.("smile"); + ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05); + if (img) { + const w = this.r * 2.2; + const metrics = typeof getImageMetrics === "function" ? getImageMetrics("smile") : null; + const h = w * (metrics?.ratio || 0.92); + ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h); + } else { + ctx.fillStyle = "#f7dfc0"; + ctx.strokeStyle = "rgba(86,62,48,0.70)"; + ctx.lineWidth = 1.7; + ctx.beginPath(); + ctx.arc(0, 0, this.r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(52,43,38,0.88)"; + ctx.beginPath(); ctx.arc(-this.r * 0.28, -this.r * 0.08, 1.5, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(this.r * 0.28, -this.r * 0.08, 1.5, 0, Math.PI * 2); ctx.fill(); + } + } + ctx.restore(); + } + } + + const StructureRegistry = { + defs: {}, + register(definition) { this.defs[definition.type] = definition; return definition; }, + get(type) { return this.defs[type] || null; }, + create(type, owner, x, y, world) { + const definition = this.get(type); + if (!definition) return null; + return definition.create ? definition.create(owner, x, y, world) : new TarinaiStructure(definition, owner, x, y, world); + }, + }; + + function applyStructureNeedShock(owner, safetyGain, fulfillGain, breaker = null) { + if (!owner || owner.dead) return; + if (typeof global.applyNeedShock === "function") global.applyNeedShock(owner, { safety: safetyGain || 0, fulfill: fulfillGain || 0 }, breaker); + else { + owner.needShock = owner.needShock || {}; + owner.needShock.safety = Math.max(owner.needShock.safety || 0, safetyGain || 0); + owner.needShock.fulfill = Math.max(owner.needShock.fulfill || 0, fulfillGain || 0); + owner.lastNeedShockBreaker = breaker || null; + } + if (typeof global.triggerNeedShockReaction === "function") { + const reason = breaker ? "\u81ea\u5206\u306e\u3082\u306e\u304c\u58ca\u308c\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u81ea\u5206\u306e\u3082\u306e\u304c\u58ca\u308c\u3066\u3044\u308b"; + global.triggerNeedShockReaction(owner, breaker, reason); + } + } + + function damageNearbyStructures(worldRef, x, y, radius, amount, breaker = null) { + let hit = 0; + for (const structure of worldRef?.nearbyItems?.(x, y, radius + 80) || worldRef?.items || []) { + if (!structure?.isStructure || structure.dead) continue; + const d = distXY(x, y, structure.x, structure.y); + const p = clamp(1 - d / Math.max(1, radius + (structure.r || 12)), 0, 1); + if (p <= 0) continue; + if (structure.damage?.(Math.max(1, amount * (0.35 + p * 0.65)), worldRef, breaker)) hit++; + } + return hit; + } + + StructureRegistry.register({ + type: "grass_bed", + label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 100, + materials: { grassMaterial: 1 }, + roles: { sleepPlace: true, ownedStructure: true }, + needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 }, + create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); }, + onUse(user, structure) { + const ownerUse = user?.id && user.id === structure.ownerId; + if (ownerUse) structure.attachment = Math.min(100, (structure.attachment || 0) + 2); + structure.usedCount = (structure.usedCount || 0) + 1; + if (user) { + user.needShock = user.needShock || {}; + user.needShock.sleep = Math.min(0, (user.needShock.sleep || 0) - (ownerUse ? 18 : 8)); + user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - (ownerUse ? 12 : 4)); + user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - (ownerUse ? 18 : 6)); + } + return true; + }, + onDestroyed(structure, world, breaker) { + applyStructureNeedShock(structure.owner(world), 42, 46, breaker); + }, + }); + + StructureRegistry.register({ + type: "plushie", + label: "\u306c\u3044\u3050\u308b\u307f", + buildNeed: "fulfill", + buildTime: 4, + maxHp: 1, + materials: { grassMaterial: 1 }, + roles: { carriedObject: true, ownedStructure: true, fulfillObject: true }, + needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 }, + create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); }, + onUse(user, structure) { + if (user?.id === structure.ownerId) structure.attachment = Math.min(100, (structure.attachment || 0) + 1); + structure.usedCount = (structure.usedCount || 0) + 1; + if (user?.id === structure.ownerId) { + user.needShock = user.needShock || {}; + user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - 24); + user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - 4); + } + return true; + }, + onDestroyed(structure, world, breaker) { + applyStructureNeedShock(structure.owner(world), 55, 64, breaker); + }, + }); + + global.TarinaiStructure = TarinaiStructure; + global.StructureRegistry = StructureRegistry; + global.damageNearbyStructures = damageNearbyStructures; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/js/tarinai.js b/js/tarinai.js index d3cd670..4862e29 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -53,7 +53,12 @@ class Tarinai { constructor(world, opts = {}) { this.hunger = opts.hunger ?? rand(16, 45); this.loneliness = opts.loneliness ?? rand(18, 62); this.energy = opts.energy ?? rand(54, 98); - this.stress = opts.stress ?? rand(0, 20); + this.needs = opts.needs && typeof opts.needs === "object" && typeof createDefaultNeeds === "function" ? { ...createDefaultNeeds(), ...opts.needs } : (typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }); + this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needs) : 0; + this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" && typeof createDefaultNeeds === "function" ? { ...createDefaultNeeds(), ...opts.previousNeeds } : { ...this.needs }; + this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : null; + this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {}; + this.buildPlan = opts.buildPlan && typeof opts.buildPlan === "object" ? { ...opts.buildPlan } : null; this.hpBarTimer = opts.hpBarTimer ?? 0; this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index c5665cb..5c30997 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -58,18 +58,30 @@ const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 }; const fearMul = opts.fearMultiplier ?? profile.fear ?? 1; const threat = opts.threat ?? opts.target ?? null; - let target = null; - if ("target" in opts) target = opts.target; - else if (opts.destination) target = opts.destination; - else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination); - this.setActionState("panic", { - target, - reason: opts.reason || this.thought || "パニックになっている", - wake: opts.wake !== false, - sleeping: false, - }); - if (Number.isFinite(opts.fearTimer)) this.fearTimer = Math.max(this.fearTimer || 0, opts.fearTimer * fearMul); - else this.fearTimer = Math.max(this.fearTimer || 0, (opts.fear ?? 0.55) * fearMul); + const safetyShock = Math.max(40, (opts.fearTimer ?? opts.fear ?? 0.55) * 32 * fearMul, Number(opts.stress || 0) * 1.8); + const healthShock = Math.max(0, Number(opts.hurtTimer || 0) > 0 ? 45 : 0); + if (typeof applyNeedShock === "function") { + applyNeedShock(this, { safety: safetyShock, health: healthShock }, threat); + } else { + this.needShock = this.needShock || {}; + this.needShock.safety = Math.max(this.needShock.safety || 0, safetyShock); + if (healthShock > 0) this.needShock.health = Math.max(this.needShock.health || 0, healthShock); + } + let handled = false; + if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "パニックになっている"); + if (!handled) { + let target = null; + if ("target" in opts) target = opts.target; + else if (opts.destination) target = opts.destination; + else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination); + this.setActionState("panic", { + target, + reason: opts.reason || this.thought || "パニックになっている", + wake: opts.wake !== false, + sleeping: false, + }); + this.fearTimer = Math.max(this.fearTimer || 0, 2.4); + } if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer); if (Number.isFinite(opts.hurtTimer)) this.hurtTimer = Math.max(this.hurtTimer || 0, opts.hurtTimer); if (Number.isFinite(opts.awakeLockTimer)) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, opts.awakeLockTimer); @@ -77,10 +89,6 @@ this.fallTimer = Math.max(this.fallTimer || 0, opts.fallTimer); this.fallMax = Math.max(this.fallMax || 0, this.fallTimer); } - if (Number.isFinite(opts.stress) && opts.stress > 0) { - if (this.addStress) this.addStress(opts.stress, { threshold: opts.stressThreshold ?? 8, duration: opts.stressDuration ?? 4.2 }); - else this.stress = clamp((this.stress || 0) + opts.stress, 0, 130); - } if (opts.bubble) this.bubble?.(opts.bubble, opts.bubbleCooldown ?? 0.9, opts.bubbleColor || "rgba(84,66,86,0.80)"); this.lastPanicCause = opts.cause || opts.reason || this.thought || "panic"; this.lastPanicAt = this.world?.time || 0; diff --git a/js/tarinai_disease_nest.js b/js/tarinai_disease_nest.js index 5bbec40..fa06865 100644 --- a/js/tarinai_disease_nest.js +++ b/js/tarinai_disease_nest.js @@ -26,7 +26,7 @@ this.zunchiDiseaseSeverity = 0; this.zunchiDiseaseCooldown = 18; this.zunchiStain = clamp((this.zunchiStain || 0) - 35, 0, 100); - this.stress = clamp(this.stress - 8, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -18, safety: -6 }); this.thought = reason || "\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f"; audio.heal?.(); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(62,104,62,0.78)"); @@ -61,7 +61,7 @@ } if (this.zunchiDisease) { this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.004, 0, 1.25); - this.stress = clamp(this.stress + dt * (0.28 + 0.18 * this.zunchiDiseaseSeverity), 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * (5 + 4 * this.zunchiDiseaseSeverity) }); this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100); if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010)); this.emitZunchiDiseaseEffect(dt); @@ -136,7 +136,7 @@ this.explosionDiseaseTimer = 0; this.thought = reason || "\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"; audio.heal?.(); - this.stress = clamp(this.stress - 10, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -22, safety: -8 }); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); this.world?.log?.(`${this.name}\u306e\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "accident", { participants: [this] }); this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_explosion", reason }); @@ -168,7 +168,7 @@ this.fightDiseaseCooldown = 20; this.thought = reason || "\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"; audio.heal?.(); - this.stress = clamp(this.stress - 12, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -24, safety: -10 }); this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)"); this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight", { participants: [this] }); this.world?.emit?.("disease:cured", { tarinai: this, diseaseId: "disease_fight", reason }); @@ -311,7 +311,7 @@ applyWaterEffect(dt, source = "water") { const power = Math.max(0, dt || 0); - this.stress = clamp(this.stress - power * 2.4, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { health: -power * 12, safety: -power * 6 }); this.energy = clamp(this.energy + power * 0.5, 0, 100); this.hunger = clamp(this.hunger - power * 0.2, 0, 115); this.zunchiStain = clamp((this.zunchiStain || 0) - power * 10.5, 0, 100); @@ -329,7 +329,7 @@ this.fightDiseaseCooldown = Math.max(0, (this.fightDiseaseCooldown || 0) - dt); if (this.explosionDisease) { this.explosionDiseaseTimer = Math.max(0, (this.explosionDiseaseTimer || 0) - dt); - this.addStress ? this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }) : (this.stress = clamp(this.stress + dt * 0.22, 0, 130)); + if (this.addStress) this.addStress(dt * 0.22, { threshold: 9, duration: 3.6 }); if (Math.random() < dt * 2.1) { const a = rand(0, Math.PI * 2); this.world?.effects?.push(new Effect("explosion", this.x + Math.cos(a) * this.radius * rand(0.2, 0.9), this.y + Math.sin(a) * this.radius * rand(0.1, 0.8), { @@ -353,7 +353,7 @@ this.recoverFightDisease("\u81ea\u7136\u306b\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); } if (this.fightDisease) { - this.stress = clamp(this.stress + dt * 0.16, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { health: dt * 3, safety: dt * 2 }); if (Math.random() < dt * 0.30) this.wanderAngle += rand(-2.8, 2.8); if (Math.random() < dt * 0.08) this.bubble("?", 2.6, "rgba(150,78,44,0.80)"); } diff --git a/js/tarinai_identity_social.js b/js/tarinai_identity_social.js index 6d0776b..c50f81f 100644 --- a/js/tarinai_identity_social.js +++ b/js/tarinai_identity_social.js @@ -147,7 +147,7 @@ const changedA = this.adjustPersonality?.("neuroticism", 0.025, "after being hurt") || 0; const changedB = this.adjustPersonality?.("aggression", -0.015, "after being hurt") || 0; this.fearTimer = Math.max(this.fearTimer || 0, 1.2); - this.stress = clamp((this.stress || 0) + 5, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { safety: 9 }); return Boolean(changedA || changedB); }, @@ -256,9 +256,15 @@ const tolerance = Number.isFinite(opts.tolerance) ? opts.tolerance : 0.88; const delta = Math.max(0, (amount || 0) * tolerance); if (delta <= 0) return 0; + if (!this.needShock) this.needShock = {}; + const shock = Math.min(100, delta * 1.8); + this.needShock.safety = Math.max(this.needShock.safety || 0, shock); const before = this.stress || 0; - this.stress = clamp(before + delta, 0, 130); - const gained = Math.max(0, this.stress - before); + if (this.needs && typeof calculateStressFromNeeds === "function") { + const nextSafety = typeof quantizeNeed === "function" ? quantizeNeed((this.needs.safety || 0) + shock) : Math.min(100, (this.needs.safety || 0) + shock); + this.stress = calculateStressFromNeeds({ ...this.needs, safety: nextSafety }); + } + const gained = Math.max(delta, Math.max(0, (this.stress || 0) - before)); const threshold = opts.threshold ?? 8; if (gained >= threshold) this.showStressBar(opts.duration ?? 4.2); return gained; diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index fc9170d..03180e7 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -1,5 +1,324 @@ "use strict"; +const TARINAI_NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"]; +const TARINAI_NEED_PRIORITY = ["safety", "health", "food", "sleep", "social", "fulfill"]; +const TARINAI_NEED_LABELS = { + food: "\u6442\u990c", + sleep: "\u7761\u7720", + health: "\u5065\u5eb7", + safety: "\u5b89\u5168", + social: "\u95a2\u4fc2", + fulfill: "\u5145\u8db3", +}; +const TARINAI_NEED_PHRASES = { + food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b", + sleep: "\u306d\u3080\u3044", + health: "\u8abf\u5b50\u304c\u60aa\u3044", + safety: "\u3042\u3076\u306a\u3044", + social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", + fulfill: "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b", +}; + +function createDefaultNeeds() { + return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; +} + +function quantizeNeed(value) { + return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10)); +} + +function getNeedDisplayValue(value) { + return Math.max(0, Math.min(10, Math.round((Number(value) || 0) / 10))); +} + +function needPressure(value) { + const x = Math.max(0, Math.min(100, Number(value) || 0)) / 100; + return x * x * 100; +} + +function calculateStressFromNeeds(needs) { + return clamp( + needPressure(needs.food) * 0.10 + + needPressure(needs.sleep) * 0.12 + + needPressure(needs.health) * 0.25 + + needPressure(needs.safety) * 0.30 + + needPressure(needs.social) * 0.08 + + needPressure(needs.fulfill) * 0.15, + 0, + 130 + ); +} + + +function applyNeedShock(tarinai, deltas = {}, breaker = null) { + if (!tarinai || tarinai.dead) return false; + tarinai.needShock = tarinai.needShock || {}; + for (const key of TARINAI_NEED_KEYS) { + const delta = Number(deltas[key] || 0) || 0; + if (delta > 0) tarinai.needShock[key] = Math.max(tarinai.needShock[key] || 0, delta); + else if (delta < 0) tarinai.needShock[key] = Math.min(tarinai.needShock[key] || 0, delta); + } + if (breaker) tarinai.lastNeedShockBreaker = breaker; + if (tarinai.needs && typeof calculateStressFromNeeds === "function") { + const projected = { ...tarinai.needs }; + for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock[key] || 0)); + tarinai.stress = calculateStressFromNeeds(projected); + } + return true; +} + +function applyNeedRelief(tarinai, deltas = {}) { + return applyNeedShock(tarinai, deltas, null); +} + +function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") { + if (!tarinai || tarinai.dead) return false; + const before = tarinai.previousNeeds || createDefaultNeeds(); + const projected = { ...(tarinai.needs || createDefaultNeeds()) }; + for (const key of TARINAI_NEED_KEYS) projected[key] = quantizeNeed((projected[key] || 0) + (tarinai.needShock?.[key] || 0)); + const safetyDelta = (projected.safety || 0) - (before.safety || 0); + const healthDelta = (projected.health || 0) - (before.health || 0); + const fulfillDelta = (projected.fulfill || 0) - (before.fulfill || 0); + const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; + if (!shockTriggered) return false; + if (["panic", "intimidate", "fight", "birth_ritual"].includes(tarinai.state)) return false; + const aggression = Number(tarinai.currentPersonality?.aggression || 0); + if (breaker && aggression > 0.6) { + tarinai.setActionState?.("intimidate", { target: breaker, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true, sleeping: false }); + tarinai.intimidateTimer = Math.max(tarinai.intimidateTimer || 0, 2.0); + } else { + tarinai.setActionState?.("panic", { target: tarinai.panicDestination?.(breaker), reason, wake: true, sleeping: false }); + tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 2.6); + } + tarinai.lastNeedShockBreaker = null; + return true; +} + +function chooseTopNeedRandom(needs) { + const max = Math.max(...TARINAI_NEED_KEYS.map(key => Number(needs?.[key]) || 0)); + const tiedNeeds = TARINAI_NEED_KEYS.filter(key => (Number(needs?.[key]) || 0) === max); + return { need: pick(tiedNeeds), tiedNeeds, max }; +} + +function buildReasonText(chosenNeed, tiedNeeds, action) { + const chosenPhrase = action?.phrase || "\u5f85\u3063\u305f"; + const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : []; + if (tied.length) { + const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0]; + return `${TARINAI_NEED_PHRASES[ignored] || ignored}\u3051\u3069\u3001${chosenPhrase}\u3002`; + } + return `${TARINAI_NEED_PHRASES[chosenNeed] || chosenNeed}\u306e\u3067\u3001${chosenPhrase}\u3002`; +} + +function roleMatches(item, role) { + if (!item || item.dead || (item.amount != null && item.amount <= 0)) return false; + if (item.roles?.[role]) return true; + if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(item.type) || (typeof isServingFoodType === "function" && isServingFoodType(item.type)); + if (role === "drink") return item.type === "water" || item.type === "water_bowl" || item.type === "zunda_juice"; + if (role === "medicine") return item.type === "sweet" || item.type === "water" || item.type === "water_bowl" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(item.type)); + if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box"; + if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "zunchi", "splat"].includes(item.type); + if (role === "grassMaterial") return item.type === "grass"; + return false; +} + +function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { + let best = null, bestD = maxDist; + for (const item of world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || []) { + if (!roleMatches(item, role)) continue; + if (tarinai?.shouldAvoidTarget?.(item)) continue; + const d = dist(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + +function findNearestSleepPlace(world, tarinai, maxDist = 520) { + const own = findOwnedStructure(world, tarinai, "grass_bed", maxDist); + return own || world?.bestBedFor?.(tarinai, maxDist) || findNearestItemWithRole(world, tarinai, "sleepPlace", maxDist); +} + +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 findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) { + return findNearestItemWithRole(world, tarinai, materialRole, maxDist); +} + +function findOwnedStructure(world, tarinai, type = "", maxDist = Infinity) { + let best = null, bestD = maxDist; + for (const item of world?.items || []) { + if (!item?.isStructure || item.dead || item.ownerId !== tarinai.id) continue; + if (type && item.type !== type) continue; + const d = dist(tarinai, item); + if (d < bestD) { best = item; bestD = d; } + } + return best; +} + +function consumeGrassMaterial(material) { + if (!material) return false; + material.amount = Math.max(0, (material.amount || 0) - 18); + material.eatenAmount = (material.eatenAmount || 0) + 18; + if (Number.isFinite(material.growth)) material.growth = clamp(material.growth - 0.18, 0.04, 1.2); + return true; +} + +function moveToOrUse(t, target, state = "seek_food", reason = "") { + if (!target) return false; + t.setActionState?.(state, { target, reason, sleeping: false }); + return true; +} + +if (typeof window !== "undefined") { + Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, chooseActionForNeed, buildReasonText, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, calculateStressFromNeeds, applyNeedShock, applyNeedRelief, triggerNeedShockReaction }); +} + +function updateNeeds(tarinai, world) { + 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 needs = createDefaultNeeds(); + needs.food = (tarinai.hunger || 0) * 0.88; + needs.sleep = Math.max(0, 100 - (tarinai.energy || 0)); + const dayP = world?.dayProgress?.() || 0; + const night = dayP >= 0.62 || dayP < 0.18; + if (night) needs.sleep += 10; + needs.health = Math.max(0, (100 - (tarinai.energy || 100)) * 0.18 + (tarinai.zunchiStain || 0) * 0.32); + if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) needs.health += 42; + if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.stuckPushpinId || "")) needs.health += 36; + const danger = findNearbyDanger(world, tarinai, 170); + if (danger) needs.safety += danger.kind ? 78 : 48; + if ((tarinai.fearTimer || 0) > 0.12 || (tarinai.lastDamageAt || -999) + 5 > (world?.time || 0)) needs.safety += 34; + needs.social = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16); + if (tarinai.parentToFollow?.()) needs.social += 18; + if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) needs.social += 16; + const enemy = tarinai.strongestRelation?.("fear"); + if (enemy?.score > 12) needs.social += 10; + needs.fulfill = 28 + (openness > 0 ? openness * 18 : 0); + if (!findOwnedStructure(world, tarinai, "grass_bed")) needs.fulfill += 20; + const ownBed = findOwnedStructure(world, tarinai, "grass_bed"); + const ownPlushie = findOwnedStructure(world, tarinai, "plushie"); + if (ownBed) { + needs.sleep -= dist(tarinai, ownBed) < 80 ? 16 : 6; + needs.safety -= dist(tarinai, ownBed) < 100 ? 14 : 4; + needs.fulfill -= dist(tarinai, ownBed) < 120 ? 22 : 8; + } + if (!ownPlushie) needs.fulfill += 16; + else needs.fulfill -= ownPlushie.carriedById === tarinai.id ? 28 : 14; + if (findNearestItemWithRole(world, tarinai, "grassMaterial", 420)) needs.fulfill += 12; + for (const key of TARINAI_NEED_KEYS) { + const shock = Number(tarinai.needShock?.[key] || 0) || 0; + if (shock !== 0) needs[key] += shock; + } + needs.safety *= clamp(1 - Math.max(0, aggression) * 0.12 + Math.max(0, neuroticism) * 0.16, 0.75, 1.25); + needs.health *= clamp(1 + Math.max(0, neuroticism) * 0.14, 0.9, 1.22); + needs.fulfill *= clamp(1 + Math.max(0, openness) * 0.14, 0.9, 1.24); + for (const key of TARINAI_NEED_KEYS) needs[key] = quantizeNeed(needs[key]); + tarinai.previousNeeds = tarinai.needs ? { ...tarinai.needs } : createDefaultNeeds(); + tarinai.needs = needs; + tarinai.stress = calculateStressFromNeeds(needs); + if (tarinai.needShock) { + for (const key of TARINAI_NEED_KEYS) { + const shock = Number(tarinai.needShock[key] || 0) || 0; + tarinai.needShock[key] = shock > 0 ? Math.max(0, shock - 12) : Math.min(0, shock + 12); + } + } + return needs; +} + +const TARINAI_ACTIONS = [ + { id: "eat_food", need: "food", label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u98df\u3079\u305f", weight: 50, condition(t, world) { return !!findNearestItemWithRole(world, t, "food", 560); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "food", 560), "seek_food", this.label); } }, + { id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 24, condition(t, world) { return !!findNearestItemWithRole(world, t, "drink", 560); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "drink", 560), "seek_water", this.label); } }, + { id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42, condition(t, world) { return !!findNearestSleepPlace(world, t, 560); }, run(t, world) { const bed = findNearestSleepPlace(world, t, 560); if (bed && dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); bed?.use?.(t, world); t.startSleeping?.(bed, this.label); return true; } }, + { id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 18, condition() { return true; }, run(t) { t.startSleeping?.(null, this.label); return true; } }, + { id: "use_medicine", need: "health", label: "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u56de\u5fa9\u3057\u305f", weight: 34, condition(t, world) { return !!findNearestItemWithRole(world, t, "medicine", 620); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "medicine", 620), "seek_food", this.label); } }, + { id: "rest_to_recover", need: "health", label: "\u56de\u5fa9\u306e\u305f\u3081\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 24, condition() { return true; }, run(t) { t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); t.energy = clamp((t.energy || 0) + 1.6, 0, 100); return true; } }, + { id: "flee", need: "safety", label: "\u5371\u306a\u3044\u3082\u306e\u304b\u3089\u96e2\u308c\u3066\u3044\u308b", phrase: "\u96e2\u308c\u305f", weight: 40, condition(t, world) { return !!findNearbyDanger(world, t, 220); }, run(t, world) { const danger = findNearbyDanger(world, t, 220); t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); return true; } }, + { id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u96a0\u308c\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 30, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, + { id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38, condition(t, world) { return !!(t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420)); }, run(t, world) { const other = t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420); return moveToOrUse(t, other, "seek_friend", this.label); } }, + { id: "approach_parent_or_child", need: "social", label: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u305f", weight: 28, condition(t) { return !!t.parentToFollow?.(); }, run(t) { return moveToOrUse(t, t.parentToFollow?.(), "follow_parent", this.label); } }, + { id: "approach_mate", need: "social", label: "\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u76f8\u624b\u306b\u8fd1\u3065\u3044\u305f", weight: 24, condition(t, world) { return (t.reproductionTimer || 0) <= 8 && !!world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); }, run(t, world) { const other = world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); if (!other) return false; if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true; return moveToOrUse(t, other, "seek_friend", this.label); } }, + { id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 12, condition(t) { return !!t.recentFightRival?.(80, 180); }, run(t) { const rival = t.recentFightRival?.(80, 180); t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 1.6); return true; } }, + { id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 24, condition(t, world) { return !!world.nearest?.(t, ["ball"], 520); }, run(t, world) { return moveToOrUse(t, world.nearest?.(t, ["ball"], 520), "play_ball", this.label); } }, + { id: "build_grass_bed", need: "fulfill", label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u305f", weight: 34, condition(t, world) { return !findOwnedStructure(world, t, "grass_bed") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); } }, + { id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 28, condition(t, world) { return !findOwnedStructure(world, t, "plushie") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); } }, + { id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 22, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, + { id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f", weight: 18, condition(t, world) { return !!findOwnedStructure(world, t, "plushie"); }, run(t, world) { const plushie = findOwnedStructure(world, t, "plushie"); plushie?.use?.(t, world); t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false }); return true; } }, + { id: "wander_lightly", need: "fulfill", label: "\u5c11\u3057\u6b69\u3044\u3066\u3044\u308b", phrase: "\u5c11\u3057\u6b69\u3044\u305f", weight: 14, condition() { return true; }, run(t) { t.wanderAngle += rand(-1.7, 1.7); t.goIdle?.(this.label); return true; } }, +]; + +function buildOwnedStructureFromGrass(t, world, type, label) { + const material = findNearbyMaterial(world, t, "grassMaterial", 520); + if (!material || !globalThis.StructureRegistry) return false; + t.buildPlan = { + type, + materialId: material.id, + timer: Math.max(0.4, t.buildPlan?.type === type ? Number(t.buildPlan.timer || 0) : 4), + }; + if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) return moveToOrUse(t, material, "seek_material", label); + t.setActionState?.("build", { target: material, reason: label, sleeping: false }); + return true; +} + +function continueBuildPlan(t, world, dt) { + const plan = t.buildPlan; + if (!plan?.type || !globalThis.StructureRegistry) return false; + if (findOwnedStructure(world, t, plan.type)) { + t.buildPlan = null; + return false; + } + let material = (world?.items || []).find(item => item && !item.dead && item.id === plan.materialId && roleMatches(item, "grassMaterial")); + if (!material) material = findNearbyMaterial(world, t, "grassMaterial", 520); + if (!material) { + t.buildPlan = null; + return false; + } + plan.materialId = material.id; + const label = globalThis.StructureRegistry.get(plan.type)?.label || "\u69cb\u9020\u7269"; + if (dist(t, material) > (t.radius || 20) + (material.r || 12) + 18) { + moveToOrUse(t, material, "seek_material", `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`); + return true; + } + plan.timer = Math.max(0, Number(plan.timer || 0) - Math.max(0.05, dt || 0.1)); + t.setActionState?.("build", { target: material, reason: `${label}\u3092\u4f5c\u3063\u3066\u3044\u308b`, sleeping: false }); + if (plan.timer > 0) return true; + consumeGrassMaterial(material); + const structure = globalThis.StructureRegistry.create(plan.type, t, t.x + rand(-18, 18), t.y + rand(16, 28), world); + if (!structure) { + t.buildPlan = null; + return false; + } + world.items.push(structure); + world.drawListDirty = true; + structure.use?.(t, world); + t.setActionState?.("idle", { target: structure, reason: `${label}\u3092\u4f5c\u3063\u305f`, sleeping: false }); + applyNeedRelief(t, { fulfill: -(plan.type === "plushie" ? 50 : 36), safety: plan.type === "plushie" ? -4 : -8 }); + t.buildPlan = null; + return true; +} + +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 chooseActionForNeed(need, tarinai, world) { + const actions = TARINAI_ACTIONS.filter(action => action.need === need && (!action.condition || action.condition(tarinai, world))); + return weightedPickAction(actions) || TARINAI_ACTIONS.find(action => action.id === "wander_lightly"); +} + (function (global) { const Tarinai = global.Tarinai; if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js"); @@ -56,7 +375,7 @@ if (this.fightDisease) return "stress_dizzy"; if ((this.oshiriByoZunchiStock || 0) >= 6) return this.variantSprite ? this.variantSprite("hurt") : "weak"; if (this.isZunchiSlave) return this.zunchiSlaveSpriteId(); - if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry"; + if (this.type === "cry" && (this.stress > 78 || this.hunger > 96 || 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"); @@ -68,7 +387,7 @@ 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.fightTimer > 0.04) return "angry"; - if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress"); + if (this.stress > 62) return this.variantSprite("stress"); if (low) return low; if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); if (this.surpriseTimer > 0.08) return "pokan"; @@ -76,9 +395,9 @@ if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode; 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.state === "seek_food") return "drool"; - if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress"); - if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode; - if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry"; + if (this.stress < 24 && (this.needs?.food || 0) < 60 && (this.needs?.social || 0) < 60 && (this.needs?.fulfill || 0) < 60) return this.variantSprite("low_stress"); + if (this.stress < 44 && (this.needs?.food || 0) < 60) return this.goodMode; + if ((this.needs?.food || 0) > 70 || (this.needs?.social || 0) > 80) return "angry"; return this.goodMode || this.variantSprite("normal"); }, @@ -104,8 +423,8 @@ 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.mood < 46 || this.stress > 64) return false; - return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); + if (this.stress > 64 || (this.needs?.safety || 0) >= 70) return false; + return (this.needs?.social || 0) < 70 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); }, sunbathSpriteId() { @@ -135,24 +454,24 @@ const sick = this.zunchiDisease || this.sleepDisease || this.explosionDisease || this.fightDisease; if (!sick || Math.random() >= 0.75) return false; let healed = false; - if (this.zunchiDisease && this.recoverZunchiDisease) healed = this.recoverZunchiDisease("日光浴でずんち病が治まった") || healed; - if (this.sleepDisease && this.recoverSleepDisease) healed = this.recoverSleepDisease("日光浴でねむり病が治った") || healed; - if (this.explosionDisease && this.recoverExplosionDisease) healed = this.recoverExplosionDisease("日光浴で爆発病が治った") || healed; - if (this.fightDisease && this.recoverFightDisease) healed = this.recoverFightDisease("日光浴できずつき病が治った") || healed; - if (healed) this.world?.log?.(`${this.name}は日光浴で病気が軽くなった。`, "event", { participants: [this] }); + 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 (this.fightDisease && this.recoverFightDisease) healed = this.recoverFightDisease("\u65e5\u5149\u6d74\u3067\u304d\u305a\u3064\u304d\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; }, startSunbath() { if (!this.canStartSunbath()) return false; - this.setActionState?.("sunbath", { target: null, reason: "日なたぼっこ", sleeping: false }); + this.setActionState?.("sunbath", { target: null, reason: "\u65e5\u306a\u305f\u307c\u3063\u3053", sleeping: false }); this.sunbathTimer = rand(6, 14); this.sunbathCooldown = rand(16, 34); this.sunbathFrameTimer = 0; this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); this.sunbathRecoveryChecked = false; this.tryRecoverBySunbath(); - if ((this.world?.time || 0) >= (this.nextBubbleAt || 0) && Math.random() < 0.45) this.bubble("ぽかぽか", 4.0, "rgba(112,86,36,0.78)"); + 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; }, @@ -164,7 +483,7 @@ this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3); this.sunbathFrameTimer = rand(0.55, 1.15); } - this.stress = clamp((this.stress || 0) - dt * 1.15, 0, 130); + applyNeedRelief(this, { safety: -dt * 6, fulfill: -dt * 4 }); this.energy = clamp((this.energy || 0) + dt * 0.08, 0, 100); if (!this.isSunbathTime() || this.hunger > 74 || this.energy < 10 || this.state === "panic") this.sunbathTimer = 0; if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") { @@ -180,7 +499,7 @@ if (d > touch) return; const p = clamp(1 - d / touch, 0, 1); const relief = dt * (1.35 + 2.65 * p); - this.stress = clamp((this.stress || 0) - relief, 0, 130); + applyNeedRelief(this, { safety: -relief * 1.2, fulfill: -relief * 1.6 }); this.adjustPersonality("openness", dt * 0.006 * (0.4 + p), "after being petted."); this.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5); this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); @@ -222,7 +541,7 @@ this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt); this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt); if (this.fightMochiTimer > 0.04) { - this.stress = clamp(this.stress + dt * 0.11, 0, 130); + applyNeedShock(this, { safety: dt * 4 }); if (this.state !== "fight") this.fearTimer = Math.max(this.fearTimer || 0, 0.10); } if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null; @@ -246,13 +565,13 @@ } this.applyCursorContactCare(dt); const colonyMoodId = this.world?.colonyMood?.id || "relaxed"; - if (colonyMoodId === "weaklings" && !this.isZunchiSlave) this.stress = clamp((this.stress || 0) - dt * 0.28, 0, 130); - if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") this.stress = clamp((this.stress || 0) - dt * 0.34, 0, 130); + if (colonyMoodId === "weaklings" && !this.isZunchiSlave) applyNeedRelief(this, { safety: -dt * 2, fulfill: -dt * 1 }); + if (colonyMoodId === "zunda_party" || colonyMoodId === "ball_party") applyNeedRelief(this, { fulfill: -dt * 3 }); if (colonyMoodId === "ant_overrun" && this.state !== "panic") { const antSeen = (this.world?.nearbyAnts?.(this.x, this.y, 110, true) || []).some(a => a && !a.dead); if (antSeen) { - if (this.enterPanic) this.enterPanic({ target: null, reason: "アリだ!", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); - else { this.setActionState?.("panic", { target: null, reason: "アリだ!", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); } + if (this.enterPanic) this.enterPanic({ target: null, reason: "\u30a2\u30ea\u3060", fear: 1.1, wake: true, cause: "colony_ant_overrun" }); + else { this.setActionState?.("panic", { target: null, reason: "\u30a2\u30ea\u3060", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 1.1 * this.personalityProfile().fear); } } } const wasFighting = this.fightTimer > 0.04; @@ -297,19 +616,19 @@ this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8); this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social); this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1); - this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress; - this.stress += tooCold * dt * 0.25; + // stress is derived from needs; no independent stress decay. + if (tooCold > 0) applyNeedShock(this, { health: tooCold * dt * 4, safety: tooCold * dt * 2 }); const phase = this.world.lightLevel(); if (phase < 0.25) this.energy += dt * 0.07; - if (phase > 0.75) this.stress += dt * 0.04; + if (phase > 0.75) applyNeedShock(this, { health: dt * 1.2 }); if (this.world.weather === "cloudy") { - this.stress -= dt * 0.018; + applyNeedRelief(this, { safety: -dt * 1 }); this.vx *= Math.pow(0.996, dt * 60); this.vy *= Math.pow(0.996, dt * 60); } else if (this.world.weather === "light_rain") { this.energy += dt * 0.018; - this.stress -= dt * 0.025; + applyNeedRelief(this, { health: -dt * 1, safety: -dt * 1 }); } else if (this.world.weather === "sunny") { this.vx *= Math.pow(1.001, dt * 60); this.vy *= Math.pow(1.001, dt * 60); @@ -320,7 +639,7 @@ this.hunger = clamp(this.hunger, 0, 115); this.loneliness = clamp(this.loneliness, 0, 110); this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); + this.stress = calculateStressFromNeeds(this.needs || createDefaultNeeds()); const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); @@ -371,14 +690,14 @@ applyEnvironment(dt) { const parent = this.parentToFollow ? this.parentToFollow() : null; if (parent && dist(this, parent) < 96) { - this.stress = clamp(this.stress - dt * 0.42, 0, 130); + applyNeedRelief(this, { social: -dt * 4, safety: -dt * 2 }); this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near family."); } const friend = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 92) : null; const friendCount = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; if (friend) { - this.stress = clamp(this.stress - dt * 0.36, 0, 130); + applyNeedRelief(this, { social: -dt * 3, fulfill: -dt * 1 }); this.adjustPersonality("sociability", dt * 0.004 * this.sociabilityFriendScale(), "after staying near friends."); } else if (friendCount <= 0 && this.loneliness > 72 && this.age > 8) { this.adjustPersonality("sociability", -dt * 0.0018, "after having no friends."); @@ -395,9 +714,9 @@ if (!it || it.dead || it.type !== "nest_box") continue; nestComfort += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 120, 0, 1); } - if (nestComfort > 0) this.stress = clamp(this.stress - dt * Math.min(1.15, nestComfort * 0.62), 0, 130); + if (nestComfort > 0) applyNeedRelief(this, { sleep: -dt * Math.min(4, nestComfort * 3), safety: -dt * Math.min(5, nestComfort * 4) }); if (grassComfort > 0) { - this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); + applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) }); if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); if (this.need === "\u9759\u3051\u3055" || this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); } @@ -418,10 +737,11 @@ }, isSleepFurniture(target) { - return Boolean(target && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box"))); + return Boolean(target && (target.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(target.type) : (target.type === "bed" || target.type === "nest_box")))); }, sleepFurnitureLabel(target) { + if (target?.label) return target.label; return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a"; }, @@ -513,11 +833,11 @@ if (!z || z.stage !== "fresh") return; const d = dist(this, z); const push = clamp(1 - d / 90, 0, 1); - this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130); + 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 = "新鮮なずんちを避けている"; + 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) { @@ -527,18 +847,18 @@ this.sleeping = false; if (this.birthRitualTimer > 0.04) { - this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "近くで体をゆすっている" }); + this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b" }); return; } if (this.intimidateTimer > 0.04) { - this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "相手を威嚇している" }); + this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); this.bubble("!", 1.2); return; } if (this.fightTimer > 0.04) { - this.setActionState?.("fight", { reason: "喧嘩している" }); + this.setActionState?.("fight", { reason: "\u55a7\u5629\u3057\u3066\u3044\u308b" }); this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; let best = null, bestD = Infinity; @@ -548,7 +868,7 @@ const d = dist(this, t); if (d < bestD) { best = t; bestD = d; } } - this.setActionState?.("fight", { target: best, reason: "喧嘩している" }); + this.setActionState?.("fight", { target: best, reason: "\u55a7\u5629\u3057\u3066\u3044\u308b" }); this.bubble("!", 1.6); return; } @@ -568,265 +888,55 @@ const pin = this.currentLodgedPin?.() || null; const pinBehavior = this.currentLodgedPinBehavior?.() || null; if (pin && pinBehavior?.panicOnAttach) { - if (this.enterPanic) this.enterPanic({ threat: pin, reason: "画鋲が刺さってパニックになっている", fear: 0.60, hurtTimer: 0.24, awakeLockTimer: 1.8, bubble: "!!", bubbleColor: "rgba(168,72,72,0.82)", cause: "pushpin_stuck" }); - else { this.setActionState?.("panic", { target: this.panicDestination(pin), reason: "画鋲が刺さってパニックになっている", wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.60); } + 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); this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; - return; } this.stuckPushpinId = null; } - - const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null; - const panicSource = (this.world?.nearbyTarinai?.(this.x, this.y, 128, true) || []) - .find(o => o && o !== this && !o.dead && !this.world?.isTarinaiHiddenInNestBox?.(o) && o.state === "panic" && (o.fearTimer || 0) > 0.12); - if (panicSource && !["panic", "sleep", "seek_bed", "fight", "birth_ritual", "eat", "sunbath"].includes(this.state)) { - const d = distXY(this.x, this.y, panicSource.x, panicSource.y); - const neuro = typeof effectivePersonalityValue === "function" ? effectivePersonalityValue(this, "neuroticism") : (this.currentPersonality?.neuroticism || 0); - const distFactor = clamp(1 - d / 128, 0, 1); - const stressFactor = clamp(((this.stress || 0) - 35) / 80, 0, 0.22); - const chance = clamp(0.10 + Math.max(0, neuro) * 0.36 + distFactor * 0.18 + stressFactor, 0.05, 0.76); - if (Math.random() < chance) { - if (this.enterPanic) this.enterPanic({ threat: panicSource.target || panicSource, reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, fear: 0.75, stress: 3 + distFactor * 5, stressThreshold: 6, cause: "nearby_panic_chain" }); - else { this.setActionState?.("panic", { target: this.panicDestination(panicSource.target || panicSource), reason: `${panicSource.name || "近くのたりない"}のパニックにつられている`, wake: true }); this.fearTimer = Math.max(this.fearTimer || 0, 0.75 * this.personalityProfile().fear); this.addStress ? this.addStress(3 + distFactor * 5, { threshold: 6 }) : (this.stress = clamp((this.stress || 0) + 3 + distFactor * 5, 0, 130)); } - return; + + if (continueBuildPlan(this, this.world, dt)) return; + + const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds(); + const needs = updateNeeds(this, this.world); + const breaker = this.lastNeedShockBreaker || null; + const safetyDelta = (needs.safety || 0) - (beforeNeeds.safety || 0); + const healthDelta = (needs.health || 0) - (beforeNeeds.health || 0); + const fulfillDelta = (needs.fulfill || 0) - (beforeNeeds.fulfill || 0); + const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; + if (shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { + const aggression = Number(this.currentPersonality?.aggression || 0); + if (breaker && aggression > 0.6) { + this.setActionState?.("intimidate", { target: breaker, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true, sleeping: false }); + this.intimidateTimer = Math.max(this.intimidateTimer || 0, 2.0); + } else { + this.setActionState?.("panic", { target: this.panicDestination?.(breaker), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true, sleeping: false }); + this.fearTimer = Math.max(this.fearTimer || 0, 2.6); } - } - - const nearbyAnts = this.world.nearbyAnts?.(this.x, this.y, 112, true) || []; - let nearestAnt = null; - let nearestAntDist = Infinity; - for (const ant of nearbyAnts) { - const ad = distXY(this.x, this.y, ant.x, ant.y); - if (ad < nearestAntDist) { - nearestAnt = ant; - nearestAntDist = ad; - } - } - if (nearestAnt) { - const aggressiveToAnts = this.shouldApplyPersonalityBehavior("aggression", 1); - if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) { - if (nearestAntDist < Math.max(36, this.radius + (nearestAnt.r || 4) + 8) && Math.random() < dt * 0.28) { - // Intimidation has no effect on ants. This is only a visible reaction. - this.setActionState?.("ant_intimidate", { target: nearestAnt, reason: "アリを威嚇している" }); - this.bubble("!", 0.9, "rgba(145,68,52,0.78)"); - return; - } - this.setActionState?.("ant_attack", { target: nearestAnt, reason: "アリを攻撃している" }); - this.goodMode = "angry"; - return; - } - if (this.enterPanic) this.enterPanic({ threat: nearestAnt, reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", fear: 0.34, wake: true, cause: "ant_seen" }); - else this.setActionState?.("panic", { target: this.panicDestination(nearestAnt), reason: "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b", wake: true }); + this.lastNeedShockBreaker = null; return; } - - const dayP = this.world.dayProgress(); - const personalSleep = this.sleepStart < this.sleepEnd - ? dayP >= this.sleepStart && dayP < this.sleepEnd - : dayP >= this.sleepStart || dayP < this.sleepEnd; - - if (this.world.canStartFight?.(this) && this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const opponent = this.world.nearestOther(this, 320, o => this.world.canBeFightTarget?.(o)); - if (opponent) { - const d = dist(this, opponent); - if (d <= Math.max(48, this.radius + opponent.radius + 18)) { - if (this.world.startForcedFight?.(this, opponent)) return; - } - this.setActionState?.("seek_friend", { target: opponent, reason: "けんか餅で相手を探している" }); - this.goodMode = "angry"; - if (Math.random() < dt * 0.24) this.bubble("!", 1.4, "rgba(160,74,45,0.78)"); - return; - } - } - - const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null; - const feared = this.strongestRelation("fear"); - if (!wasSleeping && (recentRival || (feared && feared.score > 12))) { - const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead); - if (rival && dist(this, rival) < 150) { - if (this.enterPanic) this.enterPanic({ threat: rival, reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, fear: 0.35, wake: true, cause: "avoid_rival" }); - else this.setActionState?.("panic", { target: this.panicDestination(rival), reason: `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`, wake: true }); - this.world.relationNotice(this.id, rival.id, "avoid", 22); - return; - } - } - - if (this.hasWaterCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const water = this.world.nearest(this, ["water", "water_bowl"], 760); - if (water && !water.dead) { - this.setActionState?.("seek_water", { target: water, reason: this.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している" }); - return; - } - if (this.zunchiDisease) { - this.setActionState?.("zunchi_sick", { target: null, reason: "ずんち病で当てもなく彷徨っている" }); - if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4); - return; - } - } - - if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - const cureFood = this.world.nearest(this, ["sweet"], 700); - if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { - this.setActionState?.("seek_food", { target: cureFood, reason: this.fightDisease ? "きずつき病でずんだ餅を探している" : "爆発病でずんだ餅を探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); - return; - } - } - - if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { - this.setActionState?.("fight_sick", { target: null, reason: "きずつき病で当てもなく彷徨っている" }); - if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); - return; - } - - if (!this.isZunchiSlave) { - const medicineTypes = window.TarinaiFoodRegistry?.typesByInterest?.("medicine_low") || ["love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"]; - const specialMochi = this.world.nearest(this, medicineTypes, wasSleeping ? 190 : 155); - const specialDist = specialMochi ? distXY(this.x, this.y, specialMochi.x, specialMochi.y) : Infinity; - const weakInterest = this.hunger > (wasSleeping ? 68 : 76) && (specialDist < 72 || Math.random() < dt * 0.028); - if (specialMochi && this.foodItemHasServingLeft(specialMochi) && weakInterest && !this.shouldAvoidTarget(specialMochi)) { - this.setActionState?.("seek_food", { target: specialMochi, reason: `${toolLabel(specialMochi.type)}が少し気になっている` }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.18); - return; - } - } else if (this.hunger > 20) { - const zOnly = this.world.nearest(this, ["zunchi"], 520); - if (zOnly && zOnly.amount > 1.2) { - this.setActionState?.("seek_food", { target: zOnly, reason: "ずんちを探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - return; - } - } - - const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda; - const zunda = this.world.nearest(this, ["sweet"], zundaRange); - const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); - const zundaReward = this.affection > 8 && this.hunger > 7; - if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { - this.setActionState?.("seek_food", { target: zunda, reason: "ずんだ餅を探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); - return; - } - - if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) { - if (this.enterPanic) this.enterPanic({ reason: "\u9003\u3052\u3066\u3044\u308b", fear: 0.65, wake: true, cause: "fear_or_stress" }); - else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u9003\u3052\u3066\u3044\u308b", wake: true }); - return; - } - - const sleepBias = this.personalityProfile().sleep; - const wakeLocked = (this.awakeLockTimer || 0) > 0 && !wasSleeping; - const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias); - if (!wakeLocked && (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias)) { - let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null; - if (!bed && friendNearby && this.isSleepFurniture(friendNearby.target) && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target; - if (!bed) bed = this.world.bestBedFor(this, wasSleeping ? 480 : 420); - if (bed && !wasSleeping && this.energy > 18) { - const spot = this.sleepSpotFor(bed); - if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { - this.setActionState?.("seek_bed", { target: bed, reason: `${this.sleepFurnitureLabel(bed)}へ向かっている` }); - return; - } - } - this.startSleeping?.(bed, bed ? (bed.type === "nest_box" ? "巣箱の中で眠っている" : "ベッドの近くで眠っている") : "眠っている"); - if (this.world.time > this.nextSleepBubbleAt) { - this.nextSleepBubbleAt = this.world.time + 4.5; - this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); - } - this.thought = bed ? (bed.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b") : "\u7720\u3063\u3066\u3044\u308b"; - return; - } - - const pointer = this.world.pointer; - if (pointer?.inside) { - const pd = distXY(this.x, this.y, pointer.x, pointer.y); - if (pd < 170) { - const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry"; - const friendly = !hostile && this.isAwakeCursorFriendly(); - if (hostile) { - this.setActionState?.("cursor_enemy", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルを避けている" }); - this.fearTimer = Math.max(this.fearTimer, 0.18); - if (this.world.time > this.nextFearBubbleAt) { - this.nextFearBubbleAt = this.world.time + 2.4; - this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)"); - } - this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b"; - return; - } - if (friendly) { - this.setActionState?.("cursor_friend", { target: { x: pointer.x, y: pointer.y, dead: false }, reason: "カーソルについていく" }); - this.goodMode = "smile"; - this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f"; - return; - } - } - } - - const playTrait = this.personalityProfile().play ?? 1; - const openPlay = this.shouldApplyPersonalityBehavior("openness", 1); - const closedPlay = this.shouldApplyPersonalityBehavior("openness", -1); - const ballRange = openPlay ? 540 : (closedPlay ? 170 : 240 + 60 * playTrait); - const ball = this.world.nearest(this, ["ball"], ballRange); - if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) { - const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); - const chasers = this.world.countBallChasers ? this.world.countBallChasers(ball, this) : 0; - const baseChance = openPlay ? 0.74 : (closedPlay ? 0.025 : 0.08); - const movingBonus = clamp(ballSpeed / 120, 0, 0.42); - const socialBonus = this.loneliness > 48 ? 0.10 : 0; - if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { - this.setActionState?.("play_ball", { target: ball, reason: openPlay ? "ボールに引き寄せられている" : "転がるものが気になっている" }); - this.goodMode = "smile"; - return; - } - } - - const parent = this.parentToFollow(); - if (parent && !this.shouldAvoidTarget(parent)) { - const d = dist(this, parent); - if (d > 34 || (this.hunger < 78 && this.energy > 24)) { - this.setActionState?.("follow_parent", { target: parent, reason: "親についていく" }); - this.bubble("?", 4.0); - return; - } - } - - if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "grass", "ant_corpse"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) { - this.setActionState?.("seek_food", { target: friendNearby.target, reason: "友達と一緒に食べに行く" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); - return; - } - - if (this.hunger > 62) { - const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])]; - const item = this.world.nearest(this, itemTypes, 420); - if (item) { - this.setActionState?.("seek_food", { target: item, reason: "食べ物を探している" }); - this.foodReactTimer = Math.max(this.foodReactTimer, 0.30); - return; - } - } - - const socialSeek = this.shouldApplyPersonalityBehavior("sociability", 1); - const lonerSeek = this.shouldApplyPersonalityBehavior("sociability", -1); - if (this.loneliness > 63 - (socialSeek ? 16 : 0) + (lonerSeek ? 10 : 0)) { - const other = this.world.nearestOther(this, socialSeek ? 390 : (lonerSeek ? 190 : 300)); - if (other) { - this.setActionState?.("seek_friend", { target: other, reason: "仲間を探している" }); - return; - } - } - - if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) { - if (this.enterPanic) this.enterPanic({ reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.55, wake: true, cause: "stress_panic" }); - else this.setActionState?.("panic", { target: this.panicDestination(), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); - return; - } - - if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7); - this.goIdle(`${this.need}がたりない`); + + const choice = chooseTopNeedRandom(needs); + let action = chooseActionForNeed(choice.need, this, this.world); + if (!action) action = TARINAI_ACTIONS.find(a => a.id === "wander_lightly"); + const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action); + this.intent = { + need: choice.need, + tiedNeeds: [...choice.tiedNeeds], + actionId: action?.id || "idle", + actionLabel: action?.label || "\u5f85\u3063\u3066\u3044\u308b", + reasonText, + }; + const ran = action?.run?.(this, this.world); + if (!ran) this.goIdle?.(this.intent.actionLabel); + this.thought = reasonText; + return; }, makePoop(force = 1) { @@ -835,8 +945,8 @@ if (this.hasLodgedPinEffect?.("blocksZunchi")) { this.digest = rand(0.4, 1.6); this.oshiriByoZunchiStock = Math.min(18, Math.max(0, this.oshiriByoZunchiStock || 0) + 1); - this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している"; - if ((this.oshiriByoZunchiStock || 0) >= 6) this.stress = clamp((this.stress || 0) + 1.8, 0, 130); + 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; } this.digest = rand(0.4, 1.6); @@ -864,7 +974,7 @@ if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { const eating = this.consumeFoodServing(it); if (eating > 0) { - this.beginEating(it, `${toolLabel(it.type)}を食べている`); + this.beginEating(it, `${toolLabel(it.type)}\u3092\u98df\u3079\u3066\u3044\u308b`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); const hungerRelief = this.foodHungerReliefFor(it.type, eating, it.type === "sweet" ? 0.75 : 0.95); @@ -872,7 +982,7 @@ this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; if (it.type === "sweet") { - this.stress = clamp(this.stress - eating * 0.42, 0, 130); + applyNeedRelief(this, { food: -eating * 0.4, fulfill: -eating * 0.2 }); this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); @@ -899,7 +1009,7 @@ if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) { const eating = this.consumeFoodServing(it); if (eating <= 0) continue; - this.beginEating(it, `${toolLabel(it.type)}を食べている`); + this.beginEating(it, `${toolLabel(it.type)}\u3092\u98df\u3079\u3066\u3044\u308b`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); const hungerRelief = this.foodHungerReliefFor(it.type, eating, 0.82); @@ -942,7 +1052,7 @@ if (canBite && it.type === "grass") { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); - this.beginEating(it, "草を食べている"); + this.beginEating(it, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b"); this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); this.vx *= Math.pow(0.20, dt * 60); this.vy *= Math.pow(0.20, dt * 60); @@ -951,7 +1061,7 @@ it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2); this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46); this.recoverHealth(bite * 0.18); - this.stress -= dt * 0.5; + applyNeedRelief(this, { food: -dt * 2, fulfill: -dt * 1 }); this.eatTimer = Math.max(this.eatTimer, 0.22); this.eatCooldown = rand(0.68, 1.15); this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); @@ -968,13 +1078,13 @@ if (canBite && it.type === "ant_corpse") { const bite = Math.min(it.amount, rand(3.2, 5.4)); if (bite > 0) { - this.beginEating(it, "アリの死骸を食べている"); + this.beginEating(it, "\u30a2\u30ea\u306e\u6b7b\u9ab8\u3092\u98df\u3079\u3066\u3044\u308b"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); it.amount -= bite; this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62); this.recoverHealth(bite * 0.12); - this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { safety: bite * 0.12 }); this.eatTimer = Math.max(this.eatTimer, 0.26); this.eatCooldown = rand(0.75, 1.25); this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); @@ -990,20 +1100,20 @@ if (it.type === "zunchi") { const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); if (!this.isZunchiSlave) { - this.stress = clamp(this.stress + touch * dt * 3.4 * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { health: touch * dt * 7, safety: touch * dt * 4 }); if (touch > 0.08) this.adjustPersonality("neuroticism", -dt * touch * 0.0045, "after repeated contact with poop."); } this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100); if (canZunchiBite) { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, (this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)) * servingScale); - this.beginEating(it, this.isZunchiSlave ? "ずんちどれいなのでずんちを食べている" : "ずんちを食べている"); + this.beginEating(it, this.isZunchiSlave ? "\u305a\u3093\u3061\u3069\u308c\u3044\u306a\u306e\u3067\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b" : "\u305a\u3093\u3061\u3092\u98df\u3079\u3066\u3044\u308b"); this.vx *= Math.pow(0.18, dt * 60); this.vy *= Math.pow(0.18, dt * 60); it.amount -= bite; this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * (this.isZunchiSlave ? 0.66 : 0.18)) : (this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18)); this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); - this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { health: bite * (this.isZunchiSlave ? 0.2 : 1.2), safety: bite * (this.isZunchiSlave ? 0.1 : 0.6) }); if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100); this.lastMealColor = "#6b8d3f"; @@ -1018,7 +1128,7 @@ } if (it.type === "water" || it.type === "water_bowl") { - this.thought = it.type === "water_bowl" ? "水の皿で水を飲んでいる" : "水を浴びて落ち着いている"; + this.thought = it.type === "water_bowl" ? "\u6c34\u306e\u76bf\u3067\u6c34\u3092\u98f2\u3093\u3067\u3044\u308b" : "\u6c34\u3092\u6d74\u3073\u3066\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; this.target = it; this.applyWaterEffect(dt, it.type); if ((this.world?.time || 0) > (this.nextDrinkSound || -999)) { @@ -1030,24 +1140,25 @@ } if (it.type === "stone") { - if (this.state === "idle" && (this.need === "あたたかさ" || this.stress > 48)) this.thought = "石の近くで落ち着いている"; - this.stress -= dt * 0.85; + if (this.state === "idle" && (this.need === "\u3042\u305f\u305f\u304b\u3055" || this.stress > 48)) this.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; + applyNeedRelief(this, { safety: -dt * 4, fulfill: -dt * 2 }); this.energy += dt * 0.18; if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; } if (this.isSleepFurniture(it)) { if (this.energy < 74 + 6 * this.personalityProfile().sleep) { + it.use?.(this, this.world); const occ = this.world.bedOccupancy(it); const comfort = this.world.bedComfort(it); - this.startSleeping?.(it, it.type === "nest_box" ? "巣箱の中で休んでいる" : "寝床の近くで休んでいる"); + this.startSleeping?.(it, it.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u5e8a\u306e\u8fd1\u304f\u3067\u4f11\u3093\u3067\u3044\u308b"); if (this.world.time > this.nextSleepBubbleAt) { this.nextSleepBubbleAt = this.world.time + 4.5; const crowded = occ > 5; this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)"); } this.energy += dt * (1.9 + comfort * 1.4); - this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort); + if (occ > 5) applyNeedShock(this, { safety: dt * 3 }); else applyNeedRelief(this, { sleep: -dt * 6 * comfort, safety: -dt * 4 * comfort }); it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1); } } @@ -1056,7 +1167,7 @@ const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); if (this.state === "play_ball" || this.target === it || this.shouldApplyPersonalityBehavior("openness", 1)) { this.goodMode = "smile"; - this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130); + applyNeedRelief(this, { fulfill: -dt * (4 + touch * 8), social: -dt * (1 + touch * 3) }); this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); if (this.state === "play_ball") this.foodReactTimer = Math.max(this.foodReactTimer, 0.10); } @@ -1065,19 +1176,19 @@ if (it.type === "splat") { const touch = clamp(1 - d / (this.radius + it.r + 16), 0, 1); if (touch > 0) { - this.stress = clamp(this.stress + touch * dt * 2.8 * this.personalityProfile().fear, 0, 130); + applyNeedShock(this, { safety: touch * dt * 5, health: touch * dt * 3 }); if (touch > 0.06) this.adjustPersonality("neuroticism", -dt * touch * 0.0042, "after repeated contact with corpses"); } } if (it.type === "trace") { this.loneliness -= dt * 0.25; - this.stress += dt * 0.06 * this.personalityProfile().fear; + applyNeedShock(this, { social: -dt * 1, safety: dt * 0.8 }); } } this.hunger = clamp(this.hunger, 0, 115); this.energy = clamp(this.energy, 0, 100); - this.stress = clamp(this.stress, 0, 130); + this.stress = calculateStressFromNeeds(this.needs || createDefaultNeeds()); this.loneliness = clamp(this.loneliness, 0, 110); } })); diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index c8af767..8905b6e 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -35,12 +35,12 @@ nonSlave.vx += nx * dt * 34; nonSlave.vy += ny * dt * 34; if (nonSlave.state === "idle" || nonSlave.state === "seek_friend") { - if (nonSlave.enterPanic) nonSlave.enterPanic({ threat: slave, reason: "ずんちどれいを怖がっている", fear: 0.38, wake: false, cause: "zunchi_slave_nearby" }); - else { nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); nonSlave.target = slave; nonSlave.thought = "ずんちどれいを怖がっている"; } + if (nonSlave.enterPanic) nonSlave.enterPanic({ threat: slave, reason: "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044", fear: 0.38, wake: false, cause: "zunchi_slave_nearby" }); + else { nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); nonSlave.target = slave; nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; } } else { nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); nonSlave.target = slave; - nonSlave.thought = "ずんちどれいを怖がっている"; + nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; } nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid"); @@ -66,15 +66,15 @@ this.affection += dt * 0.28 * this.trait.social; this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby"); if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) { - this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(this, { social: -dt * 5 * this.trait.social, fulfill: -dt * 2 }); this.affection = clamp(this.affection + dt * 0.18, 0, 80); if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation", { participants: [o, this] }); } if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD && this.world.relationNotice(this.id, o.id, "friend", 32)) { this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation", { participants: [o, this] }); } - if (this.type === "angry") this.stress += dt * 0.08; - if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12; + if (this.type === "angry" && typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 1.5, safety: dt * 1 }); + if (this.type === "teary" && o.type === "teary" && typeof applyNeedRelief === "function") applyNeedRelief(this, { social: -dt * 2 }); } const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id)); @@ -86,8 +86,8 @@ this.vy -= ny * rand(24, 44); o.vx += nx * rand(34, 64); o.vy += ny * rand(34, 64); - this.stress = clamp(this.stress + 1.6, 0, 130); - o.stress = clamp(o.stress + 2.8, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(this, { safety: 8, health: 4 }); + if (typeof applyNeedShock === "function") applyNeedShock(o, { safety: 12, health: 6 }); const damageToThis = o.outgoingDamage ? o.outgoingDamage(1.2) : 1.2; const damageToOther = this.outgoingDamage ? this.outgoingDamage(1.6) : 1.6; this.world.emit?.("fight:hit", { attacker: o, target: this, amount: damageToThis, cause: "\u55a7\u5629" }); @@ -106,7 +106,7 @@ o.fallDir = nx > 0 ? 1 : -1; this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55); } - if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) { + if (o.energy < this.energy - 8) { this.world.maybeDefeatFromFightDamage?.(o, this, Math.max(damageToOther, 2.4)); } this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5); @@ -122,7 +122,7 @@ const aggressiveOther = o.shouldApplyPersonalityBehavior?.("aggression", 1); const calmThis = this.shouldApplyPersonalityBehavior("aggression", -1); const calmOther = o.shouldApplyPersonalityBehavior?.("aggression", -1); - const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || aggressiveThis || aggressiveOther || battleDrug || o.isZunchiSlave || this.isZunchiSlave); + const fightRisk = (this.stress > 46 || o.stress > 46 || (this.needs?.safety || 0) > 60 || (o.needs?.safety || 0) > 60 || this.type === "angry" || o.type === "angry" || closeCount > 3 || aggressiveThis || aggressiveOther || battleDrug || o.isZunchiSlave || this.isZunchiSlave); const fearBrake = battleDrug ? clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 220, 0.74, 1.15) : clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1); const friendBrake = battleDrug ? clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 180, 0.72, 1.12) : clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1); const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight * (calmThis ? 0.62 : 1) * (calmOther ? 0.62 : 1); @@ -145,7 +145,7 @@ } if (closeCount > 8) { - this.stress += dt * 0.9 * this.personalityProfile().fear; + if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 5, safety: dt * 3 * this.personalityProfile().fear }); this.energy -= dt * 0.25; this.fearTimer = Math.max(this.fearTimer, 0.18); } @@ -174,7 +174,7 @@ const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0; const crowdedSleep = bedCrowd > 5; this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100); - this.stress = clamp(this.stress + dt * (crowdedSleep ? 0.18 * Math.max(1, bedCrowd - 5) : -0.12 * bedComfort), 0, 130); + if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); else if (typeof applyNeedRelief === "function") applyNeedRelief(this, { sleep: -dt * 3 * bedComfort, safety: -dt * 1.5 * bedComfort }); this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); if (this.target && !this.target.dead && this.target.type === "nest_box") { this.updateNestBoxPresence(dt); @@ -259,7 +259,8 @@ ? (this.insideNestBoxId === this.target.id || d <= Math.max(this.radius + 34, this.target.r * 1.08) || bedDist <= Math.max(this.target.r * 1.05, 62)) : (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)); if (reachedSleepFurniture) { - this.startSleeping?.(this.target, this.target.type === "nest_box" ? "巣箱の中で休んでいる" : "眠る場所に着いたので休んでいる"); + this.target.use?.(this, this.world); + this.startSleeping?.(this.target, this.target.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f11\u3093\u3067\u3044\u308b" : "\u5bdd\u308b\u5834\u6240\u306b\u7740\u3044\u305f\u306e\u3067\u4f11\u3093\u3067\u3044\u308b"); if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); return; } @@ -366,14 +367,6 @@ else if (this.stress >= 124) { reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; } - else if (this.mood <= 20) { - const lowMood = clamp((20 - this.mood) / 20, 0, 1); - const bodyLoad = clamp((this.hunger - 72) / 42, 0, 1) * 0.018 + clamp((28 - this.energy) / 28, 0, 1) * 0.020 + clamp((this.stress - 86) / 44, 0, 1) * 0.022; - const deathRisk = 0.010 + lowMood * 0.060 + bodyLoad; - if (Math.random() < Math.max(0, dt) * deathRisk) { - reason = this.dominantDeathCause("", { lowMood: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - } - } if (reason) this.die(reason); }, diff --git a/js/ui_selected.js b/js/ui_selected.js index 1faf3f5..88b9048 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -192,8 +192,10 @@ function renderSelected() { personalityTags.join(",") ].join(","); const itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : []; + const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; const snapshot = [ t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), + t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","), fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness), fmt(t.energy), fmt(t.stress), fmt(t.mood), t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), @@ -239,6 +241,7 @@ function renderSelected() { const basicHtml = `
\u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
\u72b6\u614b${escapeHtml(stateLabel(t.state))}
+
\u3044\u307e${escapeHtml(t.intent?.actionLabel || stateLabel(t.state))}
\u7406\u7531${escapeHtml(reasonLabel(t))}
\u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
\u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
@@ -247,13 +250,16 @@ function renderSelected() {
\u79fb\u52d5\u901f\u5ea6${geneticPercentText(t, "speed")}
\u30b5\u30a4\u30ba${geneticSizeText(t)}
`; + const needRowsHtml = needKeys.map(key => { + const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key; + const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10); + const chosen = t.intent?.need === key ? ` data-chosen-need="true"` : ""; + return `
${escapeHtml(label)}${escapeHtml(value)}
`; + }).join(""); const healthHtml = [ - selectedInfoRowHtml("\u7dcf\u5408\u7684\u6c17\u5206", fmt(t.mood), "mood"), - selectedInfoRowHtml("\u7a7a\u8179", fmt(t.hunger), "hunger"), - selectedInfoRowHtml("\u5bc2\u3057\u3055", fmt(t.loneliness), "loneliness"), + needRowsHtml, selectedInfoRowHtml("\u4f53\u529b", fmt(t.energy), "energy"), selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"), - selectedInfoRowHtml("\u305a\u3093\u3061\u6c5a\u308c", fmt(t.zunchiStain || 0), "zunchi"), ].join(""); const itemEffectHtml = itemEffectSummary.length ? itemEffectSummary.map(e => `
${escapeHtml(e.label)}${escapeHtml(e.detail)}
`).join("") @@ -297,6 +303,7 @@ function stateLabel(s) { } function reasonLabel(t) { + if (t?.intent?.reasonText) return t.intent.reasonText; return window.TEXT_CATALOG?.reasonLabel?.(t) || "なし"; } diff --git a/js/version.js b/js/version.js index ec564a1..0a7e113 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "15.24.0"; - const APP_BUILD = "safe-refactor-ui-fix"; + const APP_VERSION = "15.24.2"; + const APP_BUILD = "needs-compliance-fix"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_combat_effects.js b/js/world_combat_effects.js index 843e9b0..d1fd863 100644 --- a/js/world_combat_effects.js +++ b/js/world_combat_effects.js @@ -68,7 +68,7 @@ if (t.enterPanic) t.enterPanic({ target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", fear: 1.2, surpriseTimer: 0.95, wake: true, cause: "firecracker_wakeup" }); else { t.setActionState?.("panic", { target: { x, y, dead: false }, reason: "\u7206\u7af9\u3067\u305f\u305f\u304d\u8d77\u3053\u3055\u308c\u305f", wake: true }); t.surpriseTimer = Math.max(t.surpriseTimer, 0.95); t.fearTimer = Math.max(t.fearTimer, 1.2); } } - t.addStress ? t.addStress(rand(8, 15), { threshold: 8 }) : (t.stress = clamp(t.stress + rand(8, 15), 0, 130)); + if (t.addStress) t.addStress(rand(8, 15), { threshold: 8 }); t.fearTimer = Math.max(t.fearTimer, 0.58); const dx = t.x - x; const dy = t.y - y; @@ -76,7 +76,7 @@ if (d > blastRadius) continue; const p = clamp(1 - d / blastRadius, 0, 1); t.damage(5 + p * 18, "\u7206\u7af9"); - t.addStress ? t.addStress(22 * p, { threshold: 8 }) : (t.stress = clamp(t.stress + 22 * p, 0, 130)); + if (t.addStress) t.addStress(22 * p, { threshold: 8 }); t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8); t.surpriseTimer = Math.max(t.surpriseTimer, 0.85); if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" }); @@ -106,6 +106,7 @@ if (!t.dead && p > 0.22 && Math.random() < (t.diseaseChance ? t.diseaseChance(explosionChance) : explosionChance)) t.infectExplosionDisease?.(it); if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); } + if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, x, y, blastRadius, 34 * (it.blastScale || 1), it); this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", { push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1), }); @@ -166,7 +167,7 @@ if (d > blastRadius) continue; const q = clamp(1 - d / blastRadius, 0, 1); o.damage(5 + q * 18, "\u7206\u767a\u75c5"); - o.addStress ? o.addStress(22 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 22 * q, 0, 130)); + if (o.addStress) o.addStress(22 * q, { threshold: 8 }); let nx = dx / d; let ny = dy / d; if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) { @@ -365,7 +366,7 @@ }, isSleepFurniture(it) { - return Boolean(it && (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (it.type === "bed" || it.type === "nest_box"))); + return Boolean(it && (it.roles?.sleepPlace || (typeof isSleepFurnitureType === "function" ? isSleepFurnitureType(it.type) : (it.type === "bed" || it.type === "nest_box")))); }, isTarinaiHiddenInNestBox(t) { @@ -412,8 +413,10 @@ } return best; }; - // Prefer an available nest box, then fall back to a hay bed. - return choose("nest_box") || choose("bed"); + const owned = (this.nearbyItems(t.x, t.y, maxDist) || []) + .filter(bed => bed && !bed.dead && bed.ownerId === t.id && bed.roles?.sleepPlace) + .sort((a, b) => dist(t, a) - dist(t, b))[0] || null; + return owned || choose("nest_box") || choose("bed") || choose("grass_bed"); }, relationNotice(aId, bId, kind, cooldown = 30) { @@ -525,7 +528,7 @@ loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear); this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)"); winner.affection = clamp(winner.affection + 0.8, 0, 80); - winner.stress = clamp(winner.stress - rand(9, 18), 0, 130); + if (typeof applyNeedRelief === "function") applyNeedRelief(winner, { safety: -rand(12, 22), fulfill: -rand(8, 16) }); if (this.relationNotice(winner.id, loser.id, "fight-result", 8)) { this.log(`${loser.name}\u306f${winner.name}\u306b\u8ca0\u3051\u3001\u305d\u306e\u3053\u3068\u3092\u899a\u3048\u305f\u3002`, "fight", { participants: [loser, winner] }); } @@ -574,7 +577,7 @@ t.damage(dealtDamage, reason); if (isGenkotsu) { t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2); - t.stress = clamp((t.stress || 0) + 10 + p * 18, 0, 130); + if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 }); t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu."); t.thought = "げんこつがこわい"; } else { @@ -586,6 +589,7 @@ t.fallDir = nx >= 0 ? 1 : -1; hit += 1; } + if (typeof damageNearbyStructures === "function") damageNearbyStructures(this, it.x, it.y, radius, damage, it); const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, { push: p => push * (0.62 + p * 1.25), }); @@ -817,7 +821,7 @@ this.applyImpactDamage(t, damage, "\u885d\u7a81"); const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0); const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18); - t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130)); + if (t.addStress) t.addStress(stressGain, { threshold: 8 }); if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" }); else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true }); t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35); @@ -855,7 +859,7 @@ t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100); const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20; const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0; - t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130); + if (typeof applyNeedShock === "function" && sensitiveBump > playRelief) applyNeedShock(t, { safety: (sensitiveBump - playRelief) * 4 }); else if (typeof applyNeedRelief === "function") applyNeedRelief(t, { fulfill: -playRelief * 6, social: -playRelief * 2 }); if (playful && playIntent) { t.loneliness = clamp(t.loneliness - 0.9, 0, 100); t.affection = clamp(t.affection + 0.18, 0, 100); diff --git a/js/world_family_social.js b/js/world_family_social.js index c210a5a..fb3130c 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -730,8 +730,8 @@ b.fightTargetIds = b.fightTargetIds.slice(-4); a.fightTargetId = a.fightTargetIds[0]; b.fightTargetId = b.fightTargetIds[0]; - a.addStress ? a.addStress(rand(8, 18), { threshold: 8 }) : (a.stress = clamp(a.stress + rand(8, 18), 0, 130)); - b.addStress ? b.addStress(rand(8, 18), { threshold: 8 }) : (b.stress = clamp(b.stress + rand(8, 18), 0, 130)); + if (a.addStress) a.addStress(rand(8, 18), { threshold: 8 }); + if (b.addStress) b.addStress(rand(8, 18), { threshold: 8 }); const damageToA = b.outgoingDamage ? b.outgoingDamage(rand(2, 7)) : rand(2, 7); const damageToB = a.outgoingDamage ? a.outgoingDamage(rand(2, 7)) : rand(2, 7); a.damage(damageToA, "\u55a7\u5629"); diff --git a/service-worker.js b/service-worker.js index a062aa1..6430d70 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "15.24.0"; +const APP_VERSION = "15.24.2"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. From 8bd4405fe8df60cca2b171927821d65ab74a1d62 Mon Sep 17 00:00:00 2001 From: 33333-33333 Date: Tue, 23 Jun 2026 22:39:20 +0900 Subject: [PATCH 2/2] desire --- PATCH_NOTES.md | 36 +- app_manifest.json | 2 +- assets/ui/tool_duplicator.webp | Bin 0 -> 1846 bytes assets/ui/tool_oshibyo.webp | Bin 0 -> 1504 bytes assets/ui/tool_signboard.webp | Bin 0 -> 968 bytes css/base.css | 104 ++- css/components.css | 54 +- css/mobile.css | 9 - index.html | 134 +-- js/data.js | 29 +- js/effect_registry.js | 12 +- js/food_registry.js | 30 +- js/freeze_system.js | 57 +- js/items.js | 561 +++++++++---- js/render.js | 85 +- js/save_system.js | 35 +- js/structures.js | 32 +- js/tarinai.js | 32 +- js/tarinai_action_state.js | 40 +- js/tarinai_item_effects.js | 14 +- js/tarinai_needs_items.js | 1443 ++++++++++++++++++++++++++++---- js/tarinai_render.js | 8 +- js/tarinai_social_move_life.js | 56 +- js/text_catalog.js | 9 +- js/ui_bind.js | 12 + js/ui_selected.js | 51 +- js/ui_tools.js | 28 + js/version.js | 4 +- js/world_environment.js | 2 +- js/world_family_social.js | 28 +- js/world_placement_log.js | 156 +++- js/world_update.js | 24 +- service-worker.js | 17 +- 33 files changed, 2486 insertions(+), 618 deletions(-) create mode 100644 assets/ui/tool_duplicator.webp create mode 100644 assets/ui/tool_oshibyo.webp create mode 100644 assets/ui/tool_signboard.webp diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md index 0529b91..c17894a 100644 --- a/PATCH_NOTES.md +++ b/PATCH_NOTES.md @@ -1,23 +1,17 @@ -# v15.24.2 needs compliance fix +# Patch Notes 15.24.12 -This patch focuses on making the implementation closer to the need-system prompt. +- Added `activeBehavior` as the authoritative runtime behavior layer above legacy state timers. +- Added `forcedBehaviorQueue` and `forceBehavior()` hook for external forced actions such as love mochi, fight mochi, and drugs. +- Re-routed love mochi, fight mochi, and sleep drug effects through forced need actions. +- Converted eating, panic, fighting, intimidation, and birth ritual display/scheduling to behavior-backed actions. +- Split social need pressure internally into bond/family/mate/conflict/fear parts and used those parts in social action selection. +- Weighted fulfill action selection by its internal reason parts. +- Reduced direct old-style birth starts: proximity now raises mate pressure; ritual starts when mate behavior is active or forced. +- UI action text now prefers `activeBehavior` over stale thoughts or intent strings. -## Fixed - -- Kept the six-need system as the behavior driver. -- Kept needs clamped/quantized and displayed as 0-10 values. -- Kept tied top-need random selection and tie reason text. -- Kept StructureRegistry and both buildable structures: grass_bed and plushie. -- Removed remaining mojibake strings in the main need/action implementation file. -- Changed safety fleeing so normal safety behavior no longer directly enters panic. -- Reworked stress so direct stress mutation is removed from the main behavior files. -- Stress is now recalculated from needs or projected needs via calculateStressFromNeeds. -- Added applyNeedShock/applyNeedRelief helpers and converted shock/relief events into need changes. -- Reworked enterPanic as a legacy adapter that converts old calls into need shock first. -- Panic/intimidate are now short reaction states triggered through need shock logic. -- Updated cache/version strings to 15.24.2. - -## Notes - -- Old save compatibility is intentionally not provided. -- Some legacy fields such as loneliness, affection, mood, and fearTimer still exist as compatibility/input/display fields, but the main action selection now uses the six needs. +## 15.24.14 +- 外部強制行動を強化。薬・餅から `forceBehavior()` 経由で優先割込み、対象再探索、失敗時再試行を行うようにした。 +- へこ餅、けんか餅、ねむり薬の強制行動優先度を更新。 +- 喧嘩対象が Item 等になった場合の `relationTo is not a function` エラーを防止。 +- 睡眠欲求を概日圧 `circadianSleepPressure` 中心に変更。夜は大きく上昇、昼下がりは微増、それ以外は減衰。 +- 初期生成時、夜なら睡眠欲求が高めになるよう初期エネルギーと概日圧を調整。 diff --git a/app_manifest.json b/app_manifest.json index b9cdf68..8619e89 100644 --- a/app_manifest.json +++ b/app_manifest.json @@ -1,5 +1,5 @@ { - "version": "15.24.2", + "version": "15.24.14", "css": [ "css/base.css", "css/layout.css", diff --git a/assets/ui/tool_duplicator.webp b/assets/ui/tool_duplicator.webp new file mode 100644 index 0000000000000000000000000000000000000000..2651d13d29acd4a91f48f3337ae258db593fcbb2 GIT binary patch literal 1846 zcmV-62g&$SNk&F42LJ$9MM6+kP&il$0000G0001A003VA06|PpNFo6M00Dql+iu$i z`%mE^GKq$O(tB?|jRXk3^yHj~a3D#N5>d)Y^{Pz0JomkelJ?Ycqnl>k5nIMKPL#%|c1w_s zKNYg4$e`e_Fz!$H)^+#nG5UhE+P=ThLHu{ldv|nx_ju**cZp4d$_;6Czezm+m~u}S z?(2h!tx{dO`?-`egBy20m9j>#?|vkaz3qNIg=)=r&6dko;bVLZ^EG9GQEBrre9R^U zZnH_o52=+dkr(JgvhXow^uV?KQ5X=~TcI{-4^P^VQ_q(S~O_`j}S(LY8!n0`~}HT@xSGN>A$c3_WzUlqV?(etL!oR0Fh1qnUFkqYE!a=R8?0<zs zQZzB2^zY^Q_BwMMbW}vx8N4^OjcXp9iDY}X{)xDQ`iO6omPuNG{;U&HuPj~)>)geA z5nS*;XZpx7fwR#r%C@F!`YmZ`0@+YXw$gsDFpK~I{{LVYmG>;AsB@|Kaz7RCRx+m+ zKkt);%Vys_kV`WNb*dx`>I@1xTvjrh(*kz#Mnsa8t}KrKd3Tt8 ze+T6{Y%Rzq%jjR#5!A&b%l!Z0xEg=Xmx0baPeXWh{N#rxL~=ExvM&w;y&_QCX0i_W znWNC!zl*glIQ=Ca3k+A>X)KiA>kqExa<6rW4zds|y(Z3q=I0E@Ov0#*t}_J0enB_D z7kQ};Sn@>_ zN8t_c7w4l$S@psUDN+1{kN7xRc?FMDTxZ|>L_$-#9kruKq}qY>v8xJShCA31n)d0p z>vUC7L;+bo*(`?+l+k!f(bi-NCdU}GH786#gzYa0B~HuFue(13SFp!zHSu11&;?E5 zl4f9fKn?TE*!;M`r-q<2ukWXeIod;E{h9#MWK|a)#i;Fft41Olw2LX`^s0Gxd^I$_ zU-@o{#4(~Jr*WPb+0gp>fiY~-7_dl9##fWu6EP4XkZI#oxq9BJkR*bfx*l0(VG_kyD{Bc+%l0~+XA+@UV96u!?8Rh-V&3O^w zJT$uDBp=x6JIyL|HDL~ftZVmM^@7QtxIdng+0=)RK71?9kG;9ATkB~Q!Ha)@iLWlA z)=S(i_f69MT)DdSc0rAcztU+ty<7onN9@YBa{Ug=|2Swqpm(~y z6PT?v4GIyrfBlQXFEEn}O=!xx!03&#-{F~NCk2DQF9rVTVnMjd#uxpI10-fWR^_AGEm!fWG53RH4eboT+CI z*5L{oSQ+YGYbpQYUq5>&RcdVo!-^Oyo3NZ==b3~6vjc!cTQ^aY5-#x^7MDZ@o)tf- z5(8mkv1Bq=(K1QS>CG%q5E4!n_{&fK{f)?Mt{JzmV=VJO!}zo=1R=G?nhFDXYI5Cu zy|4)R8Qkm$x?5a?$T9glx+pFQOpJ%cZh|bOT4gtvSr(@DyY(^*pg!deO>c(TmspQ* zp_V@!@(TJWwAk?3dm_YY1oQl91|(9nq&s5PI(bn{TQ$gz+cnZZWi2A0JPLT?q+NG;f8X2$HN&2gNV&N1$=anJa zW@x~o>bu4D%eaA4{zu#UUa`#hM7GF2|A4#`WaK?;OP2t-NADN;JUj0Zzc&|h6l$6)TO1Gq$9nEc=*+i5fiIV>h4B8Z+Z z4JPe1Dnnz@2Vw`NO4Q<#fn*=5sbD{emZ5>(KUSL>fm$POvt#YG%=6yu94~^+^ zrtY~td#iz7=vLgITq4Y9>2!w%dxxGwq7 zmEhw0efWMWz2A)=H=vJu(8q0VG490OpdSjzMfVM$t2+R%*!a*Dmp7o^*Dm3T&ubY1 z09H^qAld`~0I(bYodGIf0AK((}fDyjMsE?5|lDpa`y>2^{70}a@&D)_@jtb2lYYV-q+fJ9 z{D&{|)##iAnMt@o=t~ih%Y6l^rBaYM{9@s4f*5gN#=y#sDfUn$+eG-qmMYF>54EgE zjn$A0Aob`^STA;dxbG#{$&z0Rr$ULPq;&Ll%l_^uSG$<6*`&Y#{{LVY4ZXsyX^!tf zi^+b8D(+|h`S1KSTGzwWvE)@a#>Wu)Dsv`;nb&bRAGSa7u^>`;Kk=4(%IIF$+`TOn zWdpNvH1R#I8$o zWp?EGRqC&sv}Z|mYI4y`SO4mqk*^03ga34nmmc@^p@R(1{pE9K>(A3a897JZw&n6d zuuoFidy5J5s|;fusipLffQVuBpk9@)-+=e!^*~0IwF-X67#GyKVlG*3{IW7 zx1x`0ByjX%)}hYGHy?ra4;o}h-lASZ`)yB997|A$`zri}aNAS3J_ee73lx2v2*pLx zrc^S?^rLJU9xnN~JZV7!og*Zj=R}caNY|o(;yEr5W=D(0V+T$vK6C&#hDQ&1F_T4_io1{beaaZ-h{b`O#7%xE3EXbiy^&jn0dTM5N&3A# zZLLtH@`cOQvHcHe;C*4V$pjBXlnjf+Vr6HR5S(oKgM%1(tz%^CSpI=Ul)7Wo))W5B z=}n_Y-{Q%;hYh`usYo5z_HEvr&8q#vO07bSS?%Oa2(PjBGzQ*yBHN@iz4s($O0CWM zDCRrrlnjdI4jtS332XGMz0^z%tD;*GE$Czqj>&zZ=g9Q(!9|W-pc>R>MGw^z*q!u! z=HEeJ+58Rv_S3rh{6}dqlCpmN7Z7hm0na><^!W=Q#V~6FyzeZoRgA#Fduxcvg32NN zw$x1|09eh^Q&Q(9CKFc=J>bABpanUqQvB&t!TQM)DY43$)tE1@Z?kfp@ExLpab?c{ zFk4Uc@mmCzsS&DG0{>UosMwUjyneW!_R8x_ML^@Qk=lxiy`AJGP#pmKGpV$0Ubdk| z)G76|#l*aDxm2_L;O_O{f!+j~3U8QMJW?@fM+5%5TU85*jd6|Twyn1N{(ssnrGwArd%9yb?`wzn*r=G` GK!5-Y^a2L} literal 0 HcmV?d00001 diff --git a/assets/ui/tool_signboard.webp b/assets/ui/tool_signboard.webp new file mode 100644 index 0000000000000000000000000000000000000000..5d53fdae21ef325948fc021ddb32b21e3e482976 GIT binary patch literal 968 zcmV;(12_CqNk&G%0{{S5MM6+kP&il$0000G0001A003VA06|PpNK*g+00Cg+wrwQ6 zy&uo5opCk$f{0+?wvnR3G9KR-cxABWF_=;(YBm#=s|?&W%E6le&5u0wnRX-teczu; zhxXqDdV}7eH(*SKwwu_%-#zGBF79Lk09H^qAVvcK08khJodGIf0AK(`VjYC|28WE~NmAZ;vgWwwg0RI2_)^#8MKlP3RQY4?8=+teL&9fpV z3Z%gV=0DTOTM(k{egE_v(S|GobY_iW24W0HWz~<7G1RnOqhggQTTZ`>p6l74IlO69Wg%}>L~lk7!yS)sc!IFhn zKrj+vUAkwii2J9GC3V-!b6;X=Jk)X6qeEDN1%m}uSoN(qcpS&u9`gVCDQ36>E}CEL_djQx z^w0O38U1<1XR9XkWaAp{rkH7BZHavG0S3#!%i(4hPE?{-OWj;2PXj`ydHas)2~^nt zZN}z~sN?8k=R|6ZO*o!aM?k)S47hM?xcHds@kO!-W3-xn{271$CeL1BbS1MB|J~t7 qf*2BGYqRjY7a%X56Rp$>vFd)y{$4ER?~{q^P&BE2{I!F}U;qG(!P=kz literal 0 HcmV?d00001 diff --git a/css/base.css b/css/base.css index cb7924c..16b0a80 100644 --- a/css/base.css +++ b/css/base.css @@ -384,6 +384,20 @@ h1 { opacity: 0.10; pointer-events: none; } + +.tool.tool-has-canvas-icon { padding-left: 42px; --tool-icon: none; } +.tool.tool-has-canvas-icon::before, +.tool.tool-has-canvas-icon::after { background: none !important; } +.tool-sprite-preview { + position: absolute; + left: 3px; + top: 50%; + width: 36px; + height: 36px; + transform: translateY(-50%); + pointer-events: none; + filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); +} .tool > span:not(.tool-size-badge) { display: none; } .tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.webp"); } .tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.webp"); } @@ -399,6 +413,11 @@ h1 { .tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.webp"); } .tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.webp"); } +.tool[data-tool="signboard"] { --tool-icon: url("../assets/ui/tool_signboard.webp"); } +.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp"); } +.tool[data-tool="pushpin"] { --tool-icon: url("../assets/ui/tool_pushpin.webp"); } +.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp"); } + /* Generated parameter item tool icons. */ .tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); } .tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); } @@ -409,12 +428,81 @@ h1 { .tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); } .tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); } .tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); } -.tool[data-tool="freeze"] { --tool-icon: none; } -.tool[data-tool="freeze"]::before { - content: "🧊"; - background: none; - font-size: 24px; - line-height: 1; - text-align: center; + + +.signboard-editor { + position: fixed; + inset: 0; + z-index: 42; + display: grid; + place-items: center; + padding: 18px; + background: rgba(34, 29, 22, 0.34); + backdrop-filter: blur(3px); } -.tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; } +.signboard-editor.hidden { display: none; } +.signboard-editor-panel { + width: min(430px, calc(100vw - 28px)); + border-radius: 18px; + padding: 14px; + background: linear-gradient(180deg, rgba(255,253,247,0.98), rgba(245,237,221,0.96)); + border: 1px solid rgba(94, 70, 42, 0.20); + box-shadow: 0 22px 60px rgba(36, 28, 20, 0.28), inset 0 1px 0 rgba(255,255,255,0.72); +} +.signboard-editor-titlebar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0 0 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(94, 70, 42, 0.13); +} +.signboard-editor-titlebar h2 { + margin: 0; + font-size: 16px; + letter-spacing: .03em; +} +.signboard-editor-close { + width: 30px; + height: 30px; + border-radius: 999px; + border: 1px solid rgba(84,68,48,0.16); + background: rgba(255,255,255,0.72); + color: var(--ink); + font-size: 19px; + line-height: 1; + cursor: pointer; +} +.signboard-editor-close:hover { background: rgba(255,248,232,0.96); } +#signboardEditorText { + width: 100%; + box-sizing: border-box; + resize: vertical; + min-height: 88px; + border-radius: 12px; + border: 1px solid rgba(84,68,48,0.18); + background: rgba(255,255,255,0.84); + padding: 10px 11px; + color: var(--ink); + font: 14px/1.55 ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif; + outline: none; +} +#signboardEditorText:focus { + border-color: rgba(112,178,66,0.54); + box-shadow: 0 0 0 3px rgba(112,178,66,0.14); +} +.signboard-editor-meta { + display: flex; + justify-content: space-between; + color: var(--muted); + font-size: 12px; + margin: 6px 2px 0; +} +.signboard-editor-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + diff --git a/css/components.css b/css/components.css index 119532a..6923ed6 100644 --- a/css/components.css +++ b/css/components.css @@ -451,13 +451,34 @@ .selected-info .info-row[data-metric="loneliness"] { background: rgba(116, 150, 214, 0.10); } .selected-info .info-row[data-metric="energy"] { background: rgba(104, 184, 96, 0.10); } .selected-info .info-row[data-metric="stress"] { background: rgba(214, 104, 88, 0.10); } -.selected-info .need-row[data-chosen-need="true"] { - border-color: rgba(65, 112, 74, 0.52); - background: rgba(106, 154, 84, 0.16); +.selected-info .need-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 6px; + margin: 7px 0 8px; } -.selected-info .need-row[data-chosen-need="true"] strong { +.selected-info .need-cell { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 6px 4px; + border: 1px solid rgba(84,68,48,0.12); + border-radius: 10px; + background: rgba(255,255,255,0.42); +} +.selected-info .need-cell span { font-size: 11px; color: var(--muted); } +.selected-info .need-cell strong { font-size: 16px; color: #3f3225; } +.selected-info .need-cell[data-chosen-need="true"] { + border-color: rgba(65, 112, 74, 0.58); + background: rgba(106, 154, 84, 0.20); + box-shadow: inset 0 0 0 1px rgba(65, 112, 74, 0.18); +} +.selected-info .need-cell[data-chosen-need="true"] strong { color: #315f32; } +.selected-info .action-reason-row strong { text-align: right; } .selected-info .info-row[data-metric="zunchi"] { background: rgba(112, 142, 70, 0.10); } .selected-info .info-row[data-metric="mood"] { background: rgba(222, 196, 86, 0.10); } .selected-info .info-row[data-metric="lack"] { background: rgba(142, 112, 184, 0.10); } @@ -566,19 +587,29 @@ } .save-slot-list { display: grid; - gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; margin: 14px 0; } .save-slot-row { display: grid; - grid-template-columns: minmax(86px, 0.7fr) minmax(180px, 2fr) auto; - gap: 10px; - align-items: center; - padding: 9px 10px; + grid-template-rows: auto 1fr auto; + gap: 8px; + align-items: stretch; + min-height: 132px; + padding: 10px; border: 1px solid rgba(115, 91, 62, 0.20); border-radius: 12px; background: rgba(255, 255, 255, 0.56); } +.save-warning { + color: #8a4f2f; + font-weight: 800; + background: rgba(255, 239, 220, 0.72); + border: 1px solid rgba(190, 118, 72, 0.22); + border-radius: 10px; + padding: 7px 9px; +} .save-slot-title { font-weight: 800; color: #4f3928; @@ -590,9 +621,12 @@ .save-slot-actions { display: flex; gap: 6px; - justify-content: flex-end; + justify-content: flex-start; flex-wrap: wrap; } +@media (max-width: 720px) { + .save-slot-list { grid-template-columns: 1fr; } +} .save-slot-actions .btn[disabled] { opacity: 0.42; pointer-events: none; diff --git a/css/mobile.css b/css/mobile.css index 9fedacb..1a8853b 100644 --- a/css/mobile.css +++ b/css/mobile.css @@ -230,12 +230,3 @@ body.touch-mobile-ui.mobile-mode-family .lineage-main-card { z-index: 9; } .card.selected-card { --ui-theme-rgb: 204, 121, 167; background: linear-gradient(180deg, rgba(253, 245, 250, 0.94), rgba(249, 235, 244, 0.91)); border-color: rgba(204, 121, 167, 0.24); } .card.colony-card { --ui-theme-rgb: 0, 158, 115; background: linear-gradient(180deg, rgba(240, 250, 247, 0.94), rgba(229, 245, 239, 0.91)); border-color: rgba(0, 158, 115, 0.24); } .card.log-card { --ui-theme-rgb: 230, 159, 0; background: linear-gradient(180deg, rgba(255, 249, 236, 0.94), rgba(253, 242, 215, 0.91)); border-color: rgba(230, 159, 0, 0.24); } -.tool[data-tool="freeze"] { --tool-icon: none; } -.tool[data-tool="freeze"]::before { - content: "🧊"; - background: none; - font-size: 24px; - line-height: 1; - text-align: center; -} -.tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; } diff --git a/index.html b/index.html index 4ea4a73..cf92d3b 100644 --- a/index.html +++ b/index.html @@ -5,11 +5,11 @@ たりない観察 - - - - - + + + + +
@@ -183,67 +183,67 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/js/data.js b/js/data.js index 6773a1a..4374c72 100644 --- a/js/data.js +++ b/js/data.js @@ -50,10 +50,9 @@ const TOOL_DEFINITIONS = Object.freeze({ observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" }, delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" }, poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" }, - pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" }, + pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u79fb\u52d5\u3055\u305b\u308b\u3002\u51b7\u51cd\u5eab\u306b\u3082\u904b\u3079\u308b\u3002" }, new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" }, water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, - freeze: { id: "freeze", label: "冷凍", placeable: false, scalable: false, iconText: "\u{1F9CA}", tooltip: "つまむでたりないを冷凍個体UIまで運ぶと冷凍保存する。" }, zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u305a\u3093\u3061\u3002\u75c5\u6c17\u3084\u8349\u306e\u80a5\u6599\u306b\u95a2\u308f\u308b\u3002" }, sweet: { id: "sweet", itemType: "sweet", label: "\u305a\u3093\u3060\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_sweet.webp", tooltip: "\u305f\u308a\u306a\u3044\u306e\u5927\u597d\u7269\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, love_mochi: { id: "love_mochi", itemType: "love_mochi", label: "\u3078\u3053\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_love_mochi.webp", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, @@ -77,10 +76,11 @@ const TOOL_DEFINITIONS = Object.freeze({ nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" }, ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" }, ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, - signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 24, amount: 999, iconText: "\u{1FAA7}", collisionShape: "circle", tooltip: "\u77ed\u3044\u30c6\u30ad\u30b9\u30c8\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u3002\u305f\u308a\u306a\u3044\u306b\u5bfe\u3059\u308b\u52b9\u679c\u306f\u7121\u3057" }, + signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026文字×3行まで\u3002" }, + duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "食べ物や薬をセットすると、自動供給してくれる。" }, firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" }, pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" }, - oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "おしり鋲", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/objects/oshibyo.webp", tooltip: "刺さるとずんちが出なくなる。つまんで抜くと溜まったずんちが弾け飛ぶ。" }, + oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" }, fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" }, water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true }, @@ -92,13 +92,13 @@ const TOOL_DEFINITIONS = Object.freeze({ const ITEM_TRAITS = Object.freeze({ - obstacle: new Set(["stone", "ball", "nest_box", "bed", "fence_v", "fence_h"]), - food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), - hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), + obstacle: new Set(["stone", "ball", "nest_box", "bed", "duplicator", "fence_v", "fence_h"]), + food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "duplicator", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]), + hazard: new Set(["firecracker", "genkotsu", "pushpin", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]), pin: new Set(["pushpin", "oshibyo"]), kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]), sleepFurniture: new Set(["bed", "nest_box"]), - draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "pushpin", "oshibyo"]), + draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "duplicator", "pushpin", "oshibyo"]), }); function itemHasTrait(type = "", trait = "") { @@ -136,9 +136,8 @@ const TOOL_CATEGORIES = Object.freeze([ { id: "operate", label: "\u64cd\u4f5c\u30fb\u89b3\u5bdf", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] }, { id: "food", label: "\u98df\u3079\u7269\u30fb\u6c34\u30fb\u8349", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] }, { id: "medicine", label: "\u85ac\u30fb\u500b\u4f53\u52b9\u679c", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] }, - { id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard"] }, + { id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator"] }, { id: "hazard", label: "\u5371\u967a\u30fb\u969c\u5bb3\u7269", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h"] }, - { id: "freeze", label: "冷凍", themeClass: "tool-category-freeze", order: 99, toolIds: ["freeze"] }, ]); function toolCategories() { @@ -244,7 +243,7 @@ const CONFIG = { splatLimit: 48, effectLimit: 78, worldPadding: 30, - hungerPerMinute: 11.2, + hungerPerMinute: 36.0, lonelinessPerMinute: 7.6, energyPerMinute: 8.2, stressDecayPerMinute: 10.2, @@ -256,14 +255,14 @@ const CONFIG = { weatherChangeMin: 46, weatherChangeMax: 96, bubbleLimit: 32, - grassLimit: 96, + grassLimit: 180, highPopulationMode: 70, ultraPopulationMode: 120, }; const FIELD_TYPES = { - cage: { id: "cage", label: "\u30b1\u30fc\u30b8", sizeLabel: "\u5c0f", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 0.46, population: 12, grass: 9 }, - garden: { id: "garden", label: "\u5ead", sizeLabel: "\u4e2d", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 18 }, - park: { id: "park", label: "\u516c\u5712", sizeLabel: "\u5927", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 2.35, population: 24, grass: 28 }, + cage: { id: "cage", label: "\u30b1\u30fc\u30b8", sizeLabel: "\u5c0f", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 0.46, population: 12, grass: 16 }, + garden: { id: "garden", label: "\u5ead", sizeLabel: "\u4e2d", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 1.0, population: CONFIG.initialPopulation, grass: 34 }, + park: { id: "park", label: "\u516c\u5712", sizeLabel: "\u5927", height: "calc(100vh - 140px)", minHeight: "620px", mobileHeight: "66vh", mobileMinHeight: "460px", worldScale: 2.35, population: 24, grass: 62 }, }; const LIGHTING_TUNING = { rayAlpha: 0.040, diff --git a/js/effect_registry.js b/js/effect_registry.js index 1810a8c..6ee71de 100644 --- a/js/effect_registry.js +++ b/js/effect_registry.js @@ -110,6 +110,10 @@ tarinai.loveMochiTimer = Math.max(tarinai.loveMochiTimer || 0, 42 * scale + rand(4, 12)); const nutrition = Number(context.nutrition) || 0; if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 }); + if (context.source === "eating") { + if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" }); + } return true; }, }); @@ -119,7 +123,11 @@ tarinai.fightMochiTimer = Math.max(tarinai.fightMochiTimer || 0, 52 * scale + rand(10, 18)); const nutrition = Number(context.nutrition) || 0; if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 }); - if (context.source === "eating") tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); + if (context.source === "eating") { + tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32); + if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" }); + } return true; }, }); @@ -133,6 +141,8 @@ tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5); const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075); const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance; + if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" }); + else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" }); if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true; return true; } diff --git a/js/food_registry.js b/js/food_registry.js index f19667d..72b468a 100644 --- a/js/food_registry.js +++ b/js/food_registry.js @@ -2,24 +2,24 @@ (function (global) { const rawDefinitions = { - food: { label: "\u98df\u3079\u7269", nutrition: 8.5, hungerRelief: 0.95, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 }, - sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 7.2, hungerRelief: 0.75, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] }, - love_mochi: { label: "\u3078\u3053\u9905", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" }, - fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" }, - sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" }, + food: { label: "\u98df\u3079\u7269", nutrition: 18.0, hungerRelief: 1.0, interest: "normal", decayRateMultiplier: 1.0, hygienePenalty: 0.055 }, + sweet: { label: "\u305a\u3093\u3060\u9905", nutrition: 16.5, hungerRelief: 0.95, interest: "zunda_high", decayRateMultiplier: 0.82, hygienePenalty: 0.055, cures: ["disease_explosion", "disease_fight"] }, + love_mochi: { label: "\u3078\u3053\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "love_mochi" }, + fight_mochi: { label: "\u3051\u3093\u304b\u9905", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "fight_mochi" }, + sleep_drug: { label: "\u306d\u3080\u308a\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "sleep_drug" }, laxative: { label: "\u4e0b\u5264", nutrition: 6.8, hungerRelief: 0, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "laxative", passThrough: true }, - protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" }, - niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" }, - ammo: { label: "\u5f3e\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" }, - mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" }, - mercury: { label: "\u6c34\u9280", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" }, - giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" }, - dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" }, - zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 6.8, hungerRelief: 0.82, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, - grass: { label: "\u8349", nutrition: 0.46, hungerRelief: 0.46, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, + protein: { label: "\u30d7\u30ed\u30c6\u30a4\u30f3", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "protein" }, + niteropu: { label: "\u30f3\u30a4\u30c6\u30ed\u30d7", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "niteropu" }, + ammo: { label: "\u5f3e\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "ammo" }, + mystery_drug: { label: "\u602a\u3057\u3044\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mystery_drug" }, + mercury: { label: "\u6c34\u9280", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "mercury" }, + giant_drug: { label: "\u5de8\u5927\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "giant_drug" }, + dwarf_drug: { label: "\u77ee\u5c0f\u85ac", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.0, hygienePenalty: 0.055, effectId: "dwarf_drug" }, + zunda_juice: { label: "\u305a\u3093\u3060\u6c41", nutrition: 15.0, hungerRelief: 0.92, interest: "medicine_low", decayRateMultiplier: 1.12, hygienePenalty: 0.055, effectId: "zunda_juice", removesItemEffects: true }, + grass: { label: "\u8349", nutrition: 2.6, hungerRelief: 0.55, interest: "grass_like", decayRateMultiplier: 1.0, hygienePenalty: 0 }, water: { label: "\u6c34", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, water_bowl: { label: "\u6c34\u306e\u76bf", nutrition: 0, hungerRelief: 0, interest: "water", decayRateMultiplier: 1.0, hygienePenalty: 0 }, - ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 0.62, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } + ant_corpse: { label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", nutrition: 4.2, hungerRelief: 0.62, interest: "corpse", decayRateMultiplier: 1.0, hygienePenalty: 0.065 } }; class FoodRegistry extends global.TarinaiRegistryBase { diff --git a/js/freeze_system.js b/js/freeze_system.js index 17356bd..61c99c8 100644 --- a/js/freeze_system.js +++ b/js/freeze_system.js @@ -44,14 +44,19 @@ const style = document.createElement("style"); style.id = "freezeSystemStyles"; style.textContent = ` - .frozen-panel { margin-top: 10px; padding: 10px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); } - .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:700; font-size:13px; color:#4f5f70; margin-bottom:8px; } + .frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; } + .frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; } + .frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; } + .frozen-panel-toggle::after { content:"折り畳み"; font-size:11px; opacity:.62; font-weight:700; } + .frozen-panel.collapsed .frozen-panel-toggle::after { content:"展開"; } .frozen-panel-count { font-size:12px; opacity:.72; } - .frozen-list { display:grid; grid-template-columns: 1fr; gap:7px; } - .frozen-card { display:flex; align-items:center; gap:8px; width:100%; border:1px solid rgba(72, 98, 124, .20); border-radius:12px; padding:7px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); } + .frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; } + .frozen-panel.collapsed .frozen-list { display:none; } + .frozen-panel.collapsed { padding-bottom:7px; } + .frozen-card { display:flex; align-items:center; gap:6px; flex:0 0 138px; min-width:138px; max-width:168px; border:1px solid rgba(72, 98, 124, .20); border-radius:11px; padding:5px 6px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); } .frozen-card:active { cursor:grabbing; } .frozen-card.pending-thaw { outline:2px solid rgba(70, 142, 210, .46); background:rgba(227, 242, 255, .86); } - .frozen-card img { width:34px; height:34px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); } + .frozen-card img { width:26px; height:26px; object-fit:contain; flex:none; filter: drop-shadow(0 2px 3px rgba(34,44,55,.14)); } .frozen-card-main { min-width:0; flex:1; } .frozen-card-name { display:block; font-weight:700; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .frozen-card-meta { display:block; font-size:11px; opacity:.70; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -69,11 +74,21 @@ panel.id = "frozenPanel"; panel.className = "frozen-panel"; panel.innerHTML = ` -
冷凍個体0
+
`; - const anchor = global.ui?.toolPalette || document.getElementById("toolPalette"); - if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); + const storedCollapse = global.localStorage?.getItem("tarinaiFrozenPanelCollapsed"); + const collapsedByDefault = storedCollapse !== "0"; + if (collapsedByDefault) { + panel.classList.add("collapsed"); + panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "false"); + } else { + panel.querySelector("#frozenPanelToggle")?.setAttribute("aria-expanded", "true"); + } + const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body'); + const anchor = operateBody || global.ui?.toolPalette || document.getElementById("toolPalette"); + if (operateBody) operateBody.appendChild(panel); + else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling); else document.body.appendChild(panel); return panel; } @@ -86,17 +101,17 @@ if (countEl) countEl.textContent = `${list.length}`; if (!listEl) return; if (!list.length) { - listEl.innerHTML = `
冷凍中のたりないはいません。
`; + listEl.innerHTML = `
\u51b7\u51cd\u4e2d\u306e\u305f\u308a\u306a\u3044\u306f\u3044\u307e\u305b\u3093\u3002\u51b7\u51cd\u3057\u305f\u305f\u308a\u306a\u3044\u306f\u30ea\u30bb\u30c3\u30c8\u3092\u7121\u8996\u3057\u3066\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002
`; return; } listEl.innerHTML = list.map(entry => { const data = entry.data || entry; - const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / 訃報${entry.pendingLosses.length}` : ""; - const gen = data.generation ? `第${data.generation}世代` : ""; + const losses = Array.isArray(entry.pendingLosses) && entry.pendingLosses.length ? ` / \u8a03\u5831${entry.pendingLosses.length}` : ""; + const gen = data.generation ? `\u7b2c${data.generation}\u4e16\u4ee3` : ""; const id = htmlEscape(entry.freezeId || entry.id || data.familyKey || ""); - return ``; }).join(""); } @@ -112,7 +127,7 @@ freezeId: id, frozenAt: Date.now(), worldTime: worldRef?.time || 0, - name: t.name || "たりない", + name: t.name || "\u305f\u308a\u306a\u3044", familyKey: t.familyKey || "", liveId: t.id || "", liveToken: t.liveToken || 0, @@ -188,7 +203,7 @@ t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true }); } t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)"); - t.recordChangeCause?.("冷凍中の訃報", "ストレス", { value: stress }); + t.recordChangeCause?.("冷凍中の\u8a03\u5831", "ストレス", { value: stress }); t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death"); worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] }); } @@ -257,6 +272,14 @@ }); panel.addEventListener("dragend", () => global.document?.querySelector("canvas")?.classList.remove("freeze-drop-ready")); panel.addEventListener("click", (e) => { + const toggle = e.target.closest("#frozenPanelToggle"); + if (toggle) { + const collapsed = panel.classList.toggle("collapsed"); + toggle.setAttribute("aria-expanded", collapsed ? "false" : "true"); + try { global.localStorage?.setItem("tarinaiFrozenPanelCollapsed", collapsed ? "1" : "0"); } catch (_) {} + global.audio?.uiFold?.(); + return; + } const card = e.target.closest(".frozen-card[data-frozen-id]"); if (!card) return; pendingThawId = card.dataset.frozenId || ""; @@ -312,10 +335,6 @@ const originalHandleClick = proto.handleClick; proto.handleClick = function (x, y) { if (tryPendingThawAt(x, y, this)) return; - if (this.tool === "freeze") { - global.showToast?.("冷凍は『つまむ』でたりないを冷凍個体UIへ運んでください。"); - return; - } return originalHandleClick.call(this, x, y); }; }; diff --git a/js/items.js b/js/items.js index 54e0cef..8fd40ea 100644 --- a/js/items.js +++ b/js/items.js @@ -174,6 +174,237 @@ function drawBulletSprite(ctx, r) { ctx.restore(); } +function isConsumableSpriteType(type = "") { + const key = String(type || ""); + return ["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(key) + || (typeof isParamEffectItemType === "function" && isParamEffectItemType(key)); +} + +function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {}) { + const key = String(type || "food"); + const localSeed = Number(seed || 1) || 1; + const localR = Math.max(1, Number(r) || 10); + const compact = !!opts.compact; + ctx.save(); + if (key === "food" || key === "sweet" || key === "love_mochi" || key === "fight_mochi") { + const sweet = key === "sweet"; + const loveMochi = key === "love_mochi"; + const fightMochi = key === "fight_mochi"; + ctx.fillStyle = loveMochi ? "#fff1f6" : (fightMochi ? "#fff3ec" : (sweet ? "#fffaf1" : "#f1dfb7")); + ctx.strokeStyle = loveMochi ? "#bf6f8b" : (fightMochi ? "#bc6d42" : (sweet ? "#9a8a72" : "#8d6f48")); + ctx.lineWidth = Math.max(1.2, localR * 0.15); + ctx.beginPath(); + ctx.ellipse(0, 0, localR * 1.25, localR * 0.75, Math.sin(localSeed) * 0.4, 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + if (sweet) { + ctx.beginPath(); + ctx.fillStyle = "#76b94d"; + ctx.ellipse(-localR * 0.07, -localR * 0.24, localR * 0.76, localR * 0.26, Math.sin(localSeed) * 0.25, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = "rgba(255,255,255,0.62)"; + ctx.beginPath(); + ctx.ellipse(-localR * 0.32, -localR * 0.16, localR * 0.24, localR * 0.10, -0.35, 0, Math.PI * 2); + ctx.fill(); + } else if (loveMochi) { + ctx.fillStyle = "#ff6a9c"; + ctx.font = `${Math.round(localR * 1.15)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("❤", 0, 1); + ctx.fillStyle = "rgba(255,255,255,0.55)"; + ctx.beginPath(); + ctx.ellipse(-localR * 0.28, -localR * 0.18, localR * 0.23, localR * 0.10, -0.35, 0, Math.PI * 2); + ctx.fill(); + } else if (fightMochi) { + ctx.strokeStyle = "rgba(168,76,42,0.82)"; + ctx.lineWidth = Math.max(1.2, localR * 0.13); + ctx.beginPath(); + ctx.moveTo(-localR * 0.44, -localR * 0.42); + ctx.lineTo(localR * 0.44, localR * 0.42); + ctx.moveTo(-localR * 0.44, localR * 0.42); + ctx.lineTo(localR * 0.44, -localR * 0.42); + ctx.stroke(); + } else { + ctx.fillStyle = "#fff7dc"; + const dots = compact ? 4 : 5; + for (let i = 0; i < dots; i++) { + ctx.beginPath(); + ctx.arc(randSeed(localSeed + i, -localR * 0.55, localR * 0.55), randSeed(localSeed + i + 7, -localR * 0.34, localR * 0.34), Math.max(1.0, localR * 0.12), 0, Math.PI * 2); + ctx.fill(); + } + } + } else if (key === "grass") { + ctx.strokeStyle = "#5fa53f"; + ctx.lineWidth = Math.max(1.3, localR * 0.14); + ctx.lineCap = "round"; + for (let i = 0; i < 5; i++) { + const a = -Math.PI / 2 + randSeed(localSeed + i, -0.75, 0.75); + const len = localR * randSeed(localSeed + i + 10, 0.65, 1.15); + const rootX = randSeed(localSeed + i + 20, -localR * 0.32, localR * 0.32); + ctx.beginPath(); + ctx.moveTo(rootX, localR * 0.35); + ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.46, -localR * 0.05, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.72); + ctx.stroke(); + } + } else if (key === "sleep_drug") { + drawSleepTabletSprite(ctx, localR * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug")); + } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(key)) { + const visual = itemVisualFor(key); + const palette = visual?.palette || {}; + const renderer = visual?.renderer || "split_pill"; + const fill = palette.fill || "#fffaf1"; + const stroke = palette.stroke || "#8d6f48"; + const accent = palette.highlight || palette.accent || "#ddd"; + const rr = localR * (visual?.fieldScale || visual?.scale || 1); + if (renderer === "powder_pile") { + ctx.fillStyle = "rgba(68,46,28,0.18)"; + ctx.beginPath(); + ctx.ellipse(0, rr * 0.50, rr * 1.22, rr * 0.34, 0, 0, Math.PI * 2); + ctx.fill(); + const count = compact ? 10 : 18; + for (let i = 0; i < count; i++) { + const px = randSeed(localSeed + i * 7, -rr * 0.85, rr * 0.85); + const layer = 1 - Math.abs(px) / Math.max(1, rr * 0.90); + const py = randSeed(localSeed + i * 11, -rr * 0.15, rr * 0.48) - layer * rr * 0.48; + const blobR = randSeed(localSeed + i * 13, rr * 0.14, rr * 0.32) * (0.75 + layer * 0.45); + ctx.fillStyle = i % 3 === 0 ? accent : fill; + ctx.strokeStyle = stroke; + ctx.globalAlpha = 0.86 + layer * 0.12; + ctx.lineWidth = Math.max(0.8, rr * 0.055); + ctx.beginPath(); + ctx.ellipse(px, py, blobR * 1.18, blobR * 0.72, randSeed(localSeed + i, -0.45, 0.45), 0, Math.PI * 2); + ctx.fill(); ctx.stroke(); + } + ctx.globalAlpha = 1; + ctx.fillStyle = "rgba(255,255,255,0.62)"; + ctx.beginPath(); + ctx.ellipse(-rr * 0.30, -rr * 0.42, rr * 0.28, rr * 0.10, -0.35, 0, Math.PI * 2); + ctx.fill(); + } else if (renderer === "oval_droplet") { + drawOvalWaterDropSprite(ctx, rr, { fill, stroke, highlight: accent }); + } else if (renderer === "laxative_tablet") { + drawLaxativeTabletSprite(ctx, rr); + } else if (renderer === "mystery_tablet") { + drawMysteryTabletSprite(ctx, rr); + } else if (renderer === "bullet") { + drawBulletSprite(ctx, rr); + } else { + ctx.save(); + ctx.rotate(-0.38); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = Math.max(1.3, rr * 0.12); + roundedRect(ctx, -rr * 1.15, -rr * 0.46, rr * 2.30, rr * 0.92, rr * 0.46); + ctx.fill(); ctx.stroke(); + ctx.fillStyle = accent; + ctx.globalAlpha = 0.78; + ctx.fillRect(-rr * 0.10, -rr * 0.42, rr * 0.20, rr * 0.84); + ctx.globalAlpha = 0.36; + ctx.fillStyle = "rgba(255,255,255,0.92)"; + ctx.beginPath(); + ctx.ellipse(-rr * 0.48, -rr * 0.16, rr * 0.32, rr * 0.10, -0.10, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } else { + ctx.fillStyle = "#e4ba5b"; + ctx.strokeStyle = "rgba(105,74,39,0.62)"; + ctx.lineWidth = Math.max(1.1, localR * 0.11); + ctx.beginPath(); ctx.ellipse(0, 0, localR * 0.98, localR * 0.58, -0.08, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); + } + ctx.restore(); +} + +function drawStoredConsumableSprite(ctx, type = "", r = 10, seed = 1) { + drawConsumableFieldSprite(ctx, type || "food", r, seed, { compact: true }); +} + +function previewItemRadiusFor(type = "") { + const def = typeof itemDefinition === "function" ? itemDefinition(type) : null; + const base = Number(def?.radius || itemRadiusFor?.(type, 16) || 16); + if (type === "genkotsu") return 30; + if (type === "fence_v" || type === "fence_h") return 24; + if (type === "nest_box") return 26; + if (type === "duplicator") return 24; + if (type === "signboard") return 22; + if (type === "bed") return 22; + if (type === "pushpin" || type === "oshibyo") return 12; + return clamp(base, 8, 24); +} + +function drawToolItemPreview(ctx, type = "", opts = {}) { + if (!ctx || !type) return false; + const w = Math.max(1, Number(opts.width || ctx.canvas?.width || 72)); + const h = Math.max(1, Number(opts.height || ctx.canvas?.height || 72)); + const localR = previewItemRadiusFor(type); + const item = Object.create(Item.prototype); + Object.assign(item, { + id: `tool-preview-${type}`, + type, + x: 0, + y: 0, + r: localR, + amount: 999, + age: 0, + seed: 72.43, + roles: {}, + dropTimer: 0, + dropMax: 0, + dropImpactDone: false, + growth: type === "grass" ? 0.86 : undefined, + health: 1, + wear: 0, + text: type === "signboard" ? "" : undefined, + storedFoodType: type === "duplicator" ? "sweet" : "", + storedFoodLabel: type === "duplicator" ? "ずんだ餅" : "", + pinState: "loose", + spin: type === "pushpin" || type === "oshibyo" ? -0.55 : 0, + zunchiVariant: "zunchi", + antCount: typeof ANT_NEST_START_COUNT !== "undefined" ? ANT_NEST_START_COUNT : 6, + fuseTimer: 5, + fuseMax: 5, + impactFlash: 0, + foodServingsMax: typeof foodServingsForSize === "function" ? foodServingsForSize("medium") : 5, + foodServingsRemaining: typeof foodServingsForSize === "function" ? foodServingsForSize("medium") : 5, + toolSize: "medium", + }); + if (type === "water") item.amount = 50; + ctx.save(); + ctx.clearRect(0, 0, w, h); + ctx.translate(w / 2, h / 2 + (type === "signboard" ? 11 : type === "genkotsu" ? 12 : type === "fence_v" || type === "fence_h" ? 1 : 4)); + const scaleMap = { + genkotsu: 0.52, + bed: 0.80, + nest_box: 0.78, + signboard: 0.76, + duplicator: 0.82, + fence_v: 0.70, + fence_h: 0.70, + ant_nest: 0.92, + ball: 0.92, + pushpin: 0.92, + oshibyo: 0.92, + firecracker: 0.90, + }; + ctx.scale(scaleMap[type] || 0.92, scaleMap[type] || 0.92); + try { + item.draw(ctx, 0, typeof getLightingState === "function" ? getLightingState(globalThis.world) : null); + } catch (err) { + ctx.restore(); + return false; + } + ctx.restore(); + return true; +} + +if (typeof window !== "undefined") { + window.isConsumableSpriteType = isConsumableSpriteType; + window.drawConsumableFieldSprite = drawConsumableFieldSprite; + window.drawStoredConsumableSprite = drawStoredConsumableSprite; + window.drawToolItemPreview = drawToolItemPreview; +} + + class Item { constructor(type, x, y) { this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; @@ -190,7 +421,7 @@ class Item { if (type === "water" || type === "water_bowl" || type === "zunda_juice") this.roles.drink = true; if (["sweet", "water", "water_bowl", "zunda_juice"].includes(type) || isParamEffectItemType(type)) this.roles.medicine = true; if (type === "bed" || type === "nest_box") this.roles.sleepPlace = true; - if (["firecracker", "genkotsu", "pushpin", "oshibyo", "zunchi", "splat"].includes(type)) this.roles.danger = true; + if (["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(type)) this.roles.danger = true; if (type === "grass") this.roles.grassMaterial = true; this.dropTimer = 0; this.dropMax = 0; @@ -214,6 +445,14 @@ class Item { this.text = ""; this.textEditedAt = -999; } + if (type === "duplicator") { + this.storedFoodType = ""; + this.storedFoodLabel = ""; + this.roles.food = false; + this.roles.duplicator = true; + this.needEffects.food = 55; + this.amount = 999; + } if (type === "ball") { this.vx = rand(-5, 5); this.vy = rand(-5, 5); @@ -350,6 +589,7 @@ class Item { } } if (this.type === "ball") this.updateBall(dt, worldRef); + if (this.type === "duplicator") this.updateDuplicator(dt, worldRef); if (isPinType(this.type)) this.updatePushpin(dt, worldRef); if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef); if (this.type === "grass") { @@ -372,6 +612,38 @@ class Item { } } + + updateDuplicator(dt, worldRef) { + this.amount = 999; + this.roles.food = Boolean(this.storedFoodType); + if (!worldRef?.nearbyItems) return; + const candidates = worldRef.nearbyItems(this.x, this.y, Math.max(74, (this.r || 34) * 2.4)); + let best = null, bestD = Infinity; + for (const it of candidates) { + if (!it || it === this || it.dead) continue; + if (typeof isServingFoodType !== "function" || !isServingFoodType(it.type)) continue; + if ((it.foodServingsRemaining ?? it.amount ?? 0) <= 0) continue; + const d = distXY(this.x, this.y, it.x, it.y); + if (d < bestD && d <= Math.max(58, (this.r || 34) + (it.r || 14) + 18)) { best = it; bestD = d; } + } + if (!best) return; + const newType = best.type; + const def = typeof itemDefinition === "function" ? itemDefinition(newType) : null; + const newLabel = def?.label || newType; + const changed = this.storedFoodType !== newType; + if (!changed && this.storedFoodType) return; + this.storedFoodType = newType; + this.storedFoodLabel = newLabel; + this.roles.food = true; + this.loadedAt = worldRef.time || 0; + best.amount = 0; + best.foodServingsRemaining = 0; + // Item.dead is a getter based on amount. Do not assign to it. + worldRef.markSpatialDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.markTerrainDirty?.(changed ? "duplicator-reloaded" : "duplicator-loaded"); + worldRef.emit?.("duplicator:loaded", { duplicator: this, foodType: this.storedFoodType, replaced: changed }); + worldRef.log?.(`\u8907\u88fd\u6a5f\u306b${this.storedFoodLabel}\u3092\u30bb\u30c3\u30c8\u3057\u305f\u3002`, "food"); + } updateZunchiMotion(dt, worldRef) { const speed = Math.hypot(this.vx || 0, this.vy || 0); if (speed < 0.08) { this.vx = 0; this.vy = 0; return; } @@ -676,9 +948,9 @@ class Item { this.pinState = "lodged"; this.deletable = false; this.pinTargetId = t.id; - this.pinAttachAngle = isOshibyo ? 0.62 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1)); - this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.58 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); - this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.30 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); + this.pinAttachAngle = isOshibyo ? 0.36 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1)); + this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74); + this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38); this.pinDamageTick = 0; this.pinFallCheckTimer = 0; this.vx = 0; @@ -722,8 +994,8 @@ class Item { const isOshibyo = Boolean(behavior?.blocksZunchi); t.stuckPushpinId = this.id; this.deletable = false; - this.x = isOshibyo ? t.x + (t.radius || 16) * 0.42 : t.x + (t.radius || 16) * 0.36; - this.y = isOshibyo ? t.y + (t.radius || 16) * 0.34 : t.y - (t.radius || 16) * 0.04; + this.x = isOshibyo ? t.x + (t.radius || 16) * 0.66 : t.x + (t.radius || 16) * 0.36; + this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04; this.prevX = this.x; this.prevY = this.y; this.spin = isOshibyo ? 0.52 : 0.28; @@ -957,135 +1229,8 @@ class Item { ctx.save(); ctx.translate(this.x, this.y + dropY); ctx.globalAlpha = visibleAlpha; - if (this.type === "food" || this.type === "sweet" || this.type === "love_mochi" || this.type === "fight_mochi") { - const sweet = this.type === "sweet"; - const loveMochi = this.type === "love_mochi"; - const fightMochi = this.type === "fight_mochi"; - ctx.fillStyle = loveMochi ? "#fff1f6" : (fightMochi ? "#fff3ec" : (sweet ? "#fffaf1" : "#f1dfb7")); - ctx.strokeStyle = loveMochi ? "#bf6f8b" : (fightMochi ? "#bc6d42" : (sweet ? "#9a8a72" : "#8d6f48")); - ctx.lineWidth = 2; - ctx.beginPath(); - const bob = 0; - ctx.ellipse(0, bob, this.r * 1.25, this.r * 0.75, Math.sin(this.seed) * 0.4, 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - if (sweet) { - ctx.beginPath(); - ctx.fillStyle = "#76b94d"; - ctx.ellipse(-1, -this.r * 0.24, this.r * 0.76, this.r * 0.26, Math.sin(this.seed) * 0.25, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = "rgba(255,255,255,0.62)"; - ctx.beginPath(); - ctx.ellipse(-this.r * 0.32, -this.r * 0.16, this.r * 0.24, this.r * 0.10, -0.35, 0, Math.PI * 2); - ctx.fill(); - } else if (loveMochi) { - ctx.fillStyle = "#ff6a9c"; - ctx.font = `${Math.round((this.r || 13) * 1.15)}px sans-serif`; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("\u2764", 0, 1); - ctx.fillStyle = "rgba(255,255,255,0.55)"; - ctx.beginPath(); - ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2); - ctx.fill(); - } else if (fightMochi) { - ctx.strokeStyle = "rgba(168,76,42,0.82)"; - ctx.lineWidth = 1.8; - ctx.beginPath(); - ctx.moveTo(-this.r * 0.44, -this.r * 0.42); - ctx.lineTo(this.r * 0.44, this.r * 0.42); - ctx.moveTo(-this.r * 0.44, this.r * 0.42); - ctx.lineTo(this.r * 0.44, -this.r * 0.42); - ctx.stroke(); - } else { - ctx.fillStyle = "#fff7dc"; - for (let i = 0; i < 5; i++) { - ctx.beginPath(); - ctx.arc(randSeed(this.seed + i, -8, 8), randSeed(this.seed + i + 7, -5, 5), 1.6, 0, Math.PI * 2); - ctx.fill(); - } - } - } else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) { - const visual = itemVisualFor(this.type); - const palette = visual?.palette || {}; - const renderer = visual?.renderer || "split_pill"; - const meta = { - fill: palette.fill || "#fffaf1", - stroke: palette.stroke || "#8d6f48", - accent: palette.highlight || palette.accent || "#ddd", - kind: renderer === "powder_pile" ? "powder" : renderer === "oval_droplet" ? "droplet" : renderer === "laxative_tablet" ? "laxative" : renderer === "mystery_tablet" ? "mystery" : renderer === "bullet" ? "bullet" : "pill", - }; - const localR = this.r * (visual?.fieldScale || visual?.scale || 1); - ctx.rotate(Math.sin(this.seed) * 0.10); - if (meta.kind === "powder") { - ctx.save(); - ctx.fillStyle = "rgba(68,46,28,0.18)"; - ctx.beginPath(); - ctx.ellipse(0, localR * 0.50, localR * 1.22, localR * 0.34, 0, 0, Math.PI * 2); - ctx.fill(); - for (let i = 0; i < 18; i++) { - const px = randSeed(this.seed + i * 7, -localR * 0.85, localR * 0.85); - const layer = 1 - Math.abs(px) / Math.max(1, localR * 0.90); - const py = randSeed(this.seed + i * 11, -localR * 0.15, localR * 0.48) - layer * localR * 0.48; - const rr = randSeed(this.seed + i * 13, localR * 0.14, localR * 0.32) * (0.75 + layer * 0.45); - ctx.fillStyle = i % 3 === 0 ? meta.accent : meta.fill; - ctx.strokeStyle = meta.stroke; - ctx.globalAlpha = 0.86 + layer * 0.12; - ctx.lineWidth = 1.0; - ctx.beginPath(); - ctx.ellipse(px, py, rr * 1.18, rr * 0.72, randSeed(this.seed + i, -0.45, 0.45), 0, Math.PI * 2); - ctx.fill(); ctx.stroke(); - } - ctx.globalAlpha = 1; - ctx.fillStyle = "rgba(255,255,255,0.62)"; - ctx.beginPath(); - ctx.ellipse(-localR * 0.30, -localR * 0.42, localR * 0.28, localR * 0.10, -0.35, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } else if (meta.kind === "droplet") { - drawOvalWaterDropSprite(ctx, localR, { fill: meta.fill, stroke: meta.stroke, highlight: meta.accent }); - } else if (meta.kind === "laxative") { - drawLaxativeTabletSprite(ctx, localR); - } else if (meta.kind === "mystery") { - drawMysteryTabletSprite(ctx, localR); - } else if (meta.kind === "bullet") { - drawBulletSprite(ctx, localR); - } else if (meta.kind === "box") { - ctx.fillStyle = meta.fill; - ctx.strokeStyle = meta.stroke; - ctx.lineWidth = 2; - roundedRect(ctx, -localR * 0.96, -localR * 0.78, localR * 1.92, localR * 1.56, localR * 0.24); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = meta.accent; - for (let i = 0; i < 5; i++) { - ctx.beginPath(); - ctx.ellipse(randSeed(this.seed + i, -localR * 0.50, localR * 0.50), randSeed(this.seed + i + 5, -localR * 0.30, localR * 0.34), localR * 0.18, localR * 0.34, 0.28, 0, Math.PI * 2); - ctx.fill(); - } - ctx.strokeStyle = "rgba(110,76,30,0.52)"; - ctx.beginPath(); - ctx.moveTo(-localR * 0.70, localR * 0.50); - ctx.lineTo(localR * 0.70, -localR * 0.50); - ctx.stroke(); - } else { - ctx.save(); - ctx.fillStyle = meta.fill; - ctx.strokeStyle = meta.stroke; - ctx.lineWidth = 2.2; - ctx.rotate(-0.38); - roundedRect(ctx, -localR * 1.15, -localR * 0.46, localR * 2.30, localR * 0.92, localR * 0.46); - ctx.fill(); ctx.stroke(); - ctx.fillStyle = meta.accent; - ctx.globalAlpha = 0.78; - ctx.fillRect(-1.5, -localR * 0.42, 3.0, localR * 0.84); - ctx.globalAlpha = 0.36; - ctx.fillStyle = "rgba(255,255,255,0.92)"; - ctx.beginPath(); - ctx.ellipse(-localR * 0.48, -localR * 0.16, localR * 0.32, localR * 0.10, -0.10, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - } else if (this.type === "sleep_drug") { - drawSleepTabletSprite(ctx, this.r * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug")); + if (isConsumableSpriteType(this.type)) { + drawConsumableFieldSprite(ctx, this.type, this.r, this.seed); } else if (this.type === "genkotsu") { const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu"); const impact = clamp(this.impactFlash || 0, 0, 1); @@ -1124,6 +1269,7 @@ class Item { ctx.restore(); } else if (isPinType(this.type)) { const stuck = this.pinState === "lodged"; + const isOshibyo = this.type === "oshibyo"; const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null; const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin"); const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId); @@ -1135,7 +1281,7 @@ class Item { if (img) { const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null; const ratio = metrics?.ratio || (stuck ? 1 : 2.0); - const w = isOshibyo ? (stuck ? this.r * 3.1 : this.r * 1.55) : (stuck ? this.r * 2.15 : this.r * 1.78); + const w = isOshibyo ? (stuck ? this.r * 1.55 : this.r * 0.78) : (stuck ? this.r * 2.15 : this.r * 1.78); const h = w * ratio; ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h); } else if (stuck) { @@ -1268,31 +1414,136 @@ class Item { ctx.beginPath(); ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2); ctx.fill(); - } else if (this.type === "signboard") { + } else if (this.type === "duplicator") { ctx.save(); - ctx.fillStyle = styleNight ? "#6d543b" : (styleWarm ? "#a67848" : "#8d633e"); - ctx.strokeStyle = styleNight ? "#34281c" : "#573c27"; - ctx.lineWidth = 2.2; - ctx.fillRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); - ctx.strokeRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58); - roundedRect(ctx, -this.r * 1.18, -this.r * 1.18, this.r * 2.36, this.r * 1.18, this.r * 0.14); + const loaded = !!this.storedFoodType; + const bodyGrad = ctx.createLinearGradient(-this.r * 1.1, -this.r * 1.0, this.r * 1.1, this.r * 0.9); + bodyGrad.addColorStop(0, styleNight ? "#3d4852" : "#d8e2e6"); + bodyGrad.addColorStop(0.45, styleNight ? "#65717b" : "#f2f6f7"); + bodyGrad.addColorStop(1, styleNight ? "#2c343c" : "#9fafb7"); + ctx.fillStyle = bodyGrad; + ctx.strokeStyle = styleNight ? "#1b252c" : "#52636b"; + ctx.lineWidth = Math.max(2, this.r * 0.075); + roundedRect(ctx, -this.r * 1.10, -this.r * 0.86, this.r * 1.52, this.r * 1.52, this.r * 0.20); ctx.fill(); ctx.stroke(); - ctx.fillStyle = "rgba(255,255,255,0.16)"; - ctx.fillRect(-this.r * 0.92, -this.r * 0.96, this.r * 1.02, this.r * 0.14); - const raw = String(this.text || "").replace(/\s+/g, " ").trim(); - if (raw) { - const charsPerLine = 7; - const lines = []; - for (let i = 0; i < raw.length && lines.length < 2; i += charsPerLine) lines.push(raw.slice(i, i + charsPerLine)); - ctx.fillStyle = styleNight ? "rgba(255,249,236,0.92)" : "rgba(51,34,19,0.92)"; - ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.38))}px ui-rounded, sans-serif`; + + ctx.fillStyle = styleNight ? "rgba(20,28,34,0.52)" : "rgba(255,255,255,0.72)"; + roundedRect(ctx, -this.r * 0.92, -this.r * 0.64, this.r * 1.12, this.r * 0.42, this.r * 0.10); + ctx.fill(); + ctx.strokeStyle = styleNight ? "rgba(160,190,210,0.25)" : "rgba(82,98,108,0.32)"; + ctx.stroke(); + + ctx.fillStyle = loaded ? "rgba(83,221,116,0.88)" : "rgba(139,160,170,0.62)"; + ctx.beginPath(); + ctx.arc(-this.r * 0.70, -this.r * 0.43, this.r * 0.13, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = "rgba(35,47,54,0.52)"; + ctx.stroke(); + ctx.fillStyle = loaded ? "rgba(126,245,141,0.82)" : "rgba(255,255,255,0.45)"; + ctx.beginPath(); + ctx.arc(-this.r * 0.74, -this.r * 0.47, this.r * 0.045, 0, Math.PI * 2); + ctx.fill(); + + ctx.strokeStyle = styleNight ? "#232b30" : "#657079"; + ctx.lineWidth = Math.max(1.6, this.r * 0.045); + ctx.beginPath(); + ctx.moveTo(this.r * 0.18, -this.r * 0.34); + ctx.quadraticCurveTo(this.r * 0.48, -this.r * 0.52, this.r * 0.74, -this.r * 0.28); + ctx.stroke(); + ctx.fillStyle = styleNight ? "#283139" : "#e6edf0"; + ctx.beginPath(); + ctx.arc(this.r * 0.78, -this.r * 0.28, this.r * 0.10, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + + const dishGrad = ctx.createLinearGradient(this.r * 0.08, -this.r * 0.06, this.r * 1.28, this.r * 0.62); + dishGrad.addColorStop(0, styleNight ? "#3f454b" : "#eef4f4"); + dishGrad.addColorStop(0.58, styleNight ? "#737c82" : "#ffffff"); + dishGrad.addColorStop(1, styleNight ? "#242a30" : "#adb8bd"); + ctx.fillStyle = dishGrad; + ctx.strokeStyle = styleNight ? "#12181d" : "#67767e"; + ctx.lineWidth = Math.max(2, this.r * 0.06); + ctx.beginPath(); + ctx.ellipse(this.r * 0.62, this.r * 0.42, this.r * 0.83, this.r * 0.39, -0.08, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = loaded ? "rgba(255,245,209,0.96)" : "rgba(230,235,238,0.48)"; + ctx.beginPath(); + ctx.ellipse(this.r * 0.62, this.r * 0.32, this.r * 0.50, this.r * 0.23, -0.08, 0, Math.PI * 2); + ctx.fill(); + + if (loaded) { + ctx.save(); + ctx.translate(this.r * 0.62, this.r * 0.24); + ctx.scale(0.82, 0.82); + drawStoredConsumableSprite(ctx, this.storedFoodType || "food", Math.max(7, this.r * 0.35), this.seed + 241); + ctx.restore(); + } else { + ctx.fillStyle = "rgba(72,80,86,0.62)"; + ctx.font = `bold ${Math.max(10, Math.round(this.r * 0.34))}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; - lines.forEach((line, index) => ctx.fillText(line, 0, -this.r * 0.68 + index * this.r * 0.34)); + ctx.fillText("?", this.r * 0.62, this.r * 0.28); + } + + ctx.fillStyle = styleNight ? "rgba(255,255,255,0.12)" : "rgba(255,255,255,0.45)"; + ctx.beginPath(); + ctx.ellipse(-this.r * 0.48, -this.r * 0.72, this.r * 0.34, this.r * 0.08, -0.14, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } else if (this.type === "signboard") { + ctx.save(); + const boardGrad = ctx.createLinearGradient(0, -this.r * 1.28, 0, this.r * 0.12); + boardGrad.addColorStop(0, styleNight ? "#d5cab8" : "#fff8e8"); + boardGrad.addColorStop(0.55, styleNight ? "#c0b29a" : "#f3e3c6"); + boardGrad.addColorStop(1, styleNight ? "#aa9a82" : "#dfc397"); + ctx.fillStyle = styleNight ? "#8a7557" : "#d6ba87"; + ctx.strokeStyle = styleNight ? "#5d4d3b" : "#9b7444"; + ctx.lineWidth = 2.2; + roundedRect(ctx, -this.r * 0.13, -this.r * 0.02, this.r * 0.26, this.r * 1.78, this.r * 0.04); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = boardGrad; + ctx.strokeStyle = styleNight ? "#63513c" : "#a77e48"; + roundedRect(ctx, -this.r * 1.36, -this.r * 1.28, this.r * 2.72, this.r * 1.38, this.r * 0.18); + ctx.fill(); + ctx.stroke(); + ctx.strokeStyle = styleNight ? "rgba(103,83,58,0.36)" : "rgba(167,126,72,0.38)"; + ctx.lineWidth = 1.1; + for (let i = 0; i < 3; i++) { + const y = -this.r * 1.02 + i * this.r * 0.36; + ctx.beginPath(); + ctx.moveTo(-this.r * 1.12, y); + ctx.quadraticCurveTo(-this.r * 0.12, y + Math.sin(this.seed + i) * 2, this.r * 1.12, y + Math.cos(this.seed + i) * 2); + ctx.stroke(); + } + ctx.fillStyle = "rgba(255,255,255,0.28)"; + roundedRect(ctx, -this.r * 1.12, -this.r * 1.10, this.r * 2.24, this.r * 0.20, this.r * 0.08); + ctx.fill(); + const raw = String(this.text || "").replace(/\r/g, "").trim(); + if (raw) { + const charsPerLine = 6; + const lines = []; + for (const rawLine of raw.split("\n")) { + let rest = rawLine.trim(); + if (!rest && lines.length < 3) { lines.push(""); continue; } + while (rest && lines.length < 3) { + const chars = Array.from(rest); + lines.push(chars.slice(0, charsPerLine).join("")); + rest = chars.slice(charsPerLine).join(""); + } + if (lines.length >= 3) break; + } + ctx.fillStyle = styleNight ? "rgba(37,31,24,0.95)" : "rgba(55,37,22,0.96)"; + ctx.font = `bold ${Math.max(11, Math.round(this.r * 0.39))}px ui-rounded, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + const startY = -this.r * 0.66 - (lines.length - 1) * this.r * 0.16; + lines.forEach((line, index) => ctx.fillText(line, 0, startY + index * this.r * 0.34)); } else { - ctx.fillStyle = styleNight ? "rgba(255,249,236,0.62)" : "rgba(51,34,19,0.50)"; - ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.30))}px ui-rounded, sans-serif`; + ctx.fillStyle = styleNight ? "rgba(55,45,33,0.58)" : "rgba(91,64,35,0.50)"; + ctx.font = `bold ${Math.max(14, Math.round(this.r * 0.44))}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("…", 0, -this.r * 0.58); diff --git a/js/render.js b/js/render.js index 38f79f8..3612151 100644 --- a/js/render.js +++ b/js/render.js @@ -250,10 +250,28 @@ const terrainCache = { function isCachedTerrainItem(it) { if (!it || it.dead) return false; if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true; - if (it.type === "zunchi") return Math.hypot(it.vx || 0, it.vy || 0) < 0.08; + if (it.type === "zunchi") return false; return false; } +function isBehindSpriteLayerItem(it) { + return Boolean(it && !it.dead && (it.type === "bed" || it.type === "grass_bed" || it.type === "splat")); +} + +function renderLayerSortY(entity) { + if (!entity) return 0; + if (entity.type === "bed" || entity.type === "grass_bed") return (entity.y || 0) - Math.max(10, (entity.r || 20) * 0.45); + if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return (entity.y || 0) + Math.max(6, (entity.radius || 20) * 0.28); + return entity.y || 0; +} + +function renderLayerKindRank(entity) { + if (!entity) return 0; + if (entity.kind === "ant_worker" || entity.kind === "ant_queen") return 1; + if (typeof Tarinai !== "undefined" && entity instanceof Tarinai) return 2; + return 0; +} + function terrainCacheKey(worldRef, lighting) { const perf = worldRef.performanceLevel ? worldRef.performanceLevel() : 0; const lightBucket = Math.round((lighting.light || 0) * (perf >= 2 ? 8 : 14)); @@ -737,18 +755,20 @@ function render() { beginFieldTransform(); drawPlacementPreview(ctx, world); if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH); - for (const ef of world.effects) { - if (ef.type === "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); - } + + // Low bedding is drawn one step behind the live sprite stack. + const backItems = []; + const layered = []; for (const it of world.items) { - if (isCachedTerrainItem(it)) continue; + if (!it || it.dead || isCachedTerrainItem(it)) continue; if (isPinType(it.type) && it.pinState === "lodged") continue; if (it.isStructure && it.type === "plushie" && it.carriedById) continue; - if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); - } - for (const it of world.items) { - if (it.type === "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); + if (!isEntityVisibleInRect(it, visibleRect)) continue; + if (isBehindSpriteLayerItem(it)) backItems.push(it); + else layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) }); } + backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0)); + for (const it of backItems) it.draw(ctx, world.time, lighting); ctx.save(); ctx.lineWidth = 1.15; @@ -766,10 +786,15 @@ function render() { ctx.setLineDash([]); ctx.restore(); - const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai; - for (const t of sorted) { - if (isEntityVisibleInRect(t, visibleRect)) t.draw(ctx, world.time, lighting); + for (const t of world.tarinai || []) { + if (t && !t.dead && isEntityVisibleInRect(t, visibleRect)) layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) }); } + for (const a of world.ants || []) { + if (a && !a.dead && isEntityVisibleInRect(a, visibleRect)) layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) }); + } + layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0))); + for (const entry of layered) entry.entity.draw(ctx, world.time, lighting); + for (const it of world.items) { if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } @@ -777,7 +802,7 @@ function render() { if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting); } for (const ef of world.effects) { - if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); + if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx); } ctx.restore(); @@ -800,7 +825,7 @@ function render() { } }; drawAtScreenPosition(world.selected, () => drawSelectedCard(ctx, world, lighting)); - drawNestBoxTooltip(ctx, world); + drawPointerItemTooltip(ctx, world); // Time HUD ctx.save(); @@ -843,23 +868,33 @@ function render() { } } -function drawNestBoxTooltip(ctx, world) { - const info = world.pointerNestBoxInfo?.(); - if (!info) return; - const box = info.box; - const title = "\u5de3\u7bb1"; - const lines = [title]; +function drawPointerItemTooltip(ctx, world) { + const nest = world.pointerNestBoxInfo?.(); + const bedInfo = !nest ? world.pointerOwnedBedInfo?.() : null; + let target = null; + let lines = []; + if (nest?.box) { + target = nest.box; + const names = (nest.occupants || []).map(t => t?.name).filter(Boolean).slice(0, 5); + lines = ["\u5de3\u7bb1"]; + if (names.length) lines.push(`${"\u5229\u7528\u4e2d"}: ${names.join("\u3001")}`); + else lines.push("\u5229\u7528\u4e2d: \u306a\u3057"); + } else if (bedInfo?.bed) { + target = bedInfo.bed; + lines = ["\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", `${"\u6301\u3061\u4e3b"}: ${bedInfo.owner?.name || "\u4e0d\u660e"}`]; + } + if (!target || !lines.length) return; ctx.save(); ctx.font = "12px Yomogi, sans-serif"; const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0); const lineH = 17; - const w = Math.min(240, Math.max(92, textW + 22)); + const w = Math.min(280, Math.max(92, textW + 22)); const h = Math.max(38, 16 + lines.length * lineH); - const pos = world.worldToScreen ? world.worldToScreen(box.x, box.y) : { x: box.x, y: box.y }; + const pos = world.worldToScreen ? world.worldToScreen(target.x, target.y) : { x: target.x, y: target.y }; const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8); - const y = clamp(pos.y - box.r * 1.8 - h, 8, ctx.canvas.height - h - 8); - ctx.fillStyle = "rgba(46,38,30,0.62)"; - ctx.strokeStyle = "rgba(255,248,220,0.72)"; + const y = clamp(pos.y - (target.r || 24) * 1.8 - h, 8, ctx.canvas.height - h - 8); + ctx.fillStyle = "rgba(46,38,30,0.66)"; + ctx.strokeStyle = "rgba(255,248,220,0.74)"; ctx.lineWidth = 1.2; roundedRect(ctx, x, y, w, h, 10); ctx.fill(); diff --git a/js/save_system.js b/js/save_system.js index 00cab54..52b26a2 100644 --- a/js/save_system.js +++ b/js/save_system.js @@ -82,18 +82,19 @@ dialog.setAttribute("aria-modal", "true"); dialog.innerHTML = `
-

セーブ / ロード

-

9つのスロットに現在のコロニーを保存できます。ハッシュテキストは別端末や別ブラウザへの移動に使えます。

+

\u30bb\u30fc\u30d6 / \u30ed\u30fc\u30c9

+

\u30bb\u30fc\u30d6\u6a5f\u80fd\u306f\u5b9f\u9a13\u4e2d\u3067\u3042\u308a\u3001\u4fdd\u8a3c\u5bfe\u8c61\u5916\u3067\u3059\u3002

+

9\u3064\u306e\u30b9\u30ed\u30c3\u30c8\u306b\u73fe\u5728\u306e\u30b3\u30ed\u30cb\u30fc\u3092\u4fdd\u5b58\u3067\u304d\u307e\u3059\u3002\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306f\u5225\u7aef\u672b\u3084\u5225\u30d6\u30e9\u30a6\u30b6\u3078\u306e\u79fb\u52d5\u306b\u4f7f\u3048\u307e\u3059\u3002

- - + +
- +
- +
`; document.body.appendChild(dialog); @@ -110,12 +111,12 @@ const row = document.createElement("div"); row.className = "save-slot-row"; row.innerHTML = ` -
スロット ${i}
+
\u30b9\u30ed\u30c3\u30c8 ${i}
${htmlEscape(formatSlotSummary(snapshot))}
- - - + + +
`; list.appendChild(row); } @@ -151,21 +152,21 @@ saveSlot(slot); renderSlotList(); global.audio?.notify?.(); - global.showToast?.(`スロット${slot}に保存しました。`); + global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}に\u4fdd\u5b58しました。`); } catch (error) { console.warn(error); - global.showToast?.("保存に失敗しました。空き容量を確認してください。"); + global.showToast?.("\u4fdd\u5b58に失敗しました。空き容量を確認してください。"); } return; } if (loadBtn) { const slot = Number(loadBtn.dataset.loadSlot); - if (!global.confirm?.(`スロット${slot}を読み込みます。現在の状態は上書きされます。`)) return; + if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みます。現在の状態は上書きされます。`)) return; try { loadSlot(slot); closeDialog(); global.audio?.notify?.(); - global.showToast?.(`スロット${slot}を読み込みました。`); + global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みました。`); } catch (error) { console.warn(error); global.showToast?.("読み込みに失敗しました。"); @@ -174,11 +175,11 @@ } if (deleteBtn) { const slot = Number(deleteBtn.dataset.deleteSlot); - if (!global.confirm?.(`スロット${slot}を削除しますか?`)) return; + if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を\u524a\u9664しますか?`)) return; deleteSlot(slot); renderSlotList(); global.audio?.delete?.(); - global.showToast?.(`スロット${slot}を削除しました。`); + global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を\u524a\u9664しました。`); return; } if (e.target.closest("#saveCloseBtn")) closeDialog(); @@ -195,7 +196,7 @@ const area = dialog.querySelector("#saveHashText"); const text = area?.value || ""; if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; } - if (!global.confirm?.("ハッシュテキストを読み込みます。現在の状態は上書きされます。")) return; + if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307fます。現在の状態は上書きされます。")) return; try { restoreSnapshot(decodeSnapshot(text), global.world); closeDialog(); diff --git a/js/structures.js b/js/structures.js index 59bb11d..7e6203e 100644 --- a/js/structures.js +++ b/js/structures.js @@ -22,7 +22,7 @@ this.usedCount = 0; this.x = x; this.y = y; - this.r = definition.type === "plushie" ? 13 : 24; + this.r = definition.type === "plushie" ? 13 : (definition.type === "grass_bed" ? 18 : 24); this.amount = this.hp; this.seed = Math.random() * 1000; this.roles = clonePlain(definition.roles); @@ -78,21 +78,33 @@ ctx.translate(this.x, this.y); const night = (lighting?.light || 1) < 0.42; if (this.type === "grass_bed") { - ctx.fillStyle = night ? "rgba(108,129,77,0.88)" : "rgba(134,174,83,0.92)"; - ctx.strokeStyle = night ? "rgba(55,73,49,0.72)" : "rgba(72,105,50,0.72)"; + // かんたんベッド: 緑色の小さい干草寝床。通常の干草寝床より一回り小さく、草束感を強める。 + const grassFill = night ? "rgba(78,132,67,0.90)" : "rgba(84,177,72,0.93)"; + const grassStroke = night ? "rgba(39,78,43,0.76)" : "rgba(45,112,48,0.78)"; + const strawStroke = night ? "rgba(168,207,119,0.42)" : "rgba(205,234,128,0.66)"; + ctx.fillStyle = grassFill; + ctx.strokeStyle = grassStroke; ctx.lineWidth = 2; ctx.beginPath(); - ctx.ellipse(0, 4, this.r * 1.55, this.r * 0.62, -0.08, 0, Math.PI * 2); + ctx.ellipse(0, 4, this.r * 1.72, this.r * 0.66, -0.08, 0, Math.PI * 2); ctx.fill(); ctx.stroke(); - ctx.strokeStyle = night ? "rgba(185,203,142,0.35)" : "rgba(231,229,139,0.55)"; - for (let i = 0; i < 12; i++) { - const a = -0.8 + i * 0.145; + ctx.strokeStyle = strawStroke; + ctx.lineWidth = 1.45; + for (let i = 0; i < 15; i++) { + const x = -this.r * 1.14 + i * this.r * 0.16; + const y = this.r * 0.38 + Math.sin(i * 1.7 + this.seed) * 1.8; + const endX = x + Math.cos(-0.55 + i * 0.08) * this.r * 0.74; + const endY = -this.r * 0.30 + Math.sin(time * 0.7 + i) * 2.6; ctx.beginPath(); - ctx.moveTo(-this.r * 1.05 + i * this.r * 0.19, this.r * 0.38); - ctx.quadraticCurveTo(Math.sin(time + i) * 4, -this.r * 0.12, Math.cos(a) * this.r * 0.92, -this.r * 0.42 + Math.sin(i) * 4); + ctx.moveTo(x, y); + ctx.quadraticCurveTo(Math.sin(time + i) * 3.2, -this.r * 0.10, endX, endY); ctx.stroke(); } + ctx.fillStyle = night ? "rgba(42,93,42,0.34)" : "rgba(66,145,47,0.36)"; + ctx.beginPath(); + ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2); + ctx.fill(); } else if (this.type === "plushie") { const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : global.images?.get?.("smile"); ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05); @@ -158,7 +170,7 @@ StructureRegistry.register({ type: "grass_bed", - label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9", + label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", buildNeed: "fulfill", buildTime: 4, maxHp: 100, diff --git a/js/tarinai.js b/js/tarinai.js index 4862e29..a71b69c 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -52,17 +52,34 @@ class Tarinai { constructor(world, opts = {}) { this.hasPaired = opts.hasPaired ?? false; this.hunger = opts.hunger ?? rand(16, 45); this.loneliness = opts.loneliness ?? rand(18, 62); - this.energy = opts.energy ?? rand(54, 98); - this.needs = opts.needs && typeof opts.needs === "object" && typeof createDefaultNeeds === "function" ? { ...createDefaultNeeds(), ...opts.needs } : (typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }); - this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needs) : 0; - this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" && typeof createDefaultNeeds === "function" ? { ...createDefaultNeeds(), ...opts.previousNeeds } : { ...this.needs }; + const initialDayP = world?.dayProgress?.() || 0; + const initialNight = initialDayP >= 0.66 || initialDayP < 0.18; + const initialAfternoon = initialDayP >= 0.46 && initialDayP < 0.58; + this.energy = opts.energy ?? (initialNight ? rand(34, 68) : (initialAfternoon ? rand(50, 82) : rand(66, 98))); + this.circadianSleepPressure = Number.isFinite(opts.circadianSleepPressure) ? opts.circadianSleepPressure : (initialNight ? rand(58, 78) : (initialAfternoon ? rand(8, 18) : rand(0, 5))); + const defaultNeeds = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 }; + this.needs = opts.needs && typeof opts.needs === "object" ? { ...defaultNeeds, ...opts.needs } : { ...defaultNeeds }; + this.needDisplay = opts.needDisplay && typeof opts.needDisplay === "object" ? { ...defaultNeeds, ...opts.needDisplay } : { ...this.needs }; + this.needRaw = opts.needRaw && typeof opts.needRaw === "object" ? { ...defaultNeeds, ...opts.needRaw } : { ...this.needs }; + this.needRawBase = opts.needRawBase && typeof opts.needRawBase === "object" ? { ...defaultNeeds, ...opts.needRawBase } : { ...this.needRaw }; + this.needSatisfaction = opts.needSatisfaction && typeof opts.needSatisfaction === "object" ? { ...defaultNeeds, ...opts.needSatisfaction } : { ...defaultNeeds }; + this.lastSatisfiedAt = opts.lastSatisfiedAt && typeof opts.lastSatisfiedAt === "object" ? { ...opts.lastSatisfiedAt } : {}; + this.lastSatisfactionSource = opts.lastSatisfactionSource || ""; + this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needRaw || this.needs) : 0; + this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...defaultNeeds, ...opts.previousNeeds } : { ...this.needs }; this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : null; + this.currentAction = opts.currentAction && typeof opts.currentAction === "object" ? { ...opts.currentAction } : null; + this.activeBehavior = opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null; + this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : []; + this.behaviorSerial = opts.behaviorSerial ?? 0; + this.actionCooldowns = opts.actionCooldowns && typeof opts.actionCooldowns === "object" ? { ...opts.actionCooldowns } : {}; this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {}; this.buildPlan = opts.buildPlan && typeof opts.buildPlan === "object" ? { ...opts.buildPlan } : null; + this.sleepSession = opts.sleepSession && typeof opts.sleepSession === "object" ? { ...opts.sleepSession } : null; this.hpBarTimer = opts.hpBarTimer ?? 0; this.stressBarTimer = opts.stressBarTimer ?? 0; this.affection = opts.affection ?? rand(0, 30); - this.need = opts.need || pick(NEEDS); + this.need = ""; this.state = opts.state || "idle"; this.dead = !!opts.dead; this.deathReason = opts.deathReason || ""; @@ -83,6 +100,7 @@ class Tarinai { constructor(world, opts = {}) { this.panicTargetUntil = opts.panicTargetUntil ?? 0; this.panicStuckTimer = opts.panicStuckTimer ?? 0; this.wanderAngle = rand(0, Math.PI * 2); + this.wanderTarget = opts.wanderTarget && typeof opts.wanderTarget === "object" ? { ...opts.wanderTarget } : null; this.reproductionTimer = rand(4, CONFIG.reproductionCooldown); this.lastLog = 0; this.sleeping = false; @@ -95,7 +113,9 @@ class Tarinai { constructor(world, opts = {}) { this.visualFacing = opts.visualFacing ?? this.facing; this.facingLockUntil = opts.facingLockUntil ?? 0; this.eatTimer = opts.eatTimer ?? 0; - this.eatCooldown = 0; + this.eatCooldown = opts.eatCooldown ?? 0; + this.lastAteAt = Number.isFinite(opts.lastAteAt) ? opts.lastAteAt : (this.world?.time || 0); + this.mealCooldownUntil = Number.isFinite(opts.mealCooldownUntil) ? opts.mealCooldownUntil : 0; this.foodReactTimer = opts.foodReactTimer ?? 0; this.surpriseTimer = opts.surpriseTimer ?? 0; this.fearTimer = opts.fearTimer ?? 0; diff --git a/js/tarinai_action_state.js b/js/tarinai_action_state.js index 5c30997..8e66433 100644 --- a/js/tarinai_action_state.js +++ b/js/tarinai_action_state.js @@ -7,25 +7,31 @@ Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({ setActionState(state, opts = {}) { if (this.dead) return false; - this.state = state || "idle"; + const nextState = state || "idle"; + this.state = nextState; if ("target" in opts) this.target = opts.target; if (opts.reason != null) this.thought = String(opts.reason || ""); if (opts.clearTarget) this.target = null; if (opts.sleeping != null) this.sleeping = Boolean(opts.sleeping); if (opts.wake) { this.sleeping = false; + this.sleepSession = null; if (this.insideNestBoxId && this.leaveNestBox) this.leaveNestBox(this.world?.nestBoxById?.(this.insideNestBoxId)); this.insideNestBoxId = ""; } + if (nextState !== "sleep" && opts.sleeping === false) this.sleepSession = null; return true; }, beginEating(item = null, reason = "") { - return this.setActionState("eat", { + const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている"); + const ok = this.setActionState("eat", { target: item || null, - reason: reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている"), + reason: text, sleeping: false, }); + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "食べている", reasonText: text, target: item || null, phase: "perform", source: "behavior" }); + return ok; }, currentLodgedPin() { @@ -42,17 +48,43 @@ }, goIdle(reason = "") { + if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "待っている"); return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", sleeping: false }); }, startSleeping(target = null, reason = "") { - return this.setActionState("sleep", { target, reason: reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている"), sleeping: true }); + const now = this.world?.time || 0; + if (!this.sleepSession) { + const inFurniture = Boolean(target); + this.sleepSession = { + startedAt: now, + minDuration: inFurniture ? rand(16, 28) : rand(10, 18), + targetEnergy: inFurniture ? rand(84, 94) : rand(74, 86), + maxDuration: inFurniture ? rand(42, 68) : rand(28, 48), + targetId: target?.id || null, + }; + } + this.intent = this.intent || { need: "sleep", tiedNeeds: ["sleep"], actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: reason || "眠っている", reasonText: "ねむいので、眠っている。", startedAt: now }; + this.currentAction = this.currentAction || { id: this.intent.actionId || "sleep", need: "sleep", startedAt: this.sleepSession.startedAt, minDuration: this.sleepSession.minDuration, lockSeconds: this.sleepSession.minDuration }; + const text = reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている"); + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "眠っている", reasonText: text, target, phase: "perform", source: "behavior" }); + return this.setActionState("sleep", { target, reason: text, sleeping: true }); }, seekTarget(state, target = null, reason = "") { return this.setActionState(state, { target, reason, sleeping: false }); }, + forceBehavior(actionId = "", opts = {}) { + if (typeof queueForcedTarinaiBehavior !== "function") return false; + return queueForcedTarinaiBehavior(this, actionId, opts); + }, + + currentBehaviorText() { + if (typeof activeBehaviorText === "function") return activeBehaviorText(this); + return String(this.activeBehavior?.presentText || this.activeBehavior?.reasonText || this.thought || "").trim(); + }, + enterPanic(opts = {}) { if (this.dead) return false; const profile = this.personalityProfile ? this.personalityProfile() : { fear: 1 }; diff --git a/js/tarinai_item_effects.js b/js/tarinai_item_effects.js index 20ab5e9..69364d6 100644 --- a/js/tarinai_item_effects.js +++ b/js/tarinai_item_effects.js @@ -216,9 +216,17 @@ }, applyFoodHungerRelief(amount = 0) { - if (this.hasItemEffect("laxative")) return (Number(amount) || 0) * (EFFECT_REGISTRY?.modifier?.("laxative", "hungerReliefMultiplier", 0) ?? 0); - this.hunger -= Number(amount) || 0; - return amount; + const raw = Math.max(0, Number(amount) || 0); + const actual = this.hasItemEffect("laxative") ? raw * (EFFECT_REGISTRY?.modifier?.("laxative", "hungerReliefMultiplier", 0) ?? 0) : raw; + if (actual > 0.2) this.lastAteAt = this.world?.time || 0; + if (actual >= 6) { + const day = Number(this.world?.config?.dayLength || CONFIG?.dayLength || 120) || 120; + const base = day / 4; + const jitter = typeof stableUnit === "function" ? (0.82 + stableUnit(this.familyKey || this.id, "meal-cooldown") * 0.36) : 1; + this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, (this.world?.time || 0) + base * jitter); + } + this.hunger -= actual; + return actual; }, updateItemEffects(dt = 0) { diff --git a/js/tarinai_needs_items.js b/js/tarinai_needs_items.js index 03180e7..5836476 100644 --- a/js/tarinai_needs_items.js +++ b/js/tarinai_needs_items.js @@ -16,7 +16,88 @@ const TARINAI_NEED_PHRASES = { health: "\u8abf\u5b50\u304c\u60aa\u3044", safety: "\u3042\u3076\u306a\u3044", social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b", - fulfill: "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b", + fulfill: "なにか満たされない", +}; + +const TARINAI_NEED_THRESHOLDS = { + food: { start: 54, continue: 34 }, + sleep: { start: 62, continue: 30 }, + health: { start: 54, continue: 32 }, + safety: { start: 42, continue: 20 }, + social: { start: 52, continue: 30 }, + fulfill: { start: 48, continue: 26 }, +}; + +const TARINAI_NEED_SATISFACTION_DECAY = { + food: 1.25, + sleep: 0.75, + health: 0.65, + safety: 1.10, + social: 1.05, + fulfill: 1.15, +}; + +const TARINAI_ACTION_LOCK_SECONDS = { + eat_food: 3.4, + drink_water: 4.2, + sleep_in_bed: 18.0, + sleep_anywhere: 12.0, + use_medicine: 5.4, + rest_to_recover: 6.0, + panic_escape: 4.2, + flee: 3.4, + hide_at_owned_structure: 6.4, + approach_friend: 6.4, + approach_parent_or_child: 7.0, + approach_mate: 5.6, + fight_rival: 5.2, + intimidate_enemy: 2.0, + play: 8.2, + build_grass_bed: 10.0, + build_plushie: 10.0, + return_owned_structure: 6.4, + use_plushie: 7.4, + wander_lightly: 4.2, +}; + +const TARINAI_ACTION_PRIORITY = { + fight_rival: 100, + intimidate_enemy: 96, + panic_escape: 94, + flee: 92, + hide_at_owned_structure: 90, + use_medicine: 86, + rest_to_recover: 84, + sleep_in_bed: 80, + sleep_anywhere: 78, + birth_ritual: 72, + approach_mate: 70, + approach_parent_or_child: 62, + approach_friend: 60, + eat_food: 50, + drink_water: 48, + build_grass_bed: 30, + build_plushie: 28, + use_plushie: 24, + return_owned_structure: 22, + play: 20, + wander_lightly: 10, +}; + +function actionPriority(actionOrId) { + const id = typeof actionOrId === "string" ? actionOrId : actionOrId?.id; + if (!id) return 0; + return Number(TARINAI_ACTION_PRIORITY[id] ?? actionOrId?.priority ?? 0) || 0; +} + +const TARINAI_UNINTERRUPTIBLE_STATES = ["panic", "intimidate", "fight", "birth_ritual"]; +const TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY = { + fight_mochi: 180, + love_mochi: 150, + sleep_drug: 145, + drug: 140, + user: 200, + external: 130, }; function createDefaultNeeds() { @@ -67,11 +148,74 @@ function applyNeedShock(tarinai, deltas = {}, breaker = null) { return true; } -function applyNeedRelief(tarinai, deltas = {}) { - return applyNeedShock(tarinai, deltas, null); +function applyNeedSatisfaction(tarinai, deltas = {}, source = "") { + if (!tarinai || tarinai.dead) return false; + tarinai.needSatisfaction = tarinai.needSatisfaction || createDefaultNeeds(); + tarinai.lastSatisfiedAt = tarinai.lastSatisfiedAt || {}; + const now = tarinai.world?.time || 0; + let changed = false; + for (const key of TARINAI_NEED_KEYS) { + const amount = Math.max(0, Number(deltas[key] || 0) || 0); + if (amount <= 0) continue; + tarinai.needSatisfaction[key] = clamp((tarinai.needSatisfaction[key] || 0) + amount, 0, 88); + tarinai.lastSatisfiedAt[key] = now; + changed = true; + } + if (source) tarinai.lastSatisfactionSource = source; + return changed; } -function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b") { +function applyNeedRelief(tarinai, deltas = {}) { + if (!tarinai || tarinai.dead) return false; + const shockDeltas = {}; + const satisfactionDeltas = {}; + let hasShock = false; + let hasSatisfaction = false; + for (const key of TARINAI_NEED_KEYS) { + const delta = Number(deltas[key] || 0) || 0; + if (delta < 0) { + satisfactionDeltas[key] = -delta; + hasSatisfaction = true; + } else if (delta > 0) { + shockDeltas[key] = delta; + hasShock = true; + } + } + if (hasSatisfaction) applyNeedSatisfaction(tarinai, satisfactionDeltas, "relief"); + if (hasShock) applyNeedShock(tarinai, shockDeltas, null); + return hasSatisfaction || hasShock; +} + +function startNeedDrivenEmergencyReaction(tarinai, breaker = null, reason = "パニックになっている") { + let actions = null; + try { actions = TARINAI_ACTIONS; } catch (err) { actions = null; } + if (!tarinai || tarinai.dead || !Array.isArray(actions)) return false; + const world = tarinai.world || globalThis.world || null; + const aggression = Number(tarinai.currentPersonality?.aggression || tarinai.personalityProfile?.().fight || 0); + const fightLikeTarget = breaker && breaker.type !== "firecracker" && breaker.kind !== "ant" && breaker.id && world?.canFightPair?.(tarinai, breaker); + const fightAction = actions.find(a => a.id === "fight_rival"); + if (fightLikeTarget && aggression > 0.62 && fightAction) { + tarinai.conflictTargetId = breaker.id; + tarinai.conflictUrge = Math.max(tarinai.conflictUrge || 0, 72); + const choice = { need: "social", tiedNeeds: ["social", "safety"], max: Number(tarinai.needRaw?.social || tarinai.needs?.social || 0) || 72 }; + const reasonText = buildReasonText("social", choice.tiedNeeds, fightAction, tarinai, world); + if (startNeedAction(tarinai, world, choice, fightAction, reasonText)) { + tarinai.lastNeedShockBreaker = null; + return true; + } + } + const panicAction = actions.find(a => a.id === "panic_escape") || actions.find(a => a.id === "flee"); + if (!panicAction) return false; + const choice = { need: "safety", tiedNeeds: ["safety"], max: Number(tarinai.needRaw?.safety || tarinai.needs?.safety || 0) || 80 }; + const reasonText = reason && reason.endsWith("。") ? reason : buildReasonText("safety", choice.tiedNeeds, panicAction, tarinai, world); + if (startNeedAction(tarinai, world, choice, panicAction, reasonText)) { + tarinai.lastNeedShockBreaker = null; + return true; + } + return false; +} + +function triggerNeedShockReaction(tarinai, breaker = null, reason = "パニックになっている") { if (!tarinai || tarinai.dead) return false; const before = tarinai.previousNeeds || createDefaultNeeds(); const projected = { ...(tarinai.needs || createDefaultNeeds()) }; @@ -82,53 +226,498 @@ function triggerNeedShockReaction(tarinai, breaker = null, reason = "\u30d1\u30c const shockTriggered = safetyDelta >= 40 || healthDelta >= 45 || fulfillDelta >= 50; if (!shockTriggered) return false; if (["panic", "intimidate", "fight", "birth_ritual"].includes(tarinai.state)) return false; - const aggression = Number(tarinai.currentPersonality?.aggression || 0); - if (breaker && aggression > 0.6) { - tarinai.setActionState?.("intimidate", { target: breaker, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true, sleeping: false }); - tarinai.intimidateTimer = Math.max(tarinai.intimidateTimer || 0, 2.0); - } else { - tarinai.setActionState?.("panic", { target: tarinai.panicDestination?.(breaker), reason, wake: true, sleeping: false }); - tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 2.6); - } - tarinai.lastNeedShockBreaker = null; - return true; + return startNeedDrivenEmergencyReaction(tarinai, breaker, reason); } -function chooseTopNeedRandom(needs) { - const max = Math.max(...TARINAI_NEED_KEYS.map(key => Number(needs?.[key]) || 0)); - const tiedNeeds = TARINAI_NEED_KEYS.filter(key => (Number(needs?.[key]) || 0) === max); - return { need: pick(tiedNeeds), tiedNeeds, max }; +function needThreshold(need, kind = "start") { + return TARINAI_NEED_THRESHOLDS[need]?.[kind] ?? (kind === "continue" ? 30 : 50); } -function buildReasonText(chosenNeed, tiedNeeds, action) { - const chosenPhrase = action?.phrase || "\u5f85\u3063\u305f"; +function actionLockSeconds(action, tarinai = null) { + const base = Number(action?.lockSeconds ?? TARINAI_ACTION_LOCK_SECONDS[action?.id] ?? 2.0) || 2.0; + const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${action?.id || "idle"}`; + const jitter = typeof stableUnit === "function" ? (stableUnit(key, "action-lock") - 0.5) * 0.18 : (Math.random() - 0.5) * 0.12; + return Math.max(0.5, base * (1 + jitter)); +} + +function scoreNeedForAction(needs, tarinai, world, need) { + const value = Number(needs?.[need]) || 0; + let score = value; + if (need === "safety") score += 14; + else if (need === "health") score += 8; + else if (need === "food") score += 3; + if (tarinai?.intent?.need === need || tarinai?.currentAction?.need === need) score += 10; + const cooldown = Number(tarinai?.actionCooldowns?.[need] || 0) || 0; + if (cooldown > 0) score -= Math.min(22, cooldown * 2.8); + const nowBucket = Math.floor((world?.time || 0) / 8); + const key = `${tarinai?.familyKey || tarinai?.id || "tarinai"}:${need}:${nowBucket}`; + const noise = typeof stableUnit === "function" ? (stableUnit(key, "need-score") - 0.5) * 3.2 : (Math.random() - 0.5) * 2.0; + return score + noise; +} + +function chooseTopNeedRandom(needs, tarinai = null, world = null) { + const scores = {}; + for (const key of TARINAI_NEED_KEYS) scores[key] = scoreNeedForAction(needs, tarinai, world, key); + const max = Math.max(...TARINAI_NEED_KEYS.map(key => scores[key])); + const tiedNeeds = TARINAI_NEED_KEYS.filter(key => max - scores[key] <= 1.5); + const need = TARINAI_NEED_PRIORITY.find(key => tiedNeeds.includes(key)) || tiedNeeds[0] || "fulfill"; + return { need, tiedNeeds, max: Number(needs?.[need]) || 0, score: max, scores }; +} + +function fulfillPrimaryReason(tarinai, action = null) { + const actionId = action?.id || tarinai?.intent?.actionId || tarinai?.currentAction?.id || ""; + if (actionId === "build_grass_bed") return "寝床がほしい"; + if (actionId === "build_plushie") return "ぬいぐるみがほしい"; + if (actionId === "return_owned_structure" || actionId === "hide_at_owned_structure") return "自分の場所が気になる"; + if (actionId === "use_plushie") return "ぬいぐるみを抱えたい"; + if (actionId === "play") return "退屈している"; + if (actionId === "wander_lightly") return "少し退屈している"; + + const parts = tarinai?.fulfillReasonParts || {}; + const candidates = [ + ["bed", "寝床がほしい", parts.bed], + ["plushie", "ぬいぐるみがほしい", parts.plushie], + ["boredom", "退屈している", parts.boredom], + ["material", "草でなにか作れそう", parts.material], + ["openness", "新しいことが気になる", parts.openness], + ].filter(([, , v]) => Number(v || 0) > 0); + candidates.sort((a, b) => Number(b[2] || 0) - Number(a[2] || 0)); + return candidates[0]?.[1] || TARINAI_NEED_PHRASES.fulfill || "なにか満たされない"; +} + +function needPhraseForReason(need, tarinai = null, action = null) { + if (need === "fulfill") return fulfillPrimaryReason(tarinai, action); + if (need === "social" && action?.subNeed === "mate") return "繁殖できる相手が気になる"; + if (need === "social" && action?.id === "birth_ritual") return "相手と繁殖している"; + if (need === "social" && action?.subNeed === "conflict") return "気に入らない相手がいる"; + if (need === "social" && action?.subNeed === "family") return "家族が気になる"; + if (need === "social" && action?.subNeed === "bond") return "仲間が気になる"; + if (need === "safety" && action?.id === "panic_escape") return "怖い"; + return TARINAI_NEED_PHRASES[need] || need; +} + +function buildReasonText(chosenNeed, tiedNeeds, action, tarinai = null, world = null) { + const chosenPhrase = action?.label || action?.phrase || "待っている"; const tied = Array.isArray(tiedNeeds) ? tiedNeeds.filter(key => key !== chosenNeed) : []; if (tied.length) { const ignored = TARINAI_NEED_PRIORITY.find(key => tied.includes(key)) || tied[0]; - return `${TARINAI_NEED_PHRASES[ignored] || ignored}\u3051\u3069\u3001${chosenPhrase}\u3002`; + return `${needPhraseForReason(ignored, tarinai, action)}けど、${chosenPhrase}。`; } - return `${TARINAI_NEED_PHRASES[chosenNeed] || chosenNeed}\u306e\u3067\u3001${chosenPhrase}\u3002`; + return `${needPhraseForReason(chosenNeed, tarinai, action)}ので、${chosenPhrase}。`; } + +function refreshIntentReasonText(tarinai, action = null) { + if (!tarinai?.intent?.need) return ""; + const resolvedAction = action || TARINAI_ACTIONS.find(a => a.id === tarinai.intent.actionId) || null; + const tiedNeeds = Array.isArray(tarinai.intent.tiedNeeds) && tarinai.intent.tiedNeeds.length ? tarinai.intent.tiedNeeds : [tarinai.intent.need]; + const next = buildReasonText(tarinai.intent.need, tiedNeeds, resolvedAction, tarinai, tarinai.world); + const prev = tarinai.intent.reasonText || ""; + if (next && next !== prev) tarinai.intent.reasonText = next; + if (next && (!tarinai.thought || tarinai.thought === prev)) tarinai.thought = next; + return next || tarinai.intent.reasonText || ""; +} + + + +function actionById(id = "") { + const key = String(id || ""); + return Array.isArray(TARINAI_ACTIONS) ? TARINAI_ACTIONS.find(action => action && action.id === key) || null : null; +} + +function behaviorTargetId(target = null) { + if (!target) return null; + if (target.id != null) return target.id; + if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`; + return null; +} + +function currentBehaviorTarget(tarinai, behavior = null) { + if (!tarinai) return null; + const b = behavior || tarinai.activeBehavior || null; + if (!b) return tarinai.target || null; + if (tarinai.target && !tarinai.target.dead && (!b.targetId || behaviorTargetId(tarinai.target) === b.targetId)) return tarinai.target; + if (!b.targetId) return null; + const id = b.targetId; + return (tarinai.world?.tarinai || []).find(o => o && !o.dead && o.id === id) + || (tarinai.world?.items || []).find(item => item && !item.dead && item.id === id) + || null; +} + +function isLiveTarinaiEntity(value) { + return Boolean(value && !value.dead && typeof value.relationTo === "function" && Number.isFinite(value.x) && Number.isFinite(value.y)); +} + +function relationFearSafe(holder, other) { + if (!holder || !other || typeof holder.relationTo !== "function") return 0; + return Number(holder.relationTo(other.id)?.fear || 0) || 0; +} + +function nearestForcedFightTarget(tarinai, world, maxDist = 720) { + if (!tarinai || !world) return null; + const current = currentBehaviorTarget(tarinai) || tarinai.target || null; + const ok = o => isLiveTarinaiEntity(o) && o !== tarinai && !!o.isZunchiSlave === !!tarinai.isZunchiSlave && !world.areParentChild?.(tarinai, o) && !world.areCoParents?.(tarinai, o) && !o.sleepDisease; + if (ok(current) && dist(tarinai, current) <= maxDist + 120) return current; + const conflict = conflictTargetFor(tarinai, world, 6); + if (ok(conflict?.target)) return conflict.target; + return world.nearestOther?.(tarinai, maxDist, ok) || null; +} + +function resolveForcedBehaviorTarget(tarinai, world, entry = {}, action = null) { + if (!tarinai || !world) return null; + if (entry.targetId) { + const found = (world.tarinai || []).find(o => o && !o.dead && o.id === entry.targetId) + || (world.items || []).find(item => item && !item.dead && item.id === entry.targetId) + || null; + if (found) return found; + } + const id = String(entry.id || action?.id || ""); + if (id === "fight_rival" || id === "intimidate_enemy") return nearestForcedFightTarget(tarinai, world, Number(entry.searchRange || 760)); + if (id === "approach_mate" || id === "birth_ritual") return mateTargetFor(tarinai, world, Number(entry.searchRange || 760)); + if (id === "eat_food") return behaviorFoodTarget(tarinai, world, "food", Number(entry.searchRange || 900)); + if (id === "drink_water") return behaviorFoodTarget(tarinai, world, "drink", Number(entry.searchRange || 680)); + if (id === "use_medicine") return behaviorFoodTarget(tarinai, world, "medicine", Number(entry.searchRange || 760)); + if (id === "sleep_in_bed") return findNearestSleepPlace(world, tarinai, Number(entry.searchRange || 760)); + return null; +} + +function forcedBehaviorSourcePriority(source = "external") { + return Number(TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY[String(source || "external")] || TARINAI_FORCED_BEHAVIOR_SOURCE_PRIORITY.external || 130) || 130; +} + +function clearForcedBehaviorQueue(tarinai, predicate = null) { + if (!tarinai || !Array.isArray(tarinai.forcedBehaviorQueue)) return 0; + const before = tarinai.forcedBehaviorQueue.length; + if (typeof predicate !== "function") tarinai.forcedBehaviorQueue = []; + else tarinai.forcedBehaviorQueue = tarinai.forcedBehaviorQueue.filter(entry => !predicate(entry)); + return before - tarinai.forcedBehaviorQueue.length; +} + +function activeBehaviorText(tarinai) { + const b = tarinai?.activeBehavior; + if (!b) return ""; + const state = String(tarinai.state || ""); + const timerActive = (tarinai.birthRitualTimer || 0) > 0.04 || (tarinai.fightTimer || 0) > 0.04 || (tarinai.eatTimer || 0) > 0.04 || (tarinai.intimidateTimer || 0) > 0.04 || (tarinai.fearTimer || 0) > 0.04; + const validByState = !b.state || b.state === state || timerActive || ["seek_food", "seek_water", "seek_friend", "seek_enemy", "follow_parent", "seek_bed", "wander", "build", "play_ball"].includes(state); + if (!validByState && !b.forced) return ""; + return String(b.presentText || b.reasonText || b.label || "").trim(); +} + +function setActiveBehavior(tarinai, fields = {}) { + if (!tarinai || tarinai.dead) return null; + const now = tarinai.world?.time || 0; + const previous = tarinai.activeBehavior || {}; + const actionId = fields.id || fields.actionId || previous.id || tarinai.intent?.actionId || tarinai.state || "idle"; + const action = fields.action || actionById(actionId) || null; + const need = fields.need || action?.need || previous.need || tarinai.intent?.need || "fulfill"; + const label = fields.label || fields.actionLabel || action?.label || previous.label || "行動している"; + const reasonText = fields.reasonText || fields.presentText || label; + const startedAt = Number.isFinite(fields.startedAt) ? fields.startedAt : (Number.isFinite(previous.startedAt) && previous.id === actionId ? previous.startedAt : now); + const lockSeconds = Number(fields.lockSeconds ?? previous.lockSeconds ?? (action ? actionLockSeconds(action, tarinai) : 1.0)) || 1.0; + const minDuration = Number(fields.minDuration ?? previous.minDuration ?? Math.max(0.45, lockSeconds * 0.72)) || 0.45; + const target = fields.target !== undefined ? fields.target : tarinai.target; + const targetId = fields.targetId !== undefined ? fields.targetId : behaviorTargetId(target); + const next = { + id: actionId, + need, + subNeed: fields.subNeed || action?.subNeed || previous.subNeed || "", + phase: fields.phase || previous.phase || (String(tarinai.state || "") === "idle" ? "idle" : "active"), + state: fields.state || action?.state || tarinai.state || previous.state || "idle", + label, + presentText: fields.presentText || reasonText, + reasonText, + targetId, + startedAt, + updatedAt: now, + minDuration, + lockSeconds, + deadlineAt: Number.isFinite(fields.deadlineAt) ? fields.deadlineAt : previous.deadlineAt, + source: fields.source || previous.source || "need", + forced: Boolean(fields.forced ?? previous.forced ?? false), + priority: Number(fields.priority ?? previous.priority ?? (action ? actionPriority(action) : 0)) || 0, + }; + tarinai.behaviorSerial = (tarinai.behaviorSerial || 0) + 1; + next.serial = tarinai.behaviorSerial; + tarinai.activeBehavior = next; + tarinai.intent = { + ...(tarinai.intent || {}), + need, + tiedNeeds: Array.isArray(fields.tiedNeeds) && fields.tiedNeeds.length ? [...fields.tiedNeeds] : (Array.isArray(tarinai.intent?.tiedNeeds) && tarinai.intent.tiedNeeds.length ? [...tarinai.intent.tiedNeeds] : [need]), + actionId, + actionLabel: label, + reasonText, + startedAt, + }; + tarinai.currentAction = { + ...(tarinai.currentAction || {}), + id: actionId, + need, + subNeed: next.subNeed, + startedAt, + minDuration, + lockSeconds, + phase: next.phase, + }; + if (target !== undefined) tarinai.target = target; + if (reasonText) tarinai.thought = reasonText; + return next; +} + +function setActiveBehaviorFromAction(tarinai, action, choice = {}, reasonText = "", options = {}) { + if (!action) return null; + return setActiveBehavior(tarinai, { + id: action.id, + action: action, + need: choice.need || action.need, + subNeed: options.subNeed || action.subNeed || "", + state: options.state || action.state || tarinai?.state || "idle", + label: action.label || options.actionLabel || "行動している", + presentText: reasonText || action.label, + reasonText: reasonText || action.label, + target: options.target !== undefined ? options.target : tarinai?.target, + tiedNeeds: choice.tiedNeeds || [choice.need || action.need], + source: options.source || "need", + forced: Boolean(options.forced), + priority: options.priority ?? actionPriority(action), + lockSeconds: options.lockSeconds, + minDuration: options.minDuration, + phase: options.phase || "start", + }); +} + +function clearActiveBehavior(tarinai, reason = "") { + if (!tarinai) return; + tarinai.activeBehavior = null; + tarinai.currentAction = null; + if (reason) tarinai.thought = reason; +} + +function queueForcedTarinaiBehavior(tarinai, actionId, opts = {}) { + if (!tarinai || tarinai.dead || !actionId) return false; + const now = tarinai.world?.time || 0; + tarinai.forcedBehaviorQueue = Array.isArray(tarinai.forcedBehaviorQueue) ? tarinai.forcedBehaviorQueue : []; + const source = opts.source || "external"; + const basePriority = forcedBehaviorSourcePriority(source); + const entry = { + id: String(actionId), + targetId: opts.targetId ?? behaviorTargetId(opts.target), + reasonText: opts.reasonText || opts.reason || "", + source, + priority: Number(opts.priority ?? Math.max(basePriority, actionPriority(actionId) + 70)) || basePriority, + force: opts.force !== false, + replaceSameSource: opts.replaceSameSource !== false, + interrupt: opts.interrupt !== false, + createdAt: now, + lastTriedAt: -999, + retryDelay: Number(opts.retryDelay ?? 0.25) || 0.25, + expiresAt: Number.isFinite(opts.expiresAt) ? opts.expiresAt : now + Number(opts.ttl ?? 18), + duration: Number(opts.duration ?? 0) || 0, + minDuration: Number(opts.minDuration ?? opts.duration ?? 0) || 0, + searchRange: Number(opts.searchRange ?? 0) || 0, + }; + if (entry.replaceSameSource) { + tarinai.forcedBehaviorQueue = tarinai.forcedBehaviorQueue.filter(e => !(e && e.id === entry.id && e.source === entry.source)); + } + tarinai.forcedBehaviorQueue.push(entry); + tarinai.forcedBehaviorQueue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); + tarinai.forcedBehaviorQueue = tarinai.forcedBehaviorQueue.slice(0, 12); + if (opts.target) tarinai.target = opts.target; + if (opts.applyShock && typeof applyNeedShock === "function") applyNeedShock(tarinai, opts.applyShock, opts.target || null); + return true; +} + +function processForcedBehaviorQueue(tarinai, world, needs) { + if (!tarinai || tarinai.dead) return false; + const now = world?.time || 0; + let queue = Array.isArray(tarinai.forcedBehaviorQueue) + ? tarinai.forcedBehaviorQueue.filter(entry => entry && (!Number.isFinite(entry.expiresAt) || entry.expiresAt >= now)) + : []; + if (!queue.length) { tarinai.forcedBehaviorQueue = []; return false; } + queue.sort((a, b) => (Number(b.priority || 0) - Number(a.priority || 0)) || (Number(a.createdAt || 0) - Number(b.createdAt || 0))); + tarinai.forcedBehaviorQueue = queue; + + const activePriority = Number(tarinai.activeBehavior?.priority ?? actionPriority(tarinai.activeBehavior?.id || tarinai.intent?.actionId)) || 0; + const activeForced = Boolean(tarinai.activeBehavior?.forced); + + for (let i = 0; i < queue.length; i++) { + const entry = queue[i]; + const action = actionById(entry.id); + if (!action) { queue.splice(i, 1); i--; continue; } + const entryPriority = Number(entry.priority ?? actionPriority(entry.id)) || 0; + if (tarinai.activeBehavior && (tarinai.intentLockTimer || 0) > 0.08) { + if (activeForced && entryPriority <= activePriority + 8) continue; + if (!entry.interrupt && entryPriority <= activePriority) continue; + if (entryPriority <= activePriority && !entry.force) continue; + } + if (now - Number(entry.lastTriedAt || -999) < Number(entry.retryDelay || 0.25)) continue; + entry.lastTriedAt = now; + + const target = resolveForcedBehaviorTarget(tarinai, world, entry, action); + if (target) tarinai.target = target; + if ((action.id === "fight_rival" || action.id === "intimidate_enemy" || action.id === "approach_mate" || action.id === "birth_ritual") && !target) { + continue; + } + + const need = action.need || entry.need || "safety"; + const choice = { need, tiedNeeds: [need], max: Math.max(needThreshold(need, "start"), Number(needs?.[need] || 0) || 0) }; + const reasonText = entry.reasonText || buildReasonText(need, choice.tiedNeeds, action, tarinai, world); + if (tarinai.activeBehavior && entryPriority > activePriority) { + clearActiveBehavior(tarinai); + tarinai.intentLockTimer = 0; + if (tarinai.state === "sleep" || tarinai.sleeping) { + tarinai.sleeping = false; + tarinai.sleepSession = null; + } + } + const started = startNeedAction(tarinai, world, choice, action, reasonText, { + forced: true, + priority: entryPriority, + source: entry.source, + target, + lockSeconds: entry.duration || undefined, + minDuration: entry.minDuration || undefined, + }); + if (started) { + queue.splice(i, 1); + tarinai.forcedBehaviorQueue = queue; + return true; + } + } + tarinai.forcedBehaviorQueue = queue; + return false; +} + +function setLiveActionText(tarinai, { need = null, actionId = null, actionLabel = null, reasonText = null, target = undefined, subNeed = "", phase = "active", source = "state", forced = null } = {}) { + if (!tarinai) return; + const now = tarinai.world?.time || 0; + const nextNeed = need || tarinai.intent?.need || tarinai.currentAction?.need || tarinai.activeBehavior?.need || "social"; + const nextActionId = actionId || tarinai.intent?.actionId || tarinai.currentAction?.id || tarinai.activeBehavior?.id || tarinai.state || "idle"; + const nextActionLabel = actionLabel || reasonText || tarinai.intent?.actionLabel || tarinai.activeBehavior?.label || tarinai.state || "行動している"; + const nextReasonText = reasonText || nextActionLabel; + const action = actionById(nextActionId); + tarinai.intent = { + ...(tarinai.intent || {}), + need: nextNeed, + tiedNeeds: Array.isArray(tarinai.intent?.tiedNeeds) && tarinai.intent.tiedNeeds.length ? [...tarinai.intent.tiedNeeds] : [nextNeed], + actionId: nextActionId, + actionLabel: nextActionLabel, + reasonText: nextReasonText, + startedAt: tarinai.intent?.startedAt || now, + }; + tarinai.currentAction = { + ...(tarinai.currentAction || {}), + id: nextActionId, + need: nextNeed, + subNeed: subNeed || action?.subNeed || tarinai.currentAction?.subNeed || "", + startedAt: tarinai.currentAction?.startedAt || tarinai.intent.startedAt || now, + minDuration: tarinai.currentAction?.minDuration || 0.8, + lockSeconds: tarinai.currentAction?.lockSeconds || 1.0, + phase, + }; + if (target !== undefined) tarinai.target = target; + setActiveBehavior(tarinai, { + id: nextActionId, + action, + need: nextNeed, + subNeed: subNeed || action?.subNeed || "", + state: tarinai.state || action?.state || "idle", + label: nextActionLabel, + presentText: nextReasonText, + reasonText: nextReasonText, + target: target !== undefined ? target : tarinai.target, + tiedNeeds: tarinai.intent.tiedNeeds, + source: source || tarinai.activeBehavior?.source || "state", + forced: forced ?? Boolean(tarinai.activeBehavior?.forced), + priority: tarinai.activeBehavior?.priority ?? actionPriority(action), + phase, + }); +} + + +function synchronizeActionTextFromState(tarinai) { + if (!tarinai || tarinai.dead) return; + const state = String(tarinai.state || ""); + const catalogText = typeof window !== "undefined" && window.TEXT_CATALOG?.reasonLabel ? window.TEXT_CATALOG.reasonLabel(tarinai) : ""; + if (state === "birth_ritual" || (tarinai.birthRitualTimer || 0) > 0.04) { + const partner = tarinai.world?.tarinai?.find?.(o => o && !o.dead && o.id === tarinai.birthPartnerId) || tarinai.target || null; + const label = partner?.name ? `${partner.name}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている"; + setLiveActionText(tarinai, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: label, target: partner }); + return; + } + if (state === "eat" || (tarinai.eatTimer || 0) > 0.04 || (tarinai.grassEatTimer || 0) > 0.04) { + setLiveActionText(tarinai, { need: "food", actionId: "eat_food", actionLabel: "食べている", reasonText: catalogText || "食べている" }); + return; + } + if (state === "fight" || (tarinai.fightTimer || 0) > 0.04) { + setLiveActionText(tarinai, { need: "social", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: catalogText || "喧嘩している" }); + return; + } + if (state === "panic" || (tarinai.defeatedTimer || 0) > 0.04) { + setLiveActionText(tarinai, { need: "safety", actionId: "panic_escape", actionLabel: "逃げている", reasonText: catalogText || "怖くて逃げている" }); + return; + } + if (state === "intimidate" || (tarinai.intimidateTimer || 0) > 0.04) { + setLiveActionText(tarinai, { need: "social", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: catalogText || "威嚇している" }); + return; + } + if (state === "sleep" || tarinai.sleeping) { + setLiveActionText(tarinai, { need: "sleep", actionId: tarinai.intent?.actionId || "sleep_anywhere", actionLabel: "眠っている", reasonText: catalogText || "眠っている" }); + } +} function roleMatches(item, role) { - if (!item || item.dead || (item.amount != null && item.amount <= 0)) return false; + if (!item || item.dead) return false; + if ((role === "food" || role === "medicine") && item.type === "duplicator") return Boolean(item.storedFoodType); + if (item.amount != null && item.amount <= 0) return false; if (item.roles?.[role]) return true; if (role === "food") return ["sweet", "food", "grass", "ant_corpse", "zunchi"].includes(item.type) || (typeof isServingFoodType === "function" && isServingFoodType(item.type)); if (role === "drink") return item.type === "water" || item.type === "water_bowl" || item.type === "zunda_juice"; if (role === "medicine") return item.type === "sweet" || item.type === "water" || item.type === "water_bowl" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(item.type)); if (role === "sleepPlace") return item.type === "bed" || item.type === "nest_box"; - if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "oshibyo", "zunchi", "splat"].includes(item.type); + if (role === "danger") return ["firecracker", "genkotsu", "pushpin", "zunchi", "splat"].includes(item.type); if (role === "grassMaterial") return item.type === "grass"; return false; } +function effectiveFoodTypeForItem(item) { + if (!item) return ""; + return item.type === "duplicator" ? String(item.storedFoodType || "") : String(item.type || ""); +} + +function foodPriorityRank(tarinai, item) { + const type = effectiveFoodTypeForItem(item); + if (!type) return -1; + if (type === "sweet") return 4000; + if (type === "grass") return 2000; + if (type === "zunchi") return 1000 + (tarinai?.isZunchiSlave ? 250 : 0); + if (type === "food" || type === "ant_corpse") return 3000; + if (typeof isServingFoodType === "function" && isServingFoodType(type)) return 3000; + if (typeof isParamEffectItemType === "function" && isParamEffectItemType(type)) return 3000; + 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 (effectiveFoodTypeForItem(item) === "zunchi" && !tarinai?.isZunchiSlave && hunger < 88) rank -= 900; + if (item?.type === "duplicator") rank += 45; + return rank - Math.max(0, Number(distance) || 0) * 0.65; +} + function findNearestItemWithRole(world, tarinai, role, maxDist = 520) { - let best = null, bestD = maxDist; + let best = null, bestScore = Infinity; + const hunger = Number(tarinai?.hunger || 0) || 0; for (const item of world?.nearbyItems?.(tarinai.x, tarinai.y, maxDist) || world?.items || []) { if (!roleMatches(item, role)) continue; if (tarinai?.shouldAvoidTarget?.(item)) continue; const d = dist(tarinai, item); - if (d < bestD) { best = item; bestD = d; } + if (d > maxDist) continue; + let score = d; + if (role === "food") { + const priority = foodPriorityScore(tarinai, item, d); + score = -priority; + } + if (score < bestScore) { best = item; bestScore = score; } } return best; } @@ -145,6 +734,60 @@ function findNearbyDanger(world, tarinai, maxDist = 150) { return ant || item; } + +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) * 28; + const stressPressure = clamp(((tarinai.stress || 0) - 38) / 42, 0, 1) * 26; + const angerPressure = (tarinai.type === "angry" ? 22 : 0) + ((other.type === "angry" || otherBattleDrug) ? 10 : 0); + const fearPressure = clamp(((rel.fear || 0) + (relOther.fear || 0) - 14) / 80, 0, 1) * 18; + const affinityBrake = battleDrug ? 0 : clamp(Math.max(rel.affinity || 0, relOther.affinity || 0) / 80, 0, 1) * 22; + const calmBrake = tarinai.shouldApplyPersonalityBehavior?.("aggression", -1) ? 18 : 0; + const drugBonus = battleDrug ? 56 : 0; + const zunchiBonus = mixedZunchi ? 22 : 0; + const personalityBonus = clamp(aggression, -1, 1) * 14; + 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 ? "近くの相手が落ち着かない" : battleDrug ? "きずつき餅で気が立っている" : "気に入らない相手が近い", + }; + } + } + return best && best.score > 0 ? 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?.tarinai || []).find(o => o && !o.dead && o.id === 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: "気に入らない相手がいる" } : null; +} + function findNearbyMaterial(world, tarinai, materialRole, maxDist = 520) { return findNearestItemWithRole(world, tarinai, materialRole, maxDist); } @@ -175,85 +818,311 @@ function moveToOrUse(t, target, state = "seek_food", reason = "") { } if (typeof window !== "undefined") { - Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, chooseActionForNeed, buildReasonText, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, calculateStressFromNeeds, applyNeedShock, applyNeedRelief, triggerNeedShockReaction }); + Object.assign(window, { createDefaultNeeds, quantizeNeed, updateNeeds, chooseTopNeedRandom, chooseActionForNeed, buildReasonText, refreshIntentReasonText, fulfillPrimaryReason, getNeedDisplayValue, findNearestItemWithRole, findNearestSleepPlace, findNearbyDanger, findNearbyMaterial, evaluateConflictUrge, conflictTargetFor, calculateStressFromNeeds, applyNeedShock, applyNeedRelief, triggerNeedShockReaction, applyNeedSatisfaction, synchronizeActionTextFromState, setLiveActionText, setActiveBehavior, clearActiveBehavior, activeBehaviorText, queueForcedTarinaiBehavior, clearForcedBehaviorQueue, processForcedBehaviorQueue, actionPriority, relationFearSafe, isLiveTarinaiEntity }); } -function updateNeeds(tarinai, world) { +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; + const night = p >= 0.66 || p < 0.18; + const afternoon = p >= 0.46 && p < 0.58; + return { p, night, afternoon }; +} + +function updateCircadianSleepPressure(tarinai, world, dt = 0) { + const phase = circadianSleepPhase(world); + if (!Number.isFinite(tarinai.circadianSleepPressure)) { + tarinai.circadianSleepPressure = phase.night ? rand(58, 78) : (phase.afternoon ? rand(8, 18) : rand(0, 5)); + } + const step = Math.max(0, Number(dt) || 0); + if (phase.night) { + tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 1.18, 0, 78); + } else if (phase.afternoon) { + tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure + step * 0.16, 0, 26); + } else { + const decay = (tarinai.state === "sleep" || tarinai.sleeping) ? 1.85 : 0.42; + tarinai.circadianSleepPressure = clamp(tarinai.circadianSleepPressure - step * decay, 0, 78); + } + 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 needs = createDefaultNeeds(); - needs.food = (tarinai.hunger || 0) * 0.88; - needs.sleep = Math.max(0, 100 - (tarinai.energy || 0)); - const dayP = world?.dayProgress?.() || 0; - const night = dayP >= 0.62 || dayP < 0.18; - if (night) needs.sleep += 10; - needs.health = Math.max(0, (100 - (tarinai.energy || 100)) * 0.18 + (tarinai.zunchiStain || 0) * 0.32); - if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) needs.health += 42; - if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.stuckPushpinId || "")) needs.health += 36; + const raw = createDefaultNeeds(); + raw.food = (tarinai.hunger || 0) * 0.88; + const sleepPhase = updateCircadianSleepPressure(tarinai, world, dt); + const energySleepNeed = Math.max(0, 100 - (tarinai.energy || 0)) * 0.22; + raw.sleep = energySleepNeed + sleepPhase.pressure; + if (tarinai.sleepDisease) raw.sleep += 34; + raw.health = Math.max(0, (100 - (tarinai.energy || 100)) * 0.18 + (tarinai.zunchiStain || 0) * 0.32); + if (tarinai.zunchiDisease || tarinai.sleepDisease || tarinai.explosionDisease || tarinai.fightDisease) raw.health += 42; + if ((tarinai.hurtTimer || 0) > 0.04 || (tarinai.stuckPushpinId || "")) raw.health += 36; const danger = findNearbyDanger(world, tarinai, 170); - if (danger) needs.safety += danger.kind ? 78 : 48; - if ((tarinai.fearTimer || 0) > 0.12 || (tarinai.lastDamageAt || -999) + 5 > (world?.time || 0)) needs.safety += 34; - needs.social = (tarinai.loneliness || 0) * (0.88 + Math.max(0, sociability) * 0.16); - if (tarinai.parentToFollow?.()) needs.social += 18; - if ((tarinai.reproductionTimer || 0) <= 8 && !tarinai.hasPaired) needs.social += 16; + if (danger) raw.safety += danger.kind ? 78 : 48; + 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; } + 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) needs.social += 10; - needs.fulfill = 28 + (openness > 0 ? openness * 18 : 0); - if (!findOwnedStructure(world, tarinai, "grass_bed")) needs.fulfill += 20; + if (enemy?.score > 12) { socialParts.fearSocial += 10; raw.social += 10; } + const conflict = evaluateConflictUrge(tarinai, world, 112); + if (conflict?.target) { + tarinai.conflictTargetId = conflict.target.id; + tarinai.conflictUrge = Math.max(Number(tarinai.conflictUrge || 0) || 0, conflict.score); + const conflictPart = conflict.score * 0.58; + 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(32, tarinai.conflictUrge * 0.34); + socialParts.conflict += carriedConflict; + raw.social += carriedConflict; + } + if ((tarinai.fightMochiTimer || 0) > 0.04) { + socialParts.conflict += 36; + raw.social += 36; + 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) { - needs.sleep -= dist(tarinai, ownBed) < 80 ? 16 : 6; - needs.safety -= dist(tarinai, ownBed) < 100 ? 14 : 4; - needs.fulfill -= dist(tarinai, ownBed) < 120 ? 22 : 8; + raw.sleep -= dist(tarinai, ownBed) < 80 ? 16 : 6; + raw.safety -= dist(tarinai, ownBed) < 100 ? 14 : 4; + raw.fulfill -= dist(tarinai, ownBed) < 120 ? 20 : 7; } - if (!ownPlushie) needs.fulfill += 16; - else needs.fulfill -= ownPlushie.carriedById === tarinai.id ? 28 : 14; - if (findNearestItemWithRole(world, tarinai, "grassMaterial", 420)) needs.fulfill += 12; + 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, + }; + tarinai.fulfillPrimaryReason = fulfillPrimaryReason(tarinai); + for (const key of TARINAI_NEED_KEYS) { const shock = Number(tarinai.needShock?.[key] || 0) || 0; - if (shock !== 0) needs[key] += shock; + if (shock !== 0) raw[key] += shock; } - needs.safety *= clamp(1 - Math.max(0, aggression) * 0.12 + Math.max(0, neuroticism) * 0.16, 0.75, 1.25); - needs.health *= clamp(1 + Math.max(0, neuroticism) * 0.14, 0.9, 1.22); - needs.fulfill *= clamp(1 + Math.max(0, openness) * 0.14, 0.9, 1.24); - for (const key of TARINAI_NEED_KEYS) needs[key] = quantizeNeed(needs[key]); - tarinai.previousNeeds = tarinai.needs ? { ...tarinai.needs } : createDefaultNeeds(); - tarinai.needs = needs; - tarinai.stress = calculateStressFromNeeds(needs); - if (tarinai.needShock) { - for (const key of TARINAI_NEED_KEYS) { - const shock = Number(tarinai.needShock[key] || 0) || 0; - tarinai.needShock[key] = shock > 0 ? Math.max(0, shock - 12) : Math.min(0, shock + 12); - } - } - return needs; -} + 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 = calculateStressFromNeeds(effective); + decayNeedShock(tarinai, dt); + return effective; +} const TARINAI_ACTIONS = [ - { id: "eat_food", need: "food", label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u98df\u3079\u305f", weight: 50, condition(t, world) { return !!findNearestItemWithRole(world, t, "food", 560); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "food", 560), "seek_food", this.label); } }, + { id: "eat_food", need: "food", label: "食べ物を探している", phrase: "食べ物を食べている", weight: 62, condition(t, world) { return !!findNearestItemWithRole(world, t, "food", 760); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "food", 760), "seek_food", this.label); } }, { id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 24, condition(t, world) { return !!findNearestItemWithRole(world, t, "drink", 560); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "drink", 560), "seek_water", this.label); } }, { id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42, condition(t, world) { return !!findNearestSleepPlace(world, t, 560); }, run(t, world) { const bed = findNearestSleepPlace(world, t, 560); if (bed && dist(t, bed) > (t.radius || 20) + 18) return moveToOrUse(t, bed, "seek_bed", this.label); bed?.use?.(t, world); t.startSleeping?.(bed, this.label); return true; } }, { id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 18, condition() { return true; }, run(t) { t.startSleeping?.(null, this.label); return true; } }, { id: "use_medicine", need: "health", label: "\u56de\u5fa9\u3067\u304d\u308b\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u56de\u5fa9\u3057\u305f", weight: 34, condition(t, world) { return !!findNearestItemWithRole(world, t, "medicine", 620); }, run(t, world) { return moveToOrUse(t, findNearestItemWithRole(world, t, "medicine", 620), "seek_food", this.label); } }, { id: "rest_to_recover", need: "health", label: "\u56de\u5fa9\u306e\u305f\u3081\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 24, condition() { return true; }, run(t) { t.setActionState?.("idle", { target: null, reason: this.label, sleeping: false }); t.energy = clamp((t.energy || 0) + 1.6, 0, 100); return true; } }, + { id: "panic_escape", need: "safety", label: "怖くて逃げている", phrase: "逃げた", weight: 62, condition(t, world) { return (Number(t?.needRaw?.safety ?? t?.needs?.safety ?? 0) >= 58) || !!findNearbyDanger(world, t, 240) || (t?.fearTimer || 0) > 0.18 || (t?.defeatedTimer || 0) > 0.04; }, run(t, world) { const threat = t.lastNeedShockBreaker || findNearbyDanger(world, t, 240) || ((t.defeatedById && world?.tarinai) ? world.tarinai.find(o => o.id === t.defeatedById && !o.dead) : null); t.setActionState?.("panic", { target: t.panicDestination?.(threat, true), reason: this.label, wake: true, sleeping: false }); t.fearTimer = Math.max(t.fearTimer || 0, 1.6); return true; } }, { id: "flee", need: "safety", label: "\u5371\u306a\u3044\u3082\u306e\u304b\u3089\u96e2\u308c\u3066\u3044\u308b", phrase: "\u96e2\u308c\u305f", weight: 40, condition(t, world) { return !!findNearbyDanger(world, t, 220); }, run(t, world) { const danger = findNearbyDanger(world, t, 220); t.setActionState?.("flee", { target: t.panicDestination?.(danger), reason: this.label, wake: true, sleeping: false }); return true; } }, { id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u96a0\u308c\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 30, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, { id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38, condition(t, world) { return !!(t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420)); }, run(t, world) { const other = t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 520) || world.nearestOther?.(t, 420); return moveToOrUse(t, other, "seek_friend", this.label); } }, { id: "approach_parent_or_child", need: "social", label: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u5bb6\u65cf\u306b\u8fd1\u3065\u3044\u305f", weight: 28, condition(t) { return !!t.parentToFollow?.(); }, run(t) { return moveToOrUse(t, t.parentToFollow?.(), "follow_parent", this.label); } }, - { id: "approach_mate", need: "social", label: "\u76f8\u624b\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u76f8\u624b\u306b\u8fd1\u3065\u3044\u305f", weight: 24, condition(t, world) { return (t.reproductionTimer || 0) <= 8 && !!world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); }, run(t, world) { const other = world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); if (!other) return false; if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true; return moveToOrUse(t, other, "seek_friend", this.label); } }, + { id: "approach_mate", need: "social", label: "繁殖できる相手に近づいている", phrase: "繁殖できる相手に近づいている", weight: 34, condition(t, world) { return (t.reproductionTimer || 0) <= 8 && !!world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); }, run(t, world) { const other = world.nearestOther?.(t, 440, o => !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease); if (!other) return false; if (dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12) && world.startBirthRitual?.(t, other)) return true; return moveToOrUse(t, other, "seek_friend", this.label); } }, + { id: "fight_rival", need: "social", label: "気に入らない相手に向かっている", phrase: "喧嘩を始めた", weight: 36, condition(t, world) { return !!conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 ? 18 : 34) || ((t?.fightMochiTimer || 0) > 0.04 && !!nearestForcedFightTarget(t, world, 760)); }, run(t, world) { const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || t.activeBehavior?.forced ? 18 : 34); const current = currentBehaviorTarget(t); const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, t.activeBehavior?.forced ? 760 : 360)); if (!isLiveTarinaiEntity(rival)) return false; t.conflictTargetId = rival.id; t.conflictUrge = Math.max(t.conflictUrge || 0, candidate?.score || 48); if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", this.label); if (candidate?.forced || t.activeBehavior?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); world.startConflict?.(t, rival); return true; } }, { id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 12, condition(t) { return !!t.recentFightRival?.(80, 180); }, run(t) { const rival = t.recentFightRival?.(80, 180); t.setActionState?.("intimidate", { target: rival, reason: this.label, sleeping: false }); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 1.6); return true; } }, { id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 24, condition(t, world) { return !!world.nearest?.(t, ["ball"], 520); }, run(t, world) { return moveToOrUse(t, world.nearest?.(t, ["ball"], 520), "play_ball", this.label); } }, - { id: "build_grass_bed", need: "fulfill", label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u305f", weight: 34, condition(t, world) { return !findOwnedStructure(world, t, "grass_bed") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); } }, + { id: "build_grass_bed", need: "fulfill", label: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u305f", weight: 34, condition(t, world) { return !findOwnedStructure(world, t, "grass_bed") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "grass_bed", this.label); } }, { id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 28, condition(t, world) { return !findOwnedStructure(world, t, "plushie") && !!findNearbyMaterial(world, t, "grassMaterial", 520) && !["sleep", "fight", "panic", "intimidate"].includes(t.state); }, run(t, world) { return buildOwnedStructureFromGrass(t, world, "plushie", this.label); } }, { id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 22, condition(t, world) { return !!findOwnedStructure(world, t, "grass_bed", 620); }, run(t, world) { return moveToOrUse(t, findOwnedStructure(world, t, "grass_bed", 620), "seek_bed", this.label); } }, { id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u62b1\u3048\u305f", weight: 18, condition(t, world) { return !!findOwnedStructure(world, t, "plushie"); }, run(t, world) { const plushie = findOwnedStructure(world, t, "plushie"); plushie?.use?.(t, world); t.setActionState?.("idle", { target: plushie, reason: this.label, sleeping: false }); return true; } }, - { id: "wander_lightly", need: "fulfill", label: "\u5c11\u3057\u6b69\u3044\u3066\u3044\u308b", phrase: "\u5c11\u3057\u6b69\u3044\u305f", weight: 14, condition() { return true; }, run(t) { t.wanderAngle += rand(-1.7, 1.7); t.goIdle?.(this.label); return true; } }, + { id: "wander_lightly", need: "fulfill", label: "\u5c11\u3057\u6b69\u3044\u3066\u3044\u308b", phrase: "\u5c11\u3057\u6b69\u3044\u305f", weight: 14, condition() { return true; }, run(t, world) { t.wanderAngle += rand(-1.2, 1.2); const radius = rand(80, 180); const target = { x: clamp(t.x + Math.cos(t.wanderAngle) * radius, t.radius || 20, (world?.w || 1600) - (t.radius || 20)), y: clamp(t.y + Math.sin(t.wanderAngle) * radius, t.radius || 20, (world?.h || 900) - (t.radius || 20)), dead: false, detour: true }; t.wanderTarget = target; t.setActionState?.("wander", { target, reason: this.label, sleeping: false }); return true; } }, ]; + +function behaviorFoodTarget(t, world, role = "food", maxDist = 860) { + const best = findNearestItemWithRole(world, t, role, maxDist); + if (role === "food") return best; + const current = currentBehaviorTarget(t) || t.target; + if (current && !current.dead && (roleMatches(current, role) || (role === "food" && current.type === "duplicator" && current.storedFoodType))) return current; + return best; +} + +function updateConsumableBehavior(t, world, dt, role = "food") { + if (!t || !world) return false; + const target = behaviorFoodTarget(t, world, role, role === "food" ? 900 : 700); + if (!target || target.dead) return false; + const state = role === "drink" ? "seek_water" : "seek_food"; + const label = role === "drink" ? "水を探している" : (role === "medicine" ? "回復できるものを探している" : "食べ物を探している"); + const touchPad = target.type === "duplicator" ? 34 : 14; + if (dist(t, target) > (t.radius || 20) + (target.r || 12) + touchPad) { + moveToOrUse(t, target, state, label); + return true; + } + t.target = target; + t.setActionState?.(role === "drink" ? "seek_water" : "eat", { target, reason: role === "drink" ? "水を飲んでいる" : `${typeof toolLabel === "function" ? toolLabel(target.type === "duplicator" ? (target.storedFoodType || target.type) : target.type) : "食べ物"}を食べている`, sleeping: false }); + const beforeHunger = Number(t.hunger || 0) || 0; + t.interactWithItems?.(Math.max(0.10, dt || 0.12)); + if ((t.eatTimer || 0) > 0.04 || beforeHunger - (Number(t.hunger || 0) || 0) > 0.1) return true; + if (role === "drink") return true; + return t.foodItemHasServingLeft?.(target) ? true : false; +} + +function mateTargetFor(t, world, maxDist = 520) { + if (!t || !world) return null; + const current = currentBehaviorTarget(t) || t.target; + const ok = o => o && o !== t && !o.dead && !world.areParentChild?.(t, o) && !!t.isZunchiSlave === !!o.isZunchiSlave && !o.sleepDisease && !o.fightDisease && (o.reproductionTimer || 0) <= 10 && (o.fightTimer || 0) <= 0.04 && (o.birthRitualTimer || 0) <= 0.04; + if (ok(current) && dist(t, current) <= maxDist + 120) return current; + return world.nearestOther?.(t, maxDist, ok) || null; +} + +function triggerFriendContact(t, other, world, dt = 0) { + if (!t || !other || !world) return false; + const amount = 0.30 + Math.max(0, Number(dt) || 0) * 0.85; + t.adjustRelation?.(other, amount, -0.05, "friend_contact"); + other.adjustRelation?.(t, amount * 0.78, -0.04, "friend_contact"); + t.loneliness = clamp((t.loneliness || 0) - 8, 0, 110); + if (typeof applyNeedRelief === "function") applyNeedRelief(t, { social: -16, fulfill: -3 }); + if ((world.time || 0) >= (t.nextBubbleAt || 0)) t.bubble?.("はう", 2.2, "rgba(92,132,78,0.78)"); + t.setActionState?.("idle", { target: other, reason: `${other.name || "仲間"}と一緒にいる`, sleeping: false }); + if (typeof setLiveActionText === "function") setLiveActionText(t, { need: "social", subNeed: "bond", actionId: "approach_friend", actionLabel: "仲間と一緒にいる", reasonText: `${other.name || "仲間"}と一緒にいる`, target: other, phase: "perform", source: "behavior" }); + return "finished"; +} + +function updateSocialContactBehavior(t, world, dt, mode = "bond") { + if (!t || !world) return false; + let other = currentBehaviorTarget(t) || t.target || null; + if (!other || other.dead || other === t) { + other = mode === "family" ? t.parentToFollow?.() : (t.bestFriendLive?.(FRIEND_AFFINITY_THRESHOLD, 560) || world.nearestOther?.(t, 520)); + } + if (!other || other.dead || other === t) return false; + const near = dist(t, other) <= Math.max(48, (t.radius || 20) + (other.radius || 20) + 12); + if (!near) return moveToOrUse(t, other, mode === "family" ? "follow_parent" : "seek_friend", mode === "family" ? "家族に近づいている" : "仲間に近づいている"); + + const conflict = conflictTargetFor(t, world, 30); + if (conflict?.target === other || (t.fightMochiTimer || 0) > 0.04 || (other.fightMochiTimer || 0) > 0.04) { + return updateFightBehavior(t, world, dt) || true; + } + const mate = mateTargetFor(t, world, 120); + if (mate === other && (t.reproductionTimer || 0) <= 8 && (other.reproductionTimer || 0) <= 10) { + if (world.startBirthRitual?.(t, other)) return "finished"; + } + return triggerFriendContact(t, other, world, dt); +} + +function updateMateBehavior(t, world, dt) { + const other = mateTargetFor(t, world, 560); + if (!other) return false; + const d = dist(t, other); + if (d <= Math.max(50, (t.radius || 20) + (other.radius || 20) + 14)) { + const otherWantsMate = other.activeBehavior?.id === "approach_mate" || other.intent?.actionId === "approach_mate" || (other.loveMochiTimer || 0) > 0.04; + const selfForced = t.activeBehavior?.forced || (t.loveMochiTimer || 0) > 0.04; + if ((otherWantsMate || selfForced) && world.startBirthRitual?.(t, other)) return "finished"; + moveToOrUse(t, other, "seek_friend", "繁殖できる相手に近づいている"); + if (typeof applyNeedShock === "function") applyNeedShock(other, { social: dt * 10 }, t); + return true; + } + return moveToOrUse(t, other, "seek_friend", "繁殖できる相手に近づいている"); +} + +function updateFightBehavior(t, world, dt) { + if (!t || !world) return false; + if ((t.fightTimer || 0) > 0.04) { + const rival = currentBehaviorTarget(t) || (t.fightTargetId ? world.tarinai.find(o => o && !o.dead && o.id === t.fightTargetId) : null); + t.setActionState?.("fight", { target: rival || t.target, reason: rival?.name ? `${rival.name}と喧嘩している` : "喧嘩している", sleeping: false }); + return true; + } + const candidate = conflictTargetFor(t, world, (t?.fightMochiTimer || 0) > 0.04 || t.activeBehavior?.forced ? 24 : 42); + const current = currentBehaviorTarget(t); + const rival = isLiveTarinaiEntity(candidate?.target) ? candidate.target : (isLiveTarinaiEntity(current) ? current : nearestForcedFightTarget(t, world, t.activeBehavior?.forced ? 760 : 360)); + if (!isLiveTarinaiEntity(rival)) return false; + t.conflictTargetId = rival.id; + if (dist(t, rival) > Math.max(42, (t.radius || 20) + (rival.radius || 20) + 12)) return moveToOrUse(t, rival, "seek_enemy", "気に入らない相手に向かっている"); + if (candidate?.forced || t.activeBehavior?.forced || (t.fightMochiTimer || 0) > 0.04 || (rival.fightMochiTimer || 0) > 0.04) return !!world.startForcedFight?.(t, rival); + world.startConflict?.(t, rival); + return true; +} + +function updatePanicBehavior(t, world, dt, needs) { + const danger = t.lastNeedShockBreaker || findNearbyDanger(world, t, 260) || currentBehaviorTarget(t); + if (t.state !== "panic" || !t.target || (t.target.dead && !Number.isFinite(t.target.x))) { + t.setActionState?.("panic", { target: t.panicDestination?.(danger, true), reason: "怖くて逃げている", wake: true, sleeping: false }); + } + t.fearTimer = Math.max(t.fearTimer || 0, 0.26); + if ((Number(needs?.safety || 0) || 0) < needThreshold("safety", "continue") && (t.fearTimer || 0) <= 0.08) return "finished"; + return true; +} + +function configureTarinaiNeedActions() { + const set = (id, props) => { const action = actionById(id); if (action) Object.assign(action, props); return action; }; + set("eat_food", { subNeed: "meal", state: "seek_food", update(t, world, dt) { return updateConsumableBehavior(t, world, dt, "food"); } }); + set("drink_water", { subNeed: "water", state: "seek_water", update(t, world, dt) { return updateConsumableBehavior(t, world, dt, "drink"); } }); + set("use_medicine", { subNeed: "medicine", state: "seek_food", update(t, world, dt) { return updateConsumableBehavior(t, world, dt, "medicine"); } }); + set("panic_escape", { subNeed: "panic", state: "panic", update: updatePanicBehavior }); + set("flee", { subNeed: "avoid", state: "flee", update: updatePanicBehavior }); + set("approach_friend", { subNeed: "bond", state: "seek_friend", update(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "bond"); } }); + set("approach_parent_or_child", { subNeed: "family", state: "follow_parent", update(t, world, dt) { return updateSocialContactBehavior(t, world, dt, "family"); } }); + set("approach_mate", { subNeed: "mate", state: "seek_friend", update: updateMateBehavior }); + set("fight_rival", { subNeed: "conflict", state: "seek_enemy", update: updateFightBehavior }); + set("intimidate_enemy", { subNeed: "conflict", state: "intimidate", update(t, world) { if ((t.intimidateTimer || 0) > 0.04) return true; const rival = currentBehaviorTarget(t) || t.recentFightRival?.(80, 180); if (!rival) return false; return world?.startIntimidation?.(t, rival) || true; } }); + TARINAI_ACTIONS.push({ id: "birth_ritual", need: "social", subNeed: "mate", label: "繁殖の前ぶれをしている", phrase: "繁殖の前ぶれをしている", weight: 0, state: "birth_ritual", condition(t) { return (t.birthRitualTimer || 0) > 0.04; }, run() { return true; }, update(t) { return (t.birthRitualTimer || 0) > 0.04 ? true : "finished"; } }); +} +configureTarinaiNeedActions(); + function buildOwnedStructureFromGrass(t, world, type, label) { const material = findNearbyMaterial(world, t, "grassMaterial", 520); if (!material || !globalThis.StructureRegistry) return false; @@ -316,7 +1185,207 @@ function weightedPickAction(actions) { function chooseActionForNeed(need, tarinai, world) { const actions = TARINAI_ACTIONS.filter(action => action.need === need && (!action.condition || action.condition(tarinai, world))); - return weightedPickAction(actions) || TARINAI_ACTIONS.find(action => action.id === "wander_lightly"); + const weighted = actions.map(action => { + let bonus = 0; + if (need === "social") { + const p = tarinai?.socialReasonParts || {}; + if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 1.8; + else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 1.6; + else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.4; + else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 0.55; + } 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 === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2; + else if (action.id === "return_owned_structure" || action.id === "use_plushie") bonus += Math.max(0, -Number(p.bedComfort || 0) - Number(p.plushieComfort || 0)) * 0.5; + } + return { ...action, weight: Math.max(0.01, (Number(action.weight) || 1) + bonus) }; + }); + const picked = weightedPickAction(weighted) || TARINAI_ACTIONS.find(action => action.id === "wander_lightly"); + return picked ? TARINAI_ACTIONS.find(action => action.id === picked.id) || picked : null; +} + +function eligiblePriorityActions(tarinai, world, needs) { + const result = []; + for (const action of TARINAI_ACTIONS) { + if (!action || action.id === "birth_ritual") continue; + const need = action.need || "fulfill"; + if (!shouldStartNeedAction(tarinai, need, needs)) continue; + if (action.condition && !action.condition(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"; + let bonus = 0; + if (need === "social") { + const p = tarinai?.socialReasonParts || {}; + if (action.subNeed === "mate") bonus += Number(p.mate || 0) * 1.8; + else if (action.subNeed === "conflict") bonus += Number(p.conflict || 0) * 1.6; + else if (action.subNeed === "family") bonus += Number(p.family || 0) * 1.4; + else if (action.subNeed === "bond") bonus += Number(p.bond || 0) * 0.55; + } 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 === "wander_lightly") bonus += Number(p.boredom || 0) * 1.2; + } + const priority = actionPriority(action); + const needValue = Number(needs?.[need] || 0) || 0; + return { action, priority, score: priority * 1000 + needValue * 3 + (Number(action.weight || 1) + bonus) }; + }); + weighted.sort((a, b) => (b.priority - a.priority) || (b.score - a.score)); + const topPriority = weighted[0].priority; + const top = weighted.filter(e => e.priority === topPriority).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) || weighted[0].action; + const action = TARINAI_ACTIONS.find(a => a.id === picked.id) || picked; + const need = action.need || "fulfill"; + return { action, choice: { need, tiedNeeds: [need], max: Number(needs?.[need] || 0) || 0, priority: actionPriority(action) } }; +} + +function shouldStartNeedAction(tarinai, need, needs) { + const value = Number(needs?.[need]) || 0; + if (value >= needThreshold(need, "start")) return true; + if ((tarinai?.intent?.need === need || tarinai?.currentAction?.need === 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; + setActiveBehaviorFromAction(tarinai, action, choice, reasonText, { + ...options, + lockSeconds, + minDuration: options.minDuration || Math.max(0.45, lockSeconds * 0.72), + phase: "start", + }); + tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, lockSeconds); + const ran = action?.run?.(tarinai, world, options); + if (!ran) { + clearActiveBehavior(tarinai, tarinai.intent?.actionLabel || action?.label || "待っている"); + tarinai.goIdle?.(action?.label || "待っている"); + } else { + const target = tarinai.target || options.target; + setActiveBehaviorFromAction(tarinai, action, choice, reasonText, { + ...options, + target, + state: tarinai.state || action?.state || "idle", + lockSeconds, + minDuration: tarinai.currentAction?.minDuration || Math.max(0.45, lockSeconds * 0.72), + phase: "active", + }); + } + tarinai.thought = reasonText; + return Boolean(ran); +} + +function shouldInterruptCurrentAction(tarinai, needs) { + // 欲求値の自然変動だけでは実行中行動を中断しない。 + // 薬・餅などの外部強制行動は forcedBehaviorQueue 側の優先順位で処理する。 + return false; +} + +function continueCurrentIntent(tarinai, world, needs, dt) { + const actionId = tarinai?.activeBehavior?.id || tarinai?.intent?.actionId; + if (!actionId) return false; + if (shouldInterruptCurrentAction(tarinai, needs) && !tarinai?.activeBehavior?.forced) return false; + const action = actionById(actionId); + if (!action) return false; + if (tarinai.target && tarinai.target.dead) return false; + const timerBacked = ["eat_food", "drink_water", "use_medicine", "fight_rival", "intimidate_enemy", "approach_mate", "birth_ritual", "panic_escape"].includes(action.id); + if (!timerBacked && action.condition && !action.condition(tarinai, world)) return false; + const need = action.need || tarinai.intent?.need || tarinai.activeBehavior?.need; + const value = Number(needs?.[need]) || 0; + const now = world?.time || 0; + const startedAt = Number(tarinai.activeBehavior?.startedAt ?? tarinai.currentAction?.startedAt ?? tarinai.intent?.startedAt ?? now) || now; + const minDuration = Number(tarinai.currentAction?.minDuration || tarinai.activeBehavior?.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 === "eat_food" || action.id === "drink_water" || action.id === "use_medicine") && (tarinai.eatTimer || 0) > 0.04); + if (elapsed < minDuration || value >= needThreshold(need, "continue") || mustContinueByTimer || tarinai.activeBehavior?.forced) { + tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, Math.min(0.9, Math.max(0.35, minDuration - elapsed))); + const refreshedReason = refreshIntentReasonText(tarinai, action); + const ranUpdate = action.update?.(tarinai, world, dt, needs); + if (ranUpdate === "finished") { + tarinai.actionCooldowns = tarinai.actionCooldowns || {}; + tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 1.6); + clearActiveBehavior(tarinai, refreshedReason || action.label || ""); + return false; + } + if (ranUpdate === false) return false; + setActiveBehaviorFromAction(tarinai, action, { need, tiedNeeds: tarinai.intent?.tiedNeeds || [need] }, refreshedReason || tarinai.intent?.reasonText || action.label, { + target: tarinai.target, + state: tarinai.state || action.state || "idle", + source: tarinai.activeBehavior?.source || "need", + forced: Boolean(tarinai.activeBehavior?.forced), + priority: tarinai.activeBehavior?.priority || 0, + startedAt, + lockSeconds: tarinai.activeBehavior?.lockSeconds, + minDuration, + phase: mustContinueByTimer ? "perform" : "active", + }); + tarinai.thought = activeBehaviorText(tarinai) || refreshedReason || tarinai.intent?.actionLabel || 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)) action.run?.(tarinai, world); + return true; + } + tarinai.actionCooldowns = tarinai.actionCooldowns || {}; + tarinai.actionCooldowns[need] = Math.max(tarinai.actionCooldowns[need] || 0, 2.8); + clearActiveBehavior(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); +} + +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: 12, targetEnergy: 82, maxDuration: 38 }; + const elapsed = Math.max(0, now - (Number(session.startedAt) || now)); + if ((tarinai.hurtTimer || 0) > 0.06 || (tarinai.pokeFlashTimer || 0) > 0.08) return true; + if (findNearbyDanger(world, tarinai, 160)) return true; + // 欲求値の自然変動だけでは睡眠を中断しない。直接の危険・ダメージのみ起床要因にする。 + if (elapsed < (Number(session.minDuration) || 12)) return false; + if ((tarinai.energy || 0) >= (Number(session.targetEnergy) || 84)) return true; + if (elapsed >= (Number(session.maxDuration) || 42)) 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; + tarinai.sleepSession = { startedAt: now, minDuration: rand(12, 22), targetEnergy: rand(80, 92), maxDuration: rand(34, 54), targetId: tarinai.target?.id || null }; + } + if (!shouldWakeFromSleep(tarinai, world, needs)) { + tarinai.thought = tarinai.thought || "眠っている"; + tarinai.intent = tarinai.intent || { need: "sleep", tiedNeeds: ["sleep"], actionId: "sleep", actionLabel: "眠っている", reasonText: "ねむいので、眠っている。" }; + tarinai.currentAction = tarinai.currentAction || { id: "sleep", need: "sleep", startedAt: tarinai.sleepSession.startedAt, minDuration: tarinai.sleepSession.minDuration, lockSeconds: tarinai.sleepSession.minDuration }; + tarinai.intentLockTimer = Math.max(tarinai.intentLockTimer || 0, 0.8); + return true; + } + const sleptLongEnough = (world?.time || 0) - (Number(tarinai.sleepSession?.startedAt) || 0) >= 2; + tarinai.sleeping = false; + tarinai.sleepSession = null; + tarinai.currentAction = null; + if (sleptLongEnough) applyNeedSatisfaction(tarinai, { sleep: 28, safety: 6 }, "sleep"); + tarinai.goIdle?.("目が覚めた"); + return false; } (function (global) { @@ -327,6 +1396,19 @@ function chooseActionForNeed(need, tarinai, world) { return this.isZunchiSlave ? type === "zunchi" : true; }, + forceBehavior(actionId, options = {}) { + return queueForcedTarinaiBehavior(this, actionId, options); + }, + + clearForcedBehavior(filter = null) { + return clearForcedBehaviorQueue(this, filter); + }, + + hasForcedBehavior(actionId = "") { + const key = String(actionId || ""); + return Array.isArray(this.forcedBehaviorQueue) && this.forcedBehaviorQueue.some(entry => entry && (!key || entry.id === key)); + }, + hasLoveMochiEffect() { return (this.loveMochiTimer || 0) > 0.04; }, @@ -375,18 +1457,21 @@ function chooseActionForNeed(need, tarinai, world) { if (this.fightDisease) return "stress_dizzy"; if ((this.oshiriByoZunchiStock || 0) >= 6) return this.variantSprite ? this.variantSprite("hurt") : "weak"; if (this.isZunchiSlave) return this.zunchiSlaveSpriteId(); - if (this.type === "cry" && (this.stress > 78 || this.hunger > 96 || this.energy < 12 || (this.needs?.safety || 0) >= 90)) return "cry"; + 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"; const low = this.lowHealthSprite(); if (low === "stretch") return low; - if (this.hunger >= 70 && !low) return "hungry_70"; + if (foodNeedForSprite >= 72 && !low) 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.fightTimer > 0.04) return "angry"; + if (this.state === "fight" || this.state === "seek_enemy" || this.fightTimer > 0.04) return "angry"; if (this.stress > 62) return this.variantSprite("stress"); if (low) return low; if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep"); @@ -395,9 +1480,9 @@ function chooseActionForNeed(need, tarinai, world) { if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode; 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.state === "seek_food") return "drool"; - if (this.stress < 24 && (this.needs?.food || 0) < 60 && (this.needs?.social || 0) < 60 && (this.needs?.fulfill || 0) < 60) return this.variantSprite("low_stress"); - if (this.stress < 44 && (this.needs?.food || 0) < 60) return this.goodMode; - if ((this.needs?.food || 0) > 70 || (this.needs?.social || 0) > 80) return "angry"; + if (this.stress < 24 && foodNeedForSprite < 60 && socialNeedForSprite < 60 && fulfillNeedForSprite < 60) return this.variantSprite("low_stress"); + if (this.stress < 44 && foodNeedForSprite < 60) return this.goodMode; + if (foodNeedForSprite > 78 || socialNeedForSprite > 84) return "angry"; return this.goodMode || this.variantSprite("normal"); }, @@ -639,7 +1724,7 @@ function chooseActionForNeed(need, tarinai, world) { this.hunger = clamp(this.hunger, 0, 115); this.loneliness = clamp(this.loneliness, 0, 110); this.energy = clamp(this.energy, 0, 100); - this.stress = calculateStressFromNeeds(this.needs || createDefaultNeeds()); + this.stress = calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds()); const wasSleepingAtFrameStart = this.state === "sleep" || this.sleeping; this.awakeLockTimer = Math.max(0, (this.awakeLockTimer || 0) - dt); @@ -649,6 +1734,7 @@ function chooseActionForNeed(need, tarinai, world) { return; } + this.intentLockTimer = Math.max(0, (this.intentLockTimer || 0) - dt); this.aiTimer += dt; const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02; const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0; @@ -718,7 +1804,7 @@ function chooseActionForNeed(need, tarinai, world) { if (grassComfort > 0) { applyNeedRelief(this, { fulfill: -dt * Math.min(2, grassComfort * 0.5), safety: -dt * Math.min(1.5, grassComfort * 0.4) }); if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06)); - if (this.need === "\u9759\u3051\u3055" || this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); + if (this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); } }, @@ -746,17 +1832,20 @@ function chooseActionForNeed(need, tarinai, world) { }, isServingFoodItem(it) { + if (it?.type === "duplicator" && it.storedFoodType) return true; return Boolean(it && typeof isServingFoodType === "function" && isServingFoodType(it.type)); }, foodItemHasServingLeft(it) { if (!it || it.dead) return false; + if (it.type === "duplicator") return Boolean(it.storedFoodType); if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2; this.ensureFoodServings(it); return (it.foodServingsRemaining || 0) > 0; }, ensureFoodServings(it) { + if (it?.type === "duplicator") return; if (!this.isServingFoodItem(it)) return; const max = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize || "medium") : 5; if (!Number.isFinite(it.foodServingsMax) || it.foodServingsMax <= 0) it.foodServingsMax = max; @@ -770,6 +1859,13 @@ function chooseActionForNeed(need, tarinai, world) { }, consumeFoodServing(it, nutrition) { + if (it?.type === "duplicator") { + if (!it.storedFoodType) return 0; + const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.storedFoodType, 0) ?? 42); + it.usedCount = (it.usedCount || 0) + 1; + this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.storedFoodType, sourceType: "duplicator", nutrition: value }); + return value; + } this.ensureFoodServings(it); if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.type, 0) ?? 0); @@ -841,24 +1937,37 @@ function chooseActionForNeed(need, tarinai, world) { }, resolveNeeds(dt) { - const wasSleeping = this.state === "sleep"; - const previousTarget = this.target && !this.target.dead ? this.target : null; - this.target = null; - this.sleeping = false; - - if (this.birthRitualTimer > 0.04) { - this.setActionState?.("birth_ritual", { target: this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null, reason: "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b" }); + decayActionCooldowns(this, dt); + const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds(); + const needs = updateNeeds(this, this.world, dt); + synchronizeActionTextFromState(this); + 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; + if ((this.intentLockTimer || 0) > 0 && this.intent && !lockedTargetInvalid && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { + if (continueCurrentIntent(this, this.world, needs, dt)) return; + this.thought = this.intent.reasonText || this.intent.actionLabel || this.thought; return; } - + + if (this.birthRitualTimer > 0.04) { + const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null; + this.setActionState?.("birth_ritual", { target: partner, reason: "繁殖の前ぶれをしている" }); + synchronizeActionTextFromState(this); + return; + } + if (this.intimidateTimer > 0.04) { - this.setActionState?.("intimidate", { target: this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" }); + const target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null; + this.setActionState?.("intimidate", { target, reason: "相手を威嚇している" }); + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: target?.name ? `${target.name}を威嚇している` : "相手を威嚇している", target, phase: "perform", source: "behavior" }); this.bubble("!", 1.2); return; } - + if (this.fightTimer > 0.04) { - this.setActionState?.("fight", { reason: "\u55a7\u5629\u3057\u3066\u3044\u308b" }); this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead)); this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId; let best = null, bestD = Infinity; @@ -868,15 +1977,17 @@ function chooseActionForNeed(need, tarinai, world) { const d = dist(this, t); if (d < bestD) { best = t; bestD = d; } } - this.setActionState?.("fight", { target: best, reason: "\u55a7\u5629\u3057\u3066\u3044\u308b" }); + const reasonText = best ? `${best.name || "相手"}が気に入らないので、喧嘩している。` : "気に入らない相手がいるので、喧嘩している。"; + this.setActionState?.("fight", { target: best, reason: reasonText }); + if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText, target: best, phase: "perform", source: "behavior" }); this.bubble("!", 1.6); return; } - + if (this.defeatedTimer > 0.04) { const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead); - if (this.enterPanic) this.enterPanic({ threat: opponent, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.0, wake: true, cause: "defeated_escape" }); - else this.setActionState?.("panic", { target: this.panicDestination(opponent), reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); + applyNeedShock(this, { safety: 54, health: 16 }, opponent || null); + startNeedDrivenEmergencyReaction(this, opponent || null, "喧嘩に負けて逃げている。"); if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null; return; } else if (this.defeatedById && this.fightTimer <= 0.04) { @@ -895,47 +2006,38 @@ function chooseActionForNeed(need, tarinai, world) { this.hurtTimer = Math.max(this.hurtTimer || 0, 0.24); this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 1.8); this.bubble("!!", 0.9, "rgba(168,72,72,0.82)"); - this.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b"; + this.thought = "画鋲が刺さってパニックになっている"; } this.stuckPushpinId = null; } - if (continueBuildPlan(this, this.world, dt)) return; - - const beforeNeeds = this.needs ? { ...this.needs } : createDefaultNeeds(); - const needs = updateNeeds(this, this.world); const breaker = this.lastNeedShockBreaker || null; - const safetyDelta = (needs.safety || 0) - (beforeNeeds.safety || 0); - const healthDelta = (needs.health || 0) - (beforeNeeds.health || 0); - const fulfillDelta = (needs.fulfill || 0) - (beforeNeeds.fulfill || 0); + 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 (shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { - const aggression = Number(this.currentPersonality?.aggression || 0); - if (breaker && aggression > 0.6) { - this.setActionState?.("intimidate", { target: breaker, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true, sleeping: false }); - this.intimidateTimer = Math.max(this.intimidateTimer || 0, 2.0); - } else { - this.setActionState?.("panic", { target: this.panicDestination?.(breaker), reason: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true, sleeping: false }); - this.fearTimer = Math.max(this.fearTimer || 0, 2.6); - } + if (breaker && shockTriggered && !["panic", "intimidate", "fight", "birth_ritual"].includes(this.state)) { + if (startNeedDrivenEmergencyReaction(this, breaker, "パニックになっている。")) return; + this.currentAction = null; this.lastNeedShockBreaker = null; return; } - const choice = chooseTopNeedRandom(needs); - let action = chooseActionForNeed(choice.need, this, this.world); - if (!action) action = TARINAI_ACTIONS.find(a => a.id === "wander_lightly"); - const reasonText = buildReasonText(choice.need, choice.tiedNeeds, action); - this.intent = { - need: choice.need, - tiedNeeds: [...choice.tiedNeeds], - actionId: action?.id || "idle", - actionLabel: action?.label || "\u5f85\u3063\u3066\u3044\u308b", - reasonText, - }; - const ran = action?.run?.(this, this.world); - if (!ran) this.goIdle?.(this.intent.actionLabel); - this.thought = reasonText; + if (continueCurrentIntent(this, this.world, needs, dt)) return; + + const picked = choosePriorityNeedAction(needs, this, this.world); + if (!picked?.action) { + const choice = chooseTopNeedRandom(needs, this, this.world); + this.intent = { need: choice.need, tiedNeeds: [...(choice.tiedNeeds || [])], actionId: "idle", actionLabel: "待っている", reasonText: "少し落ち着いている。", startedAt: this.world?.time || 0 }; + clearActiveBehavior(this); + this.intentLockTimer = Math.max(this.intentLockTimer || 0, 3.0); + this.goIdle?.("少し落ち着いている"); + 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: actionPriority(action) }); return; }, @@ -964,39 +2066,53 @@ function chooseActionForNeed(need, tarinai, world) { }, interactWithItems(dt) { - const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 18; - const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 7; - const canZunchiBite = this.eatCooldown <= 0 && this.hunger > (this.isZunchiSlave ? 16 : 104); - for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) { + const foodNeed = Number(this.needRaw?.food ?? this.needs?.food ?? 0) || 0; + const healthNeed = Number(this.needRaw?.health ?? this.needs?.health ?? 0) || 0; + const foodActionActive = this.intent?.need === "food" || this.currentAction?.need === "food" || ["seek_food", "eat", "seek_water"].includes(this.state); + const healthActionActive = this.intent?.need === "health" || this.currentAction?.need === "health" || this.state === "seek_food"; + const mealReady = (this.mealCooldownUntil || 0) <= (this.world?.time || 0) || this.hunger > 92 || foodNeed > 84 || foodActionActive; + const wantsFood = foodActionActive || foodNeed >= needThreshold("food", "start") || this.hunger > 70; + const wantsMedicine = healthActionActive || healthNeed >= needThreshold("health", "start"); + const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && wantsFood && this.hunger > 18; + const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && mealReady && (wantsFood || wantsMedicine) && this.hunger > 7; + const canZunchiBite = this.eatCooldown <= 0 && (this.isZunchiSlave ? this.hunger > 16 : (this.hunger > 88 || foodNeed > 82)); + const foodCandidates = this.world.nearbyItems(this.x, this.y, this.radius + 74).slice(); + if (this.target && !this.target.dead && !foodCandidates.includes(this.target) && (this.target.type === "duplicator" || roleMatches(this.target, "food") || roleMatches(this.target, "medicine"))) foodCandidates.unshift(this.target); + foodCandidates.sort((a, b) => foodPriorityScore(this, b, dist(this, b)) - foodPriorityScore(this, a, dist(this, a))); + for (const it of foodCandidates) { const d = dist(this, it); - if (d > this.radius + it.r + 12) continue; + const contactPad = it?.type === "duplicator" ? 28 : (it === this.target && foodActionActive ? 14 : 8); + if (d > this.radius + it.r + contactPad) continue; + const foodType = it.type === "duplicator" ? (it.storedFoodType || "") : it.type; + const duplicatorConsumable = it.type === "duplicator" && foodType && (foodType === "sweet" ? canSweetBite : (canBite || foodActionActive)) && !(typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType)) && foodType !== "sleep_drug" && foodType !== "love_mochi" && foodType !== "fight_mochi"; - if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { + if (((foodType === "sweet" && canSweetBite) || (foodType === "food" && (canBite || foodActionActive)) || duplicatorConsumable) && this.foodItemHasServingLeft(it)) { const eating = this.consumeFoodServing(it); if (eating > 0) { - this.beginEating(it, `${toolLabel(it.type)}\u3092\u98df\u3079\u3066\u3044\u308b`); + const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type); + this.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); - const hungerRelief = this.foodHungerReliefFor(it.type, eating, it.type === "sweet" ? 0.75 : 0.95); + const hungerRelief = this.foodHungerReliefFor(foodType || it.type, eating, foodType === "sweet" ? 0.75 : 0.95); this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); - this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); - this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; - if (it.type === "sweet") { + this.recoverHealth(eating * (foodType === "sweet" ? 0.28 : 0.46)); + this.affection += foodType === "sweet" ? eating * 0.03 : eating * 0.012; + if (foodType === "sweet") { applyNeedRelief(this, { food: -eating * 0.4, fulfill: -eating * 0.2 }); this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f"); if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"); } - this.eatTimer = Math.max(this.eatTimer, 0.32); - this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78); + this.eatTimer = Math.max(this.eatTimer, 1.0); + this.eatCooldown = rand(0.96, 1.12); this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)"); this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); if (Math.random() < dt * 2.2) { const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "sweet" ? "#78b957" : "#f1dfb7"); + this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "sweet" ? "#78b957" : "#f1dfb7"); } - this.lastMealColor = it.type === "sweet" ? "#78b957" : "#f1dfb7"; - this.makePoop(eating * (it.type === "sweet" ? 0.18 : 0.22)); + this.lastMealColor = foodType === "sweet" ? "#78b957" : "#f1dfb7"; + this.makePoop(eating * (foodType === "sweet" ? 0.18 : 0.22)); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) { this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, null, { participants: [this] }); @@ -1006,13 +2122,14 @@ function chooseActionForNeed(need, tarinai, world) { } } - if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) { + if (canSweetBite && (foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(foodType))) && this.foodItemHasServingLeft(it)) { const eating = this.consumeFoodServing(it); if (eating <= 0) continue; - this.beginEating(it, `${toolLabel(it.type)}\u3092\u98df\u3079\u3066\u3044\u308b`); + const eatLabel = it.type === "duplicator" ? (it.storedFoodLabel || toolLabel(foodType || it.type)) : toolLabel(it.type); + this.beginEating(it, `${eatLabel}\u3092\u98df\u3079\u3066\u3044\u308b`); this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); - const hungerRelief = this.foodHungerReliefFor(it.type, eating, 0.82); + const hungerRelief = this.foodHungerReliefFor(foodType || it.type, eating, 0.82); this.applyFoodHungerRelief ? this.applyFoodHungerRelief(hungerRelief) : (this.hunger -= hungerRelief); this.recoverHealth(eating * 0.24); this.affection += eating * 0.04; @@ -1026,24 +2143,24 @@ function chooseActionForNeed(need, tarinai, world) { fight_mochi: ["!", 2.0, "rgba(180,86,52,0.80)"], sleep_drug: ["Zzz...", 2.3, "rgba(104,84,168,0.78)"], }; - if (this.applyParamItemEffect) this.applyParamItemEffect(it.type, it, { + if (this.applyParamItemEffect) this.applyParamItemEffect(foodType || it.type, it, { source: "eating", nutrition: eating, - silentLog: it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug", + silentLog: foodType === "love_mochi" || foodType === "fight_mochi" || foodType === "sleep_drug", }); - if (mealBubbles[it.type]) this.bubble(...mealBubbles[it.type]); - this.lastMealColor = mealColors[it.type] || "#41aa66"; - this.eatTimer = Math.max(this.eatTimer, 0.30); - this.eatCooldown = rand(0.38, 0.68); + if (mealBubbles[foodType]) this.bubble(...mealBubbles[foodType]); + this.lastMealColor = mealColors[foodType] || "#41aa66"; + this.eatTimer = Math.max(this.eatTimer, 1.0); + this.eatCooldown = rand(0.96, 1.12); this.foodReactTimer = Math.max(this.foodReactTimer, 0.16); if (Math.random() < dt * 2.2) { const mouth = this.mouthPosition(it); - this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "love_mochi" ? "#ff7bab" : (it.type === "fight_mochi" ? "#e07c43" : (it.type === "sleep_drug" ? "#b89cff" : (this.lastMealColor || "#41aa66")))); + this.world.spawnEatEffect(mouth.x, mouth.y, foodType === "love_mochi" ? "#ff7bab" : (foodType === "fight_mochi" ? "#e07c43" : (foodType === "sleep_drug" ? "#b89cff" : (this.lastMealColor || "#41aa66")))); } this.makePoop(eating * 0.20); if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; } if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) { - this.world.log(`${this.name}\u306f${toolLabel(it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); + this.world.log(`${this.name}\u306f${toolLabel(foodType || it.type)}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`, "food", { participants: [this] }); this.lastLog = this.world.time; } break; @@ -1053,7 +2170,7 @@ function chooseActionForNeed(need, tarinai, world) { const servingScale = Number.isFinite(it.foodServingScale) ? it.foodServingScale : (it.toolSize === "large" ? 1.18 : (it.toolSize === "small" ? 0.82 : 1)); const bite = Math.min(it.amount, rand(4.2, 7.2) * servingScale); this.beginEating(it, "\u8349\u3092\u98df\u3079\u3066\u3044\u308b"); - this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); + this.grassEatTimer = Math.max(this.grassEatTimer, 1.0); this.vx *= Math.pow(0.20, dt * 60); this.vy *= Math.pow(0.20, dt * 60); it.amount -= bite; @@ -1062,8 +2179,8 @@ function chooseActionForNeed(need, tarinai, world) { this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46); this.recoverHealth(bite * 0.18); applyNeedRelief(this, { food: -dt * 2, fulfill: -dt * 1 }); - this.eatTimer = Math.max(this.eatTimer, 0.22); - this.eatCooldown = rand(0.68, 1.15); + this.eatTimer = Math.max(this.eatTimer, 1.0); + this.eatCooldown = rand(0.96, 1.14); this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)"); if (Math.random() < dt * 2.0) { const mouth = this.mouthPosition(it); @@ -1085,8 +2202,8 @@ function chooseActionForNeed(need, tarinai, world) { this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62); this.recoverHealth(bite * 0.12); applyNeedShock(this, { safety: bite * 0.12 }); - this.eatTimer = Math.max(this.eatTimer, 0.26); - this.eatCooldown = rand(0.75, 1.25); + this.eatTimer = Math.max(this.eatTimer, 1.0); + this.eatCooldown = rand(0.96, 1.16); this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)"); const mouth = this.mouthPosition(it); this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36"); @@ -1117,8 +2234,8 @@ function chooseActionForNeed(need, tarinai, world) { if (!this.isZunchiSlave) this.adjustPersonality("neuroticism", -bite * 0.0015, "after repeated contact with poop."); this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100); this.lastMealColor = "#6b8d3f"; - this.eatTimer = Math.max(this.eatTimer, 0.24); - this.eatCooldown = rand(0.9, 1.4); + this.eatTimer = Math.max(this.eatTimer, 1.0); + this.eatCooldown = rand(1.0, 1.22); this.bubble(this.isZunchiSlave ? "\u3082\u3050" : "...", 2.2, "rgba(74,91,50,0.78)"); const mouth = this.mouthPosition(it); this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f"); @@ -1140,10 +2257,10 @@ function chooseActionForNeed(need, tarinai, world) { } if (it.type === "stone") { - if (this.state === "idle" && (this.need === "\u3042\u305f\u305f\u304b\u3055" || this.stress > 48)) this.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; + if (this.state === "idle" && this.stress > 48) this.thought = "\u77f3\u306e\u8fd1\u304f\u3067\u843d\u3061\u7740\u3044\u3066\u3044\u308b"; applyNeedRelief(this, { safety: -dt * 4, fulfill: -dt * 2 }); this.energy += dt * 0.18; - if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25; + } if (this.isSleepFurniture(it)) { @@ -1188,7 +2305,7 @@ function chooseActionForNeed(need, tarinai, world) { } this.hunger = clamp(this.hunger, 0, 115); this.energy = clamp(this.energy, 0, 100); - this.stress = calculateStressFromNeeds(this.needs || createDefaultNeeds()); + this.stress = calculateStressFromNeeds(this.needRaw || this.needs || createDefaultNeeds()); this.loneliness = clamp(this.loneliness, 0, 110); } })); diff --git a/js/tarinai_render.js b/js/tarinai_render.js index 810cf62..23626ca 100644 --- a/js/tarinai_render.js +++ b/js/tarinai_render.js @@ -16,7 +16,7 @@ const gait = this.state === "sleep" ? 0.8 : clamp(moveSpeed / 42, 0.2, 1.8); const breathing = Math.sin(t * (this.state === "sleep" ? 1.1 : 2.3) + this.age * 0.18); const headbuttPulse = this.state === "fight" ? Math.max(0, Math.sin(t * 18 + this.age * 0.37)) : 0; - const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 54 + this.age) * clamp(Math.max(this.eatTimer / 0.32, this.grassEatTimer / 0.22), 0, 1) : 0; + const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 62 + this.age) * clamp(Math.max(this.eatTimer / 1.0, this.grassEatTimer / 1.0), 0, 1) : 0; const fallPose = this.fallTimer > 0 ? clamp(this.fallTimer / Math.max(this.fallMax || 1, 0.01), 0, 1) : 0; const fallLift = Math.sin(fallPose * Math.PI) * this.radius * 0.20; const intimidateShake = this.intimidateTimer > 0.04 ? Math.sin(t * 44 + this.age) * this.radius * 0.09 : 0; @@ -26,7 +26,7 @@ const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0; const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0); const diseaseSpin = this.fightDisease ? (t * 3.6 + this.age) : 0; - const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin + diseaseSpin; + const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.145 + this.fallDir * fallPose * 0.18 + fallSpin + diseaseSpin; const insideCardboard = false; const visualScale = this.effectiveScale ? this.effectiveScale() : this.scale; let drawW = w * visualScale * 0.30; @@ -212,11 +212,11 @@ if (this.eatTimer > 0.02) { ctx.save(); - ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); + ctx.globalAlpha = 0.46 * clamp(this.eatTimer / 1.0, 0, 1); ctx.strokeStyle = this.lastMealColor || "#f7cf67"; ctx.lineWidth = 1.6; ctx.beginPath(); - ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - this.eatTimer / 0.32) * 5, 0, Math.PI * 2); + ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - clamp(this.eatTimer / 1.0, 0, 1)) * 5, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } diff --git a/js/tarinai_social_move_life.js b/js/tarinai_social_move_life.js index 8905b6e..7ff70e1 100644 --- a/js/tarinai_social_move_life.js +++ b/js/tarinai_social_move_life.js @@ -15,7 +15,16 @@ const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect()); if (fightMochiContact && d <= Math.max(42, this.radius + o.radius + 16)) { - if (this.world.startForcedFight?.(this, o)) continue; + this.conflictTargetId = o.id; + o.conflictTargetId = this.id; + this.conflictUrge = Math.max(this.conflictUrge || 0, 92); + o.conflictUrge = Math.max(o.conflictUrge || 0, 82); + if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 36, safety: dt * 5 }, o); + if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * 30, safety: dt * 5 }, this); + if (typeof queueForcedTarinaiBehavior === "function") { + queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${o.name || "相手"}と喧嘩しようとしている` }); + queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${this.name || "相手"}と喧嘩しようとしている` }); + } } if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < (o.diseaseChance ? o.diseaseChance(dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) : dt * 0.065 * clamp(1 - d / 58, 0.12, 1))) { @@ -34,14 +43,11 @@ const ny = (nonSlave.y - slave.y) / d; nonSlave.vx += nx * dt * 34; nonSlave.vy += ny * dt * 34; - if (nonSlave.state === "idle" || nonSlave.state === "seek_friend") { - if (nonSlave.enterPanic) nonSlave.enterPanic({ threat: slave, reason: "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044", fear: 0.38, wake: false, cause: "zunchi_slave_nearby" }); - else { nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); nonSlave.target = slave; nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; } - } else { - nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.38 * nonSlave.personalityProfile().fear); - nonSlave.target = slave; - nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; - } + if (typeof applyNeedShock === "function") applyNeedShock(nonSlave, { safety: dt * 24, social: dt * 5 }, slave); + nonSlave.lastNeedShockBreaker = slave; + nonSlave.fearTimer = Math.max(nonSlave.fearTimer || 0, 0.22 * nonSlave.personalityProfile().fear); + nonSlave.target = slave; + nonSlave.thought = "\u305a\u3093\u3061\u3069\u308c\u3044\u304c\u8fd1\u3044"; nonSlave.adjustRelation?.(slave, -dt * 0.08, dt * 0.24, "zunchi_slave_avoid"); slave.adjustRelation?.(nonSlave, -dt * 0.02, 0, "zunchi_slave_avoid"); if (Math.random() < dt * 0.35) nonSlave.surpriseTimer = Math.max(nonSlave.surpriseTimer || 0, 0.18); @@ -131,15 +137,32 @@ const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false; if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) { - this.world.startConflict(this, o); + this.conflictTargetId = o.id; + o.conflictTargetId = this.id; + const urge = 48 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 12 : 0); + this.conflictUrge = Math.max(this.conflictUrge || 0, urge); + o.conflictUrge = Math.max(o.conflictUrge || 0, urge * 0.82); + if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * 0.32, safety: dt * (battleDrug ? 4 : 1.6) }, o); + if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * 0.24, safety: dt * (battleDrug ? 4 : 1.6) }, this); } const compatibleBirthStatus = this.isZunchiSlave === o.isZunchiSlave; - if (compatibleBirthStatus && !this.sleepDisease && !o.sleepDisease && !this.fightDisease && !o.fightDisease && d < (loveDrug ? 44 : 36) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { + if (compatibleBirthStatus && !this.sleepDisease && !o.sleepDisease && !this.fightDisease && !o.fightDisease && d < (loveDrug ? 52 : 42) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { const enough = loveDrug ? (this.hunger < 90 && o.hunger < 92 && this.energy > 18 && o.energy > 18) : (this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44); const lonelyBond = loveDrug ? (this.loneliness > 4 || o.loneliness > 4 || this.affection > 0 || o.affection > 0) : (this.loneliness > 24 || o.loneliness > 24); - if (enough && lonelyBond && Math.random() < dt * (loveDrug ? 0.72 : 0.25)) { - this.world.startBirthRitual(this, o); + if (enough && lonelyBond) { + if (typeof applyNeedShock === "function") { + applyNeedShock(this, { social: dt * (loveDrug ? 34 : 14) }, o); + applyNeedShock(o, { social: dt * (loveDrug ? 34 : 14) }, this); + } + if (loveDrug && typeof queueForcedTarinaiBehavior === "function") { + queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${o.name || "相手"}と繁殖したくて近づいている` }); + queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${this.name || "相手"}と繁殖したくて近づいている` }); + } + const mateIntent = this.activeBehavior?.id === "approach_mate" || o.activeBehavior?.id === "approach_mate" || this.intent?.actionId === "approach_mate" || o.intent?.actionId === "approach_mate"; + if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) { + this.world.startBirthRitual(this, o); + } } } } @@ -251,6 +274,13 @@ const dx = moveTarget.x - this.x; const dy = moveTarget.y - this.y; const d = Math.hypot(dx, dy) || 1; + if (this.state === "wander" && this.target?.detour && d < Math.max(22, (this.radius || 20) * 0.9)) { + if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(this, { fulfill: 6 }, "wander"); + this.currentAction = null; + this.intentLockTimer = 0; + this.goIdle?.("少し歩いた"); + return; + } if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) { const bedDx = this.target.x - this.x; const bedDy = this.target.y - this.y; diff --git a/js/text_catalog.js b/js/text_catalog.js index eb561a4..90d3086 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -107,8 +107,8 @@ TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) { TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { if (!t) return "なし"; if ((t.pinchThoughtTimer || 0) > 0) return "つままれている"; - const thought = String(t.thought || "").trim(); - if (thought) return thought; + const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim(); + if (behaviorText) return behaviorText; const target = TEXT_CATALOG.targetLabel(t.target); if (t.state === "eat") return target !== "なし" ? `${target}を食べている` : "食べている"; if (t.state === "sleep") return t.target?.type === "nest_box" ? "巣箱の中で体力を回復している" : "体力を回復している"; @@ -116,13 +116,14 @@ TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { if (t.state === "intimidate") return target !== "なし" ? `${target}を威嚇している` : "相手を威嚇している"; if (t.state === "ant_intimidate") return "アリを威嚇している"; if (t.state === "ant_attack") return "アリを攻撃している"; - if (t.state === "birth_ritual") return "子が現れる前に体をゆすっている"; + if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている"; if (t.state === "sunbath") return "晴れた日なたでストレスを下げている"; if (t.state === "frozen") return "フィールド外で冷凍保存されている"; if (t.state === "seek_food") return target !== "なし" ? `${target}を探している` : "お腹がすいている"; if (t.state === "seek_water") return t.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している"; if (t.state === "seek_bed") return target !== "なし" ? `${target}へ向かっている` : "眠る場所を探している"; if (t.state === "play_ball") return "ボールが気になっている"; + if (t.state === "seek_friend" && t.intent?.actionId === "approach_mate") return t.target?.name ? `${t.target.name}に繁殖のため近づいている` : "繁殖できる相手を探している"; if (t.state === "seek_friend") return "仲間を探している"; if (t.state === "follow_parent") return "親についていく"; if (t.state === "cursor_friend") return "カーソルについていく"; @@ -133,6 +134,8 @@ TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) { if ((t.hurtTimer || 0) > 0.08) return "痛みで混乱している"; if ((t.fearTimer || 0) > 0.08) return "怖がっている"; if ((t.grassEatTimer || 0) > 0.04) return "草を食べている"; + const thought = String(t.thought || "").trim(); + if (thought) return thought; return "周囲を見ている"; }; diff --git a/js/ui_bind.js b/js/ui_bind.js index 62cfbce..68d28f2 100644 --- a/js/ui_bind.js +++ b/js/ui_bind.js @@ -10,6 +10,13 @@ function bindUI() { window.TarinaiSaveSystem?.bindSaveSystem?.(); window.TarinaiFreezeSystem?.bindFreezeSystem?.(); uiCache.toolButtons = Array.from(ui.toolPalette?.querySelectorAll(".tool") || []); + function isActivePlacementTool() { + const def = typeof toolDefinition === "function" ? toolDefinition(world.tool) : null; + return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(world.tool)); + } + function clickedInsideGameOrToolUi(target) { + return Boolean((canvas && canvas.contains?.(target)) || ui.toolPalette?.contains?.(target)); + } syncToolSizeBadges(); for (const toggle of document.querySelectorAll(".panel-card-toggle")) { toggle.addEventListener("click", () => { @@ -179,6 +186,11 @@ function bindUI() { if (!ui.soundMenu || ui.soundMenu.contains(e.target)) return; closeSoundPanel(); }); + document.addEventListener("click", (e) => { + if (!isActivePlacementTool()) return; + if (clickedInsideGameOrToolUi(e.target)) return; + selectTool("observe"); // 道具設置モードを解除 + }); ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); openCreditsDialog(); }); ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog); ui.creditsDialog?.addEventListener("click", (e) => { diff --git a/js/ui_selected.js b/js/ui_selected.js index 88b9048..39136b7 100644 --- a/js/ui_selected.js +++ b/js/ui_selected.js @@ -166,6 +166,29 @@ function selectedInfoRowHtml(label, valueHtml, metric = "") { return `
${escapeHtml(label)}${valueHtml}
`; } + +function selectedInventoryItems(t) { + const worldRef = t?.world || world; + const labels = { grass_bed: "かんたんベッド", plushie: "ぬいぐるみ" }; + const items = (worldRef?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id); + const counts = new Map(); + for (const it of items) { + const label = labels[it.type] || toolLabel(it.type) || it.type; + counts.set(label, (counts.get(label) || 0) + 1); + } + return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} ×${count}` : label); +} + +function selectedInventoryHtml(t) { + const entries = selectedInventoryItems(t); + if (!entries.length) return `
所持品なし
`; + return `
所持品${entries.map(v => `${escapeHtml(v)}`).join("")}
`; +} + +function selectedStateText(t) { + return stateLabel(t?.state); +} + function renderSelected() { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) { @@ -195,7 +218,7 @@ function renderSelected() { const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"]; const snapshot = [ t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), - t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","), + t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", t.activeBehavior?.id || "", t.activeBehavior?.presentText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","), fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness), fmt(t.energy), fmt(t.stress), fmt(t.mood), t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), @@ -206,7 +229,7 @@ function renderSelected() { personalitySnapshot, personalityDailySnapshot(t), ["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100), itemEffectSummary.map(e => `${e.id}:${e.detail}`).join(","), - (t.recentChangeCauses || []).slice(0, 8).map(c => `${Math.round(c.time || 0)}:${c.reason || ""}:${c.target || ""}:${c.value ?? ""}`).join("~"), + selectedInventoryItems(t).join(","), (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") ].join("|"); if (uiCache.selectedSnapshot === snapshot) return; @@ -237,12 +260,9 @@ function renderSelected() { const recordText = quoteNameInRecordText(r.text || "", t.name); return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; - const recentCauseHtml = recentChangeCausesHtml(t); const basicHtml = `
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    -
    \u72b6\u614b${escapeHtml(stateLabel(t.state))}
    -
    \u3044\u307e${escapeHtml(t.intent?.actionLabel || stateLabel(t.state))}
    -
    \u7406\u7531${escapeHtml(reasonLabel(t))}
    +
    \u72b6\u614b${escapeHtml(selectedStateText(t))}
    \u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    \u6700\u5927\u5bff\u547d${geneticPercentText(t, "life")}
    @@ -254,10 +274,11 @@ function renderSelected() { const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key; const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10); const chosen = t.intent?.need === key ? ` data-chosen-need="true"` : ""; - return `
    ${escapeHtml(label)}${escapeHtml(value)}
    `; + return `
    ${escapeHtml(label)}${escapeHtml(value)}
    `; }).join(""); const healthHtml = [ - needRowsHtml, + `
    \u884c\u52d5${escapeHtml(reasonLabel(t))}
    `, + `
    ${needRowsHtml}
    `, selectedInfoRowHtml("\u4f53\u529b", fmt(t.energy), "energy"), selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"), ].join(""); @@ -280,12 +301,13 @@ function renderSelected() { ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""}
    ${selectedDataCategoryHtml("basic", "\u57fa\u672c", basicHtml)} - ${selectedDataCategoryHtml("health", "\u4f53\u8abf", healthHtml)} + ${selectedDataCategoryHtml("health", "\u6b32\u6c42", healthHtml)} ${selectedDataCategoryHtml("effects", "\u30a2\u30a4\u30c6\u30e0\u52b9\u679c", itemEffectHtml)} ${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)} ${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)} ${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)} - ${selectedDataCategoryHtml("records", "\u8a18\u9332", `

    \u6700\u8fd1\u306e\u5909\u5316\u539f\u56e0

      ${recentCauseHtml}

    \u500b\u4f53\u5225\u306e\u8a18\u9332

      ${recordHtml}
    `)} + ${selectedDataCategoryHtml("inventory", "\u6240\u6301\u54c1", selectedInventoryHtml(t))} + ${selectedDataCategoryHtml("records", "\u8a18\u9332", `
      ${recordHtml}
    `)}
    `; @@ -303,8 +325,15 @@ function stateLabel(s) { } function reasonLabel(t) { + if (!t) return "なし"; + const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim(); + if (behavior) return behavior; + const state = String(t.state || ""); + const live = window.TEXT_CATALOG?.reasonLabel?.(t) || ""; + const liveStates = new Set(["birth_ritual", "eat", "sleep", "panic", "fight", "intimidate", "ant_attack", "ant_intimidate", "frozen"]); + if (live && (liveStates.has(state) || (t.birthRitualTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04 || (t.fightTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04)) return live; if (t?.intent?.reasonText) return t.intent.reasonText; - return window.TEXT_CATALOG?.reasonLabel?.(t) || "なし"; + return live || "なし"; } function targetLabel(target) { diff --git a/js/ui_tools.js b/js/ui_tools.js index a320d1e..37d6733 100644 --- a/js/ui_tools.js +++ b/js/ui_tools.js @@ -28,6 +28,7 @@ function renderToolPalette() { btn.className = "tool"; btn.dataset.tool = toolId; btn.textContent = def.label || toolId; + attachToolSpritePreview(btn, def, toolId); if (toolId === world?.tool) btn.classList.add("selected"); body.appendChild(btn); } @@ -37,6 +38,33 @@ function renderToolPalette() { ui.toolPalette.appendChild(frag); } +function attachToolSpritePreview(btn, def, toolId) { + const itemType = def?.itemType || toolId; + if (!btn || !itemType || !def?.placeable) return; + const drawPreview = window.drawToolItemPreview || window.drawConsumableFieldSprite; + if (typeof drawPreview !== "function") return; + const canvas = document.createElement("canvas"); + canvas.className = "tool-sprite-preview"; + canvas.width = 72; + canvas.height = 72; + canvas.setAttribute("aria-hidden", "true"); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + let drawn = false; + if (typeof window.drawToolItemPreview === "function") { + drawn = window.drawToolItemPreview(ctx, itemType, { width: canvas.width, height: canvas.height, compact: true }) !== false; + } else if (typeof window.isConsumableSpriteType === "function" && window.isConsumableSpriteType(itemType)) { + ctx.translate(canvas.width / 2, canvas.height / 2); + const scale = itemType === "protein" || itemType === "niteropu" ? 0.82 : 0.92; + ctx.scale(scale, scale); + window.drawConsumableFieldSprite(ctx, itemType, 13.5, 112.4, { compact: true }); + drawn = true; + } + if (!drawn) return; + btn.classList.add("tool-has-canvas-icon"); + btn.prepend(canvas); +} + function toolSizeFor(tool) { if (!SCALABLE_TOOLS.has(tool)) return ""; return typeof normalizedToolSizeFor === "function" ? normalizedToolSizeFor(world, tool) : ((world.toolSizes && world.toolSizes[tool]) || world.toolSize || "medium"); diff --git a/js/version.js b/js/version.js index 0a7e113..a4258ad 100644 --- a/js/version.js +++ b/js/version.js @@ -1,8 +1,8 @@ "use strict"; (function () { - const APP_VERSION = "15.24.2"; - const APP_BUILD = "needs-compliance-fix"; + const APP_VERSION = "15.24.14"; + const APP_BUILD = "forced-behavior-sleep-rhythm"; const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const STATIC_VERSION_PARAM = `v=${APP_VERSION}`; diff --git a/js/world_environment.js b/js/world_environment.js index 21c63c2..f09ab74 100644 --- a/js/world_environment.js +++ b/js/world_environment.js @@ -309,7 +309,7 @@ const radius = rand(minR, maxR); const x = clamp(source.x + Math.cos(angle) * radius, 44, this.w - 44); const y = clamp(source.y + Math.sin(angle) * radius * 0.72, 44, this.h - 44); - if (this.grassSpotOpen(x, y, { minGrassGap: 30, avoidTarinai: true })) return { x, y }; + if (this.grassSpotOpen(x, y, { minGrassGap: fertile ? 24 : 22, avoidTarinai: true })) return { x, y }; } return null; } diff --git a/js/world_family_social.js b/js/world_family_social.js index fb3130c..514fb38 100644 --- a/js/world_family_social.js +++ b/js/world_family_social.js @@ -64,8 +64,6 @@ } for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70))); this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32)); - this.items.push(new Item("bed", this.w * 0.82, this.h * 0.22)); - this.items.push(new Item("bed", this.w * 0.18, this.h * 0.78)); this.updateItemCounts(); this.updateEffectCounts(); this.markTerrainDirty?.("reset"); @@ -430,8 +428,12 @@ b.birthRitualRole = 1; a.birthRitualLeader = true; b.birthRitualLeader = false; - a.setActionState("birth_ritual", { target: b, reason: "近くで体をゆすっている" }); - b.setActionState("birth_ritual", { target: a, reason: "近くで体をゆすっている" }); + a.setActionState("birth_ritual", { target: b, reason: "繁殖の前ぶれをしている" }); + b.setActionState("birth_ritual", { target: a, reason: "繁殖の前ぶれをしている" }); + if (typeof setLiveActionText === "function") { + setLiveActionText(a, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${b.name || "相手"}と繁殖の前ぶれをしている`, target: b }); + setLiveActionText(b, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${a.name || "相手"}と繁殖の前ぶれをしている`, target: a }); + } a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10); a.surpriseTimer = Math.max(a.surpriseTimer, 0.35); @@ -496,7 +498,7 @@ }, canStartFight(t) { - if (!t || t.dead) return false; + if (!t || t.dead || typeof t.relationTo !== "function") return false; if (t.sleepDisease) return false; // Disease fight rule: explosion, zunchi, and fight disease can lash out; // sleep disease cannot fight or be challenged, and other diseases cannot start fights. @@ -508,7 +510,7 @@ }, canBeFightTarget(t) { - return Boolean(t && !t.dead && !t.sleepDisease); + return Boolean(t && !t.dead && typeof t.relationTo === "function" && !t.sleepDisease); }, canFightPair(actor, target) { @@ -540,7 +542,7 @@ loneliness: rand(30, 56), energy: rand(55, 95), stress: rand(0, 14), - need: Math.random() < 0.6 ? pick([a.need, b.need]) : pick(NEEDS), + need: "", generation: Math.max(a.generation, b.generation) + 1, lifeSpan: rand(1500, 2200) * (childGenetics.lifeSpanMul || 1), parents: [this.tarinaiFamilyKey(a), this.tarinaiFamilyKey(b)], @@ -628,14 +630,15 @@ startConflict(a, b) { if (!a || !b || a.dead || b.dead) return; + if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return; if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; } if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return; if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return; const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; - const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((b.relationTo(a.id).fear || 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); - const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); const actor = scoreA >= scoreB ? a : b; const target = actor === a ? b : a; if (!this.canFightPair(actor, target)) return; @@ -654,7 +657,7 @@ startIntimidation(actor, target, actorScore = 0, targetScore = 0) { if (!actor || !target || actor.dead || target.dead) return false; if (actor.lowHealthSprite && actor.lowHealthSprite()) return false; - const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8; + const fearLoad = ((typeof target.relationTo === "function" ? (target.relationTo(actor.id).fear || 0) : 0) * 0.72) + target.personalityProfile().fear * 8; const actorAggression = Math.max(0, actor.currentPersonality?.aggression || 0); const targetAggression = Math.max(0, target.currentPersonality?.aggression || 0); const actorSize = actor.effectiveScale ? actor.effectiveScale() : (actor.scale || 0.28); @@ -670,6 +673,7 @@ actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35)); actor.intimidateTargetId = target.id; actor.setActionState("intimidate", { target, reason: "相手を威嚇している" }); + if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: `${target.name || "相手"}を威嚇している`, target, phase: "perform", source: "behavior" }); this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, { life: 0.42, size: actor.radius * 0.55, @@ -716,6 +720,10 @@ if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return; a.setActionState("fight", { target: b, reason: `${b.name || "相手"}と喧嘩している` }); b.setActionState("fight", { target: a, reason: `${a.name || "相手"}と喧嘩している` }); + if (typeof setLiveActionText === "function") { + setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${b.name || "相手"}と喧嘩している`, target: b, phase: "perform", source: "behavior" }); + setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${a.name || "相手"}と喧嘩している`, target: a, phase: "perform", source: "behavior" }); + } a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0)); b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0)); a.nextHeadbutt = this.time + rand(0.08, 0.18); diff --git a/js/world_placement_log.js b/js/world_placement_log.js index 45aaf38..6a82d6e 100644 --- a/js/world_placement_log.js +++ b/js/world_placement_log.js @@ -3,6 +3,136 @@ (function (global) { const World = global.World; if (!World) throw new Error("World is not available for mixin: world_placement_log.js"); + + const SIGNBOARD_CHARS_PER_LINE = 6; + const SIGNBOARD_LINES = 3; + const SIGNBOARD_TEXT_LIMIT = SIGNBOARD_CHARS_PER_LINE * SIGNBOARD_LINES; + + function ensureSignboardEditor() { + let dialog = document.getElementById("signboardEditor"); + if (dialog) return dialog; + dialog = document.createElement("div"); + dialog.id = "signboardEditor"; + dialog.className = "signboard-editor hidden"; + dialog.innerHTML = ` + `; + document.body.appendChild(dialog); + return dialog; + } + + + function sliceGraphemes(text, limit) { + const chars = Array.from(String(text || "")); + return chars.slice(0, Math.max(0, limit)).join(""); + } + + function sanitizeSignboardText(value = "") { + const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " "); + const rawLines = normalized.split("\n"); + const out = []; + for (const rawLine of rawLines) { + let rest = String(rawLine || "").trim(); + if (!rest && out.length < SIGNBOARD_LINES) { out.push(""); continue; } + while (rest && out.length < SIGNBOARD_LINES) { + out.push(sliceGraphemes(rest, SIGNBOARD_CHARS_PER_LINE)); + rest = Array.from(rest).slice(SIGNBOARD_CHARS_PER_LINE).join(""); + } + if (out.length >= SIGNBOARD_LINES) break; + } + return out.slice(0, SIGNBOARD_LINES).join("\n").replace(/\n+$/g, "").trim(); + } + + function signboardVisibleLength(text = "") { + return Array.from(String(text || "").replace(/\n/g, "")).length; + } + function openSignboardEditor(sign, worldRef) { + if (!sign) return false; + const dialog = ensureSignboardEditor(); + const area = dialog.querySelector("#signboardEditorText"); + const count = dialog.querySelector("#signboardEditorCount"); + const apply = dialog.querySelector("#signboardEditorApply"); + const cancel = dialog.querySelector("#signboardEditorCancel"); + const clear = dialog.querySelector("#signboardEditorClear"); + const closeBtn = dialog.querySelector("#signboardEditorClose"); + const close = () => { + dialog.classList.add("hidden"); + dialog.__sign = null; + dialog.__worldRef = null; + }; + const syncCount = () => { + if (!area || !count) return; + count.textContent = `${signboardVisibleLength(area.value || "")}/${SIGNBOARD_TEXT_LIMIT}`; + }; + const commit = (value) => { + const next = sanitizeSignboardText(value); + sign.text = next; + sign.textEditedAt = worldRef?.time || 0; + worldRef?.log?.(sign.text ? `\u770b\u677f\u3092\u66f8\u304d\u63db\u3048\u305f\u3002` : `\u770b\u677f\u306e\u6587\u5b57\u3092\u6d88\u3057\u305f\u3002`, "observe"); + worldRef.drawListDirty = true; + global.render?.(); + close(); + }; + dialog.__sign = sign; + dialog.__worldRef = worldRef; + if (area) { + area.value = sanitizeSignboardText(sign.text || ""); + area.oninput = () => { + const clean = sanitizeSignboardText(area.value || ""); + if (area.value !== clean && signboardVisibleLength(area.value || "") > SIGNBOARD_TEXT_LIMIT) area.value = clean; + syncCount(); + }; + setTimeout(() => { area.focus(); area.select(); syncCount(); }, 0); + } + if (apply) apply.onclick = () => commit(area?.value || ""); + if (clear) clear.onclick = () => commit(""); + if (cancel) cancel.onclick = close; + if (closeBtn) closeBtn.onclick = close; + dialog.onclick = (e) => { if (e.target === dialog) close(); }; + dialog.classList.remove("hidden"); + syncCount(); + return true; + } + + + function isEditableItem(item) { + return item && !item.dead && item.type === "signboard"; + } + + function findEditableItemAt(worldRef, x, y) { + let best = null; + let bestD = Infinity; + for (let i = (worldRef?.items || []).length - 1; i >= 0; i--) { + const it = worldRef.items[i]; + if (!isEditableItem(it)) continue; + const d = distXY(x, y, it.x, it.y); + if (d <= Math.max(54, (it.r || 24) * 2.4) && d < bestD) { best = it; bestD = d; } + } + return best; + } + + function openEditableItemEditor(item, worldRef) { + if (!isEditableItem(item)) return false; + if (item.type === "signboard") return openSignboardEditor(item, worldRef); + return false; + } + + function isPlacementTool(tool) { + const def = typeof toolDefinition === "function" ? toolDefinition(tool) : null; + return Boolean(def?.placeable && typeof toolItemType === "function" && toolItemType(tool)); + } + Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({ fencePlacementBlocked(item) { if (!item || !this.isFenceType(item.type)) return false; @@ -171,6 +301,7 @@ const d = distXY(x, y, it.x, it.y); if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; } } + if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem; if (foundT && foundItem) { const td = distXY(x, y, foundT.x, foundT.y); return td <= foundItemD + 8 ? foundT : foundItem; @@ -213,28 +344,21 @@ handleClick(x, y) { const tool = this.tool; + if (!isPlacementTool(tool) && !["delete", "poke", "pinch", "water_hose", "new"].includes(tool)) { + const editable = findEditableItemAt(this, x, y); + if (editable && openEditableItemEditor(editable, this)) { + this.selected = null; + return; + } + } if (tool === "observe") { let found = null; for (let i = this.tarinai.length - 1; i >= 0; i--) { if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } } if (!found) { - let sign = null; - let bestD = Infinity; - for (let i = this.items.length - 1; i >= 0; i--) { - const it = this.items[i]; - if (!it || it.dead || it.type !== "signboard") continue; - const d = distXY(x, y, it.x, it.y); - if (d <= Math.max(54, (it.r || 24) * 2.4) && d < bestD) { sign = it; bestD = d; } - } - if (sign) { - const current = String(sign.text || ""); - const next = typeof prompt === "function" ? prompt("看板のテキスト", current) : current; - if (next !== null) { - sign.text = String(next || "").replace(/[\r\n]+/g, " ").slice(0, 24).trim(); - sign.textEditedAt = this.time || 0; - this.log(sign.text ? `看板を書き換えた。` : `看板の文字を消した。`, "observe"); - } + const editable = findEditableItemAt(this, x, y); + if (editable && openEditableItemEditor(editable, this)) { this.selected = null; return; } diff --git a/js/world_update.js b/js/world_update.js index d7fb326..3c00465 100644 --- a/js/world_update.js +++ b/js/world_update.js @@ -113,6 +113,22 @@ return { box: best, occupants, capacity }; }, + + + pointerOwnedBedInfo() { + const p = this.pointer; + if (!p?.inside) return null; + let best = null, bestD = Infinity; + for (const it of this.nearbyItems(p.x, p.y, 140)) { + if (!it || it.dead || it.type !== "grass_bed") continue; + const d = distXY(p.x, p.y, it.x, it.y); + if (d < bestD && d <= Math.max(60, (it.r || 24) * 2.0)) { best = it; bestD = d; } + } + if (!best) return null; + const owner = (this.tarinai || []).find(t => t && !t.dead && t.id === best.ownerId) || null; + return { bed: best, owner }; + }, + nestBoxToolTipCount() { const pointed = this.pointerNestBoxInfo?.(); if (pointed?.box) return { current: pointed.occupants.length, max: pointed.capacity || this.nestBoxCapacity(pointed.box) }; @@ -243,17 +259,17 @@ const grassCount = this.itemCounts.grass || 0; - if (grassCount < Math.min(24, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)) && Math.random() < dt * 0.036) { + if (grassCount < Math.min(54, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 180)) && Math.random() < dt * 0.082) { const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), { allowOriginal: true, minRadius: 28, maxRadius: 140, - attempts: 24, + attempts: 36, }); if (spot) { const sprout = new Item("grass", spot.x, spot.y); - sprout.amount = rand(22, 48); - sprout.growth = rand(0.12, 0.30); + sprout.amount = rand(30, 62); + sprout.growth = rand(0.18, 0.38); this.items.push(sprout); this.itemCounts.grass = grassCount + 1; this.markTerrainDirty?.("grass-spawn"); diff --git a/service-worker.js b/service-worker.js index 6430d70..22d2571 100644 --- a/service-worker.js +++ b/service-worker.js @@ -1,6 +1,6 @@ "use strict"; -const APP_VERSION = "15.24.2"; +const APP_VERSION = "15.24.14"; const CACHE_NAME = `tarinai-colony-${APP_VERSION}`; const v = `v=${APP_VERSION}`; // Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files. @@ -33,6 +33,15 @@ function isStaticAsset(request) { return /\.(?:js|css|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(url.pathname); } +function cacheableResponse(response) { + return response && response.status === 200 && response.type !== "opaque"; +} + +function putCacheSafely(request, response) { + if (!cacheableResponse(response)) return Promise.resolve(false); + return caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())).then(() => true).catch(() => false); +} + self.addEventListener("fetch", (event) => { const request = event.request; if (request.method !== "GET") return; @@ -41,16 +50,14 @@ self.addEventListener("fetch", (event) => { if (/service-worker\.js$/i.test(url.pathname)) return; if (request.mode === "navigate" || /index\.html$/i.test(url.pathname)) { event.respondWith(fetch(request).then(response => { - const copy = response.clone(); - caches.open(CACHE_NAME).then(cache => cache.put(request, copy)); + putCacheSafely(request, response); return response; }).catch(() => caches.match(request).then(cached => cached || caches.match("./index.html")))); return; } if (isStaticAsset(request)) { event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => { - const copy = response.clone(); - caches.open(CACHE_NAME).then(cache => cache.put(request, copy)); + putCacheSafely(request, response); return response; }))); }