fixed issueeessss

This commit is contained in:
33333-33333 2026-06-20 20:29:44 +09:00
commit 874911821f
20 changed files with 1947 additions and 897 deletions

35
PATCH_NOTES.md Normal file
View file

@ -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.

View file

@ -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 `<cause> 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -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;
}

View file

@ -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.

View file

@ -7,7 +7,7 @@
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="stylesheet" href="css/styles.css?v=15.5.0" />
<link rel="stylesheet" href="css/styles.css?v=15.6.2" />
</head>
<body>
<div id="app">
@ -37,7 +37,7 @@
<aside class="panel">
<section id="selectedCard" class="card selected-card hidden">
<div class="selected-head">
<h2>&#x9078;&#x629E;&#x4E2D;</h2>
<h2>&#x305F;&#x308A;&#x306A;&#x3044;&#x30C7;&#x30FC;&#x30BF;</h2>
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="&#x89B3;&#x5BDF;&#x3092;&#x3084;&#x3081;&#x308B;">&times;</button>
</div>
<div id="selectedInfo" class="selected-info empty">&#x672A;&#x9078;&#x629E;</div>
@ -50,36 +50,36 @@
<section class="tool-category" data-tool-category="observe">
<button class="tool-category-toggle" type="button" aria-expanded="true">&#x89B3;&#x5BDF;</button>
<div class="tool-grid tool-category-body">
<button class="tool selected" data-tool="observe"><span>O</span>&#x89B3;&#x5BDF;</button>
<button class="tool" data-tool="delete"><span>D</span>&#x524A;&#x9664;</button>
<button class="tool" data-tool="poke"><span>👈</span>&#x3064;&#x3064;&#x304F;</button>
<button class="tool" data-tool="pinch"><span>🤏</span>&#x3064;&#x307E;&#x3080;</button>
<button class="tool" data-tool="new"><span>N</span>&#x8FFD;&#x52A0;</button>
<button class="tool" data-tool="water_hose"><span>🚿</span>&#x6D17;&#x6D44;</button>
<button class="tool selected" data-tool="observe">&#x89B3;&#x5BDF;</button>
<button class="tool" data-tool="delete">&#x524A;&#x9664;</button>
<button class="tool" data-tool="poke">&#x3064;&#x3064;&#x304F;</button>
<button class="tool" data-tool="pinch">&#x3064;&#x307E;&#x3080;</button>
<button class="tool" data-tool="new">&#x8FFD;&#x52A0;</button>
<button class="tool" data-tool="water_hose">&#x6D17;&#x6D44;</button>
</div>
</section>
<section class="tool-category" data-tool-category="food">
<button class="tool-category-toggle" type="button" aria-expanded="true">&#x98DF;&#x3079;&#x7269;&#x30FB;&#x81EA;&#x7136;</button>
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="zunchi"><span>Z</span>&#x305A;&#x3093;&#x3061;</button>
<button class="tool" data-tool="sweet"><span>S</span>&#x305A;&#x3093;&#x3060;&#x9905;</button>
<button class="tool" data-tool="love_mochi"><span>H</span>&#x3078;&#x3053;&#x9905;</button>
<button class="tool" data-tool="fight_mochi"><span>K</span>&#x3051;&#x3093;&#x304B;&#x9905;</button>
<button class="tool" data-tool="water_bowl"><span>U</span>&#x6C34;&#x306E;&#x76BF;</button>
<button class="tool" data-tool="sleep_drug"><span>M</span>&#x306D;&#x3080;&#x308A;&#x85AC;</button>
<button class="tool" data-tool="grass"><span>G</span>&#x8349;</button>
<button class="tool" data-tool="zunchi">&#x305A;&#x3093;&#x3061;</button>
<button class="tool" data-tool="sweet">&#x305A;&#x3093;&#x3060;&#x9905;</button>
<button class="tool" data-tool="love_mochi">&#x3078;&#x3053;&#x9905;</button>
<button class="tool" data-tool="fight_mochi">&#x3051;&#x3093;&#x304B;&#x9905;</button>
<button class="tool" data-tool="water_bowl">&#x6C34;&#x306E;&#x76BF;</button>
<button class="tool" data-tool="sleep_drug">&#x306D;&#x3080;&#x308A;&#x85AC;</button>
<button class="tool" data-tool="grass">&#x8349;</button>
</div>
</section>
<section class="tool-category" data-tool-category="object">
<button class="tool-category-toggle" type="button" aria-expanded="true">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button>
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="stone"><span>R</span>&#x77F3;</button>
<button class="tool" data-tool="bed"><span>B</span>&#x5E72;&#x8349;&#x5BDD;&#x5E8A;</button>
<button class="tool" data-tool="nest_box"><span>X</span>&#x5DE3;&#x7BB1;</button>
<button class="tool" data-tool="ball"><span>L</span>&#x30DC;&#x30FC;&#x30EB;</button>
<button class="tool" data-tool="firecracker"><span>F</span>&#x7206;&#x7AF9;</button>
<button class="tool" data-tool="fence_v"><span>V</span>&#x7E26;&#x306E;&#x67F5;</button>
<button class="tool" data-tool="fence_h"><span>H</span>&#x6A2A;&#x306E;&#x67F5;</button>
<button class="tool" data-tool="stone">&#x77F3;</button>
<button class="tool" data-tool="bed">&#x5E72;&#x8349;&#x5BDD;&#x5E8A;</button>
<button class="tool" data-tool="nest_box">&#x5DE3;&#x7BB1;</button>
<button class="tool" data-tool="ball">&#x30DC;&#x30FC;&#x30EB;</button>
<button class="tool" data-tool="firecracker">&#x7206;&#x7AF9;</button>
<button class="tool" data-tool="fence_v">&#x7E26;&#x306E;&#x67F5;</button>
<button class="tool" data-tool="fence_h">&#x6A2A;&#x306E;&#x67F5;</button>
</div>
</section>
</div>
@ -165,20 +165,20 @@
</div>
</div>
<script src="js/data.js?v=15.5.0"></script>
<script src="js/math.js?v=15.5.0"></script>
<script src="js/assets.js?v=15.5.0"></script>
<script src="js/audio.js?v=15.5.0"></script>
<script src="js/render.js?v=15.5.0"></script>
<script src="js/sim_core.js?v=15.5.0"></script>
<script src="js/items.js?v=15.5.0"></script>
<script src="js/health.js?v=15.5.0"></script>
<script src="js/tarinai.js?v=15.5.0"></script>
<script src="js/world.js?v=15.5.0"></script>
<script src="js/text_catalog.js?v=15.5.0"></script>
<script src="js/ui.js?v=15.5.0"></script>
<script src="js/ui_charts.js?v=15.5.0"></script>
<script src="js/ui_family.js?v=15.5.0"></script>
<script src="js/main.js?v=15.5.0"></script>
<script src="js/data.js?v=15.6.2"></script>
<script src="js/math.js?v=15.6.2"></script>
<script src="js/assets.js?v=15.6.2"></script>
<script src="js/audio.js?v=15.6.2"></script>
<script src="js/render.js?v=15.6.2"></script>
<script src="js/sim_core.js?v=15.6.2"></script>
<script src="js/items.js?v=15.6.2"></script>
<script src="js/health.js?v=15.6.2"></script>
<script src="js/tarinai.js?v=15.6.2"></script>
<script src="js/world.js?v=15.6.2"></script>
<script src="js/text_catalog.js?v=15.6.2"></script>
<script src="js/ui.js?v=15.6.2"></script>
<script src="js/ui_charts.js?v=15.6.2"></script>
<script src="js/ui_family.js?v=15.6.2"></script>
<script src="js/main.js?v=15.6.2"></script>
</body>
</html>

View file

@ -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,
};

View file

@ -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();
}

View file

@ -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();
}
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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;
}
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 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 = 0;
this.nestBoxEnteredAt = -Infinity;
this.nestBoxStayUntil = -Infinity;
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;
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 * 1.8, 0, 1);
this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 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) : [];
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;
if (currentBox && !this.wantsNestBox(currentBox)) {
this.leaveNestBox(currentBox);
return null;
}
this.insideNestBoxId = null;
this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1);
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;
}

View file

@ -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: "\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": "体力とストレス",
"image": "assets/ui/ecology_hp_stress.png",
"text": "体力はけがや衝突、爆発、空腹などで減り、0になると死亡する。ストレスは恐怖、混雑、汚れ、けんかなどで増え、高すぎると行動が乱れたり命に関わる。体力バーとストレスバーは、状態が変化した直後や観察時に表示される。"
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\u3084\u6c17\u6e29\u3001\u8349\u3001\u6c5a\u308c\u3001\u9053\u5177\u3082\u65e5\u3005\u306e\u72b6\u614b\u306b\u5f71\u97ff\u3059\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": "\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: "\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\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: "\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きずつき病\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: "\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": "\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: "\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\u9053\u5177\u306f\u52a9\u3051\u306b\u3082\u5371\u967a\u306b\u3082\u306a\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": "\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: "\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;

179
js/ui.js
View file

@ -136,6 +136,100 @@ function relationIconHtml(id) {
return `<img class="relation-face" src="${tarinaiSpritePathForId(id)}" alt="">`;
}
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 `<div class="relation-chip-list">${rows.map(r => `
<button type="button" class="relation-chip ${kind === "enemy" ? "enemy" : "friend"}" data-focus-tarinai-id="${escapeHtml(r.id)}" title="${escapeHtml(r.name)}\u3078\u79fb\u52d5">
${relationIconHtml(r.id)}
<span class="relation-chip-name">${escapeHtml(r.name)}</span>
<em>${sign}${fmt(r.score)}</em>
</button>`).join("")}</div>`;
}
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 => `<span>${escapeHtml(PERSONALITY_LABELS[key]?.label || key)}: ${personalityValueText(p[key])}</span>`).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(`<span class="badge">\u6027\u683c ${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</span>`);
if (t.isZunchiSlave) effectBadges.push(`<span class="badge">\u305a\u3093\u3061\u3069\u308c\u3044</span>`);
if (t.favorite) effectBadges.push(`<span class="badge">\u2605</span>`);
if (t.zunchiDisease) effectBadges.push(`<span class="badge">\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}</span>`);
if (t.sleepDisease) effectBadges.push(`<span class="badge">\u306d\u3080\u308a\u75c5</span>`);
if (t.explosionDisease) effectBadges.push(`<span class="badge">\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}</span>`);
if (t.fightDisease) effectBadges.push(`<span class="badge">きずつき病</span>`);
if (t.fightDisease) effectBadges.push(`<span class="badge">\u304d\u305a\u3064\u304d\u75c5</span>`);
if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}</span>`);
if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}</span>`);
const recordHtml = (t.records || []).slice(0, 8).map(r => `<li><span>${escapeHtml(world.clockString ? world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0) : formatRecordTime(r.time || 0))}</span><strong>${escapeHtml(r.text || "")}</strong></li>`).join("") || `<li><span>-</span><strong>\u8a18\u9332\u306a\u3057</strong></li>`;
const personalityTagHtml = personalityTags.length
? personalityTags.map(tag => `<span class="personality-tag">${escapeHtml(tag)}</span>`).join("")
: `<span class="personality-tag neutral">\u4e2d\u7acb</span>`;
const personalityHtml = `
<div class="info-row personality-values"><span>\u6027\u683c\u30bf\u30b0</span><strong>${personalityTagHtml}</strong></div>
<div class="info-row personality-values"><span>\u8a95\u751f\u6642\u6027\u683c</span><strong>${personalityRowsHtml(t.birthPersonality)}</strong></div>
<div class="info-row personality-values"><span>\u73fe\u5728\u6027\u683c</span><strong>${personalityRowsHtml(t.currentPersonality)}</strong></div>
`;
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 `<li><span>${escapeHtml(timeText)}</span><strong>${escapeHtml(recordText)}</strong></li>`;
}).join("") || `<li><span>-</span><strong>\u8a18\u9332\u306a\u3057</strong></li>`;
ui.selectedInfo.innerHTML = `
<div><span class="badge">${escapeHtml(t.label())}</span><span class="badge">${escapeHtml(t.name)}</span>${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `<span class="badge">\u4e16\u4ee3 ${t.generation}</span>` : ""}${effectBadges.join("")}</div>
<div class="info-row"><span>\u6027\u683c</span><strong>${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</strong></div>
<div class="selected-name-editor">
<input class="selected-name-input" data-selected-name data-selected-id="${escapeHtml(t.id)}" value="${escapeHtml(t.name)}" maxlength="18" aria-label="\u305f\u308a\u306a\u3044\u306e\u540d\u524d" title="\u540d\u524d\u3092\u7de8\u96c6">
</div>
${effectBadges.length ? `<div class="selected-effects">${effectBadges.join("")}</div>` : ""}
<div class="info-row"><span>\u4e16\u4ee3</span><strong>${escapeHtml(generationDisplay || "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div>
<div class="info-row"><span>\u7406\u7531</span><strong>${escapeHtml(reasonLabel(t))}</strong></div>
${personalityHtml}
<div class="info-row"><span>\u5bfe\u8c61</span><strong>${escapeHtml(targetLabel(t.target))}</strong></div>
<div class="info-row"><span>\u5e74\u9f62</span><strong>${fmt(t.age)} / ${fmt(t.lifeSpan)}</strong></div>
<div class="info-row"><span>\u7a7a\u8179</span><strong>${fmt(t.hunger)}</strong></div>
@ -191,8 +311,8 @@ function renderSelected() {
<div class="info-row"><span>\u30b9\u30c8\u30ec\u30b9</span><strong>${fmt(t.stress)}</strong></div>
<div class="info-row"><span>\u305a\u3093\u3061\u6c5a\u308c</span><strong>${fmt(t.zunchiStain || 0)}</strong></div>
<div class="info-row"><span>\u6c17\u5206</span><strong>${fmt(t.mood)}</strong></div>
<div class="info-row relation-row"><span>\u4ef2\u826f\u3057</span><strong>${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}</strong></div>
<div class="info-row relation-row"><span>\u907f\u3051\u308b\u76f8\u624b</span><strong>${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}</strong></div>
<div class="info-row relation-row relation-list-row"><span>\u53cb\u9054</span><strong>${relationListHtml(t, "friend")}</strong></div>
<div class="info-row relation-row relation-list-row"><span>\u6575\u5bfe</span><strong>${relationListHtml(t, "enemy")}</strong></div>
<div class="info-row"><span>\u55a7\u5629\u8a18\u61b6</span><strong>${escapeHtml(fightRecord)}</strong></div>
<div class="info-row"><span>\u7d2f\u8a08\u6557\u5317</span><strong>${t.totalFightLosses || 0}</strong></div>
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
@ -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 = "";

View file

@ -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 },

View file

@ -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 ? `<span class="death-reason">${escapeHtml(briefReason)}</span>` : "";
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,10 +655,12 @@ 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);
}
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 ? `<div class="lineage-empty-family">\u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>` : "";
const html = `<section class="lineage-family lineage-family-tree" data-lineage-child-edges="${childEdgeCount}" data-lineage-rendered-child-edges="${layout.childLinkEdges || 0}" data-lineage-child-groups="${layout.childLinkGroups || 0}" data-lineage-child-paths="${layout.childLinks.length}">
const html = `<section class="lineage-family lineage-family-tree" data-lineage-index="{{LINEAGE_INDEX}}" data-lineage-key="${escapeHtml(cacheKey)}" data-lineage-child-edges="${childEdgeCount}" data-lineage-rendered-child-edges="${layout.childLinkEdges || 0}" data-lineage-child-groups="${layout.childLinkGroups || 0}" data-lineage-child-paths="${layout.childLinks.length}">
<h3>\u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
<div class="lineage-tree-panel" style="width:${Math.ceil(layout.width)}px;height:${Math.ceil(layout.height)}px">
<svg class="lineage-links" viewBox="0 0 ${Math.ceil(layout.width)} ${Math.ceil(layout.height)}" aria-hidden="true">
@ -1165,6 +1421,7 @@ function renderArchive() {
if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return;
uiCache.deferArchiveUntil = 0;
uiCache.archiveScheduled = false;
const scrollState = lineageCaptureArchiveScroll();
const familyVersion = world.familyVersion || 0;
const familyDirty = Boolean(world.familyTreeDirty);
if (uiCache.archiveRenderRunning) {
@ -1197,30 +1454,12 @@ function renderArchive() {
world.pruneExtinctFamilies();
}
const family = world.family || {};
const relationSignature = lineageVisibleRelationSignature(family);
if (uiCache.archiveVersion && uiCache.archiveRelationSignature === relationSignature) {
uiCache.archiveFamilyVersion = world.familyVersion || 0;
world.familyTreeDirty = false;
uiCache.archivePendingDirtyAfterRun = false;
return;
}
const ids = Object.keys(family);
const edgeCount = ids.reduce((sum, id) => {
const n = family[id];
return sum + (Array.isArray(n?.parents) ? n.parents.length : 0) + (Array.isArray(n?.children) ? n.children.length : 0);
}, 0);
const version = `${world.familyVersion || 0}:${ids.length}:${edgeCount}`;
const version = String(world.familyVersion || 0);
if (uiCache.archiveVersion === version) {
world.familyTreeDirty = false;
uiCache.archivePendingDirtyAfterRun = false;
return;
}
const heavyArchive = ids.length > 90 || edgeCount > 160;
if (!world.paused && heavyArchive) {
uiCache.archivePendingWhileRunning = true;
lineageSetArchiveStaleStatus(true);
return;
}
const renderStarted = performance.now();
lineageSetArchiveStaleStatus(false);
if (uiCache.archiveRenderTimer) {
@ -1230,31 +1469,33 @@ function renderArchive() {
uiCache.archiveVersion = version;
uiCache.archiveFamilyVersion = world.familyVersion || 0;
uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0;
uiCache.archiveRelationSignature = relationSignature;
uiCache.archiveLastRenderedAt = performance.now();
uiCache.lastArchiveWindow = "";
const components = liveFamilyComponents(family).flatMap(lineageSplitByActualEdges).filter(c => c.length);
uiCache.archiveRows = components;
const signatures = components.map(component => lineageComponentSignature(component, family));
if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map();
const activeCacheKeys = new Set(components.map((component, index) => lineageFamilyCacheKey(component, family, signatures[index])));
for (const key of Array.from(uiCache.archiveHtmlCache.keys())) {
if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key);
}
const activeCacheKeys = new Set();
const token = `${version}:${Math.random().toString(36).slice(2)}`;
uiCache.archiveRenderToken = token;
uiCache.archiveRenderRunning = true;
if (!components.length) {
ui.archiveContent.innerHTML = `<div class="lineage-render-status">\u5bb6\u7cfb\u56f3\u3092\u6e96\u5099\u4e2d\u2026</div><div class="lineage-forest"></div>`;
lineageRestoreArchiveScroll(scrollState);
const status = ui.archiveContent.querySelector(".lineage-render-status");
const forest = ui.archiveContent.querySelector(".lineage-forest");
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 = `<div class="selected-info empty">\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>`;
lineageRestoreArchiveScroll(scrollState);
uiCache.archiveRenderRunning = false;
world.familyTreeDirty = false;
uiCache.archivePendingDirtyAfterRun = false;
return;
}
ui.archiveContent.innerHTML = `<div class="lineage-render-status">\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${components.length}</div><div class="lineage-forest"></div>`;
const status = ui.archiveContent.querySelector(".lineage-render-status");
const forest = ui.archiveContent.querySelector(".lineage-forest");
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) {
@ -1264,29 +1505,35 @@ function renderArchive() {
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() {

View file

@ -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,10 +1241,11 @@ class World {
}
bestBedFor(t, maxDist = 360) {
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)) continue;
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;
@ -1217,11 +1254,14 @@ class World {
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;
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; }
}
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);