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 ## 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. - 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`). - 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. - 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. - 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. - 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. - 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.hidden { display: none; }
.field-dialog-panel { .field-dialog-panel {
width: min(440px, 100%); width: min(440px, 100%);
max-height: calc(100vh - 36px);
overflow: auto;
border-radius: 16px; border-radius: 16px;
padding: 16px; padding: 16px;
background: rgba(255, 252, 245, 0.96); background: rgba(255, 252, 245, 0.96);
@ -220,6 +222,9 @@ h1 {
.ecology-dialog-panel { .ecology-dialog-panel {
width: min(760px, 100%); width: min(760px, 100%);
max-height: calc(100vh - 36px);
overflow-y: auto;
overscroll-behavior: contain;
} }
.ecology-grid { .ecology-grid {
display: grid; display: grid;
@ -228,17 +233,17 @@ h1 {
} }
.ecology-card { .ecology-card {
display: grid; display: grid;
grid-template-columns: 84px minmax(0, 1fr); grid-template-columns: 72px minmax(0, 1fr);
gap: 10px; gap: 10px;
align-items: center; align-items: center;
padding: 12px; padding: 10px;
border-radius: 14px; border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(243,237,225,0.76)); 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); border: 1px solid rgba(84,68,48,0.12);
} }
.ecology-card img { .ecology-card img {
width: 84px; width: 72px;
height: 84px; height: 72px;
object-fit: contain; object-fit: contain;
filter: drop-shadow(0 8px 18px rgba(56, 44, 32, 0.10)); filter: drop-shadow(0 8px 18px rgba(56, 44, 32, 0.10));
} }
@ -251,7 +256,7 @@ h1 {
margin: 0; margin: 0;
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.40;
} }
.toast { .toast {
@ -338,11 +343,10 @@ h1 {
opacity: 0.10; opacity: 0.10;
pointer-events: none; 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="observe"] { --tool-icon: url("../assets/ui/tool_observe.png"); }
.tool[data-tool="zunchi"] { --tool-icon: url("../assets/ui/tool_zunchi.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="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="grass"] { --tool-icon: url("../assets/ui/tool_grass.png"); }
.tool[data-tool="stone"] { --tool-icon: url("../assets/ui/tool_stone.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"); } .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"] { --tool-icon: none; }
.tool[data-tool="poke"]::before { .tool[data-tool="poke"]::before {
content: "\1f448 "; content: "\1F448";
background: none; background: none;
font-size: 25px; font-size: 25px;
line-height: 1; line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
} }
.tool[data-tool="poke"]::after { .tool[data-tool="poke"]::after {
content: "\1f448 "; content: "\1F448";
background: none; background: none;
font-size: 48px; font-size: 48px;
line-height: 1; line-height: 1;
@ -396,14 +400,14 @@ h1 {
.tool[data-tool="pinch"] { --tool-icon: none; } .tool[data-tool="pinch"] { --tool-icon: none; }
.tool[data-tool="pinch"]::before { .tool[data-tool="pinch"]::before {
content: "\1f90f "; content: "\1F90F";
background: none; background: none;
font-size: 25px; font-size: 25px;
line-height: 1; line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
} }
.tool[data-tool="pinch"]::after { .tool[data-tool="pinch"]::after {
content: "\1f90f "; content: "\1F90F";
background: none; background: none;
font-size: 48px; font-size: 48px;
line-height: 1; line-height: 1;
@ -418,14 +422,14 @@ h1 {
} }
.tool[data-tool="love_mochi"] { --tool-icon: none; } .tool[data-tool="love_mochi"] { --tool-icon: none; }
.tool[data-tool="love_mochi"]::before { .tool[data-tool="love_mochi"]::before {
content: "\1f495 "; content: "\1F495";
background: none; background: none;
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
} }
.tool[data-tool="love_mochi"]::after { .tool[data-tool="love_mochi"]::after {
content: "\1f495 "; content: "\1F495";
background: none; background: none;
font-size: 46px; font-size: 46px;
line-height: 1; line-height: 1;
@ -440,14 +444,14 @@ h1 {
} }
.tool[data-tool="fight_mochi"] { --tool-icon: none; } .tool[data-tool="fight_mochi"] { --tool-icon: none; }
.tool[data-tool="fight_mochi"]::before { .tool[data-tool="fight_mochi"]::before {
content: "\1f4a2 "; content: "\1F4A2";
background: none; background: none;
font-size: 24px; font-size: 24px;
line-height: 1; line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12)); filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
} }
.tool[data-tool="fight_mochi"]::after { .tool[data-tool="fight_mochi"]::after {
content: "\1f4a2 "; content: "\1F4A2";
background: none; background: none;
font-size: 46px; font-size: 46px;
line-height: 1; line-height: 1;
@ -472,7 +476,7 @@ h1 {
.hint { .hint {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.40;
margin: 10px 0 0; margin: 10px 0 0;
} }
@ -582,11 +586,36 @@ meter {
.selected-info { .selected-info {
font-size: 13px; font-size: 13px;
line-height: 1.55; line-height: 1.40;
} }
.selected-info.empty { .selected-info.empty {
color: var(--muted); 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 { .badge {
display: inline-block; display: inline-block;
font-size: 11px; font-size: 11px;
@ -605,6 +634,29 @@ meter {
padding: 5px 0; padding: 5px 0;
} }
.info-row span:first-child { color: var(--muted); } .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-card { min-height: 260px; flex: 0 0 auto; display: flex; flex-direction: column; }
.log { .log {
@ -1250,8 +1302,8 @@ body::before {
display: block; display: block;
opacity: 0.96; opacity: 0.96;
} }
.tool[data-tool="poke"]::before { content: "👈"; } .tool[data-tool="poke"]::before { content: "\1F448"; }
.tool[data-tool="pinch"]::before { content: "🤏"; } .tool[data-tool="pinch"]::before { content: "\1F90F"; }
.tool[data-tool="poke"]::after, .tool[data-tool="poke"]::after,
.tool[data-tool="pinch"]::after { .tool[data-tool="pinch"]::after {
background: none; background: none;
@ -1268,8 +1320,8 @@ body::before {
width: 54px; width: 54px;
height: 54px; height: 54px;
} }
.tool[data-tool="poke"]::after { content: "👈"; } .tool[data-tool="poke"]::after { content: "\1F448"; }
.tool[data-tool="pinch"]::after { content: "🤏"; } .tool[data-tool="pinch"]::after { content: "\1F90F"; }
.tool[data-tool="poke"] span, .tool[data-tool="poke"] span,
.tool[data-tool="pinch"] span { display: none; } .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. */ /* 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"] { --tool-icon: none; }
.tool[data-tool="water_hose"]::before { .tool[data-tool="water_hose"]::before {
content: "🚿"; content: "\1F6BF";
background: none; background: none;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 24px; font-size: 24px;
@ -1287,7 +1339,7 @@ body::before {
opacity: 0.96; opacity: 0.96;
} }
.tool[data-tool="water_hose"]::after { .tool[data-tool="water_hose"]::after {
content: "🚿"; content: "\1F6BF";
background: none; background: none;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif; font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
font-size: 46px; font-size: 46px;
@ -1300,3 +1352,67 @@ body::before {
bottom: auto; bottom: auto;
transform: translateY(-50%); 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`. ## Goal
* 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.
Primary goals: Add four continuous personality parameters to each tarinai:
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.
--- * aggression
* openness
* sociability
* neuroticism
## 1. Family tree update performance Each parameter ranges from `-1.0` to `+1.0`.
Current issue: 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.
The family tree update still performs heavy synchronous preprocessing, causing the main game to freeze.
Implement: ## Personality axes
* Split heavy preprocessing into small chunks. ### aggression
* 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.
Update timing rules: Range:
* When the family tree panel is hidden: * low side: calm
* high side: irritable
* Do not update every 5 seconds. Behavior:
* Check/update at most once every 30 seconds.
* When the family tree panel is visible:
* Do not update on a fixed 5-second timer. * High aggression makes the tarinai more likely to threaten others, start fights, or retaliate.
* Update only when the family tree is dirty, meaning a relevant change occurred. * Low aggression makes the tarinai less likely to start fights.
* Relevant dirty events include at least:
* birth Changes:
* 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.
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. ### openness
* Hidden family tree work should be very rare.
* Visible family tree should refresh promptly after actual changes.
--- Range:
## 2. Coward personality condition * low side: conservative
* high side: playful
Current issue: Behavior:
The “coward” condition may count total fight results and per-relationship fight results together, causing double counting.
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. Changes:
* Do not include per-relationship win/loss counts in this condition.
* Trigger condition:
* The Tarinai has just taken damage. * Being petted by the cursor should slightly increase openness.
* 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 `臆病`.
Expected result: ### sociability
* The calculation uses only aggregate lifetime fight result counters. Range:
* No double counting.
--- * 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. * Spending time near parents, children, or friends should slightly increase sociability.
* Tarinai with `ずんち病` can initiate fights.
* Tarinai with any other disease must not initiate fights.
* Healthy Tarinai may initiate fights according to the existing rules.
Being targeted / challenged: ### neuroticism
* Tarinai with `ねむり病` must not be targeted for fights. Range:
* Tarinai with any other disease may still be targeted/challenged by another Tarinai.
* This means:
* `爆発病` and `ずんち病`: can initiate and can be targeted. * low side: insensitive / unfazed
* Other non-sleep diseases: cannot initiate, but can be targeted. * high side: delicate / sensitive
* `ねむり病`: cannot initiate and cannot be targeted.
Apply consistently to: Behavior:
* Normal fight selection. * High neuroticism makes the tarinai gain more stress from unpleasant stimuli such as corpses, poop, nearby fights, or overcrowding.
* Forced fight logic. * Low neuroticism makes the tarinai less affected by such stimuli.
* Fight-food effects such as `けんか餅`.
* Any helper such as `canStartFight`, `canBeFightTarget`, or equivalent.
Expected result: Changes:
* Disease rules are not handled by scattered ad-hoc checks. * Touching poop or corpses should still increase stress immediately.
* Use clear helper functions if they do not already exist. * 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. Each tarinai should have two personality objects:
* 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.
Implement: ```js
birthPersonality
currentPersonality
```
* Separate normal self-propelled movement from external impulse/knockback. Rules:
* External impulses must not be clamped by the normal Tarinai walking speed cap.
* Add or refactor toward helper methods such as:
* `applyImpulse(entity, vx, vy, options)` * `birthPersonality` is created at birth and normally does not change.
* `applyKnockback(entity, sourceX, sourceY, strength, options)` * `currentPersonality` starts equal to `birthPersonality`.
* `applyImpactDamage(target, amount, cause)` * Experience modifies only `currentPersonality`.
* `resolveCircleCircleCollision(...)` * The tarinai data UI should show both birth personality and current personality.
* `resolveCircleRectCollision(...)`
* Exact helper names are flexible, but the behavior must be centralized and not duplicated.
Unlimited ball speed: ## Inheritance
* Do not reintroduce a ball speed cap. When a child is born:
* 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.
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. Then:
* 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 `衝突`.
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. ## Thresholds and personality tags
* Use the attacker’s actual movement/impulse direction and speed.
* If collision damage kills a Tarinai, death cause must be `衝突`.
Explosion knockback: Use two threshold levels:
* Make explosion knockback stronger than before. ```text
* Use external impulse, not normal movement velocity. -1.0 to -0.75: strong low-side trait
* Knockback should be partly random. -0.75 to -0.5: slight low-side trait
* Tarinai closer to the blast center should be thrown faster and farther. -0.5 to +0.5: neutral, no visible personality tag
* Tarinai farther from the blast center should be thrown less strongly. +0.5 to +0.75: slight high-side trait
* Preserve or improve existing explosion damage behavior. +0.75 to +1.0: strong high-side trait
* If an explosion kills a Tarinai, keep the appropriate explosion-related death cause, not `衝突`. ```
Expected result: Display tags:
* Normal walking speed remains bounded. * Slight traits should be displayed as \u201cslightly X\u201d.
* Knockback from explosions and collisions can exceed walking speed. * Strong traits should be displayed as \u201cX\u201d.
* High-speed objects do not frequently tunnel through collisions. * Neutral values should not create a visible tag.
* Collision behavior is handled by shared primitives rather than several unrelated implementations.
--- 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. ## Daily personality change limit
* 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.
--- Add a daily change cap so personality values cannot change too quickly.
## 6. Solid obstacle and placement logic unification Recommended limits:
Current issues: ```text
Fence, nest box, and placement preview logic are still partly fragmented. 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. If a change would exceed the daily limit, reduce or ignore the excess change.
* Fence and nest box should use the same obstacle system where possible.
* Rename misleading helpers if needed:
* For example, functions named only for “fence” but now handling nest boxes too should be renamed or wrapped with neutral names such as `solidObstacle`. Reset these daily counters at the start of each new in-game day.
* Ball, Tarinai movement, path blocking, and placement checks should reference the same obstacle data.
Fence collision: ## Observation log rules
* Fence collision bounds must match the visible fence appearance. Write personality change entries to the observation log only when a threshold is crossed.
* 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.
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 Example log messages:
* 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.
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. Use the existing observation log system if one exists.
* 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.
Expected result: ## Tarinai data UI
* Placement UI, actual placement, and collision behavior agree. Update the tarinai data UI to show personality information clearly.
* No “red but placeable” or “green but placed elsewhere” behavior.
--- It should include:
## 7. Item/tool definition unification 1. Birth personality
2. Current personality
3. Visible personality tags derived from current personality
Current issues: Example structure:
Tool names, labels, icons, sizes, placement rules, and descriptions are scattered across multiple files.
Implement: ```text
Personality Tags:
slightly irritable / social
* Create or consolidate a single source of truth for tool/item definitions. Birth Personality:
* It should include at least: Aggression: -0.12
Openness: +0.31
Sociability: +0.48
Neuroticism: +0.66
* internal id Current Personality:
* Japanese label Aggression: +0.57
* tooltip text Openness: +0.34
* UI icon or icon renderer Sociability: +0.76
* game appearance reference where applicable Neuroticism: +0.22
* 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.
Specific icon requirements: Keep the UI compact. Do not create a large new panel unless necessary.
* `つつく` must use `👈`. ## Implementation constraints
* `つまむ` 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.
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. * `ensurePersonality(tarinai)`
* If water still exists as a game object produced by rain or other simulation systems, keep that object behavior. * `adjustPersonality(tarinai, key, delta, reason)`
* Remove leftover UI-only water tool entries, labels, tooltips, and placement handlers unless they are still required by non-UI simulation. * `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. After implementation:
* Tool data is not scattered unnecessarily.
--- * Run syntax checks for all JavaScript files.
* Verify that old tarinai without personality data do not crash the game.
## 8. Observation UI event filtering * Verify that personality values are clamped.
* Verify that daily limits work.
Current issue: * Verify that threshold crossing creates observation logs.
Observation UI hides some events by matching Japanese text strings, which is fragile. * Verify that minor personality changes do not spam logs.
* Verify that tarinai data UI shows birth personality, current personality, and visible tags.
Implement: * Verify that children inherit from parents' current personality with \u00b10.2 random variation.
* 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.

View file

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

View file

@ -33,6 +33,68 @@ const DECOR_ASSETS = [
{ id: "zunchi", path: "assets/objects/zunchi.png" }, { id: "zunchi", path: "assets/objects/zunchi.png" },
{ id: "zunchi_02", path: "assets/objects/zunchi_02.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 = { const ALPHA_BOUNDS = {
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 }, 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 }, angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
@ -106,4 +168,3 @@ const LIGHTING_TUNING = {
goldenOverlayAlpha: 0.045, goldenOverlayAlpha: 0.045,
bloomWashAlpha: 0.040, bloomWashAlpha: 0.040,
}; };

View file

@ -10,12 +10,12 @@ const HEALTH = (() => {
poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f", poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f",
firecracker: "\u7206\u7af9", firecracker: "\u7206\u7af9",
accident: "\u4e8b\u6545", accident: "\u4e8b\u6545",
collision: "衝突", collision: "\u885d\u7a81",
outOfBounds: "仕様により画面外で削除", outOfBounds: "\u4ed5\u69d8\u306b\u3088\u308a\u753b\u9762\u5916\u3067\u524a\u9664",
lifespan: "天寿を全うした", lifespan: "\u5929\u5bff\u3092\u5168\u3046\u3057\u305f",
sleepDisease: "\u306d\u3080\u308a\u75c5", sleepDisease: "\u306d\u3080\u308a\u75c5",
explosionDisease: "\u7206\u767a\u75c5", explosionDisease: "\u7206\u767a\u75c5",
fightDisease: "きずつき病", fightDisease: "\u304d\u305a\u3064\u304d\u75c5",
zunchiDisease: "\u305a\u3093\u3061\u75c5", zunchiDisease: "\u305a\u3093\u3061\u75c5",
}); });
const DISEASE_RE = /([^\u3001\u3002\s]+\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 (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight; if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight;
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker;
if (/仕様により画面外で削除|画面外で削除|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds; 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 (/衝突|collision/.test(text)) return CAUSES.collision; if (/\u885d\u7a81|collision/.test(text)) return CAUSES.collision;
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke; 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 (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease;
if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease; 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 (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease;
if (/\u75c5/.test(text)) { if (/\u75c5/.test(text)) {
const disease = text.match(DISEASE_RE); 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 (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger;
if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress; 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 (/\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 ""; return "";
} }
@ -121,6 +121,7 @@ const HEALTH = (() => {
cause = rememberDamage(t, actualLoss, reason); cause = rememberDamage(t, actualLoss, reason);
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure(); 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.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); if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason);
t.showHpBar(); 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"; "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 { class Item {
constructor(type, x, y) { constructor(type, x, y) {
this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`; this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
this.type = type; this.type = type;
this.x = x; this.x = x;
this.y = y; this.y = y;
this.r = { this.r = itemRadiusFor(type, 12);
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 this.amount = itemAmountFor(type, 80);
}[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.age = 0; this.age = 0;
this.seed = Math.random() * 1000; this.seed = Math.random() * 1000;
this.dropTimer = 0; this.dropTimer = 0;
@ -57,6 +62,13 @@ class Item {
this.fuseTimer = 5; this.fuseTimer = 5;
this.fuseMax = 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) { update(dt, worldRef = world) {
this.age += dt; this.age += dt;
@ -69,7 +81,7 @@ class Item {
} }
} }
if (this.type === "water") this.amount -= dt * 0.9; 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 === "trace") this.amount -= dt * 1.35;
if (this.type === "splat") this.amount -= dt * 1.05; if (this.type === "splat") this.amount -= dt * 1.05;
if (this.type === "firecracker") { 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, 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, 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, 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, vx: this.vx, vy: this.vy, prevX: this.prevX, prevY: this.prevY,
spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId, spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId,
lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle
@ -459,12 +471,8 @@ class Item {
it.type = type; it.type = type;
it.x = o.x; it.x = o.x;
it.y = o.y; it.y = o.y;
it.r = { it.r = itemRadiusFor(type, 12);
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 it.amount = o.amount ?? itemAmountFor(type, 80);
}[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.age = o.age || 0; it.age = o.age || 0;
it.seed = o.seed || Math.random() * 1000; it.seed = o.seed || Math.random() * 1000;
it.dropTimer = 0; it.dropTimer = 0;
@ -534,6 +542,17 @@ class Item {
it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2); it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2);
it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi"; it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi";
it.toolSize = o.toolSize || it.toolSize || "medium"; 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.blastScale = o.blastScale || it.blastScale || 1;
it.fuseTimer = o.fuseTimer ?? it.fuseTimer; it.fuseTimer = o.fuseTimer ?? it.fuseTimer;
it.fuseMax = o.fuseMax ?? it.fuseMax; it.fuseMax = o.fuseMax ?? it.fuseMax;
@ -548,7 +567,8 @@ class Item {
const warm = lightState.warmth; const warm = lightState.warmth;
const styleNight = lightState.light < 0.42; const styleNight = lightState.light < 0.42;
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55); 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 dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0; const dropY = dropT > 0 ? -170 * Math.pow(dropT, 0.42) : 0;
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18)); const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
@ -869,4 +889,3 @@ class Item {
ctx.restore(); ctx.restore();
} }
} }

View file

@ -124,51 +124,30 @@ function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
return; 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) { function placementPreviewFor(world) {
const p = world?.pointer; const p = world?.pointer;
const type = PLACEABLE_TOOL_TYPES[world?.tool || ""]; const type = toolItemType(world?.tool || "");
if (!p?.inside || !type) return null; if (!p?.inside || !type) return null;
const pad = CONFIG.worldPadding || 30; const pad = CONFIG.worldPadding || 30;
let x = clamp(p.x, pad, world.w - pad); let x = p.x;
let y = clamp(p.y, pad, world.h - pad); let y = p.y;
const r = { const r = itemRadiusFor(type, 12);
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;
const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad)); 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; const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
let blocked = false; let blocked = false;
if (type === "grass" && world.findGrassPlantingSpot) { let tmp = { type, x, y, r, dead: false };
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));
}
if ((type === "fence_v" || type === "fence_h") && world.fenceRect) { if ((type === "fence_v" || type === "fence_h") && world.fenceRect) {
const raw = { type, x, y, r }; const rawRect = world.fenceRect(tmp);
const rawRect = world.fenceRect(raw); return { type, x, y, r, rect: rawRect, blocked: rectOutside(rawRect) || world.placementBlocked?.(tmp) || world.fencePlacementBlocked?.(tmp) };
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) };
}
} }
if (type === "nest_box" && world.nestBoxBaseRect) { if (type === "nest_box" && world.nestBoxBaseRect) {
const tmp = { type, x, y, r, dead: false };
const rect = world.nestBoxBaseRect(tmp); 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 }; return { type, x, y, r, blocked };
} }
@ -773,7 +752,8 @@ function drawNestBoxTooltip(ctx, world) {
const info = world.pointerNestBoxInfo?.(); const info = world.pointerNestBoxInfo?.();
if (!info) return; if (!info) return;
const box = info.box; 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 const lines = info.occupants.length
? [title, ...info.occupants.map(t => t.name)] ? [title, ...info.occupants.map(t => t.name)]
: [title, "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044"]; : [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 child = t.juvenile && t.parentToFollow?.() ? " / \u5b50" : "";
const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : ""; const zHint = t.digest > 4.9 ? " / \u305a\u3093\u3061" : "";
ctx.fillText(`${stateLabel(t.state)}${child}${zHint}`, x + 10, y + 35); 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}` : ""; 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(); ctx.restore();
} }

View file

@ -64,19 +64,189 @@ function stableUnit(seed, salt = "") {
return ((h >>> 0) % 100000) / 100000; return ((h >>> 0) % 100000) / 100000;
} }
const PERSONALITIES = { // Legacy named personalities were removed. Keep these stubs only so old save data
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 }, // and old UI fallback references cannot revive the pre-four-axis personality model.
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 }, const PERSONALITIES = Object.freeze({});
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 }, const PERSONALITY_IDS = [];
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);
function personalityForSeed(seed) { 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() { function relationDefaults() {
@ -321,4 +491,3 @@ function drawHeartShape(ctx, x, y, size, opts = {}) {
ctx.stroke(); ctx.stroke();
ctx.restore(); ctx.restore();
} }

View file

@ -19,19 +19,23 @@ class Tarinai {
this.world = world; this.world = world;
this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`); this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
this.name = opts.name || makeName(world); 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(); this.type = opts.type || baseSpriteId();
const typeMeta = SPRITES.find(s => s.id === this.type); const typeMeta = SPRITES.find(s => s.id === this.type);
if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId(); 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); 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.x = opts.x ?? rand(100, world.w - 100);
this.y = opts.y ?? rand(100, world.h - 100); this.y = opts.y ?? rand(100, world.h - 100);
this.vx = opts.vx ?? rand(-12, 12); this.vx = opts.vx ?? rand(-12, 12);
this.vy = opts.vy ?? 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.scale = opts.scale ?? rand(0.22, 0.33);
this.radius = 56 * this.scale; this.radius = 56 * this.scale;
this.age = opts.age ?? rand(0, 20); this.age = opts.age ?? rand(0, 20);
@ -109,6 +113,11 @@ class Tarinai {
this.fightDiseaseCooldown = opts.fightDiseaseCooldown ?? 0; this.fightDiseaseCooldown = opts.fightDiseaseCooldown ?? 0;
this.lastBleedAt = opts.lastBleedAt ?? -999; this.lastBleedAt = opts.lastBleedAt ?? -999;
this.favorite = Boolean(opts.favorite); 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"; if (this.isZunchiSlave) this.name = "\u305a\u3093\u3061\u3069\u308c\u3044";
this.tempComfort = opts.tempComfort ?? 0.62; this.tempComfort = opts.tempComfort ?? 0.62;
this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55; this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55;
@ -167,7 +176,7 @@ class Tarinai {
birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader, birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader,
pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed, 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, 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, zunchiDisease: this.zunchiDisease, zunchiDiseaseSeverity: this.zunchiDiseaseSeverity, zunchiDiseaseCooldown: this.zunchiDiseaseCooldown,
tempComfort: this.tempComfort, tempTimer: this.tempTimer, tempComfort: this.tempComfort, tempTimer: this.tempTimer,
parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime, 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, nextHeadbutt: this.nextHeadbutt, digest: this.digest, poopCount: this.poopCount, stretchTimer: this.stretchTimer,
grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer, grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer,
defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId, 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, blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer,
lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer, nextCursorHeartAt: this.nextCursorHeartAt, cursorPetting: this.cursorPetting, lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer, nextCursorHeartAt: this.nextCursorHeartAt, cursorPetting: this.cursorPetting,
entryTimer: this.entryTimer || 0, entryTimer: this.entryTimer || 0,
@ -225,9 +235,9 @@ class Tarinai {
} }
addRecord(text, kind = "note") { addRecord(text, kind = "note", options = {}) {
if (!text) return; if (!text) return;
if (this.world?.shouldHideFromObservationRecords?.(text)) return; if (options.hiddenFromObservation) return;
if (!Array.isArray(this.records)) this.records = []; if (!Array.isArray(this.records)) this.records = [];
const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" }; const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" };
const head = this.records[0]; const head = this.records[0];
@ -237,13 +247,33 @@ class Tarinai {
} }
personalityProfile() { 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() { 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() { fightWinCount() {
return this.totalFightWins || 0; return this.totalFightWins || 0;
} }
@ -253,17 +283,17 @@ class Tarinai {
} }
maybeBecomeTimidAfterDamage(cause = "") { maybeBecomeTimidAfterDamage(cause = "") {
if (this.dead || this.personality === "timid") return false; if (this.dead) return false;
const losses = this.totalFightLosses || 0; const losses = this.totalFightLosses || 0;
const wins = this.totalFightWins || 0; const wins = this.totalFightWins || 0;
if (losses <= wins) return false; if (losses <= wins) return false;
if (Math.random() >= 0.05) 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.fearTimer = Math.max(this.fearTimer || 0, 1.2);
this.stress = clamp((this.stress || 0) + 5, 0, 130); 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.world?.spawnBubble?.(this.x, this.y - this.radius * 1.35, "\u3053\u308f\u3044\u2026", "rgba(90,80,120,0.78)");
this.addRecord?.(`${this.name}は痛みで臆病になった。`, "health"); return Boolean(changedA || changedB);
return true;
} }
relationTo(id) { relationTo(id) {
@ -285,7 +315,7 @@ class Tarinai {
strongestRelation(kind = "friend") { strongestRelation(kind = "friend") {
let bestId = null; 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 || {})) { for (const [id, rel] of Object.entries(this.relationships || {})) {
const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0); const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0);
if (score > bestScore) { bestId = id; bestScore = score; } if (score > bestScore) { bestId = id; bestScore = score; }
@ -299,7 +329,23 @@ class Tarinai {
return { friend, fear }; 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; let best = null, bestScore = minScore;
for (const [id, rel] of Object.entries(this.relationships || {})) { for (const [id, rel] of Object.entries(this.relationships || {})) {
const score = rel.affinity || 0; const score = rel.affinity || 0;
@ -369,6 +415,24 @@ class Tarinai {
normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); } normalizeDeathReason(reason = "", opts = {}) { return HEALTH.normalizeDeathReason(this, reason, opts); }
damage(amount, reason = "") { return HEALTH.applyDamage(this, amount, reason); } 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) { recoverHealth(amount) {
if (this.dead) return; if (this.dead) return;
this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100); this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100);
@ -406,7 +470,7 @@ class Tarinai {
} }
targetIgnoresFence(target = this.target) { 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) { shouldAvoidTarget(target) {
@ -647,9 +711,9 @@ class Tarinai {
this.fightDisease = true; this.fightDisease = true;
this.fightDiseaseCooldown = 18; this.fightDiseaseCooldown = 18;
this.fearTimer = Math.max(this.fearTimer || 0, 0.35); 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?.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; return true;
} }
@ -657,10 +721,10 @@ class Tarinai {
if (!this.fightDisease) return false; if (!this.fightDisease) return false;
this.fightDisease = false; this.fightDisease = false;
this.fightDiseaseCooldown = 20; 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.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?.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; return true;
} }
@ -684,34 +748,112 @@ class Tarinai {
return true; return true;
} }
updateNestBoxPresence(dt = 0) { nestBoxById(id) {
const previous = this.insideNestBoxId || null; if (!id) return null;
let box = null; return this.world?.items?.find?.(it => it && !it.dead && it.id === id && it.type === "nest_box") || null;
if (previous) box = this.world?.items?.find?.(it => it && !it.dead && it.id === previous && it.type === "nest_box") || null; }
if (!box && this.target && !this.target.dead && this.target.type === "nest_box") box = this.target;
if (!box) { isInsideNestBox() {
this.insideNestBoxId = null; return Boolean(this.insideNestBoxId);
this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1); }
return null;
} wantsNestBox(box = null) {
const d = distXY(this.x, this.y, box.x, box.y); if (!box || box.dead || box.type !== "nest_box") return false;
const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : []; 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 capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5;
const occupantIndex = occupants.indexOf(this); const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
const hasRoom = occupantIndex >= 0 ? occupantIndex < capacity : occupants.length < capacity; const alreadyInside = this.insideNestBoxId === box.id;
const canEnter = hasRoom && (this.state === "sleep" || this.state === "seek_bed") && d <= Math.max(18, box.r * 0.58); if (!alreadyInside && occupants.length >= capacity) return false;
if (canEnter) { if (!alreadyInside) {
this.insideNestBoxId = box.id; this.nestBoxEnteredAt = this.world?.time || 0;
this.nestFade = clamp((this.nestFade || 0) + dt * 2.4, 0, 1); this.nestBoxStayUntil = Math.max(this.nestBoxStayUntil || -Infinity, (this.world?.time || 0) + 14);
const pull = clamp(dt * 4.2, 0, 1); }
this.x = lerp(this.x, box.x, pull); this.insideNestBoxId = box.id;
this.y = lerp(this.y, box.y, pull); this.target = box;
this.vx *= Math.pow(0.45, dt * 60); if (this.state === "seek_bed") this.state = "sleep";
this.vy *= Math.pow(0.45, dt * 60); if (this.state === "sleep") this.sleeping = true;
return box; 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.insideNestBoxId = null;
this.nestFade = clamp((this.nestFade || 0) - dt * 1.8, 0, 1); this.nestFade = 0;
this.nestBoxEnteredAt = -Infinity;
this.nestBoxStayUntil = -Infinity;
return true;
}
updateNestBoxPresence(dt = 0) {
const currentBox = this.nestBoxById(this.insideNestBoxId);
const targetBox = this.target && !this.target.dead && this.target.type === "nest_box" ? this.target : null;
const box = currentBox || targetBox;
if (!box) {
this.insideNestBoxId = null;
this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1);
return null;
}
if (currentBox && !this.wantsNestBox(currentBox)) {
this.leaveNestBox(currentBox);
return null;
}
if (currentBox) {
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) {
this.state = "sleep";
this.sleeping = true;
this.target = currentBox;
}
this.enterNestBox(currentBox, dt);
return currentBox;
}
if (!this.wantsNestBox(targetBox)) {
this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1);
return null;
}
const entry = this.world?.nestBoxEntryPoint ? this.world.nestBoxEntryPoint(targetBox) : { x: targetBox.x, y: targetBox.y };
const base = this.world?.nestBoxBaseRect ? this.world.nestBoxBaseRect(targetBox) : null;
const r = targetBox.r || 42;
const entryD = distXY(this.x, this.y, entry.x, entry.y);
const centerD = distXY(this.x, this.y, targetBox.x, targetBox.y);
const inDoorColumn = base && this.x >= base.left + r * 0.18 && this.x <= base.right - r * 0.18 && this.y >= base.top + r * 0.06 && this.y <= base.bottom + r * 0.48;
const captureRange = Math.max(118, r * 2.05, this.radius + 82);
const nearEnough = entryD <= captureRange || centerD <= Math.max(104, r * 1.90) || inDoorColumn;
if (nearEnough) {
this.enterNestBox(targetBox, dt || 0.016);
return targetBox;
}
this.nestFade = clamp((this.nestFade || 0) - dt * 3.0, 0, 1);
return null; 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.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)) { 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) { if (this.fightDisease) {
this.stress = clamp(this.stress + dt * 0.16, 0, 130); 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 (["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.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; 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) { applyCursorContactCare(dt) {
@ -861,6 +1003,7 @@ class Tarinai {
const p = clamp(1 - d / touch, 0, 1); const p = clamp(1 - d / touch, 0, 1);
const relief = dt * (1.35 + 2.65 * p); const relief = dt * (1.35 + 2.65 * p);
this.stress = clamp((this.stress || 0) - relief, 0, 130); 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.moodLiftTimer = Math.max(this.moodLiftTimer || 0, 0.5);
this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75); this.cursorPetting = clamp((this.cursorPetting || 0) + dt * (1.0 + p * 2.4), 0, 1.75);
this.goodMode = "smile"; this.goodMode = "smile";
@ -914,6 +1057,7 @@ class Tarinai {
} }
} }
this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt); 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); this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2);
if (this.freezeSleepDiseaseMotion(dt)) { if (this.freezeSleepDiseaseMotion(dt)) {
this.checkLife(dt); this.checkLife(dt);
@ -1035,9 +1179,16 @@ class Tarinai {
if (parent && dist(this, parent) < 96) { if (parent && dist(this, parent) < 96) {
this.stress = clamp(this.stress - dt * 0.42, 0, 130); this.stress = clamp(this.stress - dt * 0.42, 0, 130);
this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110); 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; let grassComfort = 0;
for (const it of this.world.nearbyItems(this.x, this.y, 105)) { for (const it of this.world.nearbyItems(this.x, this.y, 105)) {
if (it.type !== "grass" || it.dead) continue; if (it.type !== "grass" || it.dead) continue;
@ -1054,7 +1205,7 @@ class Tarinai {
if (grassComfort > 0) { if (grassComfort > 0) {
this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130); 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.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"; 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) { sleepSpotFor(bed) {
if (!bed) return { x: this.x, y: this.y }; if (!bed) return { x: this.x, y: this.y };
if (bed.type === "nest_box") { 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 angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2;
const baseRing = bed.r * 0.52; const baseRing = bed.r * 0.52;
@ -1128,6 +1313,7 @@ class Tarinai {
const d = dist(this, z); const d = dist(this, z);
const push = clamp(1 - d / 90, 0, 1); const push = clamp(1 - d / 90, 0, 1);
this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130); 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.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; this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40;
} }
@ -1182,12 +1368,12 @@ class Tarinai {
this.fightWinnerId = null; 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) { if (friendNearby && friendNearby.state === "panic" && friendNearby.target) {
this.state = "panic"; this.state = "panic";
this.target = this.panicDestination(friendNearby.target); this.target = this.panicDestination(friendNearby.target);
this.fearTimer = Math.max(this.fearTimer, 0.32); 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; return;
} }
@ -1245,11 +1431,11 @@ class Tarinai {
if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { if (this.hasZundaCurableDisease?.() && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
const cureFood = this.world.nearest(this, ["sweet"], 700); 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.state = "seek_food";
this.target = cureFood; this.target = cureFood;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.42); 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; return;
} }
} }
@ -1257,14 +1443,14 @@ class Tarinai {
if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) { if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
this.state = "fight_sick"; this.state = "fight_sick";
this.target = null; 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); if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4);
return; return;
} }
if (!this.isZunchiSlave) { if (!this.isZunchiSlave) {
const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi", "sleep_drug"], wasSleeping ? 520 : 390); 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.state = "seek_food";
this.target = specialMochi; this.target = specialMochi;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.34); this.foodReactTimer = Math.max(this.foodReactTimer, 0.34);
@ -1286,7 +1472,7 @@ class Tarinai {
const zunda = this.world.nearest(this, ["sweet"], zundaRange); const zunda = this.world.nearest(this, ["sweet"], zundaRange);
const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1)); const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1));
const zundaReward = this.affection > 8 && this.hunger > 7; 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.state = "seek_food";
this.target = zunda; this.target = zunda;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.36); 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) { if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) {
let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null; 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 && 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) { if (bed && !wasSleeping && this.energy > 18) {
const spot = this.sleepSpotFor(bed); const spot = this.sleepSpotFor(bed);
if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) { 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.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.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; return;
} }
@ -1355,19 +1541,21 @@ class Tarinai {
} }
const playTrait = this.personalityProfile().play ?? 1; 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); const ball = this.world.nearest(this, ["ball"], ballRange);
if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) { if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) {
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0); const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const chasers = this.world.countBallChasers ? this.world.countBallChasers(ball, this) : 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 movingBonus = clamp(ballSpeed / 120, 0, 0.42);
const socialBonus = this.loneliness > 48 ? 0.10 : 0; 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.state = "play_ball";
this.target = ball; this.target = ball;
this.goodMode = "smile"; 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; return;
} }
} }
@ -1388,7 +1576,7 @@ class Tarinai {
this.state = "seek_food"; this.state = "seek_food";
this.target = friendNearby.target; this.target = friendNearby.target;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.24); 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; return;
} }
@ -1404,8 +1592,10 @@ class Tarinai {
} }
} }
if (this.loneliness > 63 - (this.personality === "lonely" ? 16 : 0)) { const socialSeek = this.shouldApplyPersonalityBehavior("sociability", 1);
const other = this.world.nearestOther(this, 300); 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) { if (other) {
this.state = "seek_friend"; this.state = "seek_friend";
this.target = other; this.target = other;
@ -1446,14 +1636,13 @@ class Tarinai {
const d = dist(this, it); const d = dist(this, it);
if (d > this.radius + it.r + 12) continue; if (d > this.radius + it.r + 12) continue;
if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && it.amount > 1.2) { if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) {
const eating = Math.min(it.amount, it.type === "sweet" ? 7.2 : 8.5); const eating = this.consumeFoodServing(it, it.type === "sweet" ? 7.2 : 8.5);
if (eating > 0) { if (eating > 0) {
this.state = "eat"; this.state = "eat";
this.target = it; this.target = it;
this.vx *= Math.pow(0.25, dt * 60); this.vx *= Math.pow(0.25, dt * 60);
this.vy *= 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.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95);
this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46)); this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46));
this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012; 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.stress = clamp(this.stress - eating * 0.42, 0, 130);
this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035); 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.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.eatTimer = Math.max(this.eatTimer, 0.32);
this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78); 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) { if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug") && this.foodItemHasServingLeft(it)) {
const eating = Math.min(it.amount, 6.8); const eating = this.consumeFoodServing(it, 6.8);
if (eating <= 0) continue;
this.state = "eat"; this.state = "eat";
this.target = it; this.target = it;
this.vx *= Math.pow(0.25, dt * 60); this.vx *= Math.pow(0.25, dt * 60);
this.vy *= Math.pow(0.25, dt * 60); this.vy *= Math.pow(0.25, dt * 60);
it.amount -= eating;
this.hunger -= eating * 0.82; this.hunger -= eating * 0.82;
this.recoverHealth(eating * 0.24); this.recoverHealth(eating * 0.24);
this.affection += eating * 0.04; this.affection += eating * 0.04;
@ -1497,12 +1686,20 @@ class Tarinai {
this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12)); this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12));
this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)"); this.bubble("\u2661", 2.4, "rgba(190,82,132,0.78)");
this.lastMealColor = "#ff7bab"; this.lastMealColor = "#ff7bab";
} else { } else if (it.type === "fight_mochi") {
this.stress = clamp(this.stress + eating * 0.22, 0, 130); this.stress = clamp(this.stress + eating * 0.22, 0, 130);
this.fearTimer = Math.max(this.fearTimer || 0, 0.32); this.fearTimer = Math.max(this.fearTimer || 0, 0.32);
this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18)); this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 52 + rand(10, 18));
this.bubble("!", 2.0, "rgba(180,86,52,0.80)"); this.bubble("!", 2.0, "rgba(180,86,52,0.80)");
this.lastMealColor = "#e07c43"; 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.eatTimer = Math.max(this.eatTimer, 0.30);
this.eatCooldown = rand(0.38, 0.68); this.eatCooldown = rand(0.38, 0.68);
@ -1521,7 +1718,8 @@ class Tarinai {
} }
if (canBite && it.type === "grass") { 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.state = "eat";
this.target = it; this.target = it;
this.grassEatTimer = Math.max(this.grassEatTimer, 0.22); this.grassEatTimer = Math.max(this.grassEatTimer, 0.22);
@ -1548,10 +1746,14 @@ class Tarinai {
if (it.type === "zunchi") { if (it.type === "zunchi") {
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); 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); this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100);
if (canZunchiBite) { 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.state = "eat";
this.target = it; this.target = it;
this.vx *= Math.pow(0.18, dt * 60); this.vx *= Math.pow(0.18, dt * 60);
@ -1559,7 +1761,8 @@ class Tarinai {
it.amount -= bite; it.amount -= bite;
this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18); this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18);
this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02)); this.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.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100);
this.lastMealColor = "#6b8d3f"; this.lastMealColor = "#6b8d3f";
this.eatTimer = Math.max(this.eatTimer, 0.24); this.eatTimer = Math.max(this.eatTimer, 0.24);
@ -1604,7 +1807,7 @@ class Tarinai {
if (it.type === "ball") { if (it.type === "ball") {
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1); 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.goodMode = "smile";
this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130); this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130);
this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110); 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") { if (it.type === "trace") {
this.loneliness -= dt * 0.25; 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); this.hunger = clamp(this.hunger, 0, 115);
@ -1663,12 +1874,12 @@ class Tarinai {
this.loneliness -= dt * 1.65 * this.trait.social; this.loneliness -= dt * 1.65 * this.trait.social;
this.affection += dt * 0.28 * 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"); 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.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130);
this.affection = clamp(this.affection + dt * 0.18, 0, 80); 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 (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"); 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; if (this.type === "angry") this.stress += dt * 0.08;
@ -1711,10 +1922,14 @@ class Tarinai {
const relB = o.relationTo(this.id); const relB = o.relationTo(this.id);
const battleDrug = this.hasFightMochiEffect() || o.hasFightMochiEffect(); const battleDrug = this.hasFightMochiEffect() || o.hasFightMochiEffect();
const loveDrug = this.hasLoveMochiEffect() || o.hasLoveMochiEffect(); 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 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 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 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 birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false; const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false;
@ -1723,7 +1938,7 @@ class Tarinai {
this.world.startConflict(this, o); 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 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); 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)) { if (enough && lonelyBond && Math.random() < dt * (loveDrug ? 0.72 : 0.25)) {
@ -1733,7 +1948,7 @@ class Tarinai {
} }
if (closeCount > 8) { if (closeCount > 8) {
this.stress += dt * 0.9; this.stress += dt * 0.9 * this.personalityProfile().fear;
this.energy -= dt * 0.25; this.energy -= dt * 0.25;
this.fearTimer = Math.max(this.fearTimer, 0.18); 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.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.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); 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)) { if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) {
const spot = this.sleepSpotFor(this.target); const spot = this.sleepSpotFor(this.target);
const dx = spot.x - this.x; const dx = spot.x - this.x;
@ -1828,9 +2049,13 @@ class Tarinai {
const bedDx = this.target.x - this.x; const bedDx = this.target.x - this.x;
const bedDy = this.target.y - this.y; const bedDy = this.target.y - this.y;
const bedDist = Math.hypot(bedDx, bedDy); 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.state = "sleep";
this.sleeping = true; this.sleeping = true;
if (this.target.type === "nest_box") this.enterNestBox(this.target, dt);
return; return;
} }
} }
@ -1890,8 +2115,13 @@ class Tarinai {
this.vy = this.vy / v * maxV; this.vy = this.vy / v * maxV;
} }
this.x += this.vx * dt; const impulseVx = Number.isFinite(this.impulseVx) ? this.impulseVx : 0;
this.y += this.vy * dt; 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) { if (this.entryTimer > 0) {
this.entryTimer = Math.max(0, this.entryTimer - dt); this.entryTimer = Math.max(0, this.entryTimer - dt);
@ -1914,7 +2144,7 @@ class Tarinai {
reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545"; reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545";
} }
else if (this.hunger >= 108) reason = "\u98e2\u9913"; 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) { else if (this.stress >= 124) {
reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a"; 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 = { const style = {
parent: { color: "rgba(86,142,232,0.86)", label: "\u89aa" }, parent: { color: "rgba(86,142,232,0.86)", label: "\u89aa" },
child: { color: "rgba(93,170,92,0.88)", label: "\u5b50" }, 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" }, enemy: { color: "rgba(214,82,66,0.90)", label: "\u6575\u5bfe" },
}[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" }; }[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" };
ctx.save(); ctx.save();
@ -2070,27 +2300,40 @@ class Tarinai {
ctx.restore(); ctx.restore();
} }
ctx.rotate(angle);
ctx.globalAlpha *= nestHideAlpha;
if (insideCardboard) ctx.globalAlpha *= 0.58;
if (this.world.selected === this) { if (this.world.selected === this) {
ctx.save(); ctx.save();
ctx.globalAlpha = 0.85; const pulse = 0.5 + 0.5 * Math.sin(this.world.time * 7.5);
ctx.strokeStyle = "rgba(165, 220, 98, 0.92)"; const jumpPulse = clamp(this.focusPulseTimer || 0, 0, 1);
ctx.lineWidth = 2; 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.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.stroke();
ctx.setLineDash([5, 4]); ctx.shadowBlur = 0;
ctx.lineWidth = 1.2; ctx.setLineDash([9, 5]);
ctx.strokeStyle = "rgba(255,255,255,0.86)"; ctx.lineWidth = 2.0;
ctx.strokeStyle = "rgba(255,255,255,0.96)";
ctx.beginPath(); 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.stroke();
ctx.setLineDash([]); 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(); 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) { } 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(); ctx.save();
@ -2103,6 +2346,11 @@ class Tarinai {
ctx.restore(); ctx.restore();
} }
ctx.rotate(angle);
ctx.globalAlpha *= nestHideAlpha;
if (insideCardboard) ctx.globalAlpha *= 0.58;
if (this.eatTimer > 0.02) { if (this.eatTimer > 0.02) {
ctx.save(); ctx.save();
ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32); ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32);
@ -2235,16 +2483,17 @@ class Tarinai {
}; };
if (stressActive) { if (stressActive) {
const pct = clamp((this.stress || 0) / 130, 0, 1); 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) { if (hpActive) {
const pct = clamp(this.energy / 100, 0, 1); 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) { 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; 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; return distXY(this.x, this.y, x, y) < hit;
} }

View file

@ -1,76 +1,54 @@
"use strict"; "use strict";
const TEXT_CATALOG = { const TEXT_CATALOG = {
toolTips: { toolTips: toolTipsFromDefinitions(),
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: "たりないを通せんぼする。"
},
ecologyCards: [ ecologyCards: [
{ {
"title": "\u305f\u308a\u306a\u3044\u3068\u306f", title: "\u305f\u308a\u306a\u3044\u3068\u306f",
"image": "assets/sprites/tarinai_01_smile.png", 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" 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": "体力とストレス", title: "\u4f53\u529b\u3068\u30b9\u30c8\u30ec\u30b9",
"image": "assets/ui/ecology_hp_stress.png", image: "assets/ui/ecology_hp_stress.png",
"text": "体力はけがや衝突、爆発、空腹などで減り、0になると死亡する。ストレスは恐怖、混雑、汚れ、けんかなどで増え、高すぎると行動が乱れたり命に関わる。体力バーとストレスバーは、状態が変化した直後や観察時に表示される。" 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", title: "\u66ae\u3089\u3057\u3068\u6b32\u6c42",
"image": "assets/sprites/tarinai_05_drool.png", 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" 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", title: "\u6027\u683c\u3068\u500b\u6027",
"image": "assets/sprites/tarinai_21_normal_happy.png", 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" 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", title: "\u5bb6\u65cf\u3068\u95a2\u4fc2",
"image": "assets/sprites/tarinai_27_birth_ritual.png", 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" 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", title: "\u75c5\u6c17\u3068\u6cbb\u7642",
"image": "assets/sprites/tarinai_24_stress_dizzy.png", 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" 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", title: "\u3051\u3093\u304b\u3068\u4e8b\u6545",
"image": "assets/sprites/tarinai_26_intimidate.png", 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" 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", title: "\u9053\u5177\u3068\u4ecb\u5165",
"image": "assets/sprites/tarinai_18_fear.png", 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" 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", title: "\u89b3\u5bdf\u306e\u65b9\u6cd5",
"image": "assets/sprites/tarinai_11_weak.png", 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" 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; 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="">`; 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() { function renderSelected() {
const t = world.selected; const t = world.selected;
if (!t || t.dead || !world.tarinai.includes(t)) { if (!t || t.dead || !world.tarinai.includes(t)) {
@ -150,17 +244,28 @@ function renderSelected() {
ui.selectedCard?.classList.remove("hidden"); ui.selectedCard?.classList.remove("hidden");
uiCache.selectedEmpty = false; 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 rel = t.relationSummary ? t.relationSummary() : {};
const fightRecord = relationFightRecord(t); 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 = [ 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.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
fmt(t.energy), fmt(t.stress), fmt(t.mood), fmt(t.energy), fmt(t.stress), fmt(t.mood),
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack), 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.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.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, 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("~") (t.records || []).slice(0, 6).map(r => `${Math.round(r.time || 0)}:${r.kind || ""}:${r.text || ""}`).join("~")
].join("|"); ].join("|");
if (uiCache.selectedSnapshot === snapshot) return; if (uiCache.selectedSnapshot === snapshot) return;
@ -168,21 +273,36 @@ function renderSelected() {
ui.selectedInfo.className = "selected-info"; ui.selectedInfo.className = "selected-info";
const effectBadges = []; 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.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.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.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.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.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.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>`); 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 = ` 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="selected-name-editor">
<div class="info-row"><span>\u6027\u683c</span><strong>${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</strong></div> <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>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div>
<div class="info-row"><span>\u7406\u7531</span><strong>${escapeHtml(reasonLabel(t))}</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>\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>\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> <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>\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>\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"><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 relation-list-row"><span>\u53cb\u9054</span><strong>${relationListHtml(t, "friend")}</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>\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>\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>\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> <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", play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
seek_water: "\u6c34\u3092\u63a2\u3059", seek_water: "\u6c34\u3092\u63a2\u3059",
zunchi_sick: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "きずつき病", fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
system: "\u305a\u3093\u3061\u75c5", system: "\u305a\u3093\u3061\u75c5",
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059", seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f", 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 === "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_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 === "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"; if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5";
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b"; return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
} }
@ -251,7 +371,7 @@ function reasonLabel(t) {
function targetLabel(target) { function targetLabel(target) {
if (!target) return "\u306a\u3057"; if (!target) return "\u306a\u3057";
if (target.name) return target.name; 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"; if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb";
return "\u4e0d\u660e"; return "\u4e0d\u660e";
} }
@ -366,8 +486,14 @@ function applyToolTips() {
document.body.appendChild(pop); document.body.appendChild(pop);
} }
const showTip = (btn) => { const showTip = (btn) => {
const tip = btn?.dataset?.tip || ""; const baseTip = btn?.dataset?.tip || "";
if (!tip) return; 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.textContent = tip;
pop.classList.remove("hidden"); pop.classList.remove("hidden");
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
@ -487,7 +613,7 @@ function syncTopButtons() {
if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF"; 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_ORDER = ["small", "medium", "large"];
const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" }; 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); 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; } if (!t) { showToast("\u305d\u306e\u500b\u4f53\u306f\u3082\u3046\u9078\u629e\u3067\u304d\u307e\u305b\u3093\u3002"); return; }
world.selected = t; world.selected = t;
t.focusPulseTimer = Math.max(t.focusPulseTimer || 0, 1.15);
selectTool("observe"); 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(); renderStats();
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`); showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
}); });
@ -582,6 +714,12 @@ function bindUI() {
} }
ui.selectedInfo?.addEventListener("click", (e) => { 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\"]"); const btn = e.target.closest("[data-selected-action=\"favorite\"]");
if (!btn || !world.selected || world.selected.dead) return; if (!btn || !world.selected || world.selected.dead) return;
world.selected.favorite = !world.selected.favorite; world.selected.favorite = !world.selected.favorite;
@ -590,6 +728,17 @@ function bindUI() {
render(); 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"); 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", () => { ui.selectedCloseBtn?.addEventListener("click", () => {
world.selected = null; world.selected = null;
uiCache.selectedSnapshot = ""; uiCache.selectedSnapshot = "";

View file

@ -37,7 +37,7 @@ function updateColonyHistory(values) {
uiCache.environmentInitialRow = null; uiCache.environmentInitialRow = null;
uiCache.lastChartDraw = ""; uiCache.lastChartDraw = "";
} }
const objectKeys = ["grass", "zunchi"]; const objectKeys = ["grass", "zunchi", "trace"];
const makeBucket = (start) => ({ const makeBucket = (start) => ({
start, start,
end: start + interval, end: start + interval,
@ -90,7 +90,7 @@ function updateColonyHistory(values) {
function chartRows(values) { function chartRows(values) {
const bucket = uiCache.chartBucketAccum; const bucket = uiCache.chartBucketAccum;
const objectKeys = ["grass", "zunchi"]; const objectKeys = ["grass", "zunchi", "trace"];
const preview = bucket && bucket.count > 0 ? { const preview = bucket && bucket.count > 0 ? {
t: Math.max(world.time || 0, bucket.start || 0), t: Math.max(world.time || 0, bucket.start || 0),
pop: bucket.sums.pop / bucket.count, pop: bucket.sums.pop / bucket.count,
@ -182,6 +182,7 @@ function chartSeries(mode = "population") {
if (mode === "objects") return [ if (mode === "objects") return [
{ key: "obj_grass", label: "\u8349", color: "#5b9d61" }, { key: "obj_grass", label: "\u8349", color: "#5b9d61" },
{ key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" }, { key: "obj_zunchi", label: "\u305a\u3093\u3061", color: "#6f8a3f" },
{ key: "obj_trace", label: "\u6b7b\u9ab8", color: "#8a7054" },
]; ];
if (mode === "environment") return [ if (mode === "environment") return [
{ key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true }, { key: "security", label: "\u6cbb\u5b89", color: "#d65e46", percent: true },

View file

@ -152,6 +152,167 @@ function liveFamilyComponents(family) {
return comps; 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) { function familyStatusText(n) {
if (!n) return "\u4e0d\u660e"; if (!n) return "\u4e0d\u660e";
return n.alive ? "\u751f\u5b58" : "\u6b7b\u4ea1"; 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 (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 (/\u55a7\u5629|fight/.test(text)) return "\u55a7\u5629";
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9"; if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9";
if (/仕様により画面外で削除|画面外で削除|out.*bounds|offscreen/.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 (/衝突|collision/.test(text)) return "衝突"; 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 (/\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 (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5";
if (/\u75c5/.test(text)) { 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 (/\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 (/\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"; 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"; return "\u4e8b\u6545";
} }
@ -196,9 +357,7 @@ function lineageTreeNodeHtml(n, x, y) {
const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : ""; const rawReason = !n?.alive && n?.deathReason ? String(n.deathReason) : "";
const briefReason = familyDeathReasonLabel(rawReason, entity); const briefReason = familyDeathReasonLabel(rawReason, entity);
const reason = briefReason ? ` / ${briefReason}` : ""; const reason = briefReason ? ` / ${briefReason}` : "";
const personality = live?.personalityLabel ? live.personalityLabel() : ((PERSONALITIES[n?.personality] || {}).label || n?.personality || ""); const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${reason}`);
const personalityPart = personality ? ` / \u6027\u683c ${personality}` : "";
const title = escapeHtml(`${n?.name || "\u4e0d\u660e"} / \u4e16\u4ee3 ${n?.generation || "?"} / ${status}${personalityPart}${reason}`);
const deathLine = briefReason ? `<span class="death-reason">${escapeHtml(briefReason)}</span>` : ""; const deathLine = briefReason ? `<span class="death-reason">${escapeHtml(briefReason)}</span>` : "";
const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing"; const cls = n ? ((live || n.alive) ? "alive" : "dead") : "missing";
const childCls = (n?.parents || []).length ? " child" : ""; const childCls = (n?.parents || []).length ? " child" : "";
@ -485,7 +644,8 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) {
const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet); const partnerEdges = lineagePartnerEdgesByGeneration(nodes, idToNode, idSet);
const totalNodes = nodes.length; const totalNodes = nodes.length;
const maxRow = Math.max(0, ...rows.map(row => row.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++) { for (let pass = 0; pass < passes; pass++) {
let rank = lineageRankMap(rows); let rank = lineageRankMap(rows);
@ -495,9 +655,11 @@ function lineageRowsForLayout(nodes, generations, idToNode, idSet) {
const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index])); const childRank = new Map(rows[i + 1].map((n, index) => [n.id, index]));
rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank); rows[i] = lineageOrderParentsByChildren(rows[i], idSet, childRank);
} }
rank = lineageRankMap(rows); if (!veryLarge) {
rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode)); rank = lineageRankMap(rows);
if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet); rows = rows.map((row, i) => lineageOrderPartnerClusters(row, partnerEdges.get(generations[i]), rank, idToNode));
if (totalNodes <= 90 && maxRow <= 16) rows = lineageOptimizeRows(rows, idSet);
}
} }
return rows; return rows;
} }
@ -880,6 +1042,32 @@ function lineageHydrateFamilyHtml(html, index) {
.split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${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) { function lineageExpectedChildEdgeCount(component, family) {
family = lineageEdgeFamily(family); family = lineageEdgeFamily(family);
const idSet = new Set(component.map(x => x?.id).filter(Boolean)); 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); 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) { function buildLineageTreeLayout(component, family) {
family = lineageEdgeFamily(family); family = lineageEdgeFamily(family);
const cleaned = lineageCleanComponent(component, family); const cleaned = lineageCleanComponent(component, family);
@ -1121,12 +1376,13 @@ function buildLineageTreeLayout(component, family) {
const idSet = new Set(idToNode.keys()); const idSet = new Set(idToNode.keys());
const generations = Array.from(new Set(nodes.map(n => n.generation || 1))).sort((a, b) => a - b); 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 rows = lineageRowsForLayout(nodes, generations, idToNode, idSet);
const layout = lineageLayoutRows(rows, idSet); const perf = lineageFastLayoutNeeded(nodes, idSet, family);
const links = lineageBuildLinks(nodes, idSet, layout); 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 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)); 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 = "") { 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 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 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> <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"> <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"> <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; if (uiCache.deferArchiveUntil && performance.now() < uiCache.deferArchiveUntil) return;
uiCache.deferArchiveUntil = 0; uiCache.deferArchiveUntil = 0;
uiCache.archiveScheduled = false; uiCache.archiveScheduled = false;
const scrollState = lineageCaptureArchiveScroll();
const familyVersion = world.familyVersion || 0; const familyVersion = world.familyVersion || 0;
const familyDirty = Boolean(world.familyTreeDirty); const familyDirty = Boolean(world.familyTreeDirty);
if (uiCache.archiveRenderRunning) { if (uiCache.archiveRenderRunning) {
@ -1197,30 +1454,12 @@ function renderArchive() {
world.pruneExtinctFamilies(); world.pruneExtinctFamilies();
} }
const family = world.family || {}; const family = world.family || {};
const relationSignature = lineageVisibleRelationSignature(family); const version = String(world.familyVersion || 0);
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}`;
if (uiCache.archiveVersion === version) { if (uiCache.archiveVersion === version) {
world.familyTreeDirty = false; world.familyTreeDirty = false;
uiCache.archivePendingDirtyAfterRun = false; uiCache.archivePendingDirtyAfterRun = false;
return; return;
} }
const heavyArchive = ids.length > 90 || edgeCount > 160;
if (!world.paused && heavyArchive) {
uiCache.archivePendingWhileRunning = true;
lineageSetArchiveStaleStatus(true);
return;
}
const renderStarted = performance.now(); const renderStarted = performance.now();
lineageSetArchiveStaleStatus(false); lineageSetArchiveStaleStatus(false);
if (uiCache.archiveRenderTimer) { if (uiCache.archiveRenderTimer) {
@ -1230,63 +1469,71 @@ function renderArchive() {
uiCache.archiveVersion = version; uiCache.archiveVersion = version;
uiCache.archiveFamilyVersion = world.familyVersion || 0; uiCache.archiveFamilyVersion = world.familyVersion || 0;
uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0; uiCache.archiveLastScheduleFamilyVersion = world.familyVersion || 0;
uiCache.archiveRelationSignature = relationSignature;
uiCache.archiveLastRenderedAt = performance.now(); uiCache.archiveLastRenderedAt = performance.now();
uiCache.lastArchiveWindow = ""; 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(); if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map();
const activeCacheKeys = new Set(components.map((component, index) => lineageFamilyCacheKey(component, family, signatures[index]))); const activeCacheKeys = new Set();
for (const key of Array.from(uiCache.archiveHtmlCache.keys())) {
if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key);
}
const token = `${version}:${Math.random().toString(36).slice(2)}`; const token = `${version}:${Math.random().toString(36).slice(2)}`;
uiCache.archiveRenderToken = token; uiCache.archiveRenderToken = token;
uiCache.archiveRenderRunning = true; 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>`;
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 status = ui.archiveContent.querySelector(".lineage-render-status");
const forest = ui.archiveContent.querySelector(".lineage-forest"); const forest = ui.archiveContent.querySelector(".lineage-forest");
let index = 0; const renderComponents = (components) => {
const pump = () => {
if (uiCache.archiveRenderToken !== token || !forest) { if (uiCache.archiveRenderToken !== token || !forest) {
uiCache.archiveRenderRunning = false; uiCache.archiveRenderRunning = false;
return; 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;
}
if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 0 / ${splitComponents.length}`;
let index = 0;
const pump = () => {
if (uiCache.archiveRenderToken !== token || !forest) {
uiCache.archiveRenderRunning = false;
return;
}
const started = performance.now(); const started = performance.now();
let nodeBudget = 28; let nodeBudget = 28;
let renderedThisSlice = 0; let renderedThisSlice = 0;
while (index < components.length) { while (index < splitComponents.length) {
const component = components[index]; const component = splitComponents[index];
if (renderedThisSlice > 0 && component.length > nodeBudget) break; 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); nodeBudget -= Math.max(1, component.length);
index += 1; index += 1;
renderedThisSlice += 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}`; lineageRestoreArchiveScroll(scrollState);
if (index < components.length) { if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${splitComponents.length}`;
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); if (index < splitComponents.length) {
schedule(pump, { timeout: 220 }); lineageIdleSchedule(pump, 220);
} else { } else {
if (status) status.remove(); 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; const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion;
uiCache.archiveRenderRunning = false; uiCache.archiveRenderRunning = false;
world.familyTreeDirty = renderDirtiedAgain; world.familyTreeDirty = renderDirtiedAgain;
if (uiCache.archiveDiagnostics) { if (uiCache.archiveDiagnostics) {
uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1; uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1;
uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10; uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10;
uiCache.archiveDiagnostics.lastFamilies = components.length; uiCache.archiveDiagnostics.lastFamilies = splitComponents.length;
uiCache.archiveDiagnostics.lastNodes = components.reduce((sum, component) => sum + component.length, 0); uiCache.archiveDiagnostics.lastNodes = splitComponents.reduce((sum, component) => sum + component.length, 0);
} }
if (renderDirtiedAgain) { if (renderDirtiedAgain) {
uiCache.archivePendingDirtyAfterRun = false; uiCache.archivePendingDirtyAfterRun = false;
@ -1294,8 +1541,16 @@ function renderArchive() {
} }
} }
}; };
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0)); lineageIdleSchedule(pump, 160);
schedule(pump, { timeout: 160 }); };
lineageBuildComponentsIdle(
family,
token,
(label, done, total) => {
if (status) status.textContent = `${label}\u2026 ${Math.min(done || 0, total || 0)} / ${total || 0}`;
},
renderComponents
);
} }
function validateFamilyTree() { function validateFamilyTree() {

View file

@ -1,4 +1,4 @@
"use strict"; "use strict";
class World { class World {
constructor() { constructor() {
@ -276,12 +276,12 @@ class World {
} }
toolSizeFor(type = "") { 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"; return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
} }
toolSizeScale(type = "") { 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; return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1;
} }
@ -292,7 +292,16 @@ class World {
item.toolSize = size; item.toolSize = size;
this.toolSize = size; this.toolSize = size;
item.r *= scale; 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; if (item.type === "firecracker") item.blastScale = scale;
return item; return item;
} }
@ -368,12 +377,13 @@ class World {
let changed = false; let changed = false;
const nextParents = uniqueIds(t.parents || []); const nextParents = uniqueIds(t.parents || []);
const nextChildren = uniqueIds(t.children || []); const nextChildren = uniqueIds(t.children || []);
const nextPersonalityTags = getPersonalityTraitTags(t);
if (!this.family[t.id]) { if (!this.family[t.id]) {
this.family[t.id] = { this.family[t.id] = {
id: t.id, id: t.id,
name: t.name, name: t.name,
type: t.type, type: t.type,
personality: t.personality || "", personalityTags: nextPersonalityTags,
generation: t.generation, generation: t.generation,
hasPaired: !!t.hasPaired, hasPaired: !!t.hasPaired,
parents: nextParents, parents: nextParents,
@ -389,7 +399,7 @@ class World {
const nextAlive = !t.dead; const nextAlive = !t.dead;
if (entry.name !== t.name) changed = true; if (entry.name !== t.name) changed = true;
if (entry.type !== t.type) 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.hasPaired !== !!t.hasPaired) changed = true;
if (entry.generation !== t.generation) changed = true; if (entry.generation !== t.generation) changed = true;
if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true; if ((entry.deathReason || "") !== (t.deathReason || entry.deathReason || "")) changed = true;
@ -398,7 +408,8 @@ class World {
if (!sameList(entry.children, nextChildren)) changed = true; if (!sameList(entry.children, nextChildren)) changed = true;
entry.name = t.name; entry.name = t.name;
entry.type = t.type; entry.type = t.type;
entry.personality = t.personality || entry.personality || ""; delete entry.personality;
entry.personalityTags = nextPersonalityTags;
entry.alive = nextAlive; entry.alive = nextAlive;
entry.generation = t.generation; entry.generation = t.generation;
entry.hasPaired = !!t.hasPaired; entry.hasPaired = !!t.hasPaired;
@ -449,7 +460,7 @@ class World {
id, id,
name: live?.name || n.name || id, name: live?.name || n.name || id,
type: live?.type || n.type || "smile", 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), generation: Math.max(1, live?.generation || n.generation || 1),
hasPaired: !!(live?.hasPaired || n.hasPaired), hasPaired: !!(live?.hasPaired || n.hasPaired),
parents, parents,
@ -565,7 +576,7 @@ class World {
if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== finalReason) { if (this.family[t.id].alive !== false || this.family[t.id].deathReason !== finalReason) {
this.family[t.id].alive = false; this.family[t.id].alive = false;
this.family[t.id].deathReason = finalReason; 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.familyVersion = (this.familyVersion || 0) + 1;
this.markFamilyTreeDirty("death"); this.markFamilyTreeDirty("death");
} }
@ -629,6 +640,7 @@ class World {
startBirthRitual(a, b) { startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false; if (!a || !b || a.dead || b.dead) return false;
if (a.isZunchiSlave || b.isZunchiSlave) 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; 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 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); const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length);
@ -659,10 +671,15 @@ class World {
finishBirthRitual(a, b) { finishBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return null; 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.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null; a.birthPartnerId = b.birthPartnerId = null;
a.birthRitualLeader = b.birthRitualLeader = false; a.birthRitualLeader = b.birthRitualLeader = false;
a.state = b.state = "idle"; a.state = b.state = "idle";
if (reproductionBlockedByDisease) {
a.birthRitualRole = b.birthRitualRole = 0;
return null;
}
a.fightTimer = b.fightTimer = 0; a.fightTimer = b.fightTimer = 0;
a.fightTargetIds = []; b.fightTargetIds = []; a.fightTargetIds = []; b.fightTargetIds = [];
a.fightTargetId = b.fightTargetId = null; a.fightTargetId = b.fightTargetId = null;
@ -707,8 +724,10 @@ class World {
canStartFight(t) { canStartFight(t) {
if (!t || t.dead) return false; if (!t || t.dead) return false;
if (t.sleepDisease) return false; if (t.sleepDisease) return false;
if (t.explosionDisease || t.fightDisease) return true; // Disease fight rule: explosion disease and zunchi disease can lash out;
if (t.zunchiDisease) return false; // 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; return true;
} }
@ -723,10 +742,11 @@ class World {
spawnChild(a, b, index = 0, total = 1) { spawnChild(a, b, index = 0, total = 1) {
const inherited = Math.random() < 0.56 ? a.type : b.type; const inherited = Math.random() < 0.56 ? a.type : b.type;
const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited; 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({ const child = this.addTarinai({
type: mutation, type: mutation,
personality: inheritedPersonality || personalityForSeed(`${a.id}:${b.id}:${this.time}:${index}`), birthPersonality: childBirthPersonality,
currentPersonality: childBirthPersonality,
name: makeName(), name: makeName(),
age: 0, age: 0,
x: (a.x + b.x) / 2 + rand(-14, 14) + (index - (total - 1) / 2) * 18, 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 (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) 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; 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 aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
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 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 actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a; const target = actor === a ? b : a;
if (!this.canFightPair(actor, target)) return; if (!this.canFightPair(actor, target)) return;
@ -843,7 +865,7 @@ class World {
if (!actor || !target || actor.dead || target.dead) return false; if (!actor || !target || actor.dead || target.dead) return false;
if (actor.lowHealthSprite && actor.lowHealthSprite()) return false; if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
const fearLoad = (target.relationTo(actor.id).fear || 0) * 0.72 + target.personalityProfile().fear * 8; const fearLoad = (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); const successChance = clamp(0.62 + (actorScore - targetScore + fearLoad - resistance) / 76, 0.50, 0.96);
actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20); actor.surpriseTimer = Math.max(actor.surpriseTimer, 0.20);
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32); target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
@ -902,8 +924,10 @@ class World {
b.fightTargetIds = b.fightTargetIds.slice(-4); b.fightTargetIds = b.fightTargetIds.slice(-4);
a.fightTargetId = a.fightTargetIds[0]; a.fightTargetId = a.fightTargetIds[0];
b.fightTargetId = b.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 aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
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 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 loser = aScore <= bScore ? a : b;
const winner = loser === a ? b : a; const winner = loser === a ? b : a;
loser.defeatedById = winner.id; loser.defeatedById = winner.id;
@ -987,9 +1011,12 @@ class World {
ny = Math.sin(a); ny = Math.sin(a);
} }
const distanceBoost = p * p * 0.55 + p * 0.45; const distanceBoost = p * p * 0.55 + p * 0.45;
const power = (260 + distanceBoost * 820) * rand(0.78, 1.24) * (it.blastScale || 1); const power = (260 + distanceBoost * 920) * rand(0.78, 1.24) * (it.blastScale || 1);
t.vx += nx * power + rand(-95, 95) * (0.6 + p); this.applyImpulse(t, nx * power + rand(-95, 95) * (0.6 + p), ny * power + rand(-95, 95) * (0.6 + p), {
t.vy += 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.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer); t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1); 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; dx = Math.cos(a); dy = Math.sin(a); d = 1;
} }
const blast = 430 + p * 960; const blast = 430 + p * 960;
ball.vx = (ball.vx || 0) + dx / d * blast + rand(-80, 80); this.applyImpulse(ball, dx / d * blast + rand(-80, 80), dy / d * blast + rand(-80, 80));
ball.vy = (ball.vy || 0) + dy / d * blast + rand(-80, 80);
const speed = Math.hypot(ball.vx || 0, ball.vy || 0); const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38); ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38);
ball.lastPokedAt = this.time || 0; ball.lastPokedAt = this.time || 0;
@ -1050,9 +1076,12 @@ class World {
nx = Math.cos(a); nx = Math.cos(a);
ny = Math.sin(a); ny = Math.sin(a);
} }
const diseaseBlast = (190 + q * 560) * rand(0.80, 1.22); const diseaseBlast = (190 + q * 620) * rand(0.80, 1.22);
o.vx += nx * diseaseBlast + rand(-64, 64) * (0.5 + q); this.applyImpulse(o, nx * diseaseBlast + rand(-64, 64) * (0.5 + q), ny * diseaseBlast + rand(-64, 64) * (0.5 + q), {
o.vy += 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.hurtTimer = Math.max(o.hurtTimer || 0, 1.2 + q);
o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q); o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q);
if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t); if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t);
@ -1088,7 +1117,7 @@ class World {
cleaned += p * 0.55; cleaned += p * 0.55;
} }
for (const t of this.nearbyTarinai(x, y, radius + 70)) { 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); const d = distXY(t.x, t.y, x, y);
if (d > radius + t.radius) continue; if (d > radius + t.radius) continue;
const p = clamp(1 - d / (radius + t.radius), 0, 1); 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")); return Boolean(it && (it.type === "bed" || it.type === "nest_box"));
} }
isTarinaiHiddenInNestBox(t) {
return Boolean(t && !t.dead && t.insideNestBoxId);
}
bedOccupancy(bed) { bedOccupancy(bed) {
if (!this.isSleepFurniture(bed)) return 0; if (!this.isSleepFurniture(bed)) return 0;
if (bed.type === "nest_box") {
return this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity).length : 0;
}
let n = 0; let n = 0;
const radius = Math.max(86, bed.r * 2.45); const radius = Math.max(86, bed.r * 2.45);
for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) { 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; if (t.state === "sleep" || t.state === "seek_bed" || t.target === bed) n += 1;
} }
return n; return n;
@ -1205,23 +1241,27 @@ class World {
} }
bestBedFor(t, maxDist = 360) { bestBedFor(t, maxDist = 360) {
let best = null; const choose = (kind) => {
let bestScore = Infinity; let best = null;
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) { let bestScore = Infinity;
if (bed.dead || !this.isSleepFurniture(bed)) continue; for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) { if (bed.dead || !this.isSleepFurniture(bed) || bed.type !== kind) continue;
const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : []; if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) {
if (occupants.length >= this.nestBoxCapacity(bed)) continue; const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : [];
if (occupants.length >= this.nestBoxCapacity(bed)) continue;
}
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
const d = dist(t, bed);
const occ = this.bedOccupancy(bed);
const comfort = this.bedComfort(bed);
const crowdPenalty = bed.type === "nest_box" ? Math.max(0, occ - this.nestBoxCapacity(bed) + 1) * 120 : Math.max(0, occ - 3) * 34;
const score = d - comfort * (bed.type === "nest_box" ? 72 : 46) + crowdPenalty;
if (score < bestScore) { best = bed; bestScore = score; }
} }
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue; return best;
const d = dist(t, bed); };
const occ = this.bedOccupancy(bed); // Prefer an available nest box, then fall back to a hay bed.
const comfort = this.bedComfort(bed); return choose("nest_box") || choose("bed");
const crowdPenalty = Math.max(0, occ - 3) * 34;
const score = d - comfort * 46 + crowdPenalty;
if (score < bestScore) { best = bed; bestScore = score; }
}
return best;
} }
relationNotice(aId, bId, kind, cooldown = 30) { relationNotice(aId, bId, kind, cooldown = 30) {
@ -1233,6 +1273,52 @@ class World {
return true; 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) { resolveFightOutcome(winner, loser) {
if (!winner || !loser || winner.dead || loser.dead) return; if (!winner || !loser || winner.dead || loser.dead) return;
const key = `${winner.id}:${loser.id}:${Math.floor(this.time / 2)}`; 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; loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1;
winner.totalFightWins = (winner.totalFightWins || 0) + 1; winner.totalFightWins = (winner.totalFightWins || 0) + 1;
loser.totalFightLosses = (loser.totalFightLosses || 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)) { if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) {
const oldName = loser.formerName || loser.name; 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)"); 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); const push = isStone ? rand(145, 210) : rand(54, 92);
let hit = 0; let hit = 0;
for (const t of this.nearbyTarinai(it.x, it.y, radius + 48)) { 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 d = Math.max(1, distXY(t.x, t.y, it.x, it.y));
const p = clamp(1 - d / (radius + t.radius), 0, 1); const p = clamp(1 - d / (radius + t.radius), 0, 1);
if (p <= 0) continue; if (p <= 0) continue;
@ -1315,8 +1403,8 @@ class World {
for (const [ball, arr] of groups) { for (const [ball, arr] of groups) {
if (arr.length <= 5) continue; if (arr.length <= 5) continue;
arr.sort((a, b) => { arr.sort((a, b) => {
const ap = a.personality === "playful" ? -120 : 0; const ap = ((a.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0;
const bp = b.personality === "playful" ? -120 : 0; const bp = ((b.currentPersonality?.openness || 0) >= 0.5) ? -120 : 0;
return dist(a, ball) + ap - (dist(b, ball) + bp); return dist(a, ball) + ap - (dist(b, ball) + bp);
}); });
for (const t of arr.slice(5)) { for (const t of arr.slice(5)) {
@ -1355,7 +1443,8 @@ class World {
ball.amount = Math.max(ball.amount || 999, 999); ball.amount = Math.max(ball.amount || 999, 999);
audio.poke(); 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)" })); 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) { resolveBallBallCollisions(dt) {
@ -1368,7 +1457,7 @@ class World {
const avx = a.vx || 0; const avx = a.vx || 0;
const avy = a.vy || 0; const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy); 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) || []) { for (const b of this.nearbyItems(a.x, a.y, range) || []) {
if (!b || b === a || b.dead || b.type !== "ball") continue; if (!b || b === a || b.dead || b.type !== "ball") continue;
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`; 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 sy = ball.y - py;
const segLenSq = sx * sx + sy * sy; const segLenSq = sx * sx + sy * sy;
for (const t of this.nearbyTarinai(ball.x, ball.y, range)) { 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 hitX = ball.x;
let hitY = ball.y; let hitY = ball.y;
if (segLenSq > 1) { if (segLenSq > 1) {
@ -1492,11 +1581,14 @@ class World {
const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny; const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny;
const damage = clamp((ballSpeed - 285) / 18, 3.5, 38); const damage = clamp((ballSpeed - 285) / 18, 3.5, 38);
t.lastBallDamageAt = now; t.lastBallDamageAt = now;
t.vx = (t.vx || 0) * 0.35 + (ball.vx || 0) * 0.20; this.applyImpulse(t, (ball.vx || 0) * 0.20, (ball.vy || 0) * 0.20, {
t.vy = (t.vy || 0) * 0.35 + (ball.vy || 0) * 0.20; panic: true,
t.damage(damage, "\u885d\u7a81"); target: { x: ball.x, y: ball.y },
const timidStress = t.personality === "timid" ? 1.55 : 1.0; fearTimer: 0.95,
const stressGain = clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18); });
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.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130));
t.state = "panic"; t.state = "panic";
t.target = { x: ball.x, y: ball.y }; 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.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.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); 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 relVx = (t.vx || 0) - (ball.vx || 0);
const relVy = (t.vy || 0) - (ball.vy || 0); const relVy = (t.vy || 0) - (ball.vy || 0);
const relativeSpeed = Math.hypot(relVx, relVy); const relativeSpeed = Math.hypot(relVx, relVy);
const playIntent = t.state === "play_ball" || t.target === ball; 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); 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.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; 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.vy -= ny * Math.min(22, impulse * 0.10);
t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100); 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 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; const sensitiveBump = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) && !playIntent ? clamp(ballSpeed / 230, 0.6, 2.6) : 0;
t.stress = clamp(t.stress - playRelief + timidBump, 0, 130); t.stress = clamp(t.stress - playRelief + sensitiveBump, 0, 130);
if (playful && playIntent) { if (playful && playIntent) {
t.loneliness = clamp(t.loneliness - 0.9, 0, 100); t.loneliness = clamp(t.loneliness - 0.9, 0, 100);
t.affection = clamp(t.affection + 0.18, 0, 100); t.affection = clamp(t.affection + 0.18, 0, 100);
@ -1556,7 +1648,7 @@ class World {
const now = this.time || 0; const now = this.time || 0;
if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map(); if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map();
for (const a of this.tarinai || []) { 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 avx = a.vx || 0;
const avy = a.vy || 0; const avy = a.vy || 0;
const aSpeed = Math.hypot(avx, avy); const aSpeed = Math.hypot(avx, avy);
@ -1568,7 +1660,7 @@ class World {
const segLenSq = sx * sx + sy * sy; const segLenSq = sx * sx + sy * sy;
const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64; 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)) { 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}`; const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue; if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
const bvx = b.vx || 0; const bvx = b.vx || 0;
@ -1601,12 +1693,15 @@ class World {
ny = Math.sin(aa); ny = Math.sin(aa);
} }
const damage = clamp((relSpeed - 118) / 26, 1.2, 22); const damage = clamp((relSpeed - 118) / 26, 1.2, 22);
target.vx += ivx * 0.20 + nx * 28; this.applyImpulse(target, ivx * 0.20 + nx * 28, ivy * 0.20 + ny * 28, {
target.vy += 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.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10); impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
target.damage(damage, "\u885d\u7a81"); this.applyImpactDamage(target, damage, "\u885d\u7a81");
if (!impactor.dead) impactor.damage(damage * 0.35, "\u885d\u7a81"); if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81");
for (const t of [target, impactor]) { for (const t of [target, impactor]) {
if (!t || t.dead) continue; if (!t || t.dead) continue;
t.state = "panic"; t.state = "panic";
@ -1639,7 +1734,7 @@ class World {
let best = null, bestD = maxDist; let best = null, bestD = maxDist;
const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai; const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai;
for (const o of candidates) { 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 (predicate && !predicate(o)) continue;
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue; if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue;
const d = dist(entity, o); const d = dist(entity, o);
@ -1707,6 +1802,47 @@ class World {
return this.nestBoxSolidRects(it)[0] || null; 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) { solidObstacleRects(it) {
if (!it || it.dead) return []; if (!it || it.dead) return [];
if (this.isFenceType(it.type)) { if (this.isFenceType(it.type)) {
@ -1717,6 +1853,20 @@ class World {
return []; 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 } = {}) { nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) {
const rects = []; const rects = [];
let checked = 0; let checked = 0;
@ -1764,10 +1914,11 @@ class World {
} }
resolveSolidObstacleCollision(t) { resolveSolidObstacleCollision(t) {
if (!t || t.dead) return; if (!t || t.dead || this.isTarinaiHiddenInNestBox(t)) return;
const rr = Math.max(8, t.radius * 0.74); const rr = Math.max(8, t.radius * 0.74);
let pushed = false; let pushed = false;
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) { 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 (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true;
} }
if (pushed) { if (pushed) {
@ -1833,7 +1984,7 @@ class World {
grassOnTarinaiAt(x, y, minGap = 24) { grassOnTarinaiAt(x, y, minGap = 24) {
for (const t of this.nearbyTarinai(x, y, minGap + 36)) { 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; if (distXY(x, y, t.x, t.y) < Math.max(minGap, t.radius * 1.15)) return true;
} }
return false; return false;
@ -2041,12 +2192,12 @@ class World {
observeHighlightKind(t) { observeHighlightKind(t) {
const s = this.selected; 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.parents || []).includes(t.id)) return "parent";
if ((s.children || []).includes(t.id)) return "child"; if ((s.children || []).includes(t.id)) return "child";
const rel = s.relationTo ? s.relationTo(t.id) : null; 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.fear || 0) >= 8) return "enemy";
if (rel && (rel.affinity || 0) >= 10) return "friend";
return ""; return "";
} }
@ -2066,8 +2217,22 @@ class World {
if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; } if (d < bestD && d <= Math.max(80, it.r * 2.1)) { best = it; bestD = d; }
} }
if (!best) return null; if (!best) return null;
const occupants = this.nestBoxOccupants(best); const occupants = this.nestBoxOccupants(best, Infinity);
return { box: best, occupants }; 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) { blastZunchiFrom(x, y, radius, strength = 1) {
@ -2100,7 +2265,11 @@ class World {
dt = Math.min(dt, 0.12 * this.speed); dt = Math.min(dt, 0.12 * this.speed);
this.time += dt; this.time += dt;
const previousDay = this.day || 1;
this.day = Math.floor(this.time / CONFIG.dayLength) + 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); if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4);
this.updateWeather(dt); this.updateWeather(dt);
this.rebuildSpatial(); this.rebuildSpatial();
@ -2242,7 +2411,7 @@ class World {
t.vx = 0; t.vx = 0;
t.vy = 0; t.vy = 0;
t.entryTimer = 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") { } else if (result === "moved") {
t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad); 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); 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; 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) { findGrabTargetAt(x, y) {
let foundT = null; let foundT = null;
for (let i = this.tarinai.length - 1; i >= 0; i--) { for (let i = this.tarinai.length - 1; i >= 0; i--) {
const t = this.tarinai[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; } 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; 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"); 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; return null;
} }
if (this.fencePlacementBlocked(item)) { }
showToast("\u305d\u3053\u306b\u306f\u67f5\u3092\u8ffd\u52a0\u3067\u304d\u307e\u305b\u3093\u3002"); if (this.placementBlocked(item)) {
return null; 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) { if (dropped) {
item.dropMax = item.type === "stone" ? 0.72 : 0.58; item.dropMax = item.type === "stone" ? 0.72 : 0.58;
@ -2345,7 +2607,7 @@ class World {
if (tool === "observe") { if (tool === "observe") {
let found = null; let found = null;
for (let i = this.tarinai.length - 1; i >= 0; i--) { 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; this.selected = found;
if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`); if (found) this.log(`${found.name}\u3092\u9078\u629e\u3057\u305f\u3002`);
@ -2398,31 +2660,19 @@ class World {
this.compactItems(); this.compactItems();
this.updateItemCounts(); this.updateItemCounts();
this.rebuildSpatial(); 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"); this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return; return;
} }
const itemType = { const itemType = toolItemType(tool);
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];
if (itemType) { if (itemType) {
let px = x, py = y; 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 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; 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`); this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
} }
} }
@ -2441,22 +2691,25 @@ class World {
return "note"; return "note";
} }
shouldHideFromObservationRecords(text = "") { shouldHideFromObservationRecords(entry = {}) {
const s = String(text || ""); const type = typeof entry === "string" ? "" : (entry.eventType || "");
return [ return type === "ball_poke" || type === "avoid_relationship";
"\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));
} }
log(text, kind = null) { log(text, kind = null, options = {}) {
const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) }; 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 = {}; if (!this.eventCounters) this.eventCounters = {};
this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1; this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
for (const t of this.tarinai || []) { for (const t of this.tarinai || []) {
if (!t || !t.name || !text || !String(text).includes(t.name)) continue; 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.unshift(entry);
this.logs = this.logs.slice(0, 100); this.logs = this.logs.slice(0, 100);