diff --git a/PATCH_NOTES.md b/PATCH_NOTES.md new file mode 100644 index 0000000..8582fe4 --- /dev/null +++ b/PATCH_NOTES.md @@ -0,0 +1,35 @@ +# Patch Notes + +## Encoding cleanup +- Normalized `index.html` back to UTF-8. +- Replaced mojibake strings with the intended labels. +- Escaped Japanese text in source files to avoid encoding-dependent corruption. + +## Personality specification fixes +- Personality tags and data UI now use Japanese labels from the four-axis personality model. +- Threshold-crossing personality logs are now generated in Japanese and only when visible trait bands change. +- Daily personality caps now track increase and decrease separately per parameter. +- Current personality effects are neutral inside the -0.5 to +0.5 band. +- Direct conversion to the legacy timid personality after damage was removed; cowardly behavior is derived from high neuroticism plus low aggression. +- Corpse/splat contact now increases stress and slowly reduces neuroticism through habituation. + +## Follow-up fixes +- Fixed CSS emoji escapes for tool UI icons. CSS now uses code-point escapes such as `\\1F448` instead of invalid surrogate escapes. +- Hid the detailed four-axis personality rows from the compact tarinai data UI. +- Restored `bestBedFor()` fallback so tarinai can choose hay beds when no usable nest box is available. +- Damage now wakes sleeping tarinai and forces tarinai hidden inside a nest box to leave the box. +- Added a short minimum stay window after entering a nest box to avoid immediate exit loops. +- Renamed the friend relation label from `\\u4ef2\\u826f\\u3057` to `\\u53cb\\u9054` in UI text and related logs. + + +## Personality cleanup v3 +- Current live friend count now changes sociability drift. Family/friend proximity raises sociability more when the tarinai has more current friends, while a lonely tarinai with no current friends can slowly lose sociability. +- Removed the legacy named personality route from constructor, serialization, child inheritance, selected data UI, selected-card overlay, and family-tree titles. The game now uses only birthPersonality/currentPersonality four-axis data. +- Kept empty legacy stubs only for old-save compatibility so older saves cannot revive the pre-four-axis personality model. + +## Follow-up fixes v4 +- Restored compact personality information in the tarinai data panel: visible tags, birth personality, and current personality. +- Moved the focused-tarinai highlight outside the sprite rotation transform so the ring and label stay upright. +- Shortened ecology cards and added the HP/stress meter placement note: upper meter is HP, lower meter is stress. +- Raised the friendship threshold to 16 affinity and reused the same threshold for friend count, friend behavior, friend UI, and observe highlights. +- Observe mode now highlights friends of the selected tarinai as well as parents, children, and enemies. diff --git a/README.md b/README.md index e64b3d1..a55624e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Read only these first: `README.md`, `docs/AI_CONTEXT.md`, `docs/code_map.md`. Th ## Important mechanics -- Personality affects fear, fighting, social pull, sleep priority, zunda preference, playfulness; children inherit one parent's personality. +- Personality uses four axes: aggression, openness, sociability, and neuroticism. Children inherit from the parents' current four-axis personality average with variation. - Relationships track affinity/fear/fight outcomes and influence approach, avoidance, intimidation, conflict, co-parent behavior, and UI summaries. - Death causes are normalized by `js/health.js`: fight, hunger, disease, stress overload, over-poking, firecracker, accident, lifespan. Major damage within 10 seconds can become ` de suyaku` in UI text (`...\u3067\u8870\u5f31`). - Water-curable diseases force high-priority water seeking. Zunda-curable diseases force high-priority zunda seeking. @@ -72,3 +72,8 @@ docs/code_map.md Symbol-to-file lookup table. - Selected Tarinai UI includes per-individual records derived from matching observation log entries. - Observation highlights selected relatives and major social relations: parent, child, friend, enemy. - Explosions impart velocity to nearby zunchi items; cursor contact reduces stress and emits dense heart effects. + +## v4 \u8ffd\u52a0\u4fee\u6b63 +- \u5de3\u7bb1\u5165\u9000\u5ba4\u3092\u5c02\u7528\u72b6\u614b\u5316\u3057\u3001\u900f\u660e\u5316\u30fb\u5f53\u305f\u308a\u5224\u5b9a\u6d88\u6ec5\u30fb\u500b\u4f53\u6570\u53cd\u6620\u30fb\u9000\u51fa\u5fa9\u5e30\u3092\u5b89\u5b9a\u5316\u3002 +- \u7761\u7720\u6642\u306e\u512a\u5148\u9806\u4f4d\u3092\u300c\u5de3\u7bb1 \u2192 \u5e72\u3057\u8349\u30d9\u30c3\u30c9 \u2192 \u305d\u306e\u5834\u300d\u306b\u5909\u66f4\u3002 +- \u305f\u308a\u306a\u3044\u30c7\u30fc\u30bfUI\u306e\u53cb\u9054/\u6575\u5bfe\u3092\u5168\u4ef6\u30c1\u30c3\u30d7\u8868\u793a\u306b\u3057\u3001\u30af\u30ea\u30c3\u30af\u3067\u500b\u4f53\u3078\u79fb\u52d5\u3067\u304d\u308b\u3088\u3046\u306b\u5909\u66f4\u3002 diff --git a/assets/sprites/tarinai_22_stress_blue.png b/assets/sprites/tarinai_22_stress_blue.png deleted file mode 100644 index b1f3b40..0000000 Binary files a/assets/sprites/tarinai_22_stress_blue.png and /dev/null differ diff --git a/assets/sprites/tarinai_23_stress_cry.png b/assets/sprites/tarinai_23_stress_cry.png deleted file mode 100644 index 320a2b1..0000000 Binary files a/assets/sprites/tarinai_23_stress_cry.png and /dev/null differ diff --git a/assets/ui/tool_pinch.png b/assets/ui/tool_pinch.png deleted file mode 100644 index 0ad3cf1..0000000 Binary files a/assets/ui/tool_pinch.png and /dev/null differ diff --git a/assets/ui/tool_poke.png b/assets/ui/tool_poke.png deleted file mode 100644 index ce3daae..0000000 Binary files a/assets/ui/tool_poke.png and /dev/null differ diff --git a/css/styles.css b/css/styles.css index 8653502..74c557e 100644 --- a/css/styles.css +++ b/css/styles.css @@ -156,6 +156,8 @@ h1 { .field-dialog.hidden { display: none; } .field-dialog-panel { width: min(440px, 100%); + max-height: calc(100vh - 36px); + overflow: auto; border-radius: 16px; padding: 16px; background: rgba(255, 252, 245, 0.96); @@ -220,6 +222,9 @@ h1 { .ecology-dialog-panel { width: min(760px, 100%); + max-height: calc(100vh - 36px); + overflow-y: auto; + overscroll-behavior: contain; } .ecology-grid { display: grid; @@ -228,17 +233,17 @@ h1 { } .ecology-card { display: grid; - grid-template-columns: 84px minmax(0, 1fr); + grid-template-columns: 72px minmax(0, 1fr); gap: 10px; align-items: center; - padding: 12px; + padding: 10px; border-radius: 14px; background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(243,237,225,0.76)); border: 1px solid rgba(84,68,48,0.12); } .ecology-card img { - width: 84px; - height: 84px; + width: 72px; + height: 72px; object-fit: contain; filter: drop-shadow(0 8px 18px rgba(56, 44, 32, 0.10)); } @@ -251,7 +256,7 @@ h1 { margin: 0; color: var(--muted); font-size: 12px; - line-height: 1.55; + line-height: 1.40; } .toast { @@ -338,11 +343,10 @@ h1 { opacity: 0.10; pointer-events: none; } -.tool span { min-width: 16px; text-align: center; color: rgba(72,58,43,0.64); font-size: 10px; } +.tool > span:not(.tool-size-badge) { display: none; } .tool[data-tool="observe"] { --tool-icon: url("../assets/ui/tool_observe.png"); } .tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.png"); } .tool[data-tool="sweet"] { --tool-icon: url("../assets/ui/tool_sweet.png"); } -.tool[data-tool="water"] { --tool-icon: url("../assets/ui/tool_water.png"); } .tool[data-tool="grass"] { --tool-icon: url("../assets/ui/tool_grass.png"); } .tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.png"); } .tool[data-tool="firecracker"] { --tool-icon: url("../assets/ui/tool_firecracker.png"); } @@ -373,14 +377,14 @@ h1 { } .tool[data-tool="poke"] { --tool-icon: none; } .tool[data-tool="poke"]::before { - content: "\1f448 "; + content: "\1F448"; background: none; font-size: 25px; line-height: 1; filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); } .tool[data-tool="poke"]::after { - content: "\1f448 "; + content: "\1F448"; background: none; font-size: 48px; line-height: 1; @@ -396,14 +400,14 @@ h1 { .tool[data-tool="pinch"] { --tool-icon: none; } .tool[data-tool="pinch"]::before { - content: "\1f90f "; + content: "\1F90F"; background: none; font-size: 25px; line-height: 1; filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); } .tool[data-tool="pinch"]::after { - content: "\1f90f "; + content: "\1F90F"; background: none; font-size: 48px; line-height: 1; @@ -418,14 +422,14 @@ h1 { } .tool[data-tool="love_mochi"] { --tool-icon: none; } .tool[data-tool="love_mochi"]::before { - content: "\1f495 "; + content: "\1F495"; background: none; font-size: 24px; line-height: 1; filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); } .tool[data-tool="love_mochi"]::after { - content: "\1f495 "; + content: "\1F495"; background: none; font-size: 46px; line-height: 1; @@ -440,14 +444,14 @@ h1 { } .tool[data-tool="fight_mochi"] { --tool-icon: none; } .tool[data-tool="fight_mochi"]::before { - content: "\1f4a2 "; + content: "\1F4A2"; background: none; font-size: 24px; line-height: 1; filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); } .tool[data-tool="fight_mochi"]::after { - content: "\1f4a2 "; + content: "\1F4A2"; background: none; font-size: 46px; line-height: 1; @@ -472,7 +476,7 @@ h1 { .hint { color: var(--muted); font-size: 12px; - line-height: 1.55; + line-height: 1.40; margin: 10px 0 0; } @@ -582,11 +586,36 @@ meter { .selected-info { font-size: 13px; - line-height: 1.55; + line-height: 1.40; } .selected-info.empty { color: var(--muted); } + +.selected-name-editor { + margin: 0 0 8px; +} +.selected-name-input { + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(103,178,44,0.34); + border-radius: 13px; + background: rgba(246,252,238,0.96); + color: var(--ink); + font-size: 18px; + font-weight: 700; + line-height: 1.25; + padding: 8px 10px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.82); +} +.selected-name-input:focus { + outline: 2px solid rgba(103,178,44,0.24); + border-color: rgba(103,178,44,0.62); + background: #fff; +} +.selected-effects { + margin: 2px 0 6px; +} .badge { display: inline-block; font-size: 11px; @@ -605,6 +634,29 @@ meter { padding: 5px 0; } .info-row span:first-child { color: var(--muted); } +.personality-values strong { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 2px 8px; + max-width: 68%; + text-align: right; +} +.personality-values strong span { + color: var(--ink); + white-space: nowrap; +} +.personality-tag { + display: inline-block; + border-radius: 999px; + padding: 2px 7px; + background: rgba(103,178,44,0.10); + border: 1px solid rgba(103,178,44,0.22); +} +.personality-tag.neutral { + background: rgba(120,110,90,0.08); + border-color: rgba(120,110,90,0.16); +} .log-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; } .log { @@ -1250,8 +1302,8 @@ body::before { display: block; opacity: 0.96; } -.tool[data-tool="poke"]::before { content: "👈"; } -.tool[data-tool="pinch"]::before { content: "🤏"; } +.tool[data-tool="poke"]::before { content: "\1F448"; } +.tool[data-tool="pinch"]::before { content: "\1F90F"; } .tool[data-tool="poke"]::after, .tool[data-tool="pinch"]::after { background: none; @@ -1268,8 +1320,8 @@ body::before { width: 54px; height: 54px; } -.tool[data-tool="poke"]::after { content: "👈"; } -.tool[data-tool="pinch"]::after { content: "🤏"; } +.tool[data-tool="poke"]::after { content: "\1F448"; } +.tool[data-tool="pinch"]::after { content: "\1F90F"; } .tool[data-tool="poke"] span, .tool[data-tool="pinch"] span { display: none; } @@ -1277,7 +1329,7 @@ body::before { /* 15.3.0: shower uses emoji; nest box and fences use appearance-matched assets/hitboxes. */ .tool[data-tool="water_hose"] { --tool-icon: none; } .tool[data-tool="water_hose"]::before { - content: "🚿"; + content: "\1F6BF"; background: none; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-size: 24px; @@ -1287,7 +1339,7 @@ body::before { opacity: 0.96; } .tool[data-tool="water_hose"]::after { - content: "🚿"; + content: "\1F6BF"; background: none; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-size: 46px; @@ -1300,3 +1352,67 @@ body::before { bottom: auto; transform: translateY(-50%); } + +.relation-list-row { + align-items: start; +} +.relation-list-row > strong { + min-width: 0; + width: 100%; + justify-content: flex-end; +} +.relation-chip-list { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; + max-width: 100%; +} +.relation-chip { + display: inline-flex; + align-items: center; + gap: 4px; + max-width: 138px; + min-width: 0; + border: 1px solid rgba(104, 150, 84, 0.24); + border-radius: 999px; + background: rgba(250, 255, 242, 0.88); + color: #4d6140; + padding: 2px 6px 2px 2px; + font: inherit; + font-size: 11px; + line-height: 1.2; + cursor: pointer; + box-shadow: 0 1px 0 rgba(255,255,255,0.74); +} +.relation-chip.enemy { + border-color: rgba(178, 92, 74, 0.28); + background: rgba(255, 246, 240, 0.92); + color: #754739; +} +.relation-chip:hover, +.relation-chip:focus-visible { + transform: translateY(-1px); + background: rgba(255,255,255,0.98); + outline: 2px solid rgba(118, 186, 72, 0.18); +} +.relation-chip.enemy:hover, +.relation-chip.enemy:focus-visible { + outline-color: rgba(190, 92, 72, 0.18); +} +.relation-chip .relation-face { + width: 22px; + height: 22px; +} +.relation-chip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.relation-chip em { + font-style: normal; + font-size: 10px; + opacity: 0.72; + flex: 0 0 auto; +} diff --git a/docs/prompt.txt b/docs/prompt.txt index dc86a4a..dc90944 100644 --- a/docs/prompt.txt +++ b/docs/prompt.txt @@ -1,485 +1,265 @@ -You are working on the Tarinai observation game. +You are modifying an existing JavaScript browser game project. -Base input: +Implement a personality system for the \u201ctarinai\u201d creatures. Keep the implementation conservative and debuggable. Do not add extra systems beyond the specification below. -* Start from `tarinai_colony_game_fixed_v6.zip`. -* Modify the project files directly. -* Return a new zip. -* Do not remove existing gameplay features unless explicitly instructed. -* Preserve the existing Japanese UI text style. -* After changes, run syntax checks on all JavaScript files and verify zip integrity. -* Bump the cache-busting version in `index.html` so browsers do not keep loading old JS/CSS. +## Goal -Primary goals: -Fix the remaining complexity, behavior mismatch, redundant logic, and fragmented-spec issues identified in the v6 audit. Implement all items below. Do not skip any item. +Add four continuous personality parameters to each tarinai: ---- +* aggression +* openness +* sociability +* neuroticism -## 1. Family tree update performance +Each parameter ranges from `-1.0` to `+1.0`. -Current issue: -The family tree update still performs heavy synchronous preprocessing, causing the main game to freeze. +Personality should affect behavior only when the value crosses clear thresholds. Small changes should be recorded internally, but observation logs should only be written when a threshold is crossed. -Implement: +## Personality axes -* Split heavy preprocessing into small chunks. -* Use `requestIdleCallback` when available, with a safe `setTimeout` fallback. -* Avoid doing large graph construction, signature generation, relationship counting, or DOM creation in one blocking pass. -* The game loop must remain responsive while family tree data is prepared. +### aggression -Update timing rules: +Range: -* When the family tree panel is hidden: +* low side: calm +* high side: irritable - * Do not update every 5 seconds. - * Check/update at most once every 30 seconds. -* When the family tree panel is visible: +Behavior: - * Do not update on a fixed 5-second timer. - * Update only when the family tree is dirty, meaning a relevant change occurred. - * Relevant dirty events include at least: +* High aggression makes the tarinai more likely to threaten others, start fights, or retaliate. +* Low aggression makes the tarinai less likely to start fights. - * birth - * death - * parent/child relationship creation - * mate/partner relationship creation or change - * name/identity data change that affects the family tree display -* Add or use a clear dirty flag such as `familyTreeDirty`. -* Mark dirty from the source of the change, not by repeatedly recomputing expensive signatures. -* If a background update is already running, do not start another one. -* If a newer dirty event occurs while an update is running, schedule one additional update after the current update completes. +Changes: -Expected result: +* Winning fights should slightly increase aggression. +* Losing fights should slightly decrease aggression. -* Opening or updating the family tree should not freeze the main simulation. -* Hidden family tree work should be very rare. -* Visible family tree should refresh promptly after actual changes. +### openness ---- +Range: -## 2. Coward personality condition +* low side: conservative +* high side: playful -Current issue: -The “coward” condition may count total fight results and per-relationship fight results together, causing double counting. +Behavior: -Implement: +* High openness makes the tarinai more likely to wander farther, investigate new objects, and play with toys such as balls. +* Low openness makes the tarinai prefer familiar or nearby areas. -* The 5% chance to become `臆病` must be based only on total fight wins and total fight losses. -* Do not include per-relationship win/loss counts in this condition. -* Trigger condition: +Changes: - * The Tarinai has just taken damage. - * Its total fight losses are greater than its total fight wins. - * Then it has a 5% chance to change personality to `臆病`. -* Avoid changing the personality repeatedly if it is already `臆病`. +* Being petted by the cursor should slightly increase openness. -Expected result: +### sociability -* The calculation uses only aggregate lifetime fight result counters. -* No double counting. +Range: ---- +* low side: loner +* high side: social -## 3. Disease fight initiation and targeting rules +Behavior: -Implement the following disease fight rules exactly: +* High sociability makes the tarinai more likely to approach other tarinai, especially parents, children, friends, or the cursor. +* High sociability also makes the tarinai more likely to react when a parent, child, or friend is fighting nearby. +* Low sociability makes the tarinai prefer being alone. -Fight initiation: +Changes: -* Tarinai with `爆発病` can initiate fights. -* Tarinai with `ずんち病` can initiate fights. -* Tarinai with any other disease must not initiate fights. -* Healthy Tarinai may initiate fights according to the existing rules. +* Spending time near parents, children, or friends should slightly increase sociability. -Being targeted / challenged: +### neuroticism -* Tarinai with `ねむり病` must not be targeted for fights. -* Tarinai with any other disease may still be targeted/challenged by another Tarinai. -* This means: +Range: - * `爆発病` and `ずんち病`: can initiate and can be targeted. - * Other non-sleep diseases: cannot initiate, but can be targeted. - * `ねむり病`: cannot initiate and cannot be targeted. +* low side: insensitive / unfazed +* high side: delicate / sensitive -Apply consistently to: +Behavior: -* Normal fight selection. -* Forced fight logic. -* Fight-food effects such as `けんか餅`. -* Any helper such as `canStartFight`, `canBeFightTarget`, or equivalent. +* High neuroticism makes the tarinai gain more stress from unpleasant stimuli such as corpses, poop, nearby fights, or overcrowding. +* Low neuroticism makes the tarinai less affected by such stimuli. -Expected result: +Changes: -* Disease rules are not handled by scattered ad-hoc checks. -* Use clear helper functions if they do not already exist. +* Touching poop or corpses should still increase stress immediately. +* However, repeated exposure should slightly reduce neuroticism over time, representing habituation. +* This means tarinai can become less sensitive to unpleasant stimuli after repeated exposure. ---- +Important: -## 4. Collision and external-force unification +* Do not treat \u201ccowardice\u201d as a direct low-aggression state. +* If a cowardly tag is needed, derive it as a compound state from high neuroticism and low aggression. +* Do not implement a separate cowardice parameter. -Current issues: +## Birth personality and current personality -* Ball speed cap was removed, but some collision detection still uses capped sweep/search distances. -* Very fast balls can still tunnel through objects. -* Explosion knockback and ball knockback can be reduced by normal movement speed caps. -* Knockback, collision damage, explosion impulse, and normal movement are handled inconsistently. +Each tarinai should have two personality objects: -Implement: +```js +birthPersonality +currentPersonality +``` -* Separate normal self-propelled movement from external impulse/knockback. -* External impulses must not be clamped by the normal Tarinai walking speed cap. -* Add or refactor toward helper methods such as: +Rules: - * `applyImpulse(entity, vx, vy, options)` - * `applyKnockback(entity, sourceX, sourceY, strength, options)` - * `applyImpactDamage(target, amount, cause)` - * `resolveCircleCircleCollision(...)` - * `resolveCircleRectCollision(...)` -* Exact helper names are flexible, but the behavior must be centralized and not duplicated. +* `birthPersonality` is created at birth and normally does not change. +* `currentPersonality` starts equal to `birthPersonality`. +* Experience modifies only `currentPersonality`. +* The tarinai data UI should show both birth personality and current personality. -Unlimited ball speed: +## Inheritance -* Do not reintroduce a ball speed cap. -* Collision detection must handle high-speed balls using swept movement based on actual frame movement distance. -* Remove fixed low `Math.min(...)` collision-search caps that contradict unlimited ball speed. -* Ball vs wall, ball vs fence, ball vs nest box, ball vs ball, and ball vs Tarinai should remain reliable at high speed. +When a child is born: -High-speed ball vs Tarinai: +```text +child birth personality = + average of the parents' current personality + + random variation between -0.2 and +0.2 for each parameter +``` -* When a sufficiently fast ball hits a Tarinai: +Clamp each resulting value to `-1.0` through `+1.0`. - * Knock the Tarinai away using velocity equal to 1/5 of the ball velocity. - * This knockback must be an external impulse, not normal walking movement. - * Do not let the normal “play with ball” behavior also run for that same impact if it would conflict. - * If the damage from this impact kills the Tarinai, death cause must be `衝突`. +Then: -High-speed Tarinai vs Tarinai: +```js +child.currentPersonality = child.birthPersonality +``` -* When a sufficiently fast Tarinai hits another Tarinai: +The child should pass its changed current personality to future descendants, not only its original birth personality. - * The hit Tarinai should also be knocked away. - * Use the attacker’s actual movement/impulse direction and speed. - * If collision damage kills a Tarinai, death cause must be `衝突`. +## Thresholds and personality tags -Explosion knockback: +Use two threshold levels: -* Make explosion knockback stronger than before. -* Use external impulse, not normal movement velocity. -* Knockback should be partly random. -* Tarinai closer to the blast center should be thrown faster and farther. -* Tarinai farther from the blast center should be thrown less strongly. -* Preserve or improve existing explosion damage behavior. -* If an explosion kills a Tarinai, keep the appropriate explosion-related death cause, not `衝突`. +```text +-1.0 to -0.75: strong low-side trait +-0.75 to -0.5: slight low-side trait +-0.5 to +0.5: neutral, no visible personality tag ++0.5 to +0.75: slight high-side trait ++0.75 to +1.0: strong high-side trait +``` -Expected result: +Display tags: -* Normal walking speed remains bounded. -* Knockback from explosions and collisions can exceed walking speed. -* High-speed objects do not frequently tunnel through collisions. -* Collision behavior is handled by shared primitives rather than several unrelated implementations. +* Slight traits should be displayed as \u201cslightly X\u201d. +* Strong traits should be displayed as \u201cX\u201d. +* Neutral values should not create a visible tag. ---- +Example: -## 5. Ball-to-ball collision +* aggression `+0.62` -> \u201cslightly irritable\u201d +* aggression `+0.83` -> \u201cirritable\u201d +* sociability `-0.58` -> \u201cslightly loner\u201d +* sociability `-0.82` -> \u201cloner\u201d +* openness `+0.20` -> no tag -Ensure balls collide with each other robustly. +Behavior activation: -Requirements: +* Slight traits affect relevant behavior with a 50% chance when choosing a behavior. +* Strong traits affect relevant behavior with a 100% chance when choosing a behavior. +* Do not check this every frame. Apply it only during behavior selection or when evaluating a relevant event. -* Ball vs ball collision must use circle-circle collision. -* Resolve overlap. -* Exchange or reflect velocity in a physically plausible way. -* Work with high-speed balls, not only overlapping balls. -* Avoid creating infinite acceleration or NaN velocity. -* Do not apply arbitrary maximum speed caps. +## Daily personality change limit ---- +Add a daily change cap so personality values cannot change too quickly. -## 6. Solid obstacle and placement logic unification +Recommended limits: -Current issues: -Fence, nest box, and placement preview logic are still partly fragmented. +```text +Per parameter: + maximum increase per day: +0.08 + maximum decrease per day: -0.08 -Implement: +Across all parameters: + maximum total absolute personality change per day: 0.20 +``` -* Unify all solid rectangular obstacle collision logic. -* Fence and nest box should use the same obstacle system where possible. -* Rename misleading helpers if needed: +If a change would exceed the daily limit, reduce or ignore the excess change. - * For example, functions named only for “fence” but now handling nest boxes too should be renamed or wrapped with neutral names such as `solidObstacle`. -* Ball, Tarinai movement, path blocking, and placement checks should reference the same obstacle data. +Reset these daily counters at the start of each new in-game day. -Fence collision: +## Observation log rules -* Fence collision bounds must match the visible fence appearance. -* If the visual fence is not the same size as the previous hitbox, update the hitbox to match the visual footprint. -* Avoid separate visual size and collision size unless explicitly documented in one definition table. +Write personality change entries to the observation log only when a threshold is crossed. -Nest box collision: +Log when: -* Preserve the existing 3×3 nest box hitbox rule: +* a value crosses `+0.5` +* a value crosses `+0.75` +* a value crosses `-0.5` +* a value crosses `-0.75` +* a value returns from a visible trait range back into neutral - * If the nest box is divided into a 3×3 grid, the collidable cells are: +Do not log every small personality change. - * top-left - * top-center - * top-right - * middle-left - * middle-right - * The middle-center, bottom-left, bottom-center, and bottom-right cells are passable/enterable according to the current nest behavior. -* Ball must bounce off these nest box solid cells. -* Tarinai must not path through these solid cells. +Example log messages: -Placement preview: +```text +\u201cMochi\u201d has become slightly irritable after winning fights. +\u201cMimi\u201d has become playful after spending time with toys. +\u201cNoko\u201d has become less sensitive after repeated contact with corpses. +\u201cTari\u201d no longer seems especially social. +``` -* The preview highlight must use the exact same placement validation as actual placement. -* If the preview is red, clicking must not place the object. -* If the preview is green, clicking should place the object at the previewed position. -* Do not silently clamp or auto-correct an out-of-bounds placement to an in-bounds location. -* If the planned placement would extend outside the field, the highlight must turn red. -* Fix the issue where a fence preview extending outside the field does not turn red. -* The highlighted preview position should show the true placement footprint, not a different corrected footprint. +Use the existing observation log system if one exists. -Expected result: +## Tarinai data UI -* Placement UI, actual placement, and collision behavior agree. -* No “red but placeable” or “green but placed elsewhere” behavior. +Update the tarinai data UI to show personality information clearly. ---- +It should include: -## 7. Item/tool definition unification +1. Birth personality +2. Current personality +3. Visible personality tags derived from current personality -Current issues: -Tool names, labels, icons, sizes, placement rules, and descriptions are scattered across multiple files. +Example structure: -Implement: +```text +Personality Tags: +slightly irritable / social -* Create or consolidate a single source of truth for tool/item definitions. -* It should include at least: +Birth Personality: +Aggression: -0.12 +Openness: +0.31 +Sociability: +0.48 +Neuroticism: +0.66 - * internal id - * Japanese label - * tooltip text - * UI icon or icon renderer - * game appearance reference where applicable - * placement size / footprint - * whether it is placeable - * whether it is scaleable, if applicable - * collision shape if applicable -* Use this definition in the tool UI, placement preview, placement logic, and logs where appropriate. -* Do not leave conflicting duplicate label/icon/size definitions in separate files unless unavoidable. +Current Personality: +Aggression: +0.57 +Openness: +0.34 +Sociability: +0.76 +Neuroticism: +0.22 +``` -Specific icon requirements: +Keep the UI compact. Do not create a large new panel unless necessary. -* `つつく` must use `👈`. -* `つまむ` must use `🤏`. -* `洗浄` must use the shower emoji `🚿`. -* Remove or stop referencing old PNG icons for `つつく` and `つまむ`. -* Fix the nest box tool UI icon so it matches the actual in-game nest box appearance. -* Ensure every tool that appears in the tool UI has an icon. -* Avoid mojibake/garbled icon rendering. +## Implementation constraints -Water tool: +* Keep the code simple and localized. +* Reuse existing behavior, logging, and UI systems where possible. +* Avoid adding unrelated features. +* Avoid adding complex new simulation systems. +* Preserve existing save data as much as possible. +* If old save data lacks personality fields, initialize them safely. +* Clamp all personality values to `-1.0` through `+1.0`. +* Add helper functions where useful, such as: -* `水` must remain removed from the tool UI. -* If water still exists as a game object produced by rain or other simulation systems, keep that object behavior. -* Remove leftover UI-only water tool entries, labels, tooltips, and placement handlers unless they are still required by non-UI simulation. + * `ensurePersonality(tarinai)` + * `adjustPersonality(tarinai, key, delta, reason)` + * `getPersonalityTraitTags(tarinai)` + * `getTraitStrength(value)` + * `shouldApplyPersonalityBehavior(tarinai, key, direction)` -Expected result: +## Validation -* Tool UI no longer has missing icons, old icons, or mismatched nest box visuals. -* Tool data is not scattered unnecessarily. +After implementation: ---- - -## 8. Observation UI event filtering - -Current issue: -Observation UI hides some events by matching Japanese text strings, which is fragile. - -Implement: - -* Remove from the observation UI: - - * events about poking a ball - * events of the form “X avoided Y” / `○○が××を避けた` -* Prefer event type/category filtering over string matching. -* If event objects do not have a type field, add one at event creation. -* Keep the events available internally if needed, but do not show them in the observation UI. - -Expected result: - -* Future wording changes do not break event filtering. - ---- - -## 9. Death cause normalization - -Ensure these death causes are applied consistently: - -* Death by old age / lifespan: - - * `天寿を全うした` -* Tarinai deleted because it went too far outside the field: - - * `仕様により画面外で削除` -* Death caused by ball collision or Tarinai collision damage: - - * `衝突` -* Explosion death: - - * Keep explosion-related cause. -* Existing disease-related deaths: - - * Preserve current intended disease death causes unless they conflict with the above. - -Implementation: - -* Centralize death cause normalization if possible. -* Avoid scattered caller-side strings that can drift. -* Ensure the family tree, observation UI, and any memorial/death display show the normalized cause. - ---- - -## 10. Out-of-bounds object policy - -Current issue: -Different object types handle out-of-bounds behavior differently. - -Implement a unified out-of-bounds policy: - -* For objects or entities slightly outside the field: - - * Return them to the nearest valid in-field position. -* For objects or entities far outside the field, or with invalid coordinates such as NaN/Infinity: - - * Delete them. -* For Tarinai deleted by this system: - - * Death cause must be `仕様により画面外で削除`. -* For non-living objects: - - * Delete without a death cause. -* Keep wall bounce behavior for balls at normal field boundaries, but still sanitize balls that somehow get far outside or invalid coordinates. - -Expected result: - -* No permanent invisible offscreen objects. -* No NaN entities poisoning the simulation. - ---- - -## 11. Observation relationship highlight - -Current issue: -Relationship highlight during observation can be affected by sprite rotation. - -Implement: - -* Relationship highlights must be drawn in world/screen space independent of the Tarinai sprite rotation. -* Do not draw the highlight inside a rotated sprite transform. -* Ensure highlight position follows the Tarinai but does not rotate with the Tarinai body. - -Expected result: - -* Highlight remains visually stable regardless of sprite rotation. - ---- - -## 12. Nest box behavior preservation - -Preserve and verify previous nest box fixes: - -* Tarinai inside the nest box should be fully transparent in the main field. -* Nest box can contain up to 5 Tarinai. -* Nest box occupant popup should show each individual on a separate line. -* The nest box solid hitbox must follow the 3×3 rule described above. -* Ball should bounce off nest box solid hitbox cells. - -Do not regress these behaviors while refactoring obstacles. - ---- - -## 13. Encyclopedia / ecology page - -Preserve and verify the `たりないの生態` addition: - -* It must include an explanation of health. -* It must include an explanation of stress. -* It must use a Tarinai sprite/image with both the health bar and stress bar visible. -* Do not break existing encyclopedia entries. - ---- - -## 14. Naming and Japanese text preservation - -Preserve previous Japanese terminology changes: - -* `喧嘩傷病` must remain renamed to `きずつき病`. -* `寿命` death text must display as `天寿を全うした`. -* Use the existing tone/style for Japanese labels and descriptions. - -Do not reintroduce removed or old names. - ---- - -## 15. Code quality expectations - -Refactor carefully: - -* Prefer small, well-named helper functions. -* Avoid adding more scattered special cases. -* Avoid duplicating collision math. -* Avoid duplicating item/tool metadata. -* Avoid string-matching for gameplay logic where structured data is possible. -* Preserve save compatibility where possible. -* If save migration is needed, add a safe fallback. - -Add comments only where they clarify non-obvious game rules, especially: - -* disease fight rules -* nest box 3×3 collision cells -* external impulse vs normal movement -* family tree dirty/update scheduling - ---- - -## 16. Validation checklist - -Before returning the zip, verify at minimum: - -Static checks: - -* Run `node --check` on every JavaScript file. -* Ensure there are no `ReferenceError` risks from renamed helpers. -* Ensure old cache version references are updated. - -Manual or scripted behavior checks where practical: - -* Ball vs ball collision works. -* Fast ball hitting Tarinai knocks it by ball velocity / 5. -* Fast collision death cause is `衝突`. -* Explosion throws nearby Tarinai farther than distant Tarinai and is not clamped by walking speed. -* Fence preview turns red when the footprint leaves the field. -* Red placement preview cannot place an object. -* Green placement preview places at the same footprint shown. -* Nest box collision uses the required 3×3 cells. -* Ball bounces off nest box solid cells. -* Family tree hidden update is no more frequent than every 30 seconds. -* Family tree visible update happens when dirty, not every 5 seconds. -* Family tree processing does not freeze the game. -* `臆病` condition uses only total wins/losses. -* `爆発病` and `ずんち病` can initiate fights. -* Other diseases cannot initiate fights. -* `ねむり病` cannot initiate and cannot be targeted. -* Other non-sleep diseases can be targeted. -* Observation UI does not show ball-poke events or avoid events. -* Tool icons are correct: `👈`, `🤏`, `🚿`, and nest box matching in-game appearance. -* `水` is absent from the tool UI. -* Lifespan death cause is `天寿を全うした`. -* Far-outside Tarinai deletion cause is `仕様により画面外で削除`. - -Deliverable: - -* Return a zip named something like `tarinai_colony_game_fixed_v7.zip`. -* Include a concise summary of changed areas and validation results. +* Run syntax checks for all JavaScript files. +* Verify that old tarinai without personality data do not crash the game. +* Verify that personality values are clamped. +* Verify that daily limits work. +* Verify that threshold crossing creates observation logs. +* Verify that minor personality changes do not spam logs. +* Verify that tarinai data UI shows birth personality, current personality, and visible tags. +* Verify that children inherit from parents' current personality with \u00b10.2 random variation. diff --git a/index.html b/index.html index 1ca64b0..1a7885d 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ たりない観察 - +
@@ -37,7 +37,7 @@
@@ -165,20 +165,20 @@ - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/js/data.js b/js/data.js index b681204..6c4fe60 100644 --- a/js/data.js +++ b/js/data.js @@ -33,6 +33,68 @@ const DECOR_ASSETS = [ { id: "zunchi", path: "assets/objects/zunchi.png" }, { id: "zunchi_02", path: "assets/objects/zunchi_02.png" }, ]; + +const TOOL_DEFINITIONS = Object.freeze({ + observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.png", 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.png", 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" }, + new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.png", 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}", icon: "assets/ui/tool_water_hose.png", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" }, + zunchi: { id: "zunchi", itemType: "zunchi", label: "\u305a\u3093\u3061", placeable: true, scalable: true, radius: 14, amount: 240, icon: "assets/ui/tool_zunchi.png", 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.png", 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.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + fight_mochi: { id: "fight_mochi", itemType: "fight_mochi", label: "\u3051\u3093\u304b\u9905", placeable: true, scalable: true, radius: 13, amount: 62, icon: "assets/ui/tool_fight_mochi.png", tooltip: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u3051\u3093\u304b\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002" }, + water_bowl: { id: "water_bowl", itemType: "water_bowl", label: "\u6c34\u306e\u76bf", placeable: true, scalable: false, radius: 20, amount: 999, icon: "assets/ui/tool_water_bowl.png", tooltip: "\u885b\u751f\u7684\u306a\u6c34\u304c\u5165\u3063\u305f\u76bf\u3002\u4e00\u90e8\u306e\u75c5\u6c17\u3092\u6cbb\u305b\u308b\u3002" }, + sleep_drug: { id: "sleep_drug", itemType: "sleep_drug", label: "\u306d\u3080\u308a\u85ac", placeable: true, scalable: true, radius: 13, amount: 58, icon: "assets/ui/tool_sleep_drug.png", tooltip: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002" }, + grass: { id: "grass", itemType: "grass", label: "\u8349", placeable: true, scalable: true, radius: 17, amount: 120, icon: "assets/ui/tool_grass.png", tooltip: "\u98df\u3079\u7269\u3002\u305f\u308a\u306a\u3044\u304c\u5b89\u5fc3\u3059\u308b\u3002" }, + stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.png", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" }, + bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.png", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" }, + 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.png", 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" }, + ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.png", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" }, + firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.png", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\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.png", 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.png", 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 }, + trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true }, + splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true }, + food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true }, +}); + +function toolDefinition(id = "") { + return TOOL_DEFINITIONS[id] || null; +} + +function toolLabel(id = "") { + const def = toolDefinition(id); + return def?.label || id || ""; +} + +function toolItemType(id = "") { + const def = toolDefinition(id); + return def?.placeable ? (def.itemType || def.id) : null; +} + +function itemDefinition(type = "") { + return Object.values(TOOL_DEFINITIONS).find(def => (def.itemType || def.id) === type) || null; +} + +function itemRadiusFor(type = "", fallback = 12) { + return itemDefinition(type)?.radius ?? fallback; +} + +function itemAmountFor(type = "", fallback = 80) { + return itemDefinition(type)?.amount ?? fallback; +} + +function scalableToolIds() { + return Object.values(TOOL_DEFINITIONS).filter(def => def.scalable).map(def => def.id); +} + +function toolTipsFromDefinitions() { + return Object.fromEntries(Object.entries(TOOL_DEFINITIONS).filter(([, def]) => def.tooltip).map(([id, def]) => [id, def.tooltip])); +} + const ALPHA_BOUNDS = { smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 }, angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 }, @@ -106,4 +168,3 @@ const LIGHTING_TUNING = { goldenOverlayAlpha: 0.045, bloomWashAlpha: 0.040, }; - diff --git a/js/health.js b/js/health.js index 7c74473..1e3f7a9 100644 --- a/js/health.js +++ b/js/health.js @@ -10,12 +10,12 @@ const HEALTH = (() => { poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f", firecracker: "\u7206\u7af9", accident: "\u4e8b\u6545", - collision: "衝突", - outOfBounds: "仕様により画面外で削除", - lifespan: "天寿を全うした", + collision: "\u885d\u7a81", + outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664", + lifespan: "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f", sleepDisease: "\u306d\u3080\u308a\u75c5", explosionDisease: "\u7206\u767a\u75c5", - fightDisease: "きずつき病", + fightDisease: "\u304d\u305a\u3064\u304d\u75c5", zunchiDisease: "\u305a\u3093\u3061\u75c5", }); const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/; @@ -26,12 +26,12 @@ const HEALTH = (() => { if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, ""); if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker; - if (/仕様により画面外で削除|画面外で削除|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds; - if (/衝突|collision/.test(text)) return CAUSES.collision; + if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds; + if (/\u885d\u7a81|collision/.test(text)) return CAUSES.collision; if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke; if (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease; if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease; - if (/\u3051\u3093\u304b\u75c5|喧嘩傷病|きずつき病|fight.*disease/.test(text)) return CAUSES.fightDisease; + if (/\u3051\u3093\u304b\u75c5|\u304d\u305a\u3064\u304d\u75c5|fight.*disease/.test(text)) return CAUSES.fightDisease; if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease; if (/\u75c5/.test(text)) { const disease = text.match(DISEASE_RE); @@ -40,7 +40,7 @@ const HEALTH = (() => { if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger; if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress; if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return CAUSES.accident; - if (/\u5bff\u547d|天寿を全うした/.test(text)) return CAUSES.lifespan; + if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return CAUSES.lifespan; return ""; } @@ -121,6 +121,7 @@ const HEALTH = (() => { cause = rememberDamage(t, actualLoss, reason); if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f"); + if (t.wakeFromDamage) t.wakeFromDamage(cause || reason); if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason); t.showHpBar(); } diff --git a/js/items.js b/js/items.js index 094f29d..0e78cf4 100644 --- a/js/items.js +++ b/js/items.js @@ -1,18 +1,23 @@ -// tarinai_colony_game build 15.4.0: ball collisions, fence hitboxes, and archive throttling +// tarinai_colony_game build 15.6.2: food servings, nest box access, and editable tarinai data "use strict"; +const FOOD_SERVING_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"]); +const FOOD_SERVINGS_BY_SIZE = { small: 1, medium: 5, large: 15 }; +function foodServingsForSize(size = "medium") { + return FOOD_SERVINGS_BY_SIZE[size] || FOOD_SERVINGS_BY_SIZE.medium; +} +function isServingFoodType(type = "") { + return FOOD_SERVING_TYPES.has(type); +} + class Item { constructor(type, x, y) { this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; this.type = type; this.x = x; this.y = y; - this.r = { - food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14 - }[type] || 12; - this.amount = { - food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, water: 64, water_bowl: 999, grass: 120, stone: 999, bed: 999, nest_box: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240 - }[type] || 80; + this.r = itemRadiusFor(type, 12); + this.amount = itemAmountFor(type, 80); this.age = 0; this.seed = Math.random() * 1000; this.dropTimer = 0; @@ -57,6 +62,13 @@ class Item { this.fuseTimer = 5; this.fuseMax = 5; } + if (isServingFoodType(type)) { + this.toolSize = "medium"; + this.foodServingScale = 1; + this.foodServingsMax = foodServingsForSize(this.toolSize); + this.foodServingsRemaining = this.foodServingsMax; + this.amount = this.foodServingsRemaining; + } } update(dt, worldRef = world) { this.age += dt; @@ -69,7 +81,7 @@ class Item { } } if (this.type === "water") this.amount -= dt * 0.9; - if (["sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(this.type)) this.amount -= dt * 0.12; + if (["sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(this.type) && !Number.isFinite(this.foodServingsRemaining)) this.amount -= dt * 0.12; if (this.type === "trace") this.amount -= dt * 1.35; if (this.type === "splat") this.amount -= dt * 1.05; if (this.type === "firecracker") { @@ -445,7 +457,7 @@ class Item { type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed, stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness, growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost, lifeSpan: this.lifeSpan, wither: this.wither, - comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax, + comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, foodServingScale: this.foodServingScale, foodServingsMax: this.foodServingsMax, foodServingsRemaining: this.foodServingsRemaining, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax, vx: this.vx, vy: this.vy, prevX: this.prevX, prevY: this.prevY, spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId, lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle @@ -459,12 +471,8 @@ class Item { it.type = type; it.x = o.x; it.y = o.y; - it.r = { - food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14 - }[type] || 12; - it.amount = o.amount ?? ({ - food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58, water: 64, water_bowl: 999, grass: 120, stone: 999, bed: 999, nest_box: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240 - }[type] || 80); + it.r = itemRadiusFor(type, 12); + it.amount = o.amount ?? itemAmountFor(type, 80); it.age = o.age || 0; it.seed = o.seed || Math.random() * 1000; it.dropTimer = 0; @@ -534,6 +542,17 @@ class Item { it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2); it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi"; it.toolSize = o.toolSize || it.toolSize || "medium"; + it.foodServingScale = o.foodServingScale || it.foodServingScale || ({ small: 0.68, medium: 1.0, large: 1.48 }[it.toolSize] || 1); + if (isServingFoodType(it.type)) { + const servingMax = foodServingsForSize(it.toolSize); + it.foodServingsMax = Number.isFinite(o.foodServingsMax) ? o.foodServingsMax : servingMax; + if (Number.isFinite(o.foodServingsRemaining)) { + it.foodServingsRemaining = clamp(o.foodServingsRemaining, 0, it.foodServingsMax); + } else { + it.foodServingsRemaining = Math.max(1, Math.min(it.foodServingsMax, Math.ceil(it.foodServingsMax * clamp((o.amount ?? it.amount) / 70, 0.05, 1)))); + } + it.amount = it.foodServingsRemaining; + } it.blastScale = o.blastScale || it.blastScale || 1; it.fuseTimer = o.fuseTimer ?? it.fuseTimer; it.fuseMax = o.fuseMax ?? it.fuseMax; @@ -548,7 +567,8 @@ class Item { const warm = lightState.warmth; const styleNight = lightState.light < 0.42; const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55); - const visibleAlpha = clamp(this.amount / 40, 0.24, 1); + const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null; + const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1); const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0; const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0; const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18)); @@ -869,4 +889,3 @@ class Item { ctx.restore(); } } - diff --git a/js/render.js b/js/render.js index 4270e76..1b59734 100644 --- a/js/render.js +++ b/js/render.js @@ -124,51 +124,30 @@ function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) { return; } -const PLACEABLE_TOOL_TYPES = Object.freeze({ - zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", sleep_drug: "sleep_drug", water: "water", water_bowl: "water_bowl", grass: "grass", - stone: "stone", bed: "bed", nest_box: "nest_box", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h", -}); - function placementPreviewFor(world) { const p = world?.pointer; - const type = PLACEABLE_TOOL_TYPES[world?.tool || ""]; + const type = toolItemType(world?.tool || ""); if (!p?.inside || !type) return null; const pad = CONFIG.worldPadding || 30; - let x = clamp(p.x, pad, world.w - pad); - let y = clamp(p.y, pad, world.h - pad); - const r = { - food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, zunchi: 14 - }[type] || 12; + let x = p.x; + let y = p.y; + const r = itemRadiusFor(type, 12); const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad)); const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad; let blocked = false; - if (type === "grass" && world.findGrassPlantingSpot) { - const spot = world.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 16 }); - if (!spot) return { type, x, y, r, blocked: true }; - x = spot.x; - y = spot.y; - blocked = circleOutside(x, y, Math.max(14, r * 1.55)); - } + let tmp = { type, x, y, r, dead: false }; if ((type === "fence_v" || type === "fence_h") && world.fenceRect) { - const raw = { type, x, y, r }; - const rawRect = world.fenceRect(raw); - blocked = rectOutside(rawRect); - const tmp = { type, x, y, r }; - const rect = world.fenceRect(tmp); - if (rect) { - const dx = Math.max(pad - rect.left, 0) - Math.max(rect.right - (world.w - pad), 0); - const dy = Math.max(pad - rect.top, 0) - Math.max(rect.bottom - (world.h - pad), 0); - tmp.x += dx; - tmp.y += dy; - return { type, x: tmp.x, y: tmp.y, r, rect: world.fenceRect(tmp), blocked: blocked || world.fencePlacementBlocked?.(raw) || world.fencePlacementBlocked?.(tmp) }; - } + const rawRect = world.fenceRect(tmp); + return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) }; } if (type === "nest_box" && world.nestBoxBaseRect) { - const tmp = { type, x, y, r, dead: false }; const rect = world.nestBoxBaseRect(tmp); - return { type, x, y, r, rect, blocked: rectOutside(rect) }; + return { type, x, y, r, rect, blocked: rectOutside(rect) || world.placementBlocked?.(tmp) }; } - if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))); + if (type === "grass") { + blocked = !world.grassSpotOpen?.(x, y, { avoidTarinai: true }) || circleOutside(x, y, Math.max(14, r * 1.55)); + } + if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25))) || world.placementBlocked?.(tmp); return { type, x, y, r, blocked }; } @@ -773,7 +752,8 @@ function drawNestBoxTooltip(ctx, world) { const info = world.pointerNestBoxInfo?.(); if (!info) return; const box = info.box; - const title = "\u5de3\u7bb1"; + const capacity = info.capacity || world.nestBoxCapacity?.(box) || 5; + const title = `\u5de3\u7bb1 (${info.occupants.length}/${capacity})\u5339`; const lines = info.occupants.length ? [title, ...info.occupants.map(t => t.name)] : [title, "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044"]; @@ -823,10 +803,9 @@ function drawSelectedCard(ctx, world, lighting) { const child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : ""; const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : ""; ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35); - const personality = t.personalityLabel ? t.personalityLabel() : (t.personality || "\u4e0d\u660e"); - ctx.fillText(`\u6027\u683c ${personality}`, x + 10, y + 52); const genLabel = (t.displayGeneration && t.displayGeneration()) ? ` \u4e16\u4ee3 ${t.generation}` : ""; - ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 68); + ctx.fillText(`\u6c17\u5206 ${fmt(t.mood)}${genLabel} \u4f53\u529b ${fmt(t.energy)}`, x + 10, y + 52); + ctx.fillText(`\u30b9\u30c8\u30ec\u30b9 ${fmt(t.stress)} \u7a7a\u8179 ${fmt(t.hunger)}`, x + 10, y + 68); ctx.restore(); } diff --git a/js/sim_core.js b/js/sim_core.js index 89706e0..43fb3bb 100644 --- a/js/sim_core.js +++ b/js/sim_core.js @@ -64,19 +64,189 @@ function stableUnit(seed, salt = "") { return ((h >>> 0) % 100000) / 100000; } -const PERSONALITIES = { - timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00, play: 0.78 }, - glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42, play: 0.84 }, - sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92, play: 0.68 }, - lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00, play: 1.08 }, - irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94, play: 0.82 }, - playful: { label: "\u904a\u3073\u597d\u304d", desc: "\u30dc\u30fc\u30eb\u3084\u52d5\u304f\u3082\u306e\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3084\u3059\u3044", fear: 0.92, fight: 0.74, social: 1.10, relation: 1.04, sleep: 0.94, zunda: 0.96, play: 1.72 }, - calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94, play: 0.86 }, -}; -const PERSONALITY_IDS = Object.keys(PERSONALITIES); +// Legacy named personalities were removed. Keep these stubs only so old save data +// and old UI fallback references cannot revive the pre-four-axis personality model. +const PERSONALITIES = Object.freeze({}); +const PERSONALITY_IDS = []; function personalityForSeed(seed) { - return stableChoice(seed, "personality", PERSONALITY_IDS) || "calm"; + return ""; +} + +const PERSONALITY_KEYS = ["aggression", "openness", "sociability", "neuroticism"]; +const PERSONALITY_DAILY_LIMIT = { perAxis: 0.08, total: 0.20 }; +const FRIEND_AFFINITY_THRESHOLD = 16; +const PERSONALITY_LABELS = { + aggression: { label: "\u653b\u6483\u6027", low: "\u304a\u3060\u3084\u304b", high: "\u6012\u308a\u3063\u307d\u3044" }, + openness: { label: "\u958b\u653e\u6027", low: "\u4fdd\u5b88\u7684", high: "\u904a\u3073\u597d\u304d" }, + sociability: { label: "\u793e\u4ea4\u6027", low: "\u4e00\u4eba\u597d\u304d", high: "\u4ef2\u9593\u597d\u304d" }, + neuroticism: { label: "\u795e\u7d4c\u8cea\u6027", low: "\u7121\u9813\u7740", high: "\u7e4a\u7d30" }, +}; + +function clampPersonalityValue(value) { + return clamp(Number(value) || 0, -1, 1); +} + +function normalizePersonality(source = null) { + const out = {}; + for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(source?.[key]); + return out; +} + +function randomBirthPersonality(seed = "") { + const out = {}; + for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(stableUnit(seed, `personality-${key}`) * 1.2 - 0.6); + return out; +} + +function personalityFromParents(a, b) { + const pa = ensurePersonality(a).currentPersonality; + const pb = ensurePersonality(b).currentPersonality; + const out = {}; + for (const key of PERSONALITY_KEYS) out[key] = clampPersonalityValue(((pa[key] || 0) + (pb[key] || 0)) / 2 + rand(-0.2, 0.2)); + return out; +} + +function getTraitStrength(value) { + const v = clampPersonalityValue(value); + if (v >= 0.75) return { direction: 1, strength: "strong" }; + if (v >= 0.5) return { direction: 1, strength: "slight" }; + if (v <= -0.75) return { direction: -1, strength: "strong" }; + if (v <= -0.5) return { direction: -1, strength: "slight" }; + return { direction: 0, strength: "neutral" }; +} + +function personalityBand(value) { + const t = getTraitStrength(value); + return t.direction === 0 ? "neutral" : `${t.direction > 0 ? "high" : "low"}-${t.strength}`; +} + +function personalityEffectValue(value) { + const trait = getTraitStrength(value); + if (!trait.direction) return 0; + return trait.direction * (trait.strength === "strong" ? 1 : 0.5); +} + +function personalityReasonText(reason = "") { + const key = String(reason || "").replace(/[.\u3002]+$/g, "").trim(); + const map = { + "after winning fights": "\u55a7\u5629\u306b\u52dd\u3063\u305f\u5f71\u97ff\u3067", + "after losing fights": "\u55a7\u5629\u306b\u8ca0\u3051\u305f\u5f71\u97ff\u3067", + "after being petted": "\u64ab\u3067\u3089\u308c\u305f\u5f71\u97ff\u3067", + "after staying near family": "\u89aa\u5b50\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067", + "after staying near friends": "\u53cb\u9054\u306e\u8fd1\u304f\u3067\u904e\u3054\u3057\u305f\u5f71\u97ff\u3067", + "after having no friends": "\u53cb\u9054\u304c\u3044\u306a\u3044\u6642\u9593\u304c\u7d9a\u3044\u305f\u5f71\u97ff\u3067", + "after repeated contact with poop": "\u305a\u3093\u3061\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067", + "after repeated contact with corpses": "\u6b7b\u9ab8\u306b\u6163\u308c\u305f\u5f71\u97ff\u3067", + "after being hurt": "\u75db\u307f\u3092\u899a\u3048\u305f\u5f71\u97ff\u3067", + }; + return map[key] || key; +} + +function personalityBecomeText(label = "") { + const text = String(label || ""); + if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u304f"; + return text; +} + +function personalityAppearanceText(label = "") { + const text = String(label || ""); + if (text === "\u6012\u308a\u3063\u307d\u3044") return "\u6012\u308a\u3063\u307d\u3044\u69d8\u5b50"; + return `${text}\u306a\u69d8\u5b50`; +} + +function getPersonalityTraitTags(tarinai) { + const p = ensurePersonality(tarinai).currentPersonality; + const tags = []; + for (const key of PERSONALITY_KEYS) { + const trait = getTraitStrength(p[key]); + if (!trait.direction) continue; + const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key; + tags.push(trait.strength === "slight" ? `\u5c11\u3057${label}` : label); + } + const cowardly = (p.neuroticism || 0) >= 0.75 && (p.aggression || 0) <= -0.5; + if (cowardly && !tags.includes("\u81c6\u75c5")) tags.push("\u81c6\u75c5"); + return tags; +} + +function personalityDailyState(tarinai) { + const day = tarinai?.world?.day || 1; + const state = tarinai.personalityDaily || {}; + if (state.day !== day) { + tarinai.personalityDaily = { day, total: 0, byKey: {} }; + return tarinai.personalityDaily; + } + state.byKey = state.byKey || {}; + for (const key of Object.keys(state.byKey)) { + const v = state.byKey[key]; + if (typeof v === "number") state.byKey[key] = { up: Math.max(0, v), down: Math.max(0, -v) }; + else state.byKey[key] = { up: Math.max(0, Number(v?.up) || 0), down: Math.max(0, Number(v?.down) || 0) }; + } + state.total = Math.max(0, Number(state.total) || 0); + tarinai.personalityDaily = state; + return state; +} + +function ensurePersonality(tarinai) { + if (!tarinai) return { birthPersonality: normalizePersonality(), currentPersonality: normalizePersonality() }; + if (!tarinai.birthPersonality) tarinai.birthPersonality = randomBirthPersonality(tarinai.id || Math.random()); + tarinai.birthPersonality = normalizePersonality(tarinai.birthPersonality); + tarinai.currentPersonality = normalizePersonality(tarinai.currentPersonality || tarinai.birthPersonality); + personalityDailyState(tarinai); + return { birthPersonality: tarinai.birthPersonality, currentPersonality: tarinai.currentPersonality }; +} + +function personalityThresholdLogText(tarinai, key, value, reason = "", previousBand = "") { + const trait = getTraitStrength(value); + const name = tarinai?.name || "\u305f\u308a\u306a\u3044"; + if (!trait.direction) { + const oldSide = String(previousBand).startsWith("low") ? "low" : "high"; + const oldLabel = PERSONALITY_LABELS[key]?.[oldSide] || key; + return `\u300c${name}\u300d\u306f${personalityAppearanceText(oldLabel)}\u304c\u76ee\u7acb\u305f\u306a\u304f\u306a\u3063\u305f\u3002`; + } + const label = PERSONALITY_LABELS[key]?.[trait.direction > 0 ? "high" : "low"] || key; + const visible = trait.strength === "slight" ? `\u5c11\u3057${personalityBecomeText(label)}` : personalityBecomeText(label); + const prefix = personalityReasonText(reason); + return `\u300c${name}\u300d\u306f${prefix ? `${prefix}\u3001` : ""}${visible}\u306a\u3063\u305f\u3002`; +} + +function adjustPersonality(tarinai, key, delta, reason = "") { + if (!tarinai || !PERSONALITY_KEYS.includes(key)) return 0; + ensurePersonality(tarinai); + const requested = Number(delta) || 0; + if (!requested) return 0; + const state = personalityDailyState(tarinai); + const dir = Math.sign(requested); + const used = state.byKey[key] || { up: 0, down: 0 }; + const dirKey = dir > 0 ? "up" : "down"; + const keyRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.perAxis - (used[dirKey] || 0)); + const totalRoom = Math.max(0, PERSONALITY_DAILY_LIMIT.total - (state.total || 0)); + const allowed = Math.min(Math.abs(requested), keyRoom, totalRoom); + if (allowed <= 0) return 0; + const before = tarinai.currentPersonality[key] || 0; + const beforeBand = personalityBand(before); + const applied = dir * allowed; + const after = clampPersonalityValue(before + applied); + const actual = after - before; + if (!actual) return 0; + tarinai.currentPersonality[key] = after; + state.byKey[key] = used; + used[actual > 0 ? "up" : "down"] = (used[actual > 0 ? "up" : "down"] || 0) + Math.abs(actual); + state.total = (state.total || 0) + Math.abs(actual); + const afterBand = personalityBand(after); + if (beforeBand !== afterBand) { + const text = personalityThresholdLogText(tarinai, key, after, reason, beforeBand); + tarinai.world?.log?.(text, "observe"); + tarinai.addRecord?.(text, "observe"); + } + return actual; +} + +function shouldApplyPersonalityBehavior(tarinai, key, direction = 1) { + const p = ensurePersonality(tarinai).currentPersonality; + const trait = getTraitStrength(p[key]); + if (trait.direction !== Math.sign(direction || 1)) return false; + return trait.strength === "strong" || Math.random() < 0.5; } function relationDefaults() { @@ -321,4 +491,3 @@ function drawHeartShape(ctx, x, y, size, opts = {}) { ctx.stroke(); ctx.restore(); } - diff --git a/js/tarinai.js b/js/tarinai.js index 84b6eb1..3cfdd80 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -19,19 +19,23 @@ class Tarinai { this.world = world; this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); this.name = opts.name || makeName(world); - this.personality = opts.personality || personalityForSeed(this.id); + // Legacy string personality is intentionally ignored. The game now uses only the four-axis personality model. + this.personality = ""; this.type = opts.type || baseSpriteId(); const typeMeta = SPRITES.find(s => s.id === this.type); if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId(); + this.birthPersonality = normalizePersonality(opts.birthPersonality || opts.personalityBirth); + if (!opts.birthPersonality && !opts.personalityBirth) this.birthPersonality = randomBirthPersonality(this.id); + this.currentPersonality = normalizePersonality(opts.currentPersonality || opts.personalityCurrent || this.birthPersonality); + this.personalityDaily = opts.personalityDaily && typeof opts.personalityDaily === "object" ? JSON.parse(JSON.stringify(opts.personalityDaily)) : null; + ensurePersonality(this); this.trait = makeTrait(this.type); - const pdef = PERSONALITIES[this.personality] || PERSONALITIES.calm; - this.trait.social *= pdef.social ?? 1; - this.trait.stress /= pdef.fear ?? 1; - this.trait.sleep *= pdef.sleep ?? 1; this.x = opts.x ?? rand(100, world.w - 100); this.y = opts.y ?? rand(100, world.h - 100); this.vx = opts.vx ?? rand(-12, 12); this.vy = opts.vy ?? rand(-12, 12); + this.impulseVx = opts.impulseVx ?? 0; + this.impulseVy = opts.impulseVy ?? 0; this.scale = opts.scale ?? rand(0.22, 0.33); this.radius = 56 * this.scale; this.age = opts.age ?? rand(0, 20); @@ -109,6 +113,11 @@ class Tarinai { this.fightDiseaseCooldown = opts.fightDiseaseCooldown ?? 0; this.lastBleedAt = opts.lastBleedAt ?? -999; this.favorite = Boolean(opts.favorite); + this.insideNestBoxId = opts.insideNestBoxId || null; + this.nestFade = opts.nestFade ?? (this.insideNestBoxId ? 1 : 0); + this.nestBoxEnteredAt = opts.nestBoxEnteredAt ?? (this.insideNestBoxId ? (world?.time || 0) : -Infinity); + this.nestBoxStayUntil = opts.nestBoxStayUntil ?? (this.insideNestBoxId ? ((world?.time || 0) + 10) : -Infinity); + this.focusPulseTimer = opts.focusPulseTimer ?? 0; if (this.isZunchiSlave) this.name = "\u305a\u3093\u3061\u3069\u308c\u3044"; this.tempComfort = opts.tempComfort ?? 0.62; this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55; @@ -167,7 +176,7 @@ class Tarinai { birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader, pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed, totalFightLosses: this.totalFightLosses, totalFightWins: this.totalFightWins, isZunchiSlave: this.isZunchiSlave, formerName: this.formerName, loveMochiTimer: this.loveMochiTimer, fightMochiTimer: this.fightMochiTimer, - sleepDisease: this.sleepDisease, sleepDiseaseCooldown: this.sleepDiseaseCooldown, explosionDisease: this.explosionDisease, explosionDiseaseTimer: this.explosionDiseaseTimer, fightDisease: this.fightDisease, fightDiseaseCooldown: this.fightDiseaseCooldown, lastBleedAt: this.lastBleedAt, favorite: this.favorite, insideNestBoxId: this.insideNestBoxId || null, nestFade: this.nestFade || 0, sleepDiseaseAnchorX: this.sleepDiseaseAnchorX, sleepDiseaseAnchorY: this.sleepDiseaseAnchorY, records: this.records ? this.records.slice(0, 42) : [], + sleepDisease: this.sleepDisease, sleepDiseaseCooldown: this.sleepDiseaseCooldown, explosionDisease: this.explosionDisease, explosionDiseaseTimer: this.explosionDiseaseTimer, fightDisease: this.fightDisease, fightDiseaseCooldown: this.fightDiseaseCooldown, lastBleedAt: this.lastBleedAt, favorite: this.favorite, insideNestBoxId: this.insideNestBoxId || null, nestFade: this.nestFade || 0, nestBoxEnteredAt: this.nestBoxEnteredAt, nestBoxStayUntil: this.nestBoxStayUntil, focusPulseTimer: this.focusPulseTimer || 0, sleepDiseaseAnchorX: this.sleepDiseaseAnchorX, sleepDiseaseAnchorY: this.sleepDiseaseAnchorY, records: this.records ? this.records.slice(0, 42) : [], zunchiDisease: this.zunchiDisease, zunchiDiseaseSeverity: this.zunchiDiseaseSeverity, zunchiDiseaseCooldown: this.zunchiDiseaseCooldown, tempComfort: this.tempComfort, tempTimer: this.tempTimer, parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime, @@ -176,7 +185,8 @@ class Tarinai { nextHeadbutt: this.nextHeadbutt, digest: this.digest, poopCount: this.poopCount, stretchTimer: this.stretchTimer, grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer, defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId, - relationships: this.relationships, personality: this.personality, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir, + relationships: this.relationships, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir, + birthPersonality: normalizePersonality(this.birthPersonality), currentPersonality: normalizePersonality(this.currentPersonality), personalityDaily: this.personalityDaily || null, blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer, lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer, nextCursorHeartAt: this.nextCursorHeartAt, cursorPetting: this.cursorPetting, entryTimer: this.entryTimer || 0, @@ -225,9 +235,9 @@ class Tarinai { } - addRecord(text, kind = "note") { + addRecord(text, kind = "note", options = {}) { if (!text) return; - if (this.world?.shouldHideFromObservationRecords?.(text)) return; + if (options.hiddenFromObservation) return; if (!Array.isArray(this.records)) this.records = []; const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" }; const head = this.records[0]; @@ -237,13 +247,33 @@ class Tarinai { } personalityProfile() { - return PERSONALITIES[this.personality] || PERSONALITIES.calm; + const base = { label: "\u4e2d\u7acb", fear: 1, fight: 1, social: 1, relation: 1, sleep: 1, zunda: 1, play: 1 }; + const p = ensurePersonality(this).currentPersonality; + const e = { + neuroticism: personalityEffectValue(p.neuroticism), + aggression: personalityEffectValue(p.aggression), + sociability: personalityEffectValue(p.sociability), + openness: personalityEffectValue(p.openness), + }; + return { + ...base, + fear: clamp((base.fear || 1) * (1 + e.neuroticism * 0.38), 0.55, 1.85), + fight: clamp((base.fight || 1) * (1 + e.aggression * 0.55), 0.35, 2.35), + social: clamp((base.social || 1) * (1 + e.sociability * 0.24), 0.62, 1.52), + relation: clamp((base.relation || 1) * (1 + e.sociability * 0.24), 0.62, 1.60), + play: clamp((base.play || 1) * (1 + e.openness * 0.45), 0.45, 2.10), + }; } personalityLabel() { - return this.personalityProfile().label || this.personality || "\u4e0d\u660e"; + const tags = getPersonalityTraitTags(this); + return tags.length ? tags.join(" / ") : "\u4e2d\u7acb"; } + personalityTags() { return getPersonalityTraitTags(this); } + adjustPersonality(key, delta, reason = "") { return adjustPersonality(this, key, delta, reason); } + shouldApplyPersonalityBehavior(key, direction = 1) { return shouldApplyPersonalityBehavior(this, key, direction); } + fightWinCount() { return this.totalFightWins || 0; } @@ -253,17 +283,17 @@ class Tarinai { } maybeBecomeTimidAfterDamage(cause = "") { - if (this.dead || this.personality === "timid") return false; + if (this.dead) return false; const losses = this.totalFightLosses || 0; const wins = this.totalFightWins || 0; if (losses <= wins) return false; if (Math.random() >= 0.05) return false; - this.personality = "timid"; + 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); - this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.35, "こわい…", "rgba(90,80,120,0.78)"); - this.addRecord?.(`${this.name}は痛みで臆病になった。`, "health"); - return true; + this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.35, "\u3053\u308f\u3044\u2026", "rgba(90,80,120,0.78)"); + return Boolean(changedA || changedB); } relationTo(id) { @@ -285,7 +315,7 @@ class Tarinai { strongestRelation(kind = "friend") { let bestId = null; - let bestScore = kind === "fear" ? 5 : 8; + let bestScore = kind === "fear" ? 5 : FRIEND_AFFINITY_THRESHOLD; for (const [id, rel] of Object.entries(this.relationships || {})) { const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); if (score > bestScore) { bestId = id; bestScore = score; } @@ -299,7 +329,23 @@ class Tarinai { return { friend, fear }; } - bestFriendLive(minScore = 10, maxDist = Infinity) { + currentFriendCount(minScore = FRIEND_AFFINITY_THRESHOLD, liveOnly = true) { + let count = 0; + const liveIds = liveOnly ? new Set((this.world?.tarinai || []).filter(o => o && !o.dead && o !== this).map(o => o.id)) : null; + for (const [id, rel] of Object.entries(this.relationships || {})) { + if ((rel.affinity || 0) <= minScore) continue; + if (liveIds && !liveIds.has(id)) continue; + count += 1; + } + return count; + } + + sociabilityFriendScale() { + const friends = this.currentFriendCount ? this.currentFriendCount(FRIEND_AFFINITY_THRESHOLD, true) : 0; + return clamp(0.82 + Math.min(6, friends) * 0.16, 0.82, 1.78); + } + + bestFriendLive(minScore = FRIEND_AFFINITY_THRESHOLD, maxDist = Infinity) { let best = null, bestScore = minScore; for (const [id, rel] of Object.entries(this.relationships || {})) { const score = rel.affinity || 0; @@ -369,6 +415,24 @@ class Tarinai { normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); } damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); } + wakeFromDamage(reason = "") { + if (this.dead) return false; + const wasSleeping = this.state === "sleep" || this.sleeping || this.insideNestBoxId; + if (!wasSleeping) return false; + const box = this.nestBoxById ? this.nestBoxById(this.insideNestBoxId) : null; + this.sleeping = false; + this.state = "panic"; + this.target = null; + this.thought = "\u30c0\u30e1\u30fc\u30b8\u3067\u76ee\u304c\u899a\u3081\u305f"; + this.fearTimer = Math.max(this.fearTimer || 0, 0.85); + this.hurtTimer = Math.max(this.hurtTimer || 0, 1.2); + this.nestBoxStayUntil = -Infinity; + if (this.insideNestBoxId) this.leaveNestBox(box); + this.vx += rand(-34, 34); + this.vy += rand(-26, 8); + return true; + } + recoverHealth(amount) { if (this.dead) return; this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100); @@ -406,7 +470,7 @@ class Tarinai { } targetIgnoresFence(target = this.target) { - return Boolean(target?.type === "sweet" || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); + return Boolean(target?.type === "sweet" || target?.type === "nest_box" || (target?.type === "bed" && (this.state === "seek_bed" || this.state === "sleep")) || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy"); } shouldAvoidTarget(target) { @@ -647,9 +711,9 @@ class Tarinai { this.fightDisease = true; this.fightDiseaseCooldown = 18; this.fearTimer = Math.max(this.fearTimer || 0, 0.35); - this.thought = "きずつき病\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "?", "rgba(150,78,44,0.80)"); - this.world?.log?.(`${this.name}\u306fきずつき病\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight"); + this.world?.log?.(`${this.name}\u306f\u304d\u305a\u3064\u304d\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight"); return true; } @@ -657,10 +721,10 @@ class Tarinai { if (!this.fightDisease) return false; this.fightDisease = false; this.fightDiseaseCooldown = 20; - this.thought = reason || "きずつき病\u304c\u6cbb\u3063\u305f"; + this.thought = reason || "\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f"; this.stress = clamp(this.stress - 12, 0, 130); 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きずつき病\u304c\u6cbb\u3063\u305f\u3002`, "fight"); + this.world?.log?.(`${this.name}\u306e\u304d\u305a\u3064\u304d\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight"); return true; } @@ -684,34 +748,112 @@ class Tarinai { return true; } - updateNestBoxPresence(dt = 0) { - const previous = this.insideNestBoxId || null; - let box = null; - if (previous) box = this.world?.items?.find?.(it => it && !it.dead && it.id === previous && it.type === "nest_box") || null; - if (!box && this.target && !this.target.dead && this.target.type === "nest_box") box = this.target; - if (!box) { - this.insideNestBoxId = null; - this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1); - return null; - } - const d = distXY(this.x, this.y, box.x, box.y); - const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; + nestBoxById(id) { + if (!id) return null; + return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null; + } + + isInsideNestBox() { + return Boolean(this.insideNestBoxId); + } + + wantsNestBox(box = null) { + if (!box || box.dead || box.type !== "nest_box") return false; + if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) return true; + if (!(this.state === "sleep" || this.state === "seek_bed")) return false; + return this.target === box || this.insideNestBoxId === box.id; + } + + enterNestBox(box, dt = 0.016) { + if (!box || box.dead || box.type !== "nest_box" || this.dead) return false; const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5; - const occupantIndex = occupants.indexOf(this); - const hasRoom = occupantIndex >= 0 ? occupantIndex < capacity : occupants.length < capacity; - const canEnter = hasRoom && (this.state === "sleep" || this.state === "seek_bed") && d <= Math.max(18, box.r * 0.58); - if (canEnter) { - this.insideNestBoxId = box.id; - this.nestFade = clamp((this.nestFade || 0) + dt * 2.4, 0, 1); - const pull = clamp(dt * 4.2, 0, 1); - this.x = lerp(this.x, box.x, pull); - this.y = lerp(this.y, box.y, pull); - this.vx *= Math.pow(0.45, dt * 60); - this.vy *= Math.pow(0.45, dt * 60); - return box; + const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; + const alreadyInside = this.insideNestBoxId === box.id; + if (!alreadyInside && occupants.length >= capacity) return false; + if (!alreadyInside) { + this.nestBoxEnteredAt = this.world?.time || 0; + this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, (this.world?.time || 0) + 14); + } + this.insideNestBoxId = box.id; + this.target = box; + if (this.state === "seek_bed") this.state = "sleep"; + if (this.state === "sleep") this.sleeping = true; + const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y }; + const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1); + this.x = lerp(this.x, inner.x, pull); + this.y = lerp(this.y, inner.y, pull); + if (this.nestFade >= 0.88 || distXY(this.x, this.y, inner.x, inner.y) < 3.5) { + this.x = inner.x; + this.y = inner.y; + } + this.nestFade = clamp((this.nestFade || 0) + dt * 5.8, 0, 1); + this.vx = 0; + this.vy = 0; + this.impulseVx = 0; + this.impulseVy = 0; + return true; + } + + leaveNestBox(box = null) { + const b = box || this.nestBoxById(this.insideNestBoxId); + if (b && this.world?.nestBoxEntryPoint) { + const entry = this.world.nestBoxExitPoint ? this.world.nestBoxExitPoint(b, this) : this.world.nestBoxEntryPoint(b); + this.x = clamp(entry.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding); + this.y = clamp(entry.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding); + this.vx = rand(-8, 8); + this.vy = rand(6, 18); } this.insideNestBoxId = null; - this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1); + this.nestFade = 0; + this.nestBoxEnteredAt = -Infinity; + this.nestBoxStayUntil = -Infinity; + return true; + } + + updateNestBoxPresence(dt = 0) { + const currentBox = this.nestBoxById(this.insideNestBoxId); + const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null; + const box = currentBox || targetBox; + if (!box) { + this.insideNestBoxId = null; + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + } + + if (currentBox && !this.wantsNestBox(currentBox)) { + this.leaveNestBox(currentBox); + return null; + } + + if (currentBox) { + if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) { + this.state = "sleep"; + this.sleeping = true; + this.target = currentBox; + } + this.enterNestBox(currentBox, dt); + return currentBox; + } + + if (!this.wantsNestBox(targetBox)) { + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); + return null; + } + + const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y }; + const base = this.world?.nestBoxBaseRect ? this.world.nestBoxBaseRect(targetBox) : null; + const r = targetBox.r || 42; + const entryD = distXY(this.x, this.y, entry.x, entry.y); + const centerD = distXY(this.x, this.y, targetBox.x, targetBox.y); + const inDoorColumn = base && this.x >= base.left + r * 0.18 && this.x <= base.right - r * 0.18 && this.y >= base.top + r * 0.06 && this.y <= base.bottom + r * 0.48; + const captureRange = Math.max(118, r * 2.05, this.radius + 82); + const nearEnough = entryD <= captureRange || centerD <= Math.max(104, r * 1.90) || inDoorColumn; + if (nearEnough) { + this.enterNestBox(targetBox, dt || 0.016); + return targetBox; + } + + this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1); return null; } @@ -756,7 +898,7 @@ class Tarinai { if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident"); } if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) { - this.recoverFightDisease("\u81ea\u7136\u306bきずつき病\u304c\u6cbb\u3063\u305f"); + 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); @@ -849,7 +991,7 @@ class Tarinai { 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" || ["lonely", "calm", "playful", "teary", "cry"].includes(this.personality); + return this.affection > 12 || this.mood > 58 || this.goodMode === "smile" || this.shouldApplyPersonalityBehavior("sociability", 1); } applyCursorContactCare(dt) { @@ -861,6 +1003,7 @@ class Tarinai { 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); + 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); this.goodMode = "smile"; @@ -914,6 +1057,7 @@ class Tarinai { } } this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); + this.focusPulseTimer = Math.max(0, (this.focusPulseTimer || 0) - dt); this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2); if (this.freezeSleepDiseaseMotion(dt)) { this.checkLife(dt); @@ -1035,9 +1179,16 @@ class Tarinai { if (parent && dist(this, parent) < 96) { this.stress = clamp(this.stress - dt * 0.42, 0, 130); 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); + 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."); } - const friend = this.bestFriendLive ? this.bestFriendLive(12, 92) : null; - if (friend) this.stress = clamp(this.stress - dt * 0.36, 0, 130); let grassComfort = 0; for (const it of this.world.nearbyItems(this.x, this.y, 105)) { if (it.type !== "grass" || it.dead) continue; @@ -1054,7 +1205,7 @@ class Tarinai { if (grassComfort > 0) { this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); 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.personality === "timid") this.affection += dt * Math.min(0.06, grassComfort * 0.012); + if (this.need === "\u9759\u3051\u3055" || this.shouldApplyPersonalityBehavior("neuroticism", 1)) this.affection += dt * Math.min(0.06, grassComfort * 0.012); } } @@ -1080,10 +1231,44 @@ class Tarinai { return target?.type === "nest_box" ? "\u5de3\u7bb1" : "\u5e72\u8349\u5bdd\u5e8a"; } + isServingFoodItem(it) { + return Boolean(it && typeof isServingFoodType === "function" && isServingFoodType(it.type)); + } + + foodItemHasServingLeft(it) { + if (!it || it.dead) return false; + if (!this.isServingFoodItem(it)) return (it.amount || 0) > 1.2; + this.ensureFoodServings(it); + return (it.foodServingsRemaining || 0) > 0; + } + + ensureFoodServings(it) { + 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; + if (!Number.isFinite(it.foodServingsRemaining)) { + const original = { food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, sleep_drug: 58 }[it.type] || 62; + const ratio = clamp((it.amount ?? original) / original, 0.05, 1); + it.foodServingsRemaining = Math.max(1, Math.ceil(it.foodServingsMax * ratio)); + } + it.foodServingsRemaining = clamp(it.foodServingsRemaining, 0, it.foodServingsMax); + it.amount = it.foodServingsRemaining; + } + + consumeFoodServing(it, nutrition) { + this.ensureFoodServings(it); + if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0; + it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1); + it.amount = it.foodServingsRemaining; + return nutrition; + } + sleepSpotFor(bed) { if (!bed) return { x: this.x, y: this.y }; if (bed.type === "nest_box") { - return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; + if (this.insideNestBoxId === bed.id && this.world?.nestBoxInnerPoint) return this.world.nestBoxInnerPoint(bed, this); + if (this.world?.nestBoxEntryPoint) return this.world.nestBoxEntryPoint(bed); + return { x: clamp(bed.x, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding), y: clamp(bed.y + (bed.r || 42) * 0.24, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding) }; } const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2; const baseRing = bed.r * 0.52; @@ -1128,6 +1313,7 @@ class Tarinai { 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); + 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; } @@ -1182,12 +1368,12 @@ class Tarinai { this.fightWinnerId = null; } - const friendNearby = this.bestFriendLive ? this.bestFriendLive(12, 170) : null; + const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null; if (friendNearby && friendNearby.state === "panic" && friendNearby.target) { this.state = "panic"; this.target = this.panicDestination(friendNearby.target); this.fearTimer = Math.max(this.fearTimer, 0.32); - this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; + this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b"; return; } @@ -1245,11 +1431,11 @@ class Tarinai { 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 && cureFood.amount > 1.2 && !this.shouldAvoidTarget(cureFood)) { + if (cureFood && this.foodItemHasServingLeft(cureFood) && !this.shouldAvoidTarget(cureFood)) { this.state = "seek_food"; this.target = cureFood; this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); - this.thought = this.fightDisease ? "きずつき病\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u7206\u767a\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; + this.thought = this.fightDisease ? "\u304d\u305a\u3064\u304d\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u7206\u767a\u75c5\u3067\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b"; return; } } @@ -1257,14 +1443,14 @@ class Tarinai { if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { this.state = "fight_sick"; this.target = null; - this.thought = "きずつき病\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + this.thought = "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4); return; } if (!this.isZunchiSlave) { const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi", "sleep_drug"], wasSleeping ? 520 : 390); - if (specialMochi && specialMochi.amount > 1.2 && this.hunger > 10 && !this.shouldAvoidTarget(specialMochi)) { + if (specialMochi && this.foodItemHasServingLeft(specialMochi) && this.hunger > 10 && !this.shouldAvoidTarget(specialMochi)) { this.state = "seek_food"; this.target = specialMochi; this.foodReactTimer = Math.max(this.foodReactTimer, 0.34); @@ -1286,7 +1472,7 @@ class Tarinai { 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 && zunda.amount > 1.2 && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { + if (zunda && this.foodItemHasServingLeft(zunda) && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) { this.state = "seek_food"; this.target = zunda; this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); @@ -1306,7 +1492,7 @@ class Tarinai { if (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 && !wasSleeping) bed = this.world.bestBedFor(this, 360); + 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) { @@ -1323,7 +1509,7 @@ class Tarinai { 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 ? "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b"; + 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; } @@ -1355,19 +1541,21 @@ class Tarinai { } const playTrait = this.personalityProfile().play ?? 1; - const ballRange = this.personality === "playful" ? 540 : 240 + 60 * playTrait; + 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 = this.personality === "playful" ? 0.74 : 0.08; + 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 && (this.personality === "playful" || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { + if (chasers < 5 && (openPlay || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) { this.state = "play_ball"; this.target = ball; this.goodMode = "smile"; - this.thought = this.personality === "playful" ? "\u30dc\u30fc\u30eb\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3066\u3044\u308b" : "\u8ee2\u304c\u308b\u3082\u306e\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; + this.thought = openPlay ? "\u30dc\u30fc\u30eb\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3066\u3044\u308b" : "\u8ee2\u304c\u308b\u3082\u306e\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b"; return; } } @@ -1388,7 +1576,7 @@ class Tarinai { this.state = "seek_food"; this.target = friendNearby.target; this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); - this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f"; + this.thought = "\u53cb\u9054\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f"; return; } @@ -1404,8 +1592,10 @@ class Tarinai { } } - if (this.loneliness > 63 - (this.personality === "lonely" ? 16 : 0)) { - const other = this.world.nearestOther(this, 300); + 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.state = "seek_friend"; this.target = other; @@ -1446,14 +1636,13 @@ class Tarinai { const d = dist(this, it); if (d > this.radius + it.r + 12) continue; - if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && it.amount > 1.2) { - const eating = Math.min(it.amount, it.type === "sweet" ? 7.2 : 8.5); + if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) { + const eating = this.consumeFoodServing(it, it.type === "sweet" ? 7.2 : 8.5); if (eating > 0) { this.state = "eat"; this.target = it; this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); - it.amount -= eating; this.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95); this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; @@ -1461,7 +1650,7 @@ class Tarinai { this.stress = clamp(this.stress - eating * 0.42, 0, 130); 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きずつき病\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); @@ -1482,13 +1671,13 @@ class Tarinai { } } - if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug") && it.amount > 1.2) { - const eating = Math.min(it.amount, 6.8); + if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug") && this.foodItemHasServingLeft(it)) { + const eating = this.consumeFoodServing(it, 6.8); + if (eating <= 0) continue; this.state = "eat"; this.target = it; this.vx *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60); - it.amount -= eating; this.hunger -= eating * 0.82; this.recoverHealth(eating * 0.24); this.affection += eating * 0.04; @@ -1497,12 +1686,20 @@ class Tarinai { this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12)); this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)"); this.lastMealColor = "#ff7bab"; - } else { + } else if (it.type === "fight_mochi") { this.stress = clamp(this.stress + eating * 0.22, 0, 130); this.fearTimer = Math.max(this.fearTimer || 0, 0.32); this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18)); this.bubble("!", 2.0, "rgba(180,86,52,0.80)"); this.lastMealColor = "#e07c43"; + } else { + this.stress = clamp(this.stress - eating * 0.10, 0, 130); + this.energy = clamp(this.energy - eating * 0.20, 0, 100); + this.fearTimer = Math.max(0, (this.fearTimer || 0) - 0.18); + this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - 2.5); + this.bubble("Zzz...", 2.3, "rgba(104,84,168,0.78)"); + this.lastMealColor = "#b89cff"; + if (!this.sleepDisease && Math.random() < Math.min(0.98, 0.35 + eating * 0.075)) this.infectSleepDisease(it); } this.eatTimer = Math.max(this.eatTimer, 0.30); this.eatCooldown = rand(0.38, 0.68); @@ -1521,7 +1718,8 @@ class Tarinai { } if (canBite && it.type === "grass") { - const bite = Math.min(it.amount, rand(4.2, 7.2)); + 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.state = "eat"; this.target = it; this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); @@ -1548,10 +1746,14 @@ class Tarinai { 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, 0, 130); + if (!this.isZunchiSlave) { + this.stress = clamp(this.stress + touch * dt * 3.4 * this.personalityProfile().fear, 0, 130); + 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 bite = Math.min(it.amount, this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8)); + 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.state = "eat"; this.target = it; this.vx *= Math.pow(0.18, dt * 60); @@ -1559,7 +1761,8 @@ class Tarinai { it.amount -= bite; 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), 0, 130); + this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130); + 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); @@ -1604,7 +1807,7 @@ class Tarinai { if (it.type === "ball") { const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); - if (this.state === "play_ball" || this.target === it || this.personality === "playful") { + 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); this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); @@ -1612,9 +1815,17 @@ class Tarinai { } } + 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); + 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.stress += dt * 0.06 * this.personalityProfile().fear; } } this.hunger = clamp(this.hunger, 0, 115); @@ -1663,12 +1874,12 @@ class Tarinai { this.loneliness -= dt * 1.65 * this.trait.social; 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) > 10) { + if ((rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) { this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130); 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"); } - if ((rel.affinity || 0) > 8 && this.world.relationNotice(this.id, o.id, "friend", 32)) { + 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"); } if (this.type === "angry") this.stress += dt * 0.08; @@ -1711,10 +1922,14 @@ class Tarinai { const relB = o.relationTo(this.id); const battleDrug = this.hasFightMochiEffect() || o.hasFightMochiEffect(); const loveDrug = this.hasLoveMochiEffect() || o.hasLoveMochiEffect(); - const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || this.personality === "irritable" || o.personality === "irritable" || battleDrug || o.isZunchiSlave || this.isZunchiSlave); + const aggressiveThis = this.shouldApplyPersonalityBehavior("aggression", 1); + 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 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; + const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight * (calmThis ? 0.62 : 1) * (calmOther ? 0.62 : 1); const conflictBias = (battleDrug ? 2.9 : 1) * (o.isZunchiSlave ? 2.2 : 1) * (this.isZunchiSlave ? 1.15 : 1); const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0; const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; @@ -1723,7 +1938,7 @@ class Tarinai { this.world.startConflict(this, o); } - if (!this.isZunchiSlave && !o.isZunchiSlave && d < (loveDrug ? 44 : 36) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) { + if (!this.isZunchiSlave && !o.isZunchiSlave && !this.sleepDisease && !o.sleepDisease && !this.fightDisease && !o.fightDisease && d < (loveDrug ? 44 : 36) && 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)) { @@ -1733,7 +1948,7 @@ class Tarinai { } if (closeCount > 8) { - this.stress += dt * 0.9; + this.stress += dt * 0.9 * this.personalityProfile().fear; this.energy -= dt * 0.25; this.fearTimer = Math.max(this.fearTimer, 0.18); } @@ -1755,6 +1970,12 @@ class Tarinai { 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); this.hunger = clamp(this.hunger + dt * 0.018, 0, 115); + if (this.target && !this.target.dead && this.target.type === "nest_box") { + this.updateNestBoxPresence(dt); + this.vx = 0; + this.vy = 0; + return; + } if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) { const spot = this.sleepSpotFor(this.target); const dx = spot.x - this.x; @@ -1828,9 +2049,13 @@ class Tarinai { const bedDx = this.target.x - this.x; const bedDy = this.target.y - this.y; const bedDist = Math.hypot(bedDx, bedDy); - if (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)) { + const reachedSleepFurniture = this.target.type === "nest_box" + ? (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.state = "sleep"; this.sleeping = true; + if (this.target.type === "nest_box") this.enterNestBox(this.target, dt); return; } } @@ -1890,8 +2115,13 @@ class Tarinai { this.vy = this.vy / v * maxV; } - this.x += this.vx * dt; - this.y += this.vy * dt; + const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0; + const impulseVy = Number.isFinite(this.impulseVy) ? this.impulseVy : 0; + this.x += (this.vx + impulseVx) * dt; + this.y += (this.vy + impulseVy) * dt; + const impulseDrag = Math.pow(0.58, dt * 3.2); + this.impulseVx = Math.abs(impulseVx) < 0.35 ? 0 : impulseVx * impulseDrag; + this.impulseVy = Math.abs(impulseVy) < 0.35 ? 0 : impulseVy * impulseDrag; if (this.entryTimer > 0) { this.entryTimer = Math.max(0, this.entryTimer - dt); @@ -1914,7 +2144,7 @@ class Tarinai { reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545"; } else if (this.hunger >= 108) reason = "\u98e2\u9913"; - else if (this.age >= this.lifeSpan) reason = "天寿を全うした"; + else if (this.age >= this.lifeSpan) reason = "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; else if (this.stress >= 124) { reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; } @@ -2032,7 +2262,7 @@ class Tarinai { const style = { parent: { color: "rgba(86,142,232,0.86)", label: "\u89aa" }, child: { color: "rgba(93,170,92,0.88)", label: "\u5b50" }, - friend: { color: "rgba(240,91,135,0.90)", label: "\u4ef2\u826f\u3057" }, + friend: { color: "rgba(240,91,135,0.90)", label: "\u53cb\u9054" }, enemy: { color: "rgba(214,82,66,0.90)", label: "\u6575\u5bfe" }, }[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" }; ctx.save(); @@ -2070,27 +2300,40 @@ class Tarinai { ctx.restore(); } - - ctx.rotate(angle); - ctx.globalAlpha *= nestHideAlpha; - if (insideCardboard) ctx.globalAlpha *= 0.58; - - if (this.world.selected === this) { ctx.save(); - ctx.globalAlpha = 0.85; - ctx.strokeStyle = "rgba(165, 220, 98, 0.92)"; - ctx.lineWidth = 2; + const pulse = 0.5 + 0.5 * Math.sin(this.world.time * 7.5); + const jumpPulse = clamp(this.focusPulseTimer || 0, 0, 1); + ctx.globalAlpha = (0.82 + pulse * 0.12) * nestHideAlpha; + ctx.shadowColor = "rgba(124, 210, 76, 0.92)"; + ctx.shadowBlur = 14 + 18 * Math.max(pulse, jumpPulse); + ctx.strokeStyle = "rgba(112, 198, 70, 0.98)"; + ctx.lineWidth = 4.2 + jumpPulse * 2.0; ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.62, drawH * 0.48, 0, 0, Math.PI * 2); + ctx.ellipse(0, drawH * 0.14, drawW * (0.74 + jumpPulse * 0.10), drawH * (0.56 + jumpPulse * 0.08), 0, 0, Math.PI * 2); ctx.stroke(); - ctx.setLineDash([5, 4]); - ctx.lineWidth = 1.2; - ctx.strokeStyle = "rgba(255,255,255,0.86)"; + ctx.shadowBlur = 0; + ctx.setLineDash([9, 5]); + ctx.lineWidth = 2.0; + ctx.strokeStyle = "rgba(255,255,255,0.96)"; ctx.beginPath(); - ctx.ellipse(0, drawH * 0.14, drawW * 0.72, drawH * 0.56, 0, 0, Math.PI * 2); + ctx.ellipse(0, drawH * 0.14, drawW * 0.88, drawH * 0.68, 0, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); + ctx.fillStyle = "rgba(255,252,232,0.94)"; + ctx.strokeStyle = "rgba(112,198,70,0.88)"; + ctx.lineWidth = 2; + const focusLabel = "\u6ce8\u76ee"; + ctx.font = `${Math.max(11, this.radius * 0.54)}px Yomogi, ui-rounded, sans-serif`; + const lw = ctx.measureText(focusLabel).width + 18; + const ly = -drawH * 0.64; + tarinaiRoundRectPath(ctx, -lw / 2, ly - 11, lw, 22, 11); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = "rgba(72,126,42,0.96)"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(focusLabel, 0, ly); ctx.restore(); } else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) { ctx.save(); @@ -2103,6 +2346,11 @@ class Tarinai { ctx.restore(); } + ctx.rotate(angle); + ctx.globalAlpha *= nestHideAlpha; + if (insideCardboard) ctx.globalAlpha *= 0.58; + + if (this.eatTimer > 0.02) { ctx.save(); ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); @@ -2235,16 +2483,17 @@ class Tarinai { }; if (stressActive) { const pct = clamp((this.stress || 0) / 130, 0, 1); - drawMeter(baseY - (hpActive ? 11 : 0), pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); + drawMeter(baseY, pct, pct > 0.72 ? "rgba(178,74,110,0.92)" : "rgba(212,126,84,0.90)", clamp((this.stressBarTimer || 0) / 0.7, 0.35, 1)); } if (hpActive) { const pct = clamp(this.energy / 100, 0, 1); - drawMeter(baseY, pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); + drawMeter(baseY - (stressActive ? 11 : 0), pct, pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)"), clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1)); } } } contains(x, y) { + if (this.insideNestBoxId || (this.nestFade || 0) >= 0.92) return false; const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9; return distXY(this.x, this.y, x, y) < hit; } diff --git a/js/text_catalog.js b/js/text_catalog.js index 1fb771b..7e58f38 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -1,76 +1,54 @@ "use strict"; const TEXT_CATALOG = { - toolTips: { - observe: "\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: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002", - poke: "たりないをつつく。", - pinch: "たりないや物を移動させる。", - new: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002", - zunchi: "たりないのうんち。病気を生み出すかも。", - sweet: "たりないの大好物。一部の病気を治せる。", - love_mochi: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u7e41\u6b96\u884c\u52d5\u304c\u8d77\u304d\u3084\u3059\u304f\u306a\u308b\u3002", - fight_mochi: "食後しばらくの間けんかが起きやすくなる。", - water: "\u98f2\u3080\u3068\u6c17\u5206\u56de\u5fa9\u3002\u305a\u3093\u3061\u75c5\u3068\u7206\u767a\u75c5\u3092\u6cbb\u3059\u3002", - water_bowl: "衛生的な水が入った皿。一部の病気を治せる。", - sleep_drug: "\u98df\u3079\u308b\u3068\u306d\u3080\u308a\u75c5\u306b\u306a\u308b\u3002\u30c0\u30e1\u30fc\u30b8\u3067\u5b8c\u6cbb\u3059\u308b\u3002", - grass: "食べ物。たりないが安心する。", - stone: "あぶない。", - bed: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002", - nest_box: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002", - water_hose: "ドラッグで汚れをきれいにする。", - ball: "たりないが遊ぶたのしいおもちゃ。", - firecracker: "爆発を起こす。", - fence_v: "たりないを通せんぼする。", - fence_h: "たりないを通せんぼする。" - }, + toolTips: toolTipsFromDefinitions(), ecologyCards: [ - { - "title": "\u305f\u308a\u306a\u3044\u3068\u306f", - "image": "assets/sprites/tarinai_01_smile.png", - "text": "\u5ead\u3067\u52dd\u624b\u306b\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u304a\u306a\u304b\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\u308b\u3002" - }, - { - "title": "体力とストレス", - "image": "assets/ui/ecology_hp_stress.png", - "text": "体力はけがや衝突、爆発、空腹などで減り、0になると死亡する。ストレスは恐怖、混雑、汚れ、けんかなどで増え、高すぎると行動が乱れたり命に関わる。体力バーとストレスバーは、状態が変化した直後や観察時に表示される。" - }, - { - "title": "\u66ae\u3089\u3057\u3068\u6b32\u6c42", - "image": "assets/sprites/tarinai_05_drool.png", - "text": "\u98df\u3079\u7269\u3001\u6c34\u3001\u5bdd\u5e8a\u3001\u5de3\u7bb1\u3001\u4ef2\u9593\u3092\u63a2\u3057\u3066\u52d5\u304f\u3002\u96e8\u3084\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u65e5\u3005\u306e\u72b6\u614b\u306b\u5f71\u97ff\u3059\u308b\u3002" - }, - { - "title": "\u6027\u683c\u3068\u500b\u6027", - "image": "assets/sprites/tarinai_21_normal_happy.png", - "text": "\u81c6\u75c5\u3001\u98df\u3044\u3057\u3093\u574a\u3001\u7720\u305f\u304c\u308a\u3001\u5bc2\u3057\u304c\u308a\u306a\u3069\u306e\u6027\u683c\u304c\u3042\u308b\u3002\u6027\u683c\u306f\u6050\u6016\u3001\u55a7\u5629\u3001\u7761\u7720\u3001\u4ef2\u9593\u3078\u306e\u5bc4\u308a\u65b9\u306b\u73fe\u308c\u308b\u3002" - }, - { - "title": "\u5bb6\u65cf\u3068\u95a2\u4fc2", - "image": "assets/sprites/tarinai_27_birth_ritual.png", - "text": "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u4ef2\u826f\u3057\u3001\u82e6\u624b\u3001\u6050\u6016\u3092\u8a18\u9332\u3059\u308b\u3002\u5b50\u306f\u89aa\u306e\u7247\u65b9\u306e\u6027\u683c\u3092\u7d99\u304e\u3001\u5bb6\u7cfb\u56f3\u306b\u4e16\u4ee3\u304c\u6b8b\u308b\u3002" - }, - { - "title": "\u75c5\u6c17\u3068\u6cbb\u7642", - "image": "assets/sprites/tarinai_24_stress_dizzy.png", - "text": "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001きずつき病\u304c\u3042\u308b\u3002\u6c34\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u6c34\u3092\u3001\u305a\u3093\u3060\u9905\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3059\u308b\u3002" - }, - { - "title": "\u55a7\u5629\u3068\u4e8b\u6545", - "image": "assets/sprites/tarinai_26_intimidate.png", - "text": "\u5a01\u5687\u3001\u6d41\u8840\u3001\u7206\u7af9\u3001\u30dc\u30fc\u30eb\u3001\u843d\u4e0b\u7269\u3067\u50b7\u3064\u304f\u3002\u6b7b\u56e0\u306f\u55a7\u5629\u3001\u98e2\u9913\u3001\u75c5\u6c17\u3001\u30b9\u30c8\u30ec\u30b9\u904e\u591a\u3001\u7206\u7af9\u3001\u4e8b\u6545\u306a\u3069\u306b\u5206\u304b\u308c\u308b\u3002" - }, - { - "title": "\u9053\u5177\u3068\u4ecb\u5165", - "image": "assets/sprites/tarinai_18_fear.png", - "text": "\u3064\u3064\u304f\u3001\u3064\u307e\u3080\u3001\u9905\u3001\u6c34\u3001\u6d17\u6d44\u3001\u5de3\u7bb1\u3001\u67f5\u3001\u7206\u7af9\u306a\u3069\u3067\u74b0\u5883\u3092\u4f5c\u308b\u3002\u9053\u5177\u306f\u52a9\u3051\u306b\u3082\u5371\u967a\u306b\u3082\u306a\u308b\u3002" - }, - { - "title": "\u89b3\u5bdf\u306e\u65b9\u6cd5", - "image": "assets/sprites/tarinai_11_weak.png", - "text": "\u305f\u308a\u306a\u3044\u9054\u306e\u72b6\u6cc1\u3092\u89b3\u5bdf\u30c4\u30fc\u30eb\u3001\u661f\u30de\u30fc\u30af\u3001\u89b3\u5bdf\u30ed\u30b0\u3001\u5bb6\u7cfb\u56f3\u3001\u6b7b\u56e0\u7b49\u304b\u3089\u77e5\u308b\u3053\u3068\u304c\u51fa\u6765\u308b\u3002" - } - ] + { + title: "\u305f\u308a\u306a\u3044\u3068\u306f", + image: "assets/sprites/tarinai_01_smile.png", + text: "\u5ead\u3067\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u7a7a\u8179\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\u308b\u3002", + }, + { + title: "\u4f53\u529b\u3068\u30b9\u30c8\u30ec\u30b9", + image: "assets/ui/ecology_hp_stress.png", + text: "\u30d0\u30fc\u306f\u4e0a\u304c\u4f53\u529b\u3001\u4e0b\u304c\u30b9\u30c8\u30ec\u30b9\u3002\u4f53\u529b\u306f\u3051\u304c\u3084\u4e8b\u6545\u3067\u6e1b\u308a\u30010\u3067\u6b7b\u4ea1\u3002\u30b9\u30c8\u30ec\u30b9\u306f\u6050\u6016\u3001\u6df7\u96d1\u3001\u6c5a\u308c\u3001\u3051\u3093\u304b\u3067\u5897\u3048\u3001\u9ad8\u3044\u3068\u884c\u52d5\u304c\u4e71\u308c\u308b\u3002", + }, + { + title: "\u66ae\u3089\u3057\u3068\u6b32\u6c42", + image: "assets/sprites/tarinai_05_drool.png", + text: "\u98df\u3079\u7269\u3001\u6c34\u3001\u5bdd\u5e8a\u3001\u5de3\u7bb1\u3001\u4ef2\u9593\u3092\u63a2\u3057\u3066\u52d5\u304f\u3002\u96e8\u3001\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u72b6\u614b\u306b\u5f71\u97ff\u3059\u308b\u3002", + }, + { + title: "\u6027\u683c\u3068\u500b\u6027", + image: "assets/sprites/tarinai_21_normal_happy.png", + text: "\u653b\u6483\u6027\u3001\u958b\u653e\u6027\u3001\u793e\u4ea4\u6027\u3001\u795e\u7d4c\u8cea\u6027\u304c\u7d4c\u9a13\u3067\u5c11\u3057\u305a\u3064\u5909\u308f\u308b\u3002\u95be\u5024\u3092\u8d85\u3048\u308b\u3068\u6027\u683c\u30bf\u30b0\u3068\u884c\u52d5\u5dee\u306b\u51fa\u308b\u3002", + }, + { + title: "\u5bb6\u65cf\u3068\u95a2\u4fc2", + image: "assets/sprites/tarinai_27_birth_ritual.png", + text: "\u89aa\u5b50\u3001\u5171\u540c\u89aa\u3001\u53cb\u9054\u3001\u6575\u5bfe\u3001\u6050\u6016\u3092\u8a18\u9332\u3059\u308b\u3002\u5b50\u306f\u89aa\u306e\u73fe\u5728\u6027\u683c\u3092\u53d7\u3051\u7d99\u304e\u3001\u5bb6\u7cfb\u56f3\u306b\u4e16\u4ee3\u304c\u6b8b\u308b\u3002", + }, + { + title: "\u75c5\u6c17\u3068\u6cbb\u7642", + image: "assets/sprites/tarinai_24_stress_dizzy.png", + text: "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001\u304d\u305a\u3064\u304d\u75c5\u304c\u3042\u308b\u3002\u75c5\u6c17\u3054\u3068\u306b\u6c34\u3084\u305a\u3093\u3060\u9905\u306a\u3069\u3092\u6c42\u3081\u308b\u3002", + }, + { + title: "\u3051\u3093\u304b\u3068\u4e8b\u6545", + image: "assets/sprites/tarinai_26_intimidate.png", + text: "\u5a01\u5687\u3001\u6d41\u8840\u3001\u7206\u7af9\u3001\u30dc\u30fc\u30eb\u3001\u843d\u4e0b\u7269\u3067\u50b7\u3064\u304f\u3002\u6b7b\u56e0\u306f\u3051\u3093\u304b\u3001\u98e2\u9913\u3001\u75c5\u6c17\u3001\u4e8b\u6545\u306a\u3069\u306b\u5206\u304b\u308c\u308b\u3002", + }, + { + title: "\u9053\u5177\u3068\u4ecb\u5165", + image: "assets/sprites/tarinai_18_fear.png", + text: "\u3064\u3064\u304f\u3001\u3064\u307e\u3080\u3001\u9905\u3001\u6c34\u3001\u6d17\u6d44\u3001\u5de3\u7bb1\u3001\u67f5\u3001\u7206\u7af9\u306a\u3069\u3067\u74b0\u5883\u3092\u4f5c\u308b\u3002\u52a9\u3051\u306b\u3082\u5371\u967a\u306b\u3082\u306a\u308b\u3002", + }, + { + title: "\u89b3\u5bdf\u306e\u65b9\u6cd5", + image: "assets/sprites/tarinai_11_weak.png", + text: "\u89b3\u5bdf\u30c4\u30fc\u30eb\u3001\u661f\u30de\u30fc\u30af\u3001\u89b3\u5bdf\u30ed\u30b0\u3001\u5bb6\u7cfb\u56f3\u3001\u6b7b\u56e0\u7d71\u8a08\u304b\u3089\u500b\u4f53\u306e\u5909\u5316\u3092\u8ffd\u3048\u308b\u3002", + }, + ], }; window.TEXT_CATALOG = TEXT_CATALOG; diff --git a/js/ui.js b/js/ui.js index abaa975..c172696 100644 --- a/js/ui.js +++ b/js/ui.js @@ -136,6 +136,100 @@ function relationIconHtml(id) { return ``; } +function relationEntries(t, kind = "friend") { + const rows = []; + const liveIds = new Set((world?.tarinai || []).filter(o => o && !o.dead && o !== t).map(o => o.id)); + for (const [id, rel] of Object.entries(t?.relationships || {})) { + if (!liveIds.has(id)) continue; + const score = kind === "enemy" ? (rel.fear || 0) : (rel.affinity || 0); + const threshold = kind === "enemy" ? 0.5 : FRIEND_AFFINITY_THRESHOLD; + if (score <= threshold) continue; + rows.push({ id, score, name: relationDisplayName(world, id) }); + } + rows.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name, "ja")); + return rows; +} + +function relationListHtml(t, kind = "friend") { + const rows = relationEntries(t, kind); + if (!rows.length) return "\u306a\u3057"; + const sign = kind === "friend" ? "+" : ""; + return `
${rows.map(r => ` + `).join("")}
`; +} + +function focusTarinaiById(id) { + const t = world?.tarinai?.find(o => o && !o.dead && o.id === id); + if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return false; } + world.selected = t; + t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.25); + selectTool("observe"); + if (Number.isFinite(t.x) && Number.isFinite(t.y)) { + world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; + world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; + world.clampCamera?.(); + } + uiCache.selectedSnapshot = ""; + renderSelected(); + renderStats(); + render?.(); + showToast(`\u300c${t.name}\u300d\u3078\u79fb\u52d5\u3057\u307e\u3057\u305f\u3002`); + return true; +} + +function quoteNameInRecordText(text, name) { + const raw = String(text || ""); + const n = String(name || "").trim(); + if (!n) return raw; + const parts = raw.split(n); + if (parts.length <= 1) return raw; + let out = ""; + for (let i = 0; i < parts.length - 1; i++) { + const before = parts[i].slice(-1); + const after = parts[i + 1].charAt(0); + out += parts[i] + ((before === "\u300c" && after === "\u300d") ? n : `\u300c${n}\u300d`); + } + return out + parts[parts.length - 1]; +} + +function renameSelectedTarinai(rawName) { + const t = world.selected; + if (!t || t.dead || !world.tarinai.includes(t)) return false; + const next = String(rawName || "").replace(/\s+/g, " ").trim().slice(0, 18); + if (!next) { + uiCache.selectedSnapshot = ""; + renderSelected(); + return false; + } + if (next === t.name) return false; + const oldName = t.name; + t.name = next; + if (t.isZunchiSlave) t.formerName = next; + world.recordFamily?.(t); + world.markFamilyTreeDirty?.("rename"); + uiCache.archiveFamilyVersion = null; + uiCache.selectedSnapshot = ""; + renderSelected(); + renderArchive?.(); + render?.(); + showToast(`\u300c${oldName}\u300d\u3092\u300c${next}\u300d\u306b\u6539\u540d\u3057\u307e\u3057\u305f\u3002`); + return true; +} + +function personalityValueText(value) { + const n = clamp(Number(value) || 0, -1, 1); + return `${n >= 0 ? "+" : ""}${n.toFixed(2)}`; +} + +function personalityRowsHtml(personality = {}) { + const p = normalizePersonality(personality); + return PERSONALITY_KEYS.map(key => `${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}`).join(""); +} + function renderSelected() { const t = world.selected; if (!t || t.dead || !world.tarinai.includes(t)) { @@ -150,17 +244,28 @@ function renderSelected() { ui.selectedCard?.classList.remove("hidden"); uiCache.selectedEmpty = false; + const activeNameInput = document.activeElement?.matches?.("[data-selected-name]") && document.activeElement?.dataset?.selectedId === t.id; + if (activeNameInput) return; const rel = t.relationSummary ? t.relationSummary() : {}; const fightRecord = relationFightRecord(t); + const generationDisplay = t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : ""); + ensurePersonality(t); + const personalityTags = t.personalityTags ? t.personalityTags() : getPersonalityTraitTags(t); + const personalitySnapshot = [ + ...PERSONALITY_KEYS.map(key => personalityValueText(t.birthPersonality?.[key])), + ...PERSONALITY_KEYS.map(key => personalityValueText(t.currentPersonality?.[key])), + personalityTags.join(",") + ].join(","); const snapshot = [ - t.id, t.type, t.name, (t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : "")), t.state, t.personality, t.thought, targetLabel(t.target), + t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target), 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), - rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord, + relationEntries(t, "friend").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), relationEntries(t, "enemy").map(r => `${r.id}:${fmt(r.score)}:${r.name}`).join(","), fightRecord, t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0), t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0), t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0, + personalitySnapshot, (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~") ].join("|"); if (uiCache.selectedSnapshot === snapshot) return; @@ -168,21 +273,36 @@ function renderSelected() { ui.selectedInfo.className = "selected-info"; const effectBadges = []; - effectBadges.push(`\u6027\u683c ${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}`); if (t.isZunchiSlave) effectBadges.push(`\u305a\u3093\u3061\u3069\u308c\u3044`); if (t.favorite) effectBadges.push(`\u2605`); if (t.zunchiDisease) effectBadges.push(`\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}`); if (t.sleepDisease) effectBadges.push(`\u306d\u3080\u308a\u75c5`); if (t.explosionDisease) effectBadges.push(`\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}`); - if (t.fightDisease) effectBadges.push(`きずつき病`); + if (t.fightDisease) effectBadges.push(`\u304d\u305a\u3064\u304d\u75c5`); if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}`); if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}`); - const recordHtml = (t.records || []).slice(0, 8).map(r => `
  • ${escapeHtml(world.clockString ? world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0) : formatRecordTime(r.time || 0))}${escapeHtml(r.text || "")}
  • `).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; + const personalityTagHtml = personalityTags.length + ? personalityTags.map(tag => `${escapeHtml(tag)}`).join("") + : `\u4e2d\u7acb`; + const personalityHtml = ` +
    \u6027\u683c\u30bf\u30b0${personalityTagHtml}
    +
    \u8a95\u751f\u6642\u6027\u683c${personalityRowsHtml(t.birthPersonality)}
    +
    \u73fe\u5728\u6027\u683c${personalityRowsHtml(t.currentPersonality)}
    + `; + const recordHtml = (t.records || []).slice(0, 8).map(r => { + const timeText = world.clockString ? (world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0)) : formatRecordTime(r.time || 0); + const recordText = quoteNameInRecordText(r.text || "", t.name); + return `
  • ${escapeHtml(timeText)}${escapeHtml(recordText)}
  • `; + }).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; ui.selectedInfo.innerHTML = ` -
    ${escapeHtml(t.label())}${escapeHtml(t.name)}${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `\u4e16\u4ee3 ${t.generation}` : ""}${effectBadges.join("")}
    -
    \u6027\u683c${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}
    +
    + +
    + ${effectBadges.length ? `
    ${effectBadges.join("")}
    ` : ""} +
    \u4e16\u4ee3${escapeHtml(generationDisplay || "\u4e0d\u660e")}
    \u72b6\u614b${escapeHtml(stateLabel(t.state))}
    \u7406\u7531${escapeHtml(reasonLabel(t))}
    + ${personalityHtml}
    \u5bfe\u8c61${escapeHtml(targetLabel(t.target))}
    \u5e74\u9f62${fmt(t.age)} / ${fmt(t.lifeSpan)}
    \u7a7a\u8179${fmt(t.hunger)}
    @@ -191,8 +311,8 @@ function renderSelected() {
    \u30b9\u30c8\u30ec\u30b9${fmt(t.stress)}
    \u305a\u3093\u3061\u6c5a\u308c${fmt(t.zunchiStain || 0)}
    \u6c17\u5206${fmt(t.mood)}
    -
    \u4ef2\u826f\u3057${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}
    -
    \u907f\u3051\u308b\u76f8\u624b${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}
    +
    \u53cb\u9054${relationListHtml(t, "friend")}
    +
    \u6575\u5bfe${relationListHtml(t, "enemy")}
    \u55a7\u5629\u8a18\u61b6${escapeHtml(fightRecord)}
    \u7d2f\u8a08\u6557\u5317${t.totalFightLosses || 0}
    \u89aa${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}
    @@ -218,7 +338,7 @@ function stateLabel(s) { play_ball: "\u30dc\u30fc\u30eb\u904a\u3073", seek_water: "\u6c34\u3092\u63a2\u3059", zunchi_sick: "\u305a\u3093\u3061\u75c5", - fight_sick: "きずつき病", + fight_sick: "\u304d\u305a\u3064\u304d\u75c5", system: "\u305a\u3093\u3061\u75c5", seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", @@ -243,7 +363,7 @@ function reasonLabel(t) { if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b"; if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b"; if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b"; - if (t.state === "fight_sick") return "きずつき病\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; + if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b"; if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5"; return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b"; } @@ -251,7 +371,7 @@ function reasonLabel(t) { function targetLabel(target) { if (!target) return "\u306a\u3057"; if (target.name) return target.name; - if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "きずつき病", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; + if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u304d\u305a\u3064\u304d\u75c5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type; if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb"; return "\u4e0d\u660e"; } @@ -366,8 +486,14 @@ function applyToolTips() { document.body.appendChild(pop); } const showTip = (btn) => { - const tip = btn?.dataset?.tip || ""; - if (!tip) return; + const baseTip = btn?.dataset?.tip || ""; + if (!baseTip) return; + let tip = baseTip; + if (btn?.dataset?.tool === "nest_box") { + const count = world?.nestBoxToolTipCount?.(); + if (count) tip = `${baseTip} +(${count.current}/${count.max})\u5339`; + } pop.textContent = tip; pop.classList.remove("hidden"); const rect = btn.getBoundingClientRect(); @@ -487,7 +613,7 @@ function syncTopButtons() { if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF"; } -const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "stone", "firecracker", "fence_v", "fence_h"]); +const SCALABLE_TOOLS = new Set(scalableToolIds()); const SIZE_ORDER = ["small", "medium", "large"]; const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; @@ -569,7 +695,13 @@ function bindUI() { const t = world.tarinai.find(x => x.id === id && !x.dead); if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; } world.selected = t; + t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15); selectTool("observe"); + if (Number.isFinite(t.x) && Number.isFinite(t.y)) { + world.cameraX = t.x - (world.visibleWorldW ? world.visibleWorldW() : (world.viewportW || world.w)) / 2; + world.cameraY = t.y - (world.visibleWorldH ? world.visibleWorldH() : (world.viewportH || world.h)) / 2; + world.clampCamera?.(); + } renderStats(); showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); }); @@ -582,6 +714,12 @@ function bindUI() { } ui.selectedInfo?.addEventListener("click", (e) => { + const focusBtn = e.target.closest("[data-focus-tarinai-id]"); + if (focusBtn) { + e.preventDefault(); + focusTarinaiById(focusBtn.getAttribute("data-focus-tarinai-id")); + return; + } const btn = e.target.closest("[data-selected-action=\"favorite\"]"); if (!btn || !world.selected || world.selected.dead) return; world.selected.favorite = !world.selected.favorite; @@ -590,6 +728,17 @@ function bindUI() { render(); showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002"); }); + ui.selectedInfo?.addEventListener("change", (e) => { + const input = e.target.closest("[data-selected-name]"); + if (!input) return; + renameSelectedTarinai(input.value); + }); + ui.selectedInfo?.addEventListener("keydown", (e) => { + const input = e.target.closest("[data-selected-name]"); + if (!input) return; + if (e.key === "Enter") { e.preventDefault(); input.blur(); } + if (e.key === "Escape") { e.preventDefault(); input.value = world.selected?.name || ""; input.blur(); } + }); ui.selectedCloseBtn?.addEventListener("click", () => { world.selected = null; uiCache.selectedSnapshot = ""; diff --git a/js/ui_charts.js b/js/ui_charts.js index 23996d9..caf9f7b 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -37,7 +37,7 @@ function updateColonyHistory(values) { uiCache.environmentInitialRow = null; uiCache.lastChartDraw = ""; } - const objectKeys = ["grass", "zunchi"]; + const objectKeys = ["grass", "zunchi", "trace"]; const makeBucket = (start) => ({ start, end: start + interval, @@ -90,7 +90,7 @@ function updateColonyHistory(values) { function chartRows(values) { const bucket = uiCache.chartBucketAccum; - const objectKeys = ["grass", "zunchi"]; + const objectKeys = ["grass", "zunchi", "trace"]; const preview = bucket && bucket.count > 0 ? { t: Math.max(world.time || 0, bucket.start || 0), pop: bucket.sums.pop / bucket.count, @@ -182,6 +182,7 @@ function chartSeries(mode = "population") { if (mode === "objects") return [ { key: "obj_grass", label: "\u8349", color: "#5b9d61" }, { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, + { key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" }, ]; if (mode === "environment") return [ { key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true }, diff --git a/js/ui_family.js b/js/ui_family.js index 9035671..a6bff5d 100644 --- a/js/ui_family.js +++ b/js/ui_family.js @@ -152,6 +152,167 @@ function liveFamilyComponents(family) { return comps; } +function lineageIdleSchedule(fn, timeout = 180) { + const schedule = window.requestIdleCallback || ((cb) => setTimeout(cb, 0)); + schedule(fn, { timeout }); +} + +function lineageSameFamilyToken(token) { + return uiCache.archiveRenderToken === token; +} + +function lineageBuildComponentsIdle(family, token, onProgress, onDone) { + family = lineageEdgeFamily(family); + const entries = Object.values(family || {}).filter(n => n?.id); + const known = new Set(entries.map(n => n.id)); + const parentsByChild = new Map(); + const childrenByParent = new Map(); + const relatedIds = new Set(); + const graph = new Map(); + const comps = []; + let stage = "relations"; + let entryIndex = 0; + let nodeIndex = 0; + let nodes = []; + let sortedNodes = []; + let dfsIndex = 0; + const seen = new Set(); + + const addParent = (childId, parentId) => { + if (!childId || !parentId || childId === parentId || !known.has(childId) || !known.has(parentId)) return; + if (!parentsByChild.has(childId)) parentsByChild.set(childId, new Set()); + if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, new Set()); + parentsByChild.get(childId).add(parentId); + childrenByParent.get(parentId).add(childId); + }; + const addGraphEdge = (a, b) => { + if (!a || !b || !graph.has(a) || !graph.has(b)) return; + graph.get(a).add(b); + graph.get(b).add(a); + }; + const validParentChild = (childId, parentId) => { + const child = family[childId]; + const parent = family[parentId]; + if (!child || !parent) return false; + const childListsParent = Array.isArray(child.parents) && child.parents.includes(parentId); + const parentListsChild = Array.isArray(parent.children) && parent.children.includes(childId); + return childListsParent || parentListsChild; + }; + const finishEmpty = () => { + lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; + onDone([]); + }; + + const pump = () => { + if (!lineageSameFamilyToken(token)) return; + const started = performance.now(); + while (performance.now() - started < 6) { + if (stage === "relations") { + if (entryIndex >= entries.length) { + lineageRelationIndexCache = { family, version: world.familyVersion || 0, index: { parentsByChild, childrenByParent } }; + stage = "related"; + entryIndex = 0; + onProgress?.("\u95a2\u4fc2\u3092\u6574\u7406\u4e2d", 0, entries.length); + continue; + } + const n = entries[entryIndex++]; + for (const parentId of n.parents || []) addParent(n.id, parentId); + for (const childId of n.children || []) addParent(childId, n.id); + } else if (stage === "related") { + if (entryIndex >= entries.length) { + nodes = Array.from(relatedIds).map(id => family[id]).filter(Boolean); + for (const n of nodes) graph.set(n.id, new Set()); + if (!nodes.length) return finishEmpty(); + stage = "graph"; + nodeIndex = 0; + onProgress?.("\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d", 0, nodes.length); + continue; + } + const n = entries[entryIndex++]; + const parents = parentsByChild.get(n.id) || new Set(); + const children = childrenByParent.get(n.id) || new Set(); + if (parents.size || children.size) relatedIds.add(n.id); + for (const id of parents) relatedIds.add(id); + for (const id of children) relatedIds.add(id); + } else if (stage === "graph") { + if (nodeIndex >= nodes.length) { + sortedNodes = nodes.slice().sort(lineageNodeSort); + stage = "components"; + dfsIndex = 0; + onProgress?.("\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", 0, sortedNodes.length); + continue; + } + const n = nodes[nodeIndex++]; + const parentCandidates = new Set((n.parents || []).filter(Boolean)); + for (const parentId of parentsByChild.get(n.id) || []) parentCandidates.add(parentId); + for (const parentId of parentCandidates) { + if (parentId !== n.id && graph.has(parentId) && validParentChild(n.id, parentId)) addGraphEdge(n.id, parentId); + } + const childCandidates = new Set((n.children || []).filter(Boolean)); + for (const childId of childrenByParent.get(n.id) || []) childCandidates.add(childId); + for (const childId of childCandidates) { + if (childId !== n.id && graph.has(childId) && validParentChild(childId, n.id)) addGraphEdge(n.id, childId); + } + } else if (stage === "components") { + while (dfsIndex < sortedNodes.length && seen.has(sortedNodes[dfsIndex].id)) dfsIndex += 1; + if (dfsIndex >= sortedNodes.length) { + comps.sort((a, b) => { + const ag = Math.min(...a.map(n => n.generation || 1)); + const bg = Math.min(...b.map(n => n.generation || 1)); + const at = Math.min(...a.map(n => n.birthTime || 0)); + const bt = Math.min(...b.map(n => n.birthTime || 0)); + return ag - bg || at - bt || b.length - a.length; + }); + onDone(comps); + return; + } + const start = sortedNodes[dfsIndex++]; + const stack = [start.id]; + const comp = []; + seen.add(start.id); + while (stack.length && performance.now() - started < 6) { + const id = stack.pop(); + const item = family[id]; + if (item) comp.push(item); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (stack.length) { + const continueComp = () => { + if (!lineageSameFamilyToken(token)) return; + const resumedStarted = performance.now(); + while (stack.length && performance.now() - resumedStarted < 6) { + const id = stack.pop(); + const item = family[id]; + if (item) comp.push(item); + for (const next of graph.get(id) || []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + if (stack.length) return lineageIdleSchedule(continueComp, 180); + if (comp.some(x => x.alive)) comps.push(comp); + lineageIdleSchedule(pump, 180); + }; + return lineageIdleSchedule(continueComp, 180); + } + if (comp.some(x => x.alive)) comps.push(comp); + } + } + onProgress?.( + stage === "relations" ? "\u95a2\u4fc2\u3092\u6574\u7406\u4e2d" : stage === "related" ? "\u8868\u793a\u5bfe\u8c61\u3092\u9078\u5225\u4e2d" : stage === "graph" ? "\u5bb6\u7cfb\u3092\u7d50\u5408\u4e2d" : "\u5bb6\u7cfb\u3092\u5206\u5272\u4e2d", + stage === "graph" ? nodeIndex : entryIndex, + stage === "graph" ? nodes.length : entries.length + ); + lineageIdleSchedule(pump, 180); + }; + lineageIdleSchedule(pump, 160); +} + function familyStatusText(n) { if (!n) return "\u4e0d\u660e"; return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; @@ -163,8 +324,8 @@ function familyDeathReasonLabel(rawReason, entity = null) { if (text.includes("\u3067\u8870\u5f31")) return text.length > 12 ? `${text.slice(0, 12)}\u2026` : text; if (/\u55a7\u5629|fight/.test(text)) return "\u55a7\u5629"; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9"; - if (/仕様により画面外で削除|画面外で削除|out.*bounds|offscreen/.test(text)) return "仕様により画面外で削除"; - if (/衝突|collision/.test(text)) return "衝突"; + if (/\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664|\u753b\u9762\u5916\u3067\u524a\u9664|out.*bounds|offscreen/.test(text)) return "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"; + if (/\u885d\u7a81|collision/.test(text)) return "\u885d\u7a81"; if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e\u305f"; if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5"; if (/\u75c5/.test(text)) { @@ -173,7 +334,7 @@ function familyDeathReasonLabel(rawReason, entity = null) { } if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913"; if (/\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; - if (/\u5bff\u547d|天寿を全うした/.test(text)) return "天寿を全うした"; + if (/\u5bff\u547d|\u5929\u5bff/.test(text)) return "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f"; if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545"; return "\u4e8b\u6545"; } @@ -196,9 +357,7 @@ function lineageTreeNodeHtml(n, x, y) { const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; const briefReason = familyDeathReasonLabel(rawReason, entity); const reason = briefReason ? ` / ${briefReason}` : ""; - const personality = live?.personalityLabel ? live.personalityLabel() : ((PERSONALITIES[n?.personality] || {}).label || n?.personality || ""); - const personalityPart = personality ? ` / \u6027\u683c ${personality}` : ""; - const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${personalityPart}${reason}`); + const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}`); const deathLine = briefReason ? `${escapeHtml(briefReason)}` : ""; const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; const childCls = (n?.parents || []).length ? " child" : ""; @@ -485,7 +644,8 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) { const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet); const totalNodes = nodes.length; const maxRow = Math.max(0, ...rows.map(row => row.length)); - const passes = totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6); + const veryLarge = totalNodes > 220 || maxRow > 44; + const passes = veryLarge ? 1 : (totalNodes > 180 || maxRow > 32 ? 2 : (totalNodes > 90 || maxRow > 18 ? 3 : 6)); for (let pass = 0; pass < passes; pass++) { let rank = lineageRankMap(rows); @@ -495,9 +655,11 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) { const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index])); rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank); } - rank = lineageRankMap(rows); - rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode)); - if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet); + if (!veryLarge) { + rank = lineageRankMap(rows); + rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode)); + if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet); + } } return rows; } @@ -880,6 +1042,32 @@ function lineageHydrateFamilyHtml(html, index) { .split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${index}`); } +function lineageCaptureArchiveScroll() { + const root = ui.archiveContent; + if (!root) return null; + const familyScroll = {}; + for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { + const key = el.getAttribute("data-lineage-key"); + if (key) familyScroll[key] = el.scrollLeft || 0; + } + return { top: root.scrollTop || 0, left: root.scrollLeft || 0, familyScroll }; +} + +function lineageRestoreArchiveScroll(state) { + const root = ui.archiveContent; + if (!root || !state) return; + const maxTop = Math.max(0, root.scrollHeight - root.clientHeight); + const maxLeft = Math.max(0, root.scrollWidth - root.clientWidth); + root.scrollTop = Math.min(state.top || 0, maxTop); + root.scrollLeft = Math.min(state.left || 0, maxLeft); + for (const el of root.querySelectorAll(".lineage-family-tree[data-lineage-key]")) { + const key = el.getAttribute("data-lineage-key"); + if (!key || !Object.prototype.hasOwnProperty.call(state.familyScroll || {}, key)) continue; + el.scrollLeft = Math.min(state.familyScroll[key] || 0, Math.max(0, el.scrollWidth - el.clientWidth)); + } +} + + function lineageExpectedChildEdgeCount(component, family) { family = lineageEdgeFamily(family); const idSet = new Set(component.map(x => x?.id).filter(Boolean)); @@ -1113,6 +1301,73 @@ function lineageCleanComponent(component, family) { return expanded.filter(n => lineageMutualParentIds(n, family, idSet).length || lineageMutualChildIds(n, family, idSet).length); } +function lineageFastLayoutNeeded(nodes, idSet, family) { + const edgeCount = nodes.reduce((sum, n) => sum + lineageParentIds(n, idSet, family).length, 0); + const maxGenerationWidth = Math.max(0, ...Array.from(new Set(nodes.map(n => n.generation || 1))).map(g => nodes.filter(n => (n.generation || 1) === g).length)); + return { + edgeCount, + fast: nodes.length > 140 || edgeCount > 260 || maxGenerationWidth > 34, + }; +} + +function lineageLayoutRowsFast(rows, idSet) { + const NODE_W = 132; + const NODE_H = 68; + const SIBLING_GAP = 22; + const ROW_GAP = 116; + const LEFT_PAD = 82; + const TOP_PAD = 44; + const RIGHT_PAD = 48; + const BOTTOM_PAD = 30; + const positions = new Map(); + let width = 420; + rows.forEach((row, rowIndex) => { + const y = TOP_PAD + rowIndex * (NODE_H + ROW_GAP); + let x = LEFT_PAD; + for (const n of row) { + positions.set(n.id, { x, y, rowIndex, node: n }); + x += NODE_W + SIBLING_GAP; + } + width = Math.max(width, x - SIBLING_GAP + RIGHT_PAD); + }); + const height = TOP_PAD + Math.max(0, rows.length - 1) * (NODE_H + ROW_GAP) + NODE_H + BOTTOM_PAD; + return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP, fastMode: true }; +} + +function lineageBuildLinksFast(nodes, idSet, layout) { + const { positions, nodeW, nodeH } = layout; + const partnerLinks = []; + const childLinks = []; + const seenPartners = new Set(); + let childLinkGroups = 0; + let childLinkEdges = 0; + for (const child of nodes) { + const childPos = positions.get(child.id); + if (!childPos) continue; + const parents = lineageParentIds(child, idSet).map(id => positions.get(id)).filter(Boolean).sort((a, b) => a.x - b.x); + if (!parents.length) continue; + childLinkGroups += 1; + childLinkEdges += parents.length; + const childX = childPos.x + nodeW / 2; + const childY = childPos.y; + const fromY = Math.max(...parents.map(p => p.y + nodeH)); + const joinX = parents.reduce((sum, p) => sum + p.x + nodeW / 2, 0) / parents.length; + if (parents.length >= 2) { + const left = parents[0]; + const right = parents[parents.length - 1]; + const partnerKey = parents.map(p => p.node?.id).filter(Boolean).sort().join("+"); + if (partnerKey && !seenPartners.has(partnerKey)) { + seenPartners.add(partnerKey); + const y = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2; + partnerLinks.push(`M ${(left.x + nodeW).toFixed(1)} ${y.toFixed(1)} L ${right.x.toFixed(1)} ${y.toFixed(1)}`); + } + } + const laneY = Math.min(childY - 18, fromY + Math.max(28, (childY - fromY) * 0.48)); + childLinks.push(`M ${joinX.toFixed(1)} ${fromY.toFixed(1)} L ${joinX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${laneY.toFixed(1)} L ${childX.toFixed(1)} ${childY.toFixed(1)}`); + } + return { partnerLinks, childLinks, childLinkGroups, childLinkEdges }; +} + function buildLineageTreeLayout(component, family) { family = lineageEdgeFamily(family); const cleaned = lineageCleanComponent(component, family); @@ -1121,12 +1376,13 @@ function buildLineageTreeLayout(component, family) { const idSet = new Set(idToNode.keys()); const generations = Array.from(new Set(nodes.map(n => n.generation || 1))).sort((a, b) => a - b); const rows = lineageRowsForLayout(nodes, generations, idToNode, idSet); - const layout = lineageLayoutRows(rows, idSet); - const links = lineageBuildLinks(nodes, idSet, layout); + const perf = lineageFastLayoutNeeded(nodes, idSet, family); + const layout = perf.fast ? lineageLayoutRowsFast(rows, idSet) : lineageLayoutRows(rows, idSet); + const links = perf.fast ? lineageBuildLinksFast(nodes, idSet, layout) : lineageBuildLinks(nodes, idSet, layout); const labels = generations.map((g, rowIndex) => ({ generation: g, y: layout.topPad + rowIndex * (layout.nodeH + layout.rowGap) + 18 })); const renderedNodes = nodes.map(n => ({ node: n, ...(layout.positions.get(n.id) || {}) })).filter(n => Number.isFinite(n.x) && Number.isFinite(n.y)); - return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges }; + return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges, fastMode: perf.fast, edgeCount: perf.edgeCount }; } function lineageFamilyHtml(component, index, family, signature = "") { @@ -1146,7 +1402,7 @@ function lineageFamilyHtml(component, index, family, signature = "") { const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join(""); const empty = !layout.childLinks.length ? `
    \u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    ` : ""; - const html = `
    + const html = `

    \u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

    \u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    `; - uiCache.archiveRenderRunning = false; - world.familyTreeDirty = false; - uiCache.archivePendingDirtyAfterRun = false; - return; - } - - ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${components.length}
    `; + ui.archiveContent.innerHTML = `
    \u5bb6\u7cfb\u56f3\u3092\u6e96\u5099\u4e2d\u2026
    `; + lineageRestoreArchiveScroll(scrollState); const status = ui.archiveContent.querySelector(".lineage-render-status"); const forest = ui.archiveContent.querySelector(".lineage-forest"); - let index = 0; - const pump = () => { + const renderComponents = (components) => { if (uiCache.archiveRenderToken !== token || !forest) { uiCache.archiveRenderRunning = false; return; } + const splitComponents = components.flatMap(lineageSplitByActualEdges).filter(c => c.length); + uiCache.archiveRows = splitComponents; + if (!splitComponents.length) { + ui.archiveContent.innerHTML = `
    \u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    `; + lineageRestoreArchiveScroll(scrollState); + uiCache.archiveRenderRunning = false; + world.familyTreeDirty = false; + uiCache.archivePendingDirtyAfterRun = false; + return; + } + if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${splitComponents.length}`; + let index = 0; + const pump = () => { + if (uiCache.archiveRenderToken !== token || !forest) { + uiCache.archiveRenderRunning = false; + return; + } const started = performance.now(); let nodeBudget = 28; let renderedThisSlice = 0; - while (index < components.length) { - const component = components[index]; + while (index < splitComponents.length) { + const component = splitComponents[index]; if (renderedThisSlice > 0 && component.length > nodeBudget) break; - forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signatures[index])); + const signature = lineageComponentSignature(component, family); + activeCacheKeys.add(lineageFamilyCacheKey(component, family, signature)); + forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family, signature)); nodeBudget -= Math.max(1, component.length); index += 1; renderedThisSlice += 1; - if (index < components.length && (nodeBudget <= 0 || performance.now() - started > 6)) break; + if (index < splitComponents.length && (nodeBudget <= 0 || performance.now() - started > 6)) break; } - if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${components.length}`; - if (index < components.length) { - const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); - schedule(pump, { timeout: 220 }); + lineageRestoreArchiveScroll(scrollState); + if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${splitComponents.length}`; + if (index < splitComponents.length) { + lineageIdleSchedule(pump, 220); } else { if (status) status.remove(); + for (const key of Array.from(uiCache.archiveHtmlCache.keys())) { + if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key); + } + lineageRestoreArchiveScroll(scrollState); const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion; uiCache.archiveRenderRunning = false; world.familyTreeDirty = renderDirtiedAgain; if (uiCache.archiveDiagnostics) { uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1; uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10; - uiCache.archiveDiagnostics.lastFamilies = components.length; - uiCache.archiveDiagnostics.lastNodes = components.reduce((sum, component) => sum + component.length, 0); + uiCache.archiveDiagnostics.lastFamilies = splitComponents.length; + uiCache.archiveDiagnostics.lastNodes = splitComponents.reduce((sum, component) => sum + component.length, 0); } if (renderDirtiedAgain) { uiCache.archivePendingDirtyAfterRun = false; @@ -1294,8 +1541,16 @@ function renderArchive() { } } }; - const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); - schedule(pump, { timeout: 160 }); + lineageIdleSchedule(pump, 160); + }; + lineageBuildComponentsIdle( + family, + token, + (label, done, total) => { + if (status) status.textContent = `${label}\u2026 ${Math.min(done || 0, total || 0)} / ${total || 0}`; + }, + renderComponents + ); } function validateFamilyTree() { diff --git a/js/world.js b/js/world.js index 4ac8673..c5dbe67 100644 --- a/js/world.js +++ b/js/world.js @@ -1,4 +1,4 @@ -"use strict"; +"use strict"; class World { constructor() { @@ -276,12 +276,12 @@ class World { } toolSizeFor(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return "medium"; + if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return "medium"; return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium"; } toolSizeScale(type = "") { - if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return 1; + if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "food", "sweet", "love_mochi", "fight_mochi", "sleep_drug", "zunchi"].includes(type)) return 1; return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1; } @@ -292,7 +292,16 @@ class World { item.toolSize = size; this.toolSize = size; item.r *= scale; - if (["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale; + if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) { + const servings = typeof foodServingsForSize === "function" ? foodServingsForSize(size) : 5; + item.foodServingScale = scale; + item.foodServingsMax = servings; + item.foodServingsRemaining = servings; + item.amount = servings; + } else if (["grass", "zunchi"].includes(item.type)) { + item.amount *= scale * scale; + item.foodServingScale = scale; + } if (item.type === "firecracker") item.blastScale = scale; return item; } @@ -368,12 +377,13 @@ class World { let changed = false; const nextParents = uniqueIds(t.parents || []); const nextChildren = uniqueIds(t.children || []); + const nextPersonalityTags = getPersonalityTraitTags(t); if (!this.family[t.id]) { this.family[t.id] = { id: t.id, name: t.name, type: t.type, - personality: t.personality || "", + personalityTags: nextPersonalityTags, generation: t.generation, hasPaired: !!t.hasPaired, parents: nextParents, @@ -389,7 +399,7 @@ class World { const nextAlive = !t.dead; if (entry.name !== t.name) changed = true; if (entry.type !== t.type) changed = true; - if ((entry.personality || "") !== (t.personality || "")) changed = true; + if (!sameList(entry.personalityTags, nextPersonalityTags)) changed = true; if (entry.hasPaired !== !!t.hasPaired) changed = true; if (entry.generation !== t.generation) changed = true; if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true; @@ -398,7 +408,8 @@ class World { if (!sameList(entry.children, nextChildren)) changed = true; entry.name = t.name; entry.type = t.type; - entry.personality = t.personality || entry.personality || ""; + delete entry.personality; + entry.personalityTags = nextPersonalityTags; entry.alive = nextAlive; entry.generation = t.generation; entry.hasPaired = !!t.hasPaired; @@ -449,7 +460,7 @@ class World { id, name: live?.name || n.name || id, type: live?.type || n.type || "smile", - personality: live?.personality || n.personality || "", + personalityTags: live ? getPersonalityTraitTags(live) : [...(n.personalityTags || [])], generation: Math.max(1, live?.generation || n.generation || 1), hasPaired: !!(live?.hasPaired || n.hasPaired), parents, @@ -565,7 +576,7 @@ class World { if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== finalReason) { this.family[t.id].alive = false; this.family[t.id].deathReason = finalReason; - this.family[t.id].personality = t.personality || this.family[t.id].personality || ""; + delete this.family[t.id].personality; this.familyVersion = (this.familyVersion || 0) + 1; this.markFamilyTreeDirty("death"); } @@ -629,6 +640,7 @@ class World { startBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return false; if (a.isZunchiSlave || b.isZunchiSlave) return false; + if (a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease) return false; if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false; const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length); const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length); @@ -659,10 +671,15 @@ class World { finishBirthRitual(a, b) { if (!a || !b || a.dead || b.dead) return null; + const reproductionBlockedByDisease = a.sleepDisease || b.sleepDisease || a.fightDisease || b.fightDisease; a.birthRitualTimer = b.birthRitualTimer = 0; a.birthPartnerId = b.birthPartnerId = null; a.birthRitualLeader = b.birthRitualLeader = false; a.state = b.state = "idle"; + if (reproductionBlockedByDisease) { + a.birthRitualRole = b.birthRitualRole = 0; + return null; + } a.fightTimer = b.fightTimer = 0; a.fightTargetIds = []; b.fightTargetIds = []; a.fightTargetId = b.fightTargetId = null; @@ -707,8 +724,10 @@ class World { canStartFight(t) { if (!t || t.dead) return false; if (t.sleepDisease) return false; - if (t.explosionDisease || t.fightDisease) return true; - if (t.zunchiDisease) return false; + // Disease fight rule: explosion disease and zunchi disease can lash out; + // sleep disease cannot fight or be challenged, and other diseases cannot start fights. + if (t.explosionDisease || t.zunchiDisease) return true; + if (t.fightDisease) return false; return true; } @@ -723,10 +742,11 @@ class World { spawnChild(a, b, index = 0, total = 1) { const inherited = Math.random() < 0.56 ? a.type : b.type; const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited; - const inheritedPersonality = a.personality === b.personality ? a.personality : (Math.random() < 0.5 ? a.personality : b.personality); + const childBirthPersonality = personalityFromParents(a, b); const child = this.addTarinai({ type: mutation, - personality: inheritedPersonality || personalityForSeed(`${a.id}:${b.id}:${this.time}:${index}`), + birthPersonality: childBirthPersonality, + currentPersonality: childBirthPersonality, name: makeName(), age: 0, x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, @@ -822,8 +842,10 @@ class World { 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 scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((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) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5); + 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 actor = scoreA >= scoreB ? a : b; const target = actor === a ? b : a; if (!this.canFightPair(actor, target)) return; @@ -843,7 +865,7 @@ class World { 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 resistance = target.energy * 0.30 + target.mood * 0.16 + (target.personality === "irritable" ? 5 : 0) + rand(-5, 5); + const resistance = target.energy * 0.30 + target.mood * 0.16 + (target.shouldApplyPersonalityBehavior?.("aggression", 1) ? 5 : 0) + rand(-5, 5); const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96); actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); @@ -902,8 +924,10 @@ class World { b.fightTargetIds = b.fightTargetIds.slice(-4); a.fightTargetId = a.fightTargetIds[0]; b.fightTargetId = b.fightTargetIds[0]; - const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7); - const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7); + const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0; + const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + aAggressiveIntent * 5 + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7); + const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + bAggressiveIntent * 5 + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7); const loser = aScore <= bScore ? a : b; const winner = loser === a ? b : a; loser.defeatedById = winner.id; @@ -987,9 +1011,12 @@ class World { ny = Math.sin(a); } const distanceBoost = p * p * 0.55 + p * 0.45; - const power = (260 + distanceBoost * 820) * rand(0.78, 1.24) * (it.blastScale || 1); - t.vx += nx * power + rand(-95, 95) * (0.6 + p); - t.vy += ny * power + rand(-95, 95) * (0.6 + p); + const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1); + this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), { + panic: true, + target: { x, y }, + fearTimer: 1.7 + p, + }); t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); @@ -1012,8 +1039,7 @@ class World { dx = Math.cos(a); dy = Math.sin(a); d = 1; } const blast = 430 + p * 960; - ball.vx = (ball.vx || 0) + dx / d * blast + rand(-80, 80); - ball.vy = (ball.vy || 0) + dy / d * blast + rand(-80, 80); + this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80)); const speed = Math.hypot(ball.vx || 0, ball.vy || 0); ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); ball.lastPokedAt = this.time || 0; @@ -1050,9 +1076,12 @@ class World { nx = Math.cos(a); ny = Math.sin(a); } - const diseaseBlast = (190 + q * 560) * rand(0.80, 1.22); - o.vx += nx * diseaseBlast + rand(-64, 64) * (0.5 + q); - o.vy += ny * diseaseBlast + rand(-64, 64) * (0.5 + q); + const diseaseBlast = (190 + q * 620) * rand(0.80, 1.22); + this.applyImpulse(o, nx * diseaseBlast + rand(-64, 64) * (0.5 + q), ny * diseaseBlast + rand(-64, 64) * (0.5 + q), { + panic: true, + target: { x, y }, + fearTimer: 1.0 + q, + }); o.hurtTimer = Math.max(o.hurtTimer || 0, 1.2 + q); o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q); if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t); @@ -1088,7 +1117,7 @@ class World { cleaned += p * 0.55; } for (const t of this.nearbyTarinai(x, y, radius + 70)) { - if (!t || t.dead) continue; + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; const d = distXY(t.x, t.y, x, y); if (d > radius + t.radius) continue; const p = clamp(1 - d / (radius + t.radius), 0, 1); @@ -1187,12 +1216,19 @@ class World { return Boolean(it && (it.type === "bed" || it.type === "nest_box")); } + isTarinaiHiddenInNestBox(t) { + return Boolean(t && !t.dead && t.insideNestBoxId); + } + bedOccupancy(bed) { if (!this.isSleepFurniture(bed)) return 0; + if (bed.type === "nest_box") { + return this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity).length : 0; + } let n = 0; const radius = Math.max(86, bed.r * 2.45); for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) { - if (t.dead) continue; + if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1; } return n; @@ -1205,23 +1241,27 @@ class World { } bestBedFor(t, maxDist = 360) { - let best = null; - let bestScore = Infinity; - for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { - if (bed.dead || !this.isSleepFurniture(bed)) continue; - if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) { - const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : []; - if (occupants.length >= this.nestBoxCapacity(bed)) continue; + const choose = (kind) => { + let best = null; + let bestScore = Infinity; + for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { + if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue; + if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) { + const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : []; + if (occupants.length >= this.nestBoxCapacity(bed)) continue; + } + if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; + const d = dist(t, bed); + const occ = this.bedOccupancy(bed); + const comfort = this.bedComfort(bed); + const crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34; + const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty; + if (score < bestScore) { best = bed; bestScore = score; } } - if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; - const d = dist(t, bed); - const occ = this.bedOccupancy(bed); - const comfort = this.bedComfort(bed); - const crowdPenalty = Math.max(0, occ - 3) * 34; - const score = d - comfort * 46 + crowdPenalty; - if (score < bestScore) { best = bed; bestScore = score; } - } - return best; + return best; + }; + // Prefer an available nest box, then fall back to a hay bed. + return choose("nest_box") || choose("bed"); } relationNotice(aId, bId, kind, cooldown = 30) { @@ -1233,6 +1273,52 @@ class World { return true; } + applyImpulse(entity, vx = 0, vy = 0, options = {}) { + if (!entity || entity.dead) return false; + if (!Number.isFinite(vx) || !Number.isFinite(vy)) return false; + if (entity instanceof Tarinai || "impulseVx" in entity || "impulseVy" in entity) { + entity.impulseVx = (Number.isFinite(entity.impulseVx) ? entity.impulseVx : 0) + vx; + entity.impulseVy = (Number.isFinite(entity.impulseVy) ? entity.impulseVy : 0) + vy; + } else { + entity.vx = (Number.isFinite(entity.vx) ? entity.vx : 0) + vx; + entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy; + } + if (options.panic && entity instanceof Tarinai) { + entity.state = "panic"; + entity.target = options.target || null; + entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); + } + return true; + } + + applyKnockback(entity, sourceX, sourceY, strength = 0, options = {}) { + if (!entity || !Number.isFinite(strength)) return false; + let dx = (entity.x || 0) - sourceX; + let dy = (entity.y || 0) - sourceY; + let d = Math.hypot(dx, dy); + if (!Number.isFinite(d) || d < 0.001) { + const a = Number.isFinite(options.angle) ? options.angle : rand(0, Math.PI * 2); + dx = Math.cos(a); + dy = Math.sin(a); + d = 1; + } + const random = options.random || 0; + return this.applyImpulse( + entity, + dx / d * strength + rand(-random, random), + dy / d * strength + rand(-random, random), + options, + ); + } + + applyImpactDamage(target, amount, cause = "\u885d\u7a81", options = {}) { + if (!target || target.dead || !Number.isFinite(amount) || amount <= 0) return false; + const normalized = options.cause || cause || "\u885d\u7a81"; + if (target.damage) target.damage(amount, normalized); + else if (Number.isFinite(target.hp)) target.hp -= amount; + return true; + } + resolveFightOutcome(winner, loser) { if (!winner || !loser || winner.dead || loser.dead) return; const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; @@ -1250,6 +1336,8 @@ class World { loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1; winner.totalFightWins = (winner.totalFightWins || 0) + 1; loser.totalFightLosses = (loser.totalFightLosses || 0) + 1; + winner.adjustPersonality?.("aggression", 0.018, "after winning fights."); + loser.adjustPersonality?.("aggression", -0.018, "after losing fights."); if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) { const oldName = loser.formerName || loser.name; this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "\u3076\u308a\u3085\u2026", "rgba(74,91,50,0.78)"); @@ -1272,7 +1360,7 @@ class World { const push = isStone ? rand(145, 210) : rand(54, 92); let hit = 0; for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) { - if (!t || t.dead) continue; + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; const d = Math.max(1, distXY(t.x, t.y, it.x, it.y)); const p = clamp(1 - d / (radius + t.radius), 0, 1); if (p <= 0) continue; @@ -1315,8 +1403,8 @@ class World { for (const [ball, arr] of groups) { if (arr.length <= 5) continue; arr.sort((a, b) => { - const ap = a.personality === "playful" ? -120 : 0; - const bp = b.personality === "playful" ? -120 : 0; + const ap = ((a.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; + const bp = ((b.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0; return dist(a, ball) + ap - (dist(b, ball) + bp); }); for (const t of arr.slice(5)) { @@ -1355,7 +1443,8 @@ class World { ball.amount = Math.max(ball.amount || 999, 999); audio.poke(); this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" })); - // 繝懊・繝ォ繧偵▽縺、縺・◆繧、繝吶Φ繝医・隕ウ蟇欟I縺ク谿九&縺ェ縺・€・ return true; + this.log("\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u8ee2\u304c\u3057\u305f\u3002", "observe", { eventType: "ball_poke", hiddenFromObservation: true }); + return true; } resolveBallBallCollisions(dt) { @@ -1368,7 +1457,7 @@ class World { const avx = a.vx || 0; const avy = a.vy || 0; const aSpeed = Math.hypot(avx, avy); - const range = ar * 2 + Math.min(560, aSpeed * Math.max(0.016, dt || 0.016)) + 52; + const range = ar * 2 + aSpeed * Math.max(0.016, dt || 0.016) + 52; for (const b of this.nearbyItems(a.x, a.y, range) || []) { if (!b || b === a || b.dead || b.type !== "ball") continue; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; @@ -1460,7 +1549,7 @@ class World { const sy = ball.y - py; const segLenSq = sx * sx + sy * sy; for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { - if (!t || t.dead || t.state === "sleep") continue; + if (!t || t.dead || t.state === "sleep" || this.isTarinaiHiddenInNestBox(t)) continue; let hitX = ball.x; let hitY = ball.y; if (segLenSq > 1) { @@ -1492,11 +1581,14 @@ class World { const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; const damage = clamp((ballSpeed - 285) / 18, 3.5, 38); t.lastBallDamageAt = now; - t.vx = (t.vx || 0) * 0.35 + (ball.vx || 0) * 0.20; - t.vy = (t.vy || 0) * 0.35 + (ball.vy || 0) * 0.20; - t.damage(damage, "\u885d\u7a81"); - const timidStress = t.personality === "timid" ? 1.55 : 1.0; - const stressGain = clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18); + this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, { + panic: true, + target: { x: ball.x, y: ball.y }, + fearTimer: 0.95, + }); + 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)); t.state = "panic"; t.target = { x: ball.x, y: ball.y }; @@ -1513,14 +1605,14 @@ class World { ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10); ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10); ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38); - if (t.dead) continue; + continue; } const relVx = (t.vx || 0) - (ball.vx || 0); const relVy = (t.vy || 0) - (ball.vy || 0); const relativeSpeed = Math.hypot(relVx, relVy); const playIntent = t.state === "play_ball" || t.target === ball; - const playful = t.personality === "playful"; + const playful = t.shouldApplyPersonalityBehavior?.("openness", 1); const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260); ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52; ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52; @@ -1534,8 +1626,8 @@ class World { t.vy -= ny * Math.min(22, impulse * 0.10); 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 timidBump = t.personality === "timid" && !playIntent ? clamp(ballSpeed / 190, 0.9, 3.8) : 0; - t.stress = clamp(t.stress - playRelief + timidBump, 0, 130); + 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 (playful && playIntent) { t.loneliness = clamp(t.loneliness - 0.9, 0, 100); t.affection = clamp(t.affection + 0.18, 0, 100); @@ -1556,7 +1648,7 @@ class World { const now = this.time || 0; if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); for (const a of this.tarinai || []) { - if (!a || a.dead) continue; + if (!a || a.dead || this.isTarinaiHiddenInNestBox(a)) continue; const avx = a.vx || 0; const avy = a.vy || 0; const aSpeed = Math.hypot(avx, avy); @@ -1568,7 +1660,7 @@ class World { const segLenSq = sx * sx + sy * sy; const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; for (const b of this.nearbyTarinai(a.x, a.y, range)) { - if (!b || b === a || b.dead) continue; + if (!b || b === a || b.dead || this.isTarinaiHiddenInNestBox(b)) continue; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; const bvx = b.vx || 0; @@ -1601,12 +1693,15 @@ class World { ny = Math.sin(aa); } const damage = clamp((relSpeed - 118) / 26, 1.2, 22); - target.vx += ivx * 0.20 + nx * 28; - target.vy += ivy * 0.20 + ny * 28; + this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, { + panic: true, + target: { x: impactor.x, y: impactor.y }, + fearTimer: 0.55, + }); impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10); impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); - target.damage(damage, "\u885d\u7a81"); - if (!impactor.dead) impactor.damage(damage * 0.35, "\u885d\u7a81"); + this.applyImpactDamage(target, damage, "\u885d\u7a81"); + if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81"); for (const t of [target, impactor]) { if (!t || t.dead) continue; t.state = "panic"; @@ -1639,7 +1734,7 @@ class World { let best = null, bestD = maxDist; const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; for (const o of candidates) { - if (o === entity || o.dead) continue; + if (o === entity || o.dead || this.isTarinaiHiddenInNestBox(o)) continue; if (predicate && !predicate(o)) continue; if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; const d = dist(entity, o); @@ -1707,6 +1802,47 @@ class World { return this.nestBoxSolidRects(it)[0] || null; } + nestBoxEntryPoint(box) { + const base = this.nestBoxBaseRect(box); + if (!base) return { x: box?.x || 0, y: box?.y || 0 }; + const r = box.r || 42; + // Move the approach point to the center of the open middle cell. The + // collision resolver also ignores this nest box for active sleepers near + // the door, so tarinai can cross the threshold instead of sliding off posts. + return { + x: clamp(box.x, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(box.y + r * 0.02, base.top + r * 0.46, base.bottom - r * 0.18), + }; + } + + nestBoxExitPoint(box, occupant = null) { + const base = this.nestBoxBaseRect(box); + if (!base) return this.nestBoxEntryPoint(box); + const r = box.r || 42; + const side = occupant ? (stableUnit(occupant.id || "nest", `nest-exit-${box.id || "box"}`) - 0.5) * r * 0.36 : 0; + return { + x: clamp(box.x + side, CONFIG.worldPadding, this.w - CONFIG.worldPadding), + y: clamp(base.bottom + r * 0.22, CONFIG.worldPadding, this.h - CONFIG.worldPadding), + }; + } + + nestBoxInnerPoint(box, occupant = null) { + const base = this.nestBoxBaseRect(box); + if (!base) return { x: box?.x || 0, y: box?.y || 0 }; + const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(box, Infinity) : []; + let index = occupants.indexOf(occupant); + if (index < 0) index = Math.min(occupants.length, this.nestBoxCapacity(box) - 1); + const r = box.r || 42; + const slots = [ + [0.00, -0.02], [-0.22, 0.04], [0.22, 0.04], [-0.11, 0.16], [0.11, 0.16], + ]; + const slot = slots[index % slots.length]; + return { + x: clamp(box.x + slot[0] * r, base.left + r * 0.46, base.right - r * 0.46), + y: clamp(box.y + slot[1] * r, base.top + r * 0.34, base.bottom - r * 0.18), + }; + } + solidObstacleRects(it) { if (!it || it.dead) return []; if (this.isFenceType(it.type)) { @@ -1717,6 +1853,20 @@ class World { return []; } + shouldIgnoreNestBoxCollisionFor(t, box) { + if (!t || !box || box.dead || box.type !== "nest_box") return false; + if (t.insideNestBoxId) return true; + if (t.target !== box || (t.state !== "seek_bed" && t.state !== "sleep")) return false; + const entry = this.nestBoxEntryPoint(box); + const base = this.nestBoxBaseRect(box); + const r = box.r || 42; + const entryD = distXY(t.x, t.y, entry.x, entry.y); + const centerD = distXY(t.x, t.y, box.x, box.y); + const aroundDoor = entryD <= Math.max(128, r * 2.25) || centerD <= Math.max(116, r * 2.0); + const inDoorColumn = base && t.x >= base.left + r * 0.18 && t.x <= base.right - r * 0.18 && t.y >= base.top + r * 0.02 && t.y <= base.bottom + r * 0.58; + return Boolean(aroundDoor || inDoorColumn); + } + nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) { const rects = []; let checked = 0; @@ -1764,10 +1914,11 @@ class World { } resolveSolidObstacleCollision(t) { - if (!t || t.dead) return; + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return; const rr = Math.max(8, t.radius * 0.74); let pushed = false; for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) { + if (rect?.type === "nest_box" && this.shouldIgnoreNestBoxCollisionFor(t, rect.item)) continue; if (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true; } if (pushed) { @@ -1833,7 +1984,7 @@ class World { grassOnTarinaiAt(x, y, minGap = 24) { for (const t of this.nearbyTarinai(x, y, minGap + 36)) { - if (t.dead) continue; + if (t.dead || this.isTarinaiHiddenInNestBox(t)) continue; if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true; } return false; @@ -2041,12 +2192,12 @@ class World { observeHighlightKind(t) { const s = this.selected; - if (!s || !t || t === s || t.dead) return ""; + if (!s || !t || t === s || t.dead || this.tool !== "observe") return ""; if ((s.parents || []).includes(t.id)) return "parent"; if ((s.children || []).includes(t.id)) return "child"; const rel = s.relationTo ? s.relationTo(t.id) : null; + if (rel && (rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) return "friend"; if (rel && (rel.fear || 0) >= 8) return "enemy"; - if (rel && (rel.affinity || 0) >= 10) return "friend"; return ""; } @@ -2066,8 +2217,22 @@ class World { if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; } } if (!best) return null; - const occupants = this.nestBoxOccupants(best); - return { box: best, occupants }; + const occupants = this.nestBoxOccupants(best, Infinity); + const capacity = this.nestBoxCapacity(best); + return { box: best, occupants, capacity }; + } + + nestBoxToolTipCount() { + const pointed = this.pointerNestBoxInfo?.(); + if (pointed?.box) return { current: pointed.occupants.length, max: pointed.capacity || this.nestBoxCapacity(pointed.box) }; + const boxes = (this.items || []).filter(it => it && !it.dead && it.type === "nest_box"); + if (!boxes.length) return { current: 0, max: this.nestBoxCapacity() }; + let current = 0, max = 0; + for (const box of boxes) { + current += this.nestBoxOccupants(box, Infinity).length; + max += this.nestBoxCapacity(box); + } + return { current, max }; } blastZunchiFrom(x, y, radius, strength = 1) { @@ -2100,7 +2265,11 @@ class World { dt = Math.min(dt, 0.12 * this.speed); this.time += dt; + const previousDay = this.day || 1; this.day = Math.floor(this.time / CONFIG.dayLength) + 1; + if (this.day !== previousDay) { + for (const t of this.tarinai || []) if (t) t.personalityDaily = { day: this.day, total: 0, byKey: {} }; + } if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4); this.updateWeather(dt); this.rebuildSpatial(); @@ -2242,7 +2411,7 @@ class World { t.vx = 0; t.vy = 0; t.entryTimer = 0; - t.die("莉墓ァ倥↓繧医j逕サ髱「螟悶〒蜑企勁"); + t.die(HEALTH?.CAUSES?.outOfBounds || "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664"); } else if (result === "moved") { t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad); @@ -2289,11 +2458,104 @@ class World { return false; } + placementBlocked(item) { + if (!item || item.dead) return true; + const pad = CONFIG.worldPadding || 30; + const rects = this.solidObstacleRects(item); + if (rects.length) { + for (const rect of rects) { + if (rect.left < pad || rect.top < pad || rect.right > this.w - pad || rect.bottom > this.h - pad) return true; + } + } else { + const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); + if (item.x - radius < pad || item.y - radius < pad || item.x + radius > this.w - pad || item.y + radius > this.h - pad) return true; + } + if (item.type === "grass" && !this.grassSpotOpen(item.x, item.y, { avoidTarinai: true })) return true; + if (this.isFenceType(item.type) && this.fencePlacementBlocked(item)) return true; + if (item.type === "nest_box") { + const margin = Math.max(8, (item.r || 42) * 0.18); + for (const it of this.nearbyItems(item.x, item.y, Math.max(120, (item.r || 42) * 3))) { + if (!it || it === item || it.dead) continue; + for (const rect of this.solidObstacleRects(it)) { + for (const own of rects) { + const separated = own.right + margin < rect.left || own.left - margin > rect.right || own.bottom + margin < rect.top || own.top - margin > rect.bottom; + if (!separated) return true; + } + } + } + } + return false; + } + + placementClampPointFor(item, x = item?.x || 0, y = item?.y || 0) { + const pad = CONFIG.worldPadding || 30; + const rects = this.solidObstacleRects(item); + if (rects.length) { + let left = 0, right = 0, top = 0, bottom = 0; + for (const rect of rects) { + left = Math.max(left, (item.x || 0) - rect.left); + right = Math.max(right, rect.right - (item.x || 0)); + top = Math.max(top, (item.y || 0) - rect.top); + bottom = Math.max(bottom, rect.bottom - (item.y || 0)); + } + return { + x: clamp(x, pad + left, this.w - pad - right), + y: clamp(y, pad + top, this.h - pad - bottom), + }; + } + const radius = Math.max(14, (item.r || 12) * (item.type === "bed" ? 1.55 : 1.25)); + return { + x: clamp(x, pad + radius, this.w - pad - radius), + y: clamp(y, pad + radius, this.h - pad - radius), + }; + } + + placementProbeItem(item, x, y) { + return { ...item, x, y, dead: false }; + } + + findPlacementSpot(item, { allowOriginal = true, maxRadius = null, attempts = null } = {}) { + if (!item || item.dead) return null; + if (item.type === "grass") { + const spot = this.findGrassPlantingSpot + ? this.findGrassPlantingSpot(item.x, item.y, { allowOriginal, attempts: attempts || 42, maxRadius: maxRadius || 170, avoidTarinai: true }) + : null; + if (!spot) return null; + const probe = this.placementProbeItem(item, spot.x, spot.y); + return this.placementBlocked(probe) ? null : spot; + } + + const start = this.placementClampPointFor(item, item.x, item.y); + const original = this.placementProbeItem(item, start.x, start.y); + if (allowOriginal && !this.placementBlocked(original)) return start; + + const solid = this.isFenceType(item.type) || item.type === "nest_box"; + const searchRadius = maxRadius || (solid ? Math.max(150, (item.r || 42) * 4.2) : Math.max(90, (item.r || 16) * 3.2)); + const count = attempts || (solid ? 72 : 36); + const golden = Math.PI * (3 - Math.sqrt(5)); + let best = null; + let bestD = Infinity; + for (let i = 0; i < count; i++) { + const t = count <= 1 ? 1 : i / (count - 1); + const radius = Math.max(8, searchRadius * Math.sqrt(t)); + const angle = i * golden + stableUnit(`${item.type}:${item.x.toFixed(1)},${item.y.toFixed(1)}`, "place") * Math.PI * 2; + const rawX = item.x + Math.cos(angle) * radius; + const rawY = item.y + Math.sin(angle) * radius * 0.74; + const p = this.placementClampPointFor(item, rawX, rawY); + const probe = this.placementProbeItem(item, p.x, p.y); + if (this.placementBlocked(probe)) continue; + const d = distXY(item.x, item.y, p.x, p.y); + if (d < bestD) { best = p; bestD = d; } + if (d < Math.max(18, (item.r || 12) * 0.75)) break; + } + return best; + } + findGrabTargetAt(x, y) { let foundT = null; for (let i = this.tarinai.length - 1; i >= 0; i--) { const t = this.tarinai[i]; - if (!t || t.dead) continue; + if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) continue; if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; } } let foundItem = null, foundItemD = Infinity; @@ -2324,10 +2586,10 @@ class World { showToast("\u67f5\u304c\u591a\u3059\u304e\u307e\u3059\u3002\u5148\u306b\u4e00\u90e8\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002"); return null; } - if (this.fencePlacementBlocked(item)) { - showToast("\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002"); - return null; - } + } + if (this.placementBlocked(item)) { + showToast(this.isFenceType(item.type) ? "\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002" : "\u305d\u3053\u306b\u306f\u914d\u7f6e\u3067\u304d\u307e\u305b\u3093\u3002"); + return null; } if (dropped) { item.dropMax = item.type === "stone" ? 0.72 : 0.58; @@ -2345,7 +2607,7 @@ class World { if (tool === "observe") { let found = null; for (let i = this.tarinai.length - 1; i >= 0; i--) { - if (this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } + if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; } } this.selected = found; if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`); @@ -2398,31 +2660,19 @@ class World { this.compactItems(); this.updateItemCounts(); this.rebuildSpatial(); - const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type; + const label = toolLabel(best.type); this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe"); return; } - const itemType = { - zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", sleep_drug: "sleep_drug", water: "water", water_bowl: "water_bowl", grass: "grass", - stone: "stone", bed: "bed", nest_box: "nest_box", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h" - }[tool]; + const itemType = toolItemType(tool); if (itemType) { let px = x, py = y; - if (itemType === "grass") { - const spot = this.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 48 }); - if (!spot) { - showToast("\u8349\u306b\u306f\u7a7a\u3044\u305f\u571f\u304c\u5fc5\u8981\u3067\u3059\u3002"); - this.log("\u305d\u3053\u306b\u306f\u8349\u304c\u690d\u3048\u3089\u308c\u308b\u7a7a\u304d\u5730\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); - return; - } - px = spot.x; - py = spot.y; - } const rawItem = this.applyToolSize(new Item(itemType, px, py)); - // 譟オ縺ッ隕九◆逶ョ縺ョ遏ゥ蠖「縺後ヵ繧」繝シ繝ォ繝牙、悶↓縺ッ縺ソ蜃コ縺吩ス咲スョ縺ォ縺ッ鄂ョ縺九↑縺・€・ // 驟咲スョ繝励Ξ繝薙Η繝シ縺ョ襍、陦ィ遉コ縺ィ螳滄圀縺ョ驟咲スョ蜿ッ蜷ヲ繧剃ク€閾エ縺輔○繧九€・ const placed = this.placeItem(rawItem, true); + // Placement uses the same footprint that the preview validates. + const placed = this.placeItem(rawItem, true); if (!placed) return; - const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType]; + const label = toolLabel(itemType); this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`); } } @@ -2441,22 +2691,25 @@ class World { return "note"; } - shouldHideFromObservationRecords(text = "") { - const s = String(text || ""); - return [ - "\u30dc\u30fc\u30eb\u3092\u9023\u7d9a\u3067\u3064\u3064", - "\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066", - "\u3092\u907f\u3051\u305f", - ].some(part => s.includes(part)); + shouldHideFromObservationRecords(entry = {}) { + const type = typeof entry === "string" ? "" : (entry.eventType || ""); + return type === "ball_poke" || type === "avoid_relationship"; } - log(text, kind = null) { - const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) }; + log(text, kind = null, options = {}) { + const entry = { + time: this.time, + text, + kind: kind || this.inferLogKind(text), + eventType: options.eventType || "", + hiddenFromObservation: Boolean(options.hiddenFromObservation), + }; + if (this.shouldHideFromObservationRecords(entry)) entry.hiddenFromObservation = true; if (!this.eventCounters) this.eventCounters = {}; this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; for (const t of this.tarinai || []) { if (!t || !t.name || !text || !String(text).includes(t.name)) continue; - if (!this.shouldHideFromObservationRecords(text) && t.addRecord) t.addRecord(text, entry.kind); + if (!entry.hiddenFromObservation && t.addRecord) t.addRecord(text, entry.kind); } this.logs.unshift(entry); this.logs = this.logs.slice(0, 100);