fixed issues
|
Before Width: | Height: | Size: 389 KiB |
|
|
@ -1,175 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "smile",
|
||||
"file": "assets/sprites/tarinai_01_smile.png",
|
||||
"width": 512,
|
||||
"height": 468
|
||||
},
|
||||
{
|
||||
"id": "angry",
|
||||
"file": "assets/sprites/tarinai_02_angry.png",
|
||||
"width": 512,
|
||||
"height": 464
|
||||
},
|
||||
{
|
||||
"id": "teary",
|
||||
"file": "assets/sprites/tarinai_03_teary.png",
|
||||
"width": 512,
|
||||
"height": 472
|
||||
},
|
||||
{
|
||||
"id": "jito",
|
||||
"file": "assets/sprites/tarinai_04_jito.png",
|
||||
"width": 512,
|
||||
"height": 487
|
||||
},
|
||||
{
|
||||
"id": "drool",
|
||||
"file": "assets/sprites/tarinai_05_drool.png",
|
||||
"width": 512,
|
||||
"height": 461
|
||||
},
|
||||
{
|
||||
"id": "cry",
|
||||
"file": "assets/sprites/tarinai_06_cry.png",
|
||||
"width": 512,
|
||||
"height": 478
|
||||
},
|
||||
{
|
||||
"id": "sleep",
|
||||
"file": "assets/sprites/tarinai_07_sleep.png",
|
||||
"width": 511,
|
||||
"height": 475
|
||||
},
|
||||
{
|
||||
"id": "pokan",
|
||||
"file": "assets/sprites/tarinai_08_pokan.png",
|
||||
"width": 512,
|
||||
"height": 469
|
||||
},
|
||||
{
|
||||
"id": "stretch",
|
||||
"file": "assets/sprites/tarinai_09_stretch.png",
|
||||
"width": 512,
|
||||
"height": 384
|
||||
},
|
||||
{
|
||||
"id": "hurt",
|
||||
"file": "assets/sprites/tarinai_10_hurt.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "weak",
|
||||
"file": "assets/sprites/tarinai_11_weak.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "back",
|
||||
"file": "assets/sprites/tarinai_12_back.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "flee",
|
||||
"file": "assets/sprites/tarinai_13_flee.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "zunda_eat",
|
||||
"file": "assets/sprites/tarinai_14_zunda_eat.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "hurt2",
|
||||
"file": "assets/sprites/tarinai_15_hurt2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_smirk",
|
||||
"file": "assets/sprites/tarinai_16_normal_smirk.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_tongue",
|
||||
"file": "assets/sprites/tarinai_17_normal_tongue.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear",
|
||||
"file": "assets/sprites/tarinai_18_fear.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "flee_fear2",
|
||||
"file": "assets/sprites/tarinai_19_flee_fear2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "sleep2",
|
||||
"file": "assets/sprites/tarinai_20_sleep2.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "normal_happy",
|
||||
"file": "assets/sprites/tarinai_21_normal_happy.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"displayOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear_blue",
|
||||
"file": "assets/sprites/tarinai_22_fear_blue.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "fear_cry",
|
||||
"file": "assets/sprites/tarinai_23_fear_cry.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "stress_dizzy",
|
||||
"file": "assets/sprites/tarinai_24_stress_dizzy.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "stress_sweat",
|
||||
"file": "assets/sprites/tarinai_25_stress_sweat.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
},
|
||||
{
|
||||
"id": "intimidate",
|
||||
"file": "assets/sprites/tarinai_26_intimidate.png",
|
||||
"width": 512,
|
||||
"height": 468,
|
||||
"actionOnly": true
|
||||
}
|
||||
]
|
||||
BIN
assets/ui/ecology_hp_stress.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/ui/tool_ball.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
assets/ui/tool_fight_mochi.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
assets/ui/tool_love_mochi.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
assets/ui/tool_nest_box.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
assets/ui/tool_pinch.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/ui/tool_sleep_drug.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
assets/ui/tool_water_bowl.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
assets/ui/tool_water_hose.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
152
css/styles.css
|
|
@ -554,6 +554,32 @@ meter {
|
|||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
.selected-card.hidden { display: none; }
|
||||
.selected-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.selected-head h2 { margin-bottom: 0; }
|
||||
.selected-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(84,68,48,0.16);
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.76);
|
||||
color: rgba(76,58,42,0.78);
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
}
|
||||
.selected-close:hover { background: #fff; }
|
||||
|
||||
.selected-info {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
|
|
@ -1068,13 +1094,13 @@ body::before {
|
|||
max-width: min(220px, calc(100vw - 16px));
|
||||
padding: 7px 9px;
|
||||
border-radius: 10px;
|
||||
background: rgba(46, 38, 30, 0.20);
|
||||
background: rgba(46, 38, 30, 0.78);
|
||||
color: #fff8e6;
|
||||
font-family: "Yomogi", "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
box-shadow: 0 10px 28px rgba(40, 32, 22, 0.26);
|
||||
backdrop-filter: blur(2px);
|
||||
box-shadow: 0 10px 28px rgba(40, 32, 22, 0.18);
|
||||
backdrop-filter: blur(1px);
|
||||
text-shadow: 0 1px 3px rgba(25, 18, 12, 0.55);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
|
@ -1154,3 +1180,123 @@ body::before {
|
|||
content: "";
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Tool UI icons: use in-game styled image assets instead of emoji glyphs. */
|
||||
.tool[data-tool="love_mochi"] { --tool-icon: url("../assets/ui/tool_love_mochi.png"); }
|
||||
.tool[data-tool="fight_mochi"] { --tool-icon: url("../assets/ui/tool_fight_mochi.png"); }
|
||||
.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.png"); }
|
||||
.tool[data-tool="sleep_drug"] { --tool-icon: url("../assets/ui/tool_sleep_drug.png"); }
|
||||
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.png"); }
|
||||
.tool[data-tool="water_hose"] { --tool-icon: url("../assets/ui/tool_water_hose.png"); }
|
||||
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.png"); }
|
||||
|
||||
.tool[data-tool="love_mochi"]::before,
|
||||
.tool[data-tool="fight_mochi"]::before,
|
||||
.tool[data-tool="water_bowl"]::before,
|
||||
.tool[data-tool="sleep_drug"]::before,
|
||||
.tool[data-tool="nest_box"]::before,
|
||||
.tool[data-tool="water_hose"]::before,
|
||||
.tool[data-tool="ball"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
top: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
transform: translateY(-50%);
|
||||
background: var(--tool-icon) center / contain no-repeat;
|
||||
opacity: 0.92;
|
||||
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
|
||||
line-height: normal;
|
||||
text-align: initial;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tool[data-tool="love_mochi"]::after,
|
||||
.tool[data-tool="fight_mochi"]::after,
|
||||
.tool[data-tool="water_bowl"]::after,
|
||||
.tool[data-tool="sleep_drug"]::after,
|
||||
.tool[data-tool="nest_box"]::after,
|
||||
.tool[data-tool="water_hose"]::after,
|
||||
.tool[data-tool="ball"]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: auto;
|
||||
height: auto;
|
||||
left: auto;
|
||||
right: auto;
|
||||
top: auto;
|
||||
bottom: auto;
|
||||
transform: none;
|
||||
background: var(--tool-icon) right -8px center / 54px 54px no-repeat;
|
||||
opacity: 0.10;
|
||||
pointer-events: none;
|
||||
font-size: initial;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
|
||||
/* 15.2.0: replace legacy poke/pinch PNG icons with emoji glyphs. */
|
||||
.tool[data-tool="poke"],
|
||||
.tool[data-tool="pinch"] { --tool-icon: none; }
|
||||
.tool[data-tool="poke"]::before,
|
||||
.tool[data-tool="pinch"]::before {
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
display: block;
|
||||
opacity: 0.96;
|
||||
}
|
||||
.tool[data-tool="poke"]::before { content: "👈"; }
|
||||
.tool[data-tool="pinch"]::before { content: "🤏"; }
|
||||
.tool[data-tool="poke"]::after,
|
||||
.tool[data-tool="pinch"]::after {
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 46px;
|
||||
line-height: 54px;
|
||||
text-align: center;
|
||||
opacity: 0.10;
|
||||
right: -6px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
}
|
||||
.tool[data-tool="poke"]::after { content: "👈"; }
|
||||
.tool[data-tool="pinch"]::after { content: "🤏"; }
|
||||
.tool[data-tool="poke"] span,
|
||||
.tool[data-tool="pinch"] span { display: none; }
|
||||
|
||||
|
||||
/* 15.3.0: shower uses emoji; nest box and fences use appearance-matched assets/hitboxes. */
|
||||
.tool[data-tool="water_hose"] { --tool-icon: none; }
|
||||
.tool[data-tool="water_hose"]::before {
|
||||
content: "🚿";
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 24px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
display: block;
|
||||
opacity: 0.96;
|
||||
}
|
||||
.tool[data-tool="water_hose"]::after {
|
||||
content: "🚿";
|
||||
background: none;
|
||||
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
|
||||
font-size: 46px;
|
||||
line-height: 54px;
|
||||
text-align: center;
|
||||
opacity: 0.10;
|
||||
right: -6px;
|
||||
left: auto;
|
||||
top: 50%;
|
||||
bottom: auto;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
|
|
|||
485
docs/prompt.txt
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
You are working on the Tarinai observation game.
|
||||
|
||||
Base input:
|
||||
|
||||
* Start from `tarinai_colony_game_fixed_v6.zip`.
|
||||
* Modify the project files directly.
|
||||
* Return a new zip.
|
||||
* Do not remove existing gameplay features unless explicitly instructed.
|
||||
* Preserve the existing Japanese UI text style.
|
||||
* After changes, run syntax checks on all JavaScript files and verify zip integrity.
|
||||
* Bump the cache-busting version in `index.html` so browsers do not keep loading old JS/CSS.
|
||||
|
||||
Primary goals:
|
||||
Fix the remaining complexity, behavior mismatch, redundant logic, and fragmented-spec issues identified in the v6 audit. Implement all items below. Do not skip any item.
|
||||
|
||||
---
|
||||
|
||||
## 1. Family tree update performance
|
||||
|
||||
Current issue:
|
||||
The family tree update still performs heavy synchronous preprocessing, causing the main game to freeze.
|
||||
|
||||
Implement:
|
||||
|
||||
* Split heavy preprocessing into small chunks.
|
||||
* Use `requestIdleCallback` when available, with a safe `setTimeout` fallback.
|
||||
* Avoid doing large graph construction, signature generation, relationship counting, or DOM creation in one blocking pass.
|
||||
* The game loop must remain responsive while family tree data is prepared.
|
||||
|
||||
Update timing rules:
|
||||
|
||||
* When the family tree panel is hidden:
|
||||
|
||||
* Do not update every 5 seconds.
|
||||
* Check/update at most once every 30 seconds.
|
||||
* When the family tree panel is visible:
|
||||
|
||||
* Do not update on a fixed 5-second timer.
|
||||
* Update only when the family tree is dirty, meaning a relevant change occurred.
|
||||
* Relevant dirty events include at least:
|
||||
|
||||
* birth
|
||||
* death
|
||||
* parent/child relationship creation
|
||||
* mate/partner relationship creation or change
|
||||
* name/identity data change that affects the family tree display
|
||||
* Add or use a clear dirty flag such as `familyTreeDirty`.
|
||||
* Mark dirty from the source of the change, not by repeatedly recomputing expensive signatures.
|
||||
* If a background update is already running, do not start another one.
|
||||
* If a newer dirty event occurs while an update is running, schedule one additional update after the current update completes.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Opening or updating the family tree should not freeze the main simulation.
|
||||
* Hidden family tree work should be very rare.
|
||||
* Visible family tree should refresh promptly after actual changes.
|
||||
|
||||
---
|
||||
|
||||
## 2. Coward personality condition
|
||||
|
||||
Current issue:
|
||||
The “coward” condition may count total fight results and per-relationship fight results together, causing double counting.
|
||||
|
||||
Implement:
|
||||
|
||||
* The 5% chance to become `臆病` must be based only on total fight wins and total fight losses.
|
||||
* Do not include per-relationship win/loss counts in this condition.
|
||||
* Trigger condition:
|
||||
|
||||
* The Tarinai has just taken damage.
|
||||
* Its total fight losses are greater than its total fight wins.
|
||||
* Then it has a 5% chance to change personality to `臆病`.
|
||||
* Avoid changing the personality repeatedly if it is already `臆病`.
|
||||
|
||||
Expected result:
|
||||
|
||||
* The calculation uses only aggregate lifetime fight result counters.
|
||||
* No double counting.
|
||||
|
||||
---
|
||||
|
||||
## 3. Disease fight initiation and targeting rules
|
||||
|
||||
Implement the following disease fight rules exactly:
|
||||
|
||||
Fight initiation:
|
||||
|
||||
* Tarinai with `爆発病` can initiate fights.
|
||||
* Tarinai with `ずんち病` can initiate fights.
|
||||
* Tarinai with any other disease must not initiate fights.
|
||||
* Healthy Tarinai may initiate fights according to the existing rules.
|
||||
|
||||
Being targeted / challenged:
|
||||
|
||||
* Tarinai with `ねむり病` must not be targeted for fights.
|
||||
* Tarinai with any other disease may still be targeted/challenged by another Tarinai.
|
||||
* This means:
|
||||
|
||||
* `爆発病` and `ずんち病`: can initiate and can be targeted.
|
||||
* Other non-sleep diseases: cannot initiate, but can be targeted.
|
||||
* `ねむり病`: cannot initiate and cannot be targeted.
|
||||
|
||||
Apply consistently to:
|
||||
|
||||
* Normal fight selection.
|
||||
* Forced fight logic.
|
||||
* Fight-food effects such as `けんか餅`.
|
||||
* Any helper such as `canStartFight`, `canBeFightTarget`, or equivalent.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Disease rules are not handled by scattered ad-hoc checks.
|
||||
* Use clear helper functions if they do not already exist.
|
||||
|
||||
---
|
||||
|
||||
## 4. Collision and external-force unification
|
||||
|
||||
Current issues:
|
||||
|
||||
* Ball speed cap was removed, but some collision detection still uses capped sweep/search distances.
|
||||
* Very fast balls can still tunnel through objects.
|
||||
* Explosion knockback and ball knockback can be reduced by normal movement speed caps.
|
||||
* Knockback, collision damage, explosion impulse, and normal movement are handled inconsistently.
|
||||
|
||||
Implement:
|
||||
|
||||
* Separate normal self-propelled movement from external impulse/knockback.
|
||||
* External impulses must not be clamped by the normal Tarinai walking speed cap.
|
||||
* Add or refactor toward helper methods such as:
|
||||
|
||||
* `applyImpulse(entity, vx, vy, options)`
|
||||
* `applyKnockback(entity, sourceX, sourceY, strength, options)`
|
||||
* `applyImpactDamage(target, amount, cause)`
|
||||
* `resolveCircleCircleCollision(...)`
|
||||
* `resolveCircleRectCollision(...)`
|
||||
* Exact helper names are flexible, but the behavior must be centralized and not duplicated.
|
||||
|
||||
Unlimited ball speed:
|
||||
|
||||
* Do not reintroduce a ball speed cap.
|
||||
* Collision detection must handle high-speed balls using swept movement based on actual frame movement distance.
|
||||
* Remove fixed low `Math.min(...)` collision-search caps that contradict unlimited ball speed.
|
||||
* Ball vs wall, ball vs fence, ball vs nest box, ball vs ball, and ball vs Tarinai should remain reliable at high speed.
|
||||
|
||||
High-speed ball vs Tarinai:
|
||||
|
||||
* When a sufficiently fast ball hits a Tarinai:
|
||||
|
||||
* Knock the Tarinai away using velocity equal to 1/5 of the ball velocity.
|
||||
* This knockback must be an external impulse, not normal walking movement.
|
||||
* Do not let the normal “play with ball” behavior also run for that same impact if it would conflict.
|
||||
* If the damage from this impact kills the Tarinai, death cause must be `衝突`.
|
||||
|
||||
High-speed Tarinai vs Tarinai:
|
||||
|
||||
* When a sufficiently fast Tarinai hits another Tarinai:
|
||||
|
||||
* The hit Tarinai should also be knocked away.
|
||||
* Use the attacker’s actual movement/impulse direction and speed.
|
||||
* If collision damage kills a Tarinai, death cause must be `衝突`.
|
||||
|
||||
Explosion knockback:
|
||||
|
||||
* Make explosion knockback stronger than before.
|
||||
* Use external impulse, not normal movement velocity.
|
||||
* Knockback should be partly random.
|
||||
* Tarinai closer to the blast center should be thrown faster and farther.
|
||||
* Tarinai farther from the blast center should be thrown less strongly.
|
||||
* Preserve or improve existing explosion damage behavior.
|
||||
* If an explosion kills a Tarinai, keep the appropriate explosion-related death cause, not `衝突`.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Normal walking speed remains bounded.
|
||||
* Knockback from explosions and collisions can exceed walking speed.
|
||||
* High-speed objects do not frequently tunnel through collisions.
|
||||
* Collision behavior is handled by shared primitives rather than several unrelated implementations.
|
||||
|
||||
---
|
||||
|
||||
## 5. Ball-to-ball collision
|
||||
|
||||
Ensure balls collide with each other robustly.
|
||||
|
||||
Requirements:
|
||||
|
||||
* Ball vs ball collision must use circle-circle collision.
|
||||
* Resolve overlap.
|
||||
* Exchange or reflect velocity in a physically plausible way.
|
||||
* Work with high-speed balls, not only overlapping balls.
|
||||
* Avoid creating infinite acceleration or NaN velocity.
|
||||
* Do not apply arbitrary maximum speed caps.
|
||||
|
||||
---
|
||||
|
||||
## 6. Solid obstacle and placement logic unification
|
||||
|
||||
Current issues:
|
||||
Fence, nest box, and placement preview logic are still partly fragmented.
|
||||
|
||||
Implement:
|
||||
|
||||
* Unify all solid rectangular obstacle collision logic.
|
||||
* 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`.
|
||||
* Ball, Tarinai movement, path blocking, and placement checks should reference the same obstacle data.
|
||||
|
||||
Fence collision:
|
||||
|
||||
* Fence collision bounds must match the visible fence appearance.
|
||||
* If the visual fence is not the same size as the previous hitbox, update the hitbox to match the visual footprint.
|
||||
* Avoid separate visual size and collision size unless explicitly documented in one definition table.
|
||||
|
||||
Nest box collision:
|
||||
|
||||
* Preserve the existing 3×3 nest box hitbox rule:
|
||||
|
||||
* If the nest box is divided into a 3×3 grid, the collidable cells are:
|
||||
|
||||
* top-left
|
||||
* top-center
|
||||
* top-right
|
||||
* middle-left
|
||||
* middle-right
|
||||
* The middle-center, bottom-left, bottom-center, and bottom-right cells are passable/enterable according to the current nest behavior.
|
||||
* Ball must bounce off these nest box solid cells.
|
||||
* Tarinai must not path through these solid cells.
|
||||
|
||||
Placement preview:
|
||||
|
||||
* The preview highlight must use the exact same placement validation as actual placement.
|
||||
* If the preview is red, clicking must not place the object.
|
||||
* If the preview is green, clicking should place the object at the previewed position.
|
||||
* Do not silently clamp or auto-correct an out-of-bounds placement to an in-bounds location.
|
||||
* If the planned placement would extend outside the field, the highlight must turn red.
|
||||
* Fix the issue where a fence preview extending outside the field does not turn red.
|
||||
* The highlighted preview position should show the true placement footprint, not a different corrected footprint.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Placement UI, actual placement, and collision behavior agree.
|
||||
* No “red but placeable” or “green but placed elsewhere” behavior.
|
||||
|
||||
---
|
||||
|
||||
## 7. Item/tool definition unification
|
||||
|
||||
Current issues:
|
||||
Tool names, labels, icons, sizes, placement rules, and descriptions are scattered across multiple files.
|
||||
|
||||
Implement:
|
||||
|
||||
* Create or consolidate a single source of truth for tool/item definitions.
|
||||
* It should include at least:
|
||||
|
||||
* internal id
|
||||
* Japanese label
|
||||
* tooltip text
|
||||
* UI icon or icon renderer
|
||||
* game appearance reference where applicable
|
||||
* placement size / footprint
|
||||
* whether it is placeable
|
||||
* whether it is scaleable, if applicable
|
||||
* collision shape if applicable
|
||||
* Use this definition in the tool UI, placement preview, placement logic, and logs where appropriate.
|
||||
* Do not leave conflicting duplicate label/icon/size definitions in separate files unless unavoidable.
|
||||
|
||||
Specific icon requirements:
|
||||
|
||||
* `つつく` must use `👈`.
|
||||
* `つまむ` must use `🤏`.
|
||||
* `洗浄` must use the shower emoji `🚿`.
|
||||
* Remove or stop referencing old PNG icons for `つつく` and `つまむ`.
|
||||
* Fix the nest box tool UI icon so it matches the actual in-game nest box appearance.
|
||||
* Ensure every tool that appears in the tool UI has an icon.
|
||||
* Avoid mojibake/garbled icon rendering.
|
||||
|
||||
Water tool:
|
||||
|
||||
* `水` must remain removed from the tool UI.
|
||||
* If water still exists as a game object produced by rain or other simulation systems, keep that object behavior.
|
||||
* Remove leftover UI-only water tool entries, labels, tooltips, and placement handlers unless they are still required by non-UI simulation.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Tool UI no longer has missing icons, old icons, or mismatched nest box visuals.
|
||||
* Tool data is not scattered unnecessarily.
|
||||
|
||||
---
|
||||
|
||||
## 8. Observation UI event filtering
|
||||
|
||||
Current issue:
|
||||
Observation UI hides some events by matching Japanese text strings, which is fragile.
|
||||
|
||||
Implement:
|
||||
|
||||
* Remove from the observation UI:
|
||||
|
||||
* events about poking a ball
|
||||
* events of the form “X avoided Y” / `○○が××を避けた`
|
||||
* Prefer event type/category filtering over string matching.
|
||||
* If event objects do not have a type field, add one at event creation.
|
||||
* Keep the events available internally if needed, but do not show them in the observation UI.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Future wording changes do not break event filtering.
|
||||
|
||||
---
|
||||
|
||||
## 9. Death cause normalization
|
||||
|
||||
Ensure these death causes are applied consistently:
|
||||
|
||||
* Death by old age / lifespan:
|
||||
|
||||
* `天寿を全うした`
|
||||
* Tarinai deleted because it went too far outside the field:
|
||||
|
||||
* `仕様により画面外で削除`
|
||||
* Death caused by ball collision or Tarinai collision damage:
|
||||
|
||||
* `衝突`
|
||||
* Explosion death:
|
||||
|
||||
* Keep explosion-related cause.
|
||||
* Existing disease-related deaths:
|
||||
|
||||
* Preserve current intended disease death causes unless they conflict with the above.
|
||||
|
||||
Implementation:
|
||||
|
||||
* Centralize death cause normalization if possible.
|
||||
* Avoid scattered caller-side strings that can drift.
|
||||
* Ensure the family tree, observation UI, and any memorial/death display show the normalized cause.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out-of-bounds object policy
|
||||
|
||||
Current issue:
|
||||
Different object types handle out-of-bounds behavior differently.
|
||||
|
||||
Implement a unified out-of-bounds policy:
|
||||
|
||||
* For objects or entities slightly outside the field:
|
||||
|
||||
* Return them to the nearest valid in-field position.
|
||||
* For objects or entities far outside the field, or with invalid coordinates such as NaN/Infinity:
|
||||
|
||||
* Delete them.
|
||||
* For Tarinai deleted by this system:
|
||||
|
||||
* Death cause must be `仕様により画面外で削除`.
|
||||
* For non-living objects:
|
||||
|
||||
* Delete without a death cause.
|
||||
* Keep wall bounce behavior for balls at normal field boundaries, but still sanitize balls that somehow get far outside or invalid coordinates.
|
||||
|
||||
Expected result:
|
||||
|
||||
* No permanent invisible offscreen objects.
|
||||
* No NaN entities poisoning the simulation.
|
||||
|
||||
---
|
||||
|
||||
## 11. Observation relationship highlight
|
||||
|
||||
Current issue:
|
||||
Relationship highlight during observation can be affected by sprite rotation.
|
||||
|
||||
Implement:
|
||||
|
||||
* Relationship highlights must be drawn in world/screen space independent of the Tarinai sprite rotation.
|
||||
* Do not draw the highlight inside a rotated sprite transform.
|
||||
* Ensure highlight position follows the Tarinai but does not rotate with the Tarinai body.
|
||||
|
||||
Expected result:
|
||||
|
||||
* Highlight remains visually stable regardless of sprite rotation.
|
||||
|
||||
---
|
||||
|
||||
## 12. Nest box behavior preservation
|
||||
|
||||
Preserve and verify previous nest box fixes:
|
||||
|
||||
* Tarinai inside the nest box should be fully transparent in the main field.
|
||||
* Nest box can contain up to 5 Tarinai.
|
||||
* Nest box occupant popup should show each individual on a separate line.
|
||||
* The nest box solid hitbox must follow the 3×3 rule described above.
|
||||
* Ball should bounce off nest box solid hitbox cells.
|
||||
|
||||
Do not regress these behaviors while refactoring obstacles.
|
||||
|
||||
---
|
||||
|
||||
## 13. Encyclopedia / ecology page
|
||||
|
||||
Preserve and verify the `たりないの生態` addition:
|
||||
|
||||
* It must include an explanation of health.
|
||||
* It must include an explanation of stress.
|
||||
* It must use a Tarinai sprite/image with both the health bar and stress bar visible.
|
||||
* Do not break existing encyclopedia entries.
|
||||
|
||||
---
|
||||
|
||||
## 14. Naming and Japanese text preservation
|
||||
|
||||
Preserve previous Japanese terminology changes:
|
||||
|
||||
* `喧嘩傷病` must remain renamed to `きずつき病`.
|
||||
* `寿命` death text must display as `天寿を全うした`.
|
||||
* Use the existing tone/style for Japanese labels and descriptions.
|
||||
|
||||
Do not reintroduce removed or old names.
|
||||
|
||||
---
|
||||
|
||||
## 15. Code quality expectations
|
||||
|
||||
Refactor carefully:
|
||||
|
||||
* Prefer small, well-named helper functions.
|
||||
* Avoid adding more scattered special cases.
|
||||
* Avoid duplicating collision math.
|
||||
* Avoid duplicating item/tool metadata.
|
||||
* Avoid string-matching for gameplay logic where structured data is possible.
|
||||
* Preserve save compatibility where possible.
|
||||
* If save migration is needed, add a safe fallback.
|
||||
|
||||
Add comments only where they clarify non-obvious game rules, especially:
|
||||
|
||||
* disease fight rules
|
||||
* nest box 3×3 collision cells
|
||||
* external impulse vs normal movement
|
||||
* family tree dirty/update scheduling
|
||||
|
||||
---
|
||||
|
||||
## 16. Validation checklist
|
||||
|
||||
Before returning the zip, verify at minimum:
|
||||
|
||||
Static checks:
|
||||
|
||||
* Run `node --check` on every JavaScript file.
|
||||
* Ensure there are no `ReferenceError` risks from renamed helpers.
|
||||
* Ensure old cache version references are updated.
|
||||
|
||||
Manual or scripted behavior checks where practical:
|
||||
|
||||
* Ball vs ball collision works.
|
||||
* Fast ball hitting Tarinai knocks it by ball velocity / 5.
|
||||
* Fast collision death cause is `衝突`.
|
||||
* Explosion throws nearby Tarinai farther than distant Tarinai and is not clamped by walking speed.
|
||||
* Fence preview turns red when the footprint leaves the field.
|
||||
* Red placement preview cannot place an object.
|
||||
* Green placement preview places at the same footprint shown.
|
||||
* Nest box collision uses the required 3×3 cells.
|
||||
* Ball bounces off nest box solid cells.
|
||||
* Family tree hidden update is no more frequent than every 30 seconds.
|
||||
* Family tree visible update happens when dirty, not every 5 seconds.
|
||||
* Family tree processing does not freeze the game.
|
||||
* `臆病` condition uses only total wins/losses.
|
||||
* `爆発病` and `ずんち病` can initiate fights.
|
||||
* Other diseases cannot initiate fights.
|
||||
* `ねむり病` cannot initiate and cannot be targeted.
|
||||
* Other non-sleep diseases can be targeted.
|
||||
* Observation UI does not show ball-poke events or avoid events.
|
||||
* Tool icons are correct: `👈`, `🤏`, `🚿`, and nest box matching in-game appearance.
|
||||
* `水` is absent from the tool UI.
|
||||
* Lifespan death cause is `天寿を全うした`.
|
||||
* Far-outside Tarinai deletion cause is `仕様により画面外で削除`.
|
||||
|
||||
Deliverable:
|
||||
|
||||
* Return a zip named something like `tarinai_colony_game_fixed_v7.zip`.
|
||||
* Include a concise summary of changed areas and validation results.
|
||||
55
index.html
|
|
@ -3,8 +3,11 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>たりない観察</title>
|
||||
<link rel="stylesheet" href="css/styles.css?v=15.0.0" />
|
||||
<link rel="stylesheet" href="css/styles.css?v=15.5.0" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
@ -32,6 +35,14 @@
|
|||
</section>
|
||||
|
||||
<aside class="panel">
|
||||
<section id="selectedCard" class="card selected-card hidden">
|
||||
<div class="selected-head">
|
||||
<h2>選択中</h2>
|
||||
<button id="selectedCloseBtn" class="selected-close" type="button" aria-label="観察をやめる">×</button>
|
||||
</div>
|
||||
<div id="selectedInfo" class="selected-info empty">未選択</div>
|
||||
</section>
|
||||
|
||||
<section id="toolCard" class="card tool-card">
|
||||
<div class="tool-head"><h2>道具</h2></div>
|
||||
<div class="tool-body">
|
||||
|
|
@ -41,10 +52,10 @@
|
|||
<div class="tool-grid tool-category-body">
|
||||
<button class="tool selected" data-tool="observe"><span>O</span>観察</button>
|
||||
<button class="tool" data-tool="delete"><span>D</span>削除</button>
|
||||
<button class="tool" data-tool="poke"><span>P</span>つつく</button>
|
||||
<button class="tool" data-tool="pinch"><span>🤏</span>つまむ</button>
|
||||
<button class="tool" data-tool="poke"><span>👈</span>つつく</button>
|
||||
<button class="tool" data-tool="pinch"><span>🤏</span>つまむ</button>
|
||||
<button class="tool" data-tool="new"><span>N</span>追加</button>
|
||||
<button class="tool" data-tool="water_hose"><span>Q</span>洗浄</button>
|
||||
<button class="tool" data-tool="water_hose"><span>🚿</span>洗浄</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="tool-category" data-tool-category="food">
|
||||
|
|
@ -54,7 +65,6 @@
|
|||
<button class="tool" data-tool="sweet"><span>S</span>ずんだ餅</button>
|
||||
<button class="tool" data-tool="love_mochi"><span>H</span>へこ餅</button>
|
||||
<button class="tool" data-tool="fight_mochi"><span>K</span>けんか餅</button>
|
||||
<button class="tool" data-tool="water"><span>W</span>水</button>
|
||||
<button class="tool" data-tool="water_bowl"><span>U</span>水の皿</button>
|
||||
<button class="tool" data-tool="sleep_drug"><span>M</span>ねむり薬</button>
|
||||
<button class="tool" data-tool="grass"><span>G</span>草</button>
|
||||
|
|
@ -88,11 +98,6 @@
|
|||
<div id="colonyChart" class="colony-chart" aria-label="コロニーの推移グラフ" role="img"></div>
|
||||
</section>
|
||||
|
||||
<section class="card selected-card">
|
||||
<h2>選択中</h2>
|
||||
<div id="selectedInfo" class="selected-info empty">未選択</div>
|
||||
</section>
|
||||
|
||||
<section class="card log-card">
|
||||
<h2>イベント</h2>
|
||||
<div id="log" class="log"></div>
|
||||
|
|
@ -160,20 +165,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/data.js?v=15.0.0"></script>
|
||||
<script src="js/math.js?v=15.0.0"></script>
|
||||
<script src="js/assets.js?v=15.0.0"></script>
|
||||
<script src="js/audio.js?v=15.0.0"></script>
|
||||
<script src="js/render.js?v=15.0.0"></script>
|
||||
<script src="js/sim_core.js?v=15.0.0"></script>
|
||||
<script src="js/items.js?v=15.0.0"></script>
|
||||
<script src="js/health.js?v=15.0.0"></script>
|
||||
<script src="js/tarinai.js?v=15.0.0"></script>
|
||||
<script src="js/world.js?v=15.0.0"></script>
|
||||
<script src="js/text_catalog.js?v=15.0.0"></script>
|
||||
<script src="js/ui.js?v=15.0.0"></script>
|
||||
<script src="js/ui_charts.js?v=15.0.0"></script>
|
||||
<script src="js/ui_family.js?v=15.0.0"></script>
|
||||
<script src="js/main.js?v=15.0.0"></script>
|
||||
<script src="js/data.js?v=15.5.0"></script>
|
||||
<script src="js/math.js?v=15.5.0"></script>
|
||||
<script src="js/assets.js?v=15.5.0"></script>
|
||||
<script src="js/audio.js?v=15.5.0"></script>
|
||||
<script src="js/render.js?v=15.5.0"></script>
|
||||
<script src="js/sim_core.js?v=15.5.0"></script>
|
||||
<script src="js/items.js?v=15.5.0"></script>
|
||||
<script src="js/health.js?v=15.5.0"></script>
|
||||
<script src="js/tarinai.js?v=15.5.0"></script>
|
||||
<script src="js/world.js?v=15.5.0"></script>
|
||||
<script src="js/text_catalog.js?v=15.5.0"></script>
|
||||
<script src="js/ui.js?v=15.5.0"></script>
|
||||
<script src="js/ui_charts.js?v=15.5.0"></script>
|
||||
<script src="js/ui_family.js?v=15.5.0"></script>
|
||||
<script src="js/main.js?v=15.5.0"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
20
js/health.js
|
|
@ -10,10 +10,12 @@ const HEALTH = (() => {
|
|||
poke: "\u3064\u3064\u304b\u308c\u3059\u304e\u305f",
|
||||
firecracker: "\u7206\u7af9",
|
||||
accident: "\u4e8b\u6545",
|
||||
lifespan: "\u5bff\u547d",
|
||||
collision: "衝突",
|
||||
outOfBounds: "仕様により画面外で削除",
|
||||
lifespan: "天寿を全うした",
|
||||
sleepDisease: "\u306d\u3080\u308a\u75c5",
|
||||
explosionDisease: "\u7206\u767a\u75c5",
|
||||
fightDisease: "\u55a7\u5629\u50b7\u75c5",
|
||||
fightDisease: "きずつき病",
|
||||
zunchiDisease: "\u305a\u3093\u3061\u75c5",
|
||||
});
|
||||
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
|
||||
|
|
@ -24,10 +26,12 @@ const HEALTH = (() => {
|
|||
if (text.includes(WEAKENED_SUFFIX)) return text.replace(/\u3067\u8870\u5f31.*$/, "");
|
||||
if (/\u55a7\u5629|fight|headbutt/.test(text)) return CAUSES.fight;
|
||||
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return CAUSES.firecracker;
|
||||
if (/仕様により画面外で削除|画面外で削除|out.*bounds|offscreen/.test(text)) return CAUSES.outOfBounds;
|
||||
if (/衝突|collision/.test(text)) return CAUSES.collision;
|
||||
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return CAUSES.poke;
|
||||
if (/\u306d\u3080\u308a\u75c5|sleep.*disease/.test(text)) return CAUSES.sleepDisease;
|
||||
if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease;
|
||||
if (/\u3051\u3093\u304b\u75c5|\u55a7\u5629\u50b7\u75c5|fight.*disease/.test(text)) return CAUSES.fightDisease;
|
||||
if (/\u3051\u3093\u304b\u75c5|喧嘩傷病|きずつき病|fight.*disease/.test(text)) return CAUSES.fightDisease;
|
||||
if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease;
|
||||
if (/\u75c5/.test(text)) {
|
||||
const disease = text.match(DISEASE_RE);
|
||||
|
|
@ -35,8 +39,8 @@ const HEALTH = (() => {
|
|||
}
|
||||
if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return CAUSES.hunger;
|
||||
if (/\u30b9\u30c8\u30ec\u30b9|stress/.test(text)) return CAUSES.stress;
|
||||
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return CAUSES.accident;
|
||||
if (/\u5bff\u547d/.test(text)) return CAUSES.lifespan;
|
||||
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;
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
@ -81,6 +85,7 @@ const HEALTH = (() => {
|
|||
const recentMajor = hasRecentMajorDamage(t);
|
||||
const recent = recentDamageCause(t, 20);
|
||||
const fighting = isFightingContext(t, recent);
|
||||
if (recentMajor && t.lastMajorDamageCause === CAUSES.collision) return CAUSES.collision;
|
||||
if (recentMajor && (!direct || direct === t.lastMajorDamageCause || opts.fromDamage || t.energy <= 22 || t.mood <= 20 || t.stress >= 118)) {
|
||||
return weakenedDeathReasonFor(t.lastMajorDamageCause);
|
||||
}
|
||||
|
|
@ -89,7 +94,7 @@ const HEALTH = (() => {
|
|||
if (t.hunger >= 108 || (t.hunger >= 94 && t.energy <= 26) || /\u98e2|\u7a7a\u8179|hunger/.test(text)) return CAUSES.hunger;
|
||||
if (fighting && (t.energy <= 44 || t.stress >= 112 || t.mood <= 22 || opts.fromDamage)) return CAUSES.fight;
|
||||
if (recent && recent !== CAUSES.stress && (t.energy <= 34 || t.mood <= 20 || t.stress >= 118 || opts.fromDamage)) return recent;
|
||||
if (text.includes(CAUSES.lifespan)) return CAUSES.lifespan;
|
||||
if (text.includes(CAUSES.lifespan) || text.includes("\u5bff\u547d")) return CAUSES.lifespan;
|
||||
if (direct) return direct;
|
||||
if (t.stress >= 118 || /\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return CAUSES.stress;
|
||||
if (t.energy <= 0.5) return CAUSES.accident;
|
||||
|
|
@ -101,7 +106,7 @@ const HEALTH = (() => {
|
|||
if (text.includes(WEAKENED_SUFFIX)) return text;
|
||||
const cause = dominantDeathCause(t, text, opts) || CAUSES.accident;
|
||||
if (cause.includes(WEAKENED_SUFFIX)) return cause;
|
||||
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan;
|
||||
const canWeaken = cause && !cause.endsWith("\u75c5") && cause !== CAUSES.hunger && cause !== CAUSES.stress && cause !== CAUSES.lifespan && cause !== CAUSES.outOfBounds && cause !== CAUSES.collision;
|
||||
if (canWeaken && (opts.weakened || (hasRecentMajorDamage(t) && cause === t.lastMajorDamageCause))) return weakenedDeathReasonFor(cause);
|
||||
return cause;
|
||||
}
|
||||
|
|
@ -116,6 +121,7 @@ const HEALTH = (() => {
|
|||
cause = rememberDamage(t, actualLoss, reason);
|
||||
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure();
|
||||
if (t.sleepDisease) t.recoverSleepDisease("\u30c0\u30e1\u30fc\u30b8\u3067\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
if (t.maybeBecomeTimidAfterDamage) t.maybeBecomeTimidAfterDamage(cause || reason);
|
||||
t.showHpBar();
|
||||
}
|
||||
if (t.energy <= 0.5) {
|
||||
|
|
|
|||
138
js/items.js
|
|
@ -1,3 +1,4 @@
|
|||
// tarinai_colony_game build 15.4.0: ball collisions, fence hitboxes, and archive throttling
|
||||
"use strict";
|
||||
|
||||
class Item {
|
||||
|
|
@ -122,16 +123,7 @@ class Item {
|
|||
updateBall(dt, worldRef) {
|
||||
this.prevX = this.x;
|
||||
this.prevY = this.y;
|
||||
const maxSpeed = 1250;
|
||||
const clampVelocity = () => {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (speed > maxSpeed) {
|
||||
this.vx = (this.vx || 0) / speed * maxSpeed;
|
||||
this.vy = (this.vy || 0) / speed * maxSpeed;
|
||||
}
|
||||
return Math.hypot(this.vx || 0, this.vy || 0);
|
||||
};
|
||||
const moving = clampVelocity();
|
||||
const moving = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (moving > 0.04) {
|
||||
this.x += (this.vx || 0) * dt;
|
||||
this.y += (this.vy || 0) * dt;
|
||||
|
|
@ -143,7 +135,6 @@ class Item {
|
|||
this.resolveBallObstacleCollisions(dt, worldRef);
|
||||
this.x = clamp(this.x, p, worldRef.w - p);
|
||||
this.y = clamp(this.y, p, worldRef.h - p);
|
||||
clampVelocity();
|
||||
const groundFriction = Math.pow(0.72, dt);
|
||||
this.vx *= groundFriction;
|
||||
this.vy *= groundFriction;
|
||||
|
|
@ -156,21 +147,22 @@ class Item {
|
|||
resolveBallObstacleCollisions(dt, worldRef) {
|
||||
if (!worldRef?.nearbyItems) return;
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
const searchRadius = Math.max(130, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 96);
|
||||
const searchRadius = Math.max(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
|
||||
const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || [];
|
||||
let fenceChecks = 0;
|
||||
const maxFenceChecks = CONFIG.maxFenceCollisionChecks ?? 18;
|
||||
let solidChecks = 0;
|
||||
const maxSolidChecks = CONFIG.maxFenceCollisionChecks ?? 24;
|
||||
for (const it of items) {
|
||||
if (!it || it === this || it.dead) continue;
|
||||
if (it.type === "bed") {
|
||||
this.applyBallHayDrag(it, dt, worldRef);
|
||||
} else if (it.type === "stone") {
|
||||
this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef);
|
||||
} else if (it.type === "fence_v" || it.type === "fence_h") {
|
||||
if (fenceChecks >= maxFenceChecks) continue;
|
||||
fenceChecks += 1;
|
||||
this.resolveBallFenceBounce(it, worldRef);
|
||||
}
|
||||
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
|
||||
if (!rects.length) continue;
|
||||
if (solidChecks >= maxSolidChecks) continue;
|
||||
solidChecks += 1;
|
||||
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +187,27 @@ class Item {
|
|||
let dx = this.x - obstacle.x;
|
||||
let dy = this.y - obstacle.y;
|
||||
let d = Math.hypot(dx, dy);
|
||||
if (d >= hitRadius) return;
|
||||
if (d >= hitRadius) {
|
||||
const px = this.prevX;
|
||||
const py = this.prevY;
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
|
||||
const sx = this.x - px;
|
||||
const sy = this.y - py;
|
||||
const len2 = sx * sx + sy * sy;
|
||||
if (len2 <= 0.0001) return;
|
||||
const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1);
|
||||
const cx = px + sx * t;
|
||||
const cy = py + sy * t;
|
||||
dx = cx - obstacle.x;
|
||||
dy = cy - obstacle.y;
|
||||
d = Math.hypot(dx, dy);
|
||||
if (d >= hitRadius) return;
|
||||
if (d < 0.001) {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
|
||||
else { dx = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; }
|
||||
}
|
||||
}
|
||||
if (d < 0.001) {
|
||||
const speed = Math.hypot(this.vx || 0, this.vy || 0);
|
||||
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
|
||||
|
|
@ -219,29 +231,69 @@ class Item {
|
|||
this.emitBallBounce(worldRef, obstacle);
|
||||
}
|
||||
|
||||
resolveBallFenceBounce(fence, worldRef) {
|
||||
const vertical = fence.type === "fence_v";
|
||||
const len = Math.max(112, (fence.r || 42) * 3.55);
|
||||
const thick = Math.max(10, (fence.r || 42) * 0.31);
|
||||
const halfLen = len / 2;
|
||||
const sx1 = vertical ? fence.x : fence.x - halfLen;
|
||||
const sy1 = vertical ? fence.y - halfLen : fence.y;
|
||||
const sx2 = vertical ? fence.x : fence.x + halfLen;
|
||||
const sy2 = vertical ? fence.y + halfLen : fence.y;
|
||||
const vx = sx2 - sx1;
|
||||
const vy = sy2 - sy1;
|
||||
const segLenSq = vx * vx + vy * vy || 1;
|
||||
const u = clamp(((this.x - sx1) * vx + (this.y - sy1) * vy) / segLenSq, 0, 1);
|
||||
const cx = sx1 + vx * u;
|
||||
const cy = sy1 + vy * u;
|
||||
const hitRadius = (this.r || 18) + thick / 2 + 2;
|
||||
resolveBallRectBounce(rect, worldRef, source = null) {
|
||||
if (!rect) return;
|
||||
const hitRadius = (this.r || 18) + 2;
|
||||
const cx = clamp(this.x, rect.left, rect.right);
|
||||
const cy = clamp(this.y, rect.top, rect.bottom);
|
||||
let dx = this.x - cx;
|
||||
let dy = this.y - cy;
|
||||
let d = Math.hypot(dx, dy);
|
||||
if (d >= hitRadius) return;
|
||||
if (d >= hitRadius) {
|
||||
const px = this.prevX;
|
||||
const py = this.prevY;
|
||||
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
|
||||
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return;
|
||||
let nx = 0;
|
||||
let ny = 0;
|
||||
const vx = this.vx || 0;
|
||||
const vy = this.vy || 0;
|
||||
const candidates = [];
|
||||
const sx = this.x - px;
|
||||
const sy = this.y - py;
|
||||
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
|
||||
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
|
||||
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
|
||||
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
|
||||
if (candidates.length) {
|
||||
candidates.sort((a, b) => a.t - b.t);
|
||||
nx = candidates[0].nx;
|
||||
ny = candidates[0].ny;
|
||||
} else if (Math.abs(vx) >= Math.abs(vy)) {
|
||||
nx = vx >= 0 ? -1 : 1;
|
||||
} else {
|
||||
ny = vy >= 0 ? -1 : 1;
|
||||
}
|
||||
const toward = vx * nx + vy * ny;
|
||||
if (nx < 0) this.x = expanded.left - 0.5;
|
||||
else if (nx > 0) this.x = expanded.right + 0.5;
|
||||
if (ny < 0) this.y = expanded.top - 0.5;
|
||||
else if (ny > 0) this.y = expanded.bottom + 0.5;
|
||||
if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom);
|
||||
if (ny) this.x = clamp(this.x, expanded.left, expanded.right);
|
||||
if (toward < 0) {
|
||||
this.vx = vx - 1.78 * toward * nx;
|
||||
this.vy = vy - 1.78 * toward * ny;
|
||||
} else {
|
||||
this.vx = vx + nx * 18;
|
||||
this.vy = vy + ny * 18;
|
||||
}
|
||||
this.vx *= 0.94;
|
||||
this.vy *= 0.94;
|
||||
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
||||
this.emitBallBounce(worldRef, source || rect.item || rect);
|
||||
return;
|
||||
}
|
||||
if (d < 0.001) {
|
||||
if (vertical) { dx = (this.vx || 0) >= 0 ? -1 : 1; dy = 0; }
|
||||
else { dx = 0; dy = (this.vy || 0) >= 0 ? -1 : 1; }
|
||||
const left = Math.abs(this.x - rect.left);
|
||||
const right = Math.abs(rect.right - this.x);
|
||||
const top = Math.abs(this.y - rect.top);
|
||||
const bottom = Math.abs(rect.bottom - this.y);
|
||||
const m = Math.min(left, right, top, bottom);
|
||||
if (m === left) { dx = -1; dy = 0; }
|
||||
else if (m === right) { dx = 1; dy = 0; }
|
||||
else if (m === top) { dx = 0; dy = -1; }
|
||||
else { dx = 0; dy = 1; }
|
||||
d = 1;
|
||||
}
|
||||
const nx = dx / d;
|
||||
|
|
@ -252,11 +304,19 @@ class Item {
|
|||
if (toward < 0) {
|
||||
this.vx = (this.vx || 0) - 1.78 * toward * nx;
|
||||
this.vy = (this.vy || 0) - 1.78 * toward * ny;
|
||||
} else {
|
||||
this.vx = (this.vx || 0) + nx * 18;
|
||||
this.vy = (this.vy || 0) + ny * 18;
|
||||
}
|
||||
this.vx *= 0.94;
|
||||
this.vy *= 0.94;
|
||||
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
|
||||
this.emitBallBounce(worldRef, fence);
|
||||
this.emitBallBounce(worldRef, source || rect.item || rect);
|
||||
}
|
||||
|
||||
resolveBallFenceBounce(fence, worldRef) {
|
||||
const rects = worldRef?.solidObstacleRects ? worldRef.solidObstacleRects(fence) : [];
|
||||
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, fence);
|
||||
}
|
||||
|
||||
emitBallBounce(worldRef, obstacle) {
|
||||
|
|
|
|||
106
js/render.js
|
|
@ -124,6 +124,98 @@ function drawItemGlow(ctx, item, lighting, visibleAlpha = 1) {
|
|||
return;
|
||||
}
|
||||
|
||||
const PLACEABLE_TOOL_TYPES = Object.freeze({
|
||||
zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", sleep_drug: "sleep_drug", water: "water", water_bowl: "water_bowl", grass: "grass",
|
||||
stone: "stone", bed: "bed", nest_box: "nest_box", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h",
|
||||
});
|
||||
|
||||
function placementPreviewFor(world) {
|
||||
const p = world?.pointer;
|
||||
const type = PLACEABLE_TOOL_TYPES[world?.tool || ""];
|
||||
if (!p?.inside || !type) return null;
|
||||
const pad = CONFIG.worldPadding || 30;
|
||||
let x = clamp(p.x, pad, world.w - pad);
|
||||
let y = clamp(p.y, pad, world.h - pad);
|
||||
const r = {
|
||||
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, sleep_drug: 13, water: 12, water_bowl: 20, grass: 17, stone: 20, bed: 37, nest_box: 42, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, zunchi: 14
|
||||
}[type] || 12;
|
||||
const rectOutside = (rect) => Boolean(rect && (rect.left < pad || rect.top < pad || rect.right > world.w - pad || rect.bottom > world.h - pad));
|
||||
const circleOutside = (cx, cy, radius) => cx - radius < pad || cy - radius < pad || cx + radius > world.w - pad || cy + radius > world.h - pad;
|
||||
let blocked = false;
|
||||
if (type === "grass" && world.findGrassPlantingSpot) {
|
||||
const spot = world.findGrassPlantingSpot(x, y, { allowOriginal: true, minRadius: 22, maxRadius: 128, attempts: 16 });
|
||||
if (!spot) return { type, x, y, r, blocked: true };
|
||||
x = spot.x;
|
||||
y = spot.y;
|
||||
blocked = circleOutside(x, y, Math.max(14, r * 1.55));
|
||||
}
|
||||
if ((type === "fence_v" || type === "fence_h") && world.fenceRect) {
|
||||
const raw = { type, x, y, r };
|
||||
const rawRect = world.fenceRect(raw);
|
||||
blocked = rectOutside(rawRect);
|
||||
const tmp = { type, x, y, r };
|
||||
const rect = world.fenceRect(tmp);
|
||||
if (rect) {
|
||||
const dx = Math.max(pad - rect.left, 0) - Math.max(rect.right - (world.w - pad), 0);
|
||||
const dy = Math.max(pad - rect.top, 0) - Math.max(rect.bottom - (world.h - pad), 0);
|
||||
tmp.x += dx;
|
||||
tmp.y += dy;
|
||||
return { type, x: tmp.x, y: tmp.y, r, rect: world.fenceRect(tmp), blocked: blocked || world.fencePlacementBlocked?.(raw) || world.fencePlacementBlocked?.(tmp) };
|
||||
}
|
||||
}
|
||||
if (type === "nest_box" && world.nestBoxBaseRect) {
|
||||
const tmp = { type, x, y, r, dead: false };
|
||||
const rect = world.nestBoxBaseRect(tmp);
|
||||
return { type, x, y, r, rect, blocked: rectOutside(rect) };
|
||||
}
|
||||
if (!blocked) blocked = circleOutside(x, y, Math.max(14, r * (type === "bed" ? 1.55 : 1.25)));
|
||||
return { type, x, y, r, blocked };
|
||||
}
|
||||
|
||||
function drawPlacementPreview(ctx, world) {
|
||||
const preview = placementPreviewFor(world);
|
||||
if (!preview) return;
|
||||
const { type, x, y, r, rect, blocked } = preview;
|
||||
const pulse = 0.55 + Math.sin((world.time || 0) * 7.0) * 0.10;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = blocked ? 0.44 : 0.72;
|
||||
ctx.lineWidth = blocked ? 2.2 : 2.0;
|
||||
ctx.setLineDash(blocked ? [5, 4] : [7, 5]);
|
||||
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.86)" : "rgba(110,178,55,0.88)";
|
||||
ctx.fillStyle = blocked ? "rgba(232,84,72,0.14)" : "rgba(190,236,92,0.15)";
|
||||
if (rect) {
|
||||
roundedRect(ctx, rect.left, rect.top, rect.right - rect.left, rect.bottom - rect.top, 8);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
if (type === "nest_box" && world.nestBoxSolidRects) {
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.strokeStyle = blocked ? "rgba(194,70,58,0.38)" : "rgba(110,178,55,0.42)";
|
||||
for (const part of world.nestBoxSolidRects({ type, x, y, r, dead: false })) {
|
||||
ctx.strokeRect(part.left, part.top, part.right - part.left, part.bottom - part.top);
|
||||
}
|
||||
}
|
||||
} else if (type === "water_bowl" || type === "stone" || type === "ball" || type === "firecracker" || type === "zunchi" || type.includes("mochi") || type === "sweet" || type === "sleep_drug") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, Math.max(16, r * (1.18 + pulse * 0.04)), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(x, y, Math.max(14, r * 1.55), Math.max(9, r * 0.86), 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.setLineDash([]);
|
||||
ctx.globalAlpha = blocked ? 0.52 : 0.70;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x - 8, y);
|
||||
ctx.lineTo(x + 8, y);
|
||||
ctx.moveTo(x, y - 8);
|
||||
ctx.lineTo(x, y + 8);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawCreatureGlow(ctx, tarinai, drawW, drawH, lighting) {
|
||||
if (tarinai.dead || tarinai.world.selected !== tarinai) return;
|
||||
ctx.save();
|
||||
|
|
@ -580,6 +672,7 @@ function render() {
|
|||
if (perf < 3 || world.weather === "light_rain") drawRain(ctx, screenW, screenH, world.time);
|
||||
|
||||
beginFieldTransform();
|
||||
drawPlacementPreview(ctx, world);
|
||||
for (const it of world.items) {
|
||||
if (it.type === "trace" || it.type === "splat") it.draw(ctx, world.time, lighting);
|
||||
}
|
||||
|
|
@ -680,13 +773,16 @@ function drawNestBoxTooltip(ctx, world) {
|
|||
const info = world.pointerNestBoxInfo?.();
|
||||
if (!info) return;
|
||||
const box = info.box;
|
||||
const names = info.occupants.length ? info.occupants.map(t => t.name).join("\u3001") : "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044";
|
||||
const title = "\u5de3\u7bb1";
|
||||
const text = `${title}: ${names}`;
|
||||
const lines = info.occupants.length
|
||||
? [title, ...info.occupants.map(t => t.name)]
|
||||
: [title, "\u4e2d\u306b\u306f\u8ab0\u3082\u3044\u306a\u3044"];
|
||||
ctx.save();
|
||||
ctx.font = "12px Yomogi, sans-serif";
|
||||
const w = Math.min(240, Math.max(92, ctx.measureText(text).width + 22));
|
||||
const h = 38;
|
||||
const textW = lines.reduce((m, line) => Math.max(m, ctx.measureText(line).width), 0);
|
||||
const lineH = 17;
|
||||
const w = Math.min(240, Math.max(92, textW + 22));
|
||||
const h = Math.max(38, 16 + lines.length * lineH);
|
||||
const pos = world.worldToScreen ? world.worldToScreen(box.x, box.y) : { x: box.x, y: box.y };
|
||||
const x = clamp(pos.x - w / 2, 8, ctx.canvas.width - w - 8);
|
||||
const y = clamp(pos.y - box.r * 1.8 - h, 8, ctx.canvas.height - h - 8);
|
||||
|
|
@ -699,7 +795,7 @@ function drawNestBoxTooltip(ctx, world) {
|
|||
ctx.fillStyle = "#fff8e6";
|
||||
ctx.textAlign = "left";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(text, x + 11, y + h / 2);
|
||||
lines.forEach((line, i) => ctx.fillText(line, x + 11, y + 11 + lineH * i + lineH / 2));
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world };
|
||||
|
|
@ -96,6 +96,7 @@ class Tarinai {
|
|||
this.zunchiDiseaseSeverity = opts.zunchiDiseaseSeverity ?? (this.zunchiDisease ? 1 : 0);
|
||||
this.zunchiDiseaseCooldown = opts.zunchiDiseaseCooldown ?? 0;
|
||||
this.totalFightLosses = opts.totalFightLosses || 0;
|
||||
this.totalFightWins = opts.totalFightWins || 0;
|
||||
this.isZunchiSlave = Boolean(opts.isZunchiSlave);
|
||||
this.formerName = opts.formerName || null;
|
||||
this.loveMochiTimer = opts.loveMochiTimer ?? 0;
|
||||
|
|
@ -165,7 +166,7 @@ class Tarinai {
|
|||
birthRitualTimer: this.birthRitualTimer, birthRitualMax: this.birthRitualMax, birthPartnerId: this.birthPartnerId,
|
||||
birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader,
|
||||
pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed,
|
||||
totalFightLosses: this.totalFightLosses, 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) : [],
|
||||
zunchiDisease: this.zunchiDisease, zunchiDiseaseSeverity: this.zunchiDiseaseSeverity, zunchiDiseaseCooldown: this.zunchiDiseaseCooldown,
|
||||
tempComfort: this.tempComfort, tempTimer: this.tempTimer,
|
||||
|
|
@ -226,6 +227,7 @@ class Tarinai {
|
|||
|
||||
addRecord(text, kind = "note") {
|
||||
if (!text) return;
|
||||
if (this.world?.shouldHideFromObservationRecords?.(text)) return;
|
||||
if (!Array.isArray(this.records)) this.records = [];
|
||||
const entry = { time: this.world?.time || 0, text: String(text), kind: kind || "note" };
|
||||
const head = this.records[0];
|
||||
|
|
@ -242,6 +244,28 @@ class Tarinai {
|
|||
return this.personalityProfile().label || this.personality || "\u4e0d\u660e";
|
||||
}
|
||||
|
||||
fightWinCount() {
|
||||
return this.totalFightWins || 0;
|
||||
}
|
||||
|
||||
fightLossCount() {
|
||||
return this.totalFightLosses || 0;
|
||||
}
|
||||
|
||||
maybeBecomeTimidAfterDamage(cause = "") {
|
||||
if (this.dead || this.personality === "timid") return false;
|
||||
const losses = this.totalFightLosses || 0;
|
||||
const wins = this.totalFightWins || 0;
|
||||
if (losses <= wins) return false;
|
||||
if (Math.random() >= 0.05) return false;
|
||||
this.personality = "timid";
|
||||
this.fearTimer = Math.max(this.fearTimer || 0, 1.2);
|
||||
this.stress = clamp((this.stress || 0) + 5, 0, 130);
|
||||
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.35, "こわい…", "rgba(90,80,120,0.78)");
|
||||
this.addRecord?.(`${this.name}は痛みで臆病になった。`, "health");
|
||||
return true;
|
||||
}
|
||||
|
||||
relationTo(id) {
|
||||
if (!id) return relationDefaults();
|
||||
if (!this.relationships) this.relationships = {};
|
||||
|
|
@ -623,9 +647,9 @@ class Tarinai {
|
|||
this.fightDisease = true;
|
||||
this.fightDiseaseCooldown = 18;
|
||||
this.fearTimer = Math.max(this.fearTimer || 0, 0.35);
|
||||
this.thought = "\u55a7\u5629\u50b7\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
this.thought = "きずつき病\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "?", "rgba(150,78,44,0.80)");
|
||||
this.world?.log?.(`${this.name}\u306f\u55a7\u5629\u50b7\u75c5\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight");
|
||||
this.world?.log?.(`${this.name}\u306fきずつき病\u3092\u767a\u75c7\u3057\u305f\u3002`, "fight");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -633,10 +657,10 @@ class Tarinai {
|
|||
if (!this.fightDisease) return false;
|
||||
this.fightDisease = false;
|
||||
this.fightDiseaseCooldown = 20;
|
||||
this.thought = reason || "\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f";
|
||||
this.thought = reason || "きずつき病\u304c\u6cbb\u3063\u305f";
|
||||
this.stress = clamp(this.stress - 12, 0, 130);
|
||||
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "\u306f\u3046", "rgba(82,126,88,0.76)");
|
||||
this.world?.log?.(`${this.name}\u306e\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f\u3002`, "fight");
|
||||
this.world?.log?.(`${this.name}\u306eきずつき病\u304c\u6cbb\u3063\u305f\u3002`, "fight");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -671,7 +695,11 @@ class Tarinai {
|
|||
return null;
|
||||
}
|
||||
const d = distXY(this.x, this.y, box.x, box.y);
|
||||
const canEnter = (this.state === "sleep" || this.state === "seek_bed") && d <= Math.max(18, box.r * 0.58);
|
||||
const occupants = this.world?.nestBoxOccupants ? this.world.nestBoxOccupants(box, Infinity) : [];
|
||||
const capacity = this.world?.nestBoxCapacity ? this.world.nestBoxCapacity(box) : 5;
|
||||
const occupantIndex = occupants.indexOf(this);
|
||||
const hasRoom = occupantIndex >= 0 ? occupantIndex < capacity : occupants.length < capacity;
|
||||
const canEnter = hasRoom && (this.state === "sleep" || this.state === "seek_bed") && d <= Math.max(18, box.r * 0.58);
|
||||
if (canEnter) {
|
||||
this.insideNestBoxId = box.id;
|
||||
this.nestFade = clamp((this.nestFade || 0) + dt * 2.4, 0, 1);
|
||||
|
|
@ -728,7 +756,7 @@ class Tarinai {
|
|||
if (this.recoverZunchiDisease("\u81ea\u7136\u306b\u305a\u3093\u3061\u75c5\u304c\u6cbb\u307e\u3063\u305f")) this.world?.log?.(`${this.name}\u306e\u305a\u3093\u3061\u75c5\u304c\u81ea\u7136\u306b\u6cbb\u3063\u305f\u3002`, "accident");
|
||||
}
|
||||
if (this.fightDisease && Math.random() < dt * 0.00070 * (this.energy > 58 ? 1.4 : 1.0)) {
|
||||
this.recoverFightDisease("\u81ea\u7136\u306b\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
this.recoverFightDisease("\u81ea\u7136\u306bきずつき病\u304c\u6cbb\u3063\u305f");
|
||||
}
|
||||
if (this.fightDisease) {
|
||||
this.stress = clamp(this.stress + dt * 0.16, 0, 130);
|
||||
|
|
@ -848,6 +876,8 @@ class Tarinai {
|
|||
|
||||
update(dt) {
|
||||
if (this.dead) return;
|
||||
this.prevX = this.x;
|
||||
this.prevY = this.y;
|
||||
|
||||
const minute = dt / 60;
|
||||
this.age += dt;
|
||||
|
|
@ -1166,8 +1196,8 @@ class Tarinai {
|
|||
? dayP >= this.sleepStart && dayP < this.sleepEnd
|
||||
: dayP >= this.sleepStart || dayP < this.sleepEnd;
|
||||
|
||||
if (this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) {
|
||||
const opponent = this.world.nearestOther(this, 320);
|
||||
if (this.world.canStartFight?.(this) && this.hasFightMochiEffect() && this.fightTimer <= 0.04 && this.intimidateTimer <= 0.04 && this.defeatedTimer <= 0.04) {
|
||||
const opponent = this.world.nearestOther(this, 320, o => this.world.canBeFightTarget?.(o));
|
||||
if (opponent) {
|
||||
const d = dist(this, opponent);
|
||||
if (d <= Math.max(48, this.radius + opponent.radius + 18)) {
|
||||
|
|
@ -1191,7 +1221,7 @@ class Tarinai {
|
|||
this.target = this.panicDestination(rival);
|
||||
this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear);
|
||||
this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`;
|
||||
if (this.world.relationNotice(this.id, rival.id, "avoid", 22)) this.world.log(`${this.name}\u306f\u4ee5\u524d\u306e\u55a7\u5629\u5f8c\u3001${rival.name}\u3092\u907f\u3051\u305f\u3002`, "relation");
|
||||
this.world.relationNotice(this.id, rival.id, "avoid", 22);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1219,7 +1249,7 @@ class Tarinai {
|
|||
this.state = "seek_food";
|
||||
this.target = cureFood;
|
||||
this.foodReactTimer = Math.max(this.foodReactTimer, 0.42);
|
||||
this.thought = this.fightDisease ? "\u55a7\u5629\u50b7\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";
|
||||
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";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -1227,7 +1257,7 @@ class Tarinai {
|
|||
if (this.fightDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
|
||||
this.state = "fight_sick";
|
||||
this.target = null;
|
||||
this.thought = "\u55a7\u5629\u50b7\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
this.thought = "きずつき病\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
if (Math.random() < dt * 0.45) this.wanderAngle += rand(-3.4, 3.4);
|
||||
return;
|
||||
}
|
||||
|
|
@ -1431,7 +1461,7 @@ class Tarinai {
|
|||
this.stress = clamp(this.stress - eating * 0.42, 0, 130);
|
||||
this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035);
|
||||
if (this.explosionDisease) this.recoverExplosionDisease("\u305a\u3093\u3060\u9905\u3067\u7206\u767a\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067\u55a7\u5629\u50b7\u75c5\u304c\u6cbb\u3063\u305f");
|
||||
if (this.fightDisease) this.recoverFightDisease("\u305a\u3093\u3060\u9905\u3067きずつき病\u304c\u6cbb\u3063\u305f");
|
||||
}
|
||||
this.eatTimer = Math.max(this.eatTimer, 0.32);
|
||||
this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78);
|
||||
|
|
@ -1602,7 +1632,7 @@ class Tarinai {
|
|||
if (d < 0.01 || d > 74) continue;
|
||||
closeCount += 1;
|
||||
|
||||
const fightMochiContact = this.hasFightMochiEffect() || o.hasFightMochiEffect();
|
||||
const fightMochiContact = this.world.canFightPair?.(this, o) && (this.hasFightMochiEffect() || o.hasFightMochiEffect());
|
||||
if (fightMochiContact && d <= Math.max(42, this.radius + o.radius + 16)) {
|
||||
if (this.world.startForcedFight?.(this, o)) continue;
|
||||
}
|
||||
|
|
@ -1689,7 +1719,7 @@ class Tarinai {
|
|||
const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
|
||||
const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false;
|
||||
const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false;
|
||||
if (d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) {
|
||||
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) {
|
||||
this.world.startConflict(this, o);
|
||||
}
|
||||
|
||||
|
|
@ -1884,7 +1914,7 @@ class Tarinai {
|
|||
reason = this.dominantDeathCause("", { lowHealth: true }) || "\u4e8b\u6545";
|
||||
}
|
||||
else if (this.hunger >= 108) reason = "\u98e2\u9913";
|
||||
else if (this.age >= this.lifeSpan) reason = "\u5bff\u547d";
|
||||
else if (this.age >= this.lifeSpan) reason = "天寿を全うした";
|
||||
else if (this.stress >= 124) {
|
||||
reason = this.dominantDeathCause("\u30b9\u30c8\u30ec\u30b9\u904e\u591a", { stressDeath: true }) || "\u30b9\u30c8\u30ec\u30b9\u904e\u591a";
|
||||
}
|
||||
|
|
@ -1970,7 +2000,8 @@ class Tarinai {
|
|||
? this.sleepFacing > 0
|
||||
: this.facingDir() > 0;
|
||||
const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt;
|
||||
const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade * 0.92, 0.08, 1) : 1;
|
||||
const nestHideAlpha = this.nestFade ? clamp(1 - this.nestFade, 0, 1) : 1;
|
||||
if (nestHideAlpha <= 0.01) return;
|
||||
|
||||
const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8);
|
||||
const shadowContactY = this.y + bob + (drawH / squish) * 0.30;
|
||||
|
|
@ -1996,9 +2027,6 @@ class Tarinai {
|
|||
|
||||
ctx.save();
|
||||
ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY);
|
||||
ctx.rotate(angle);
|
||||
if (insideCardboard) ctx.globalAlpha *= 0.58;
|
||||
|
||||
const observeKind = this.world.observeHighlightKind ? this.world.observeHighlightKind(this) : "";
|
||||
if (observeKind && this.world.selected !== this) {
|
||||
const style = {
|
||||
|
|
@ -2008,7 +2036,7 @@ class Tarinai {
|
|||
enemy: { color: "rgba(214,82,66,0.90)", label: "\u6575\u5bfe" },
|
||||
}[observeKind] || { color: "rgba(255,250,220,0.78)", label: "" };
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.96;
|
||||
ctx.globalAlpha = 0.96 * nestHideAlpha;
|
||||
ctx.shadowColor = style.color;
|
||||
ctx.shadowBlur = 16;
|
||||
ctx.strokeStyle = style.color;
|
||||
|
|
@ -2042,6 +2070,12 @@ class Tarinai {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
ctx.rotate(angle);
|
||||
ctx.globalAlpha *= nestHideAlpha;
|
||||
if (insideCardboard) ctx.globalAlpha *= 0.58;
|
||||
|
||||
|
||||
if (this.world.selected === this) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.85;
|
||||
|
|
@ -2112,7 +2146,6 @@ class Tarinai {
|
|||
ctx.restore();
|
||||
}
|
||||
|
||||
ctx.globalAlpha *= nestHideAlpha;
|
||||
if (faceRight) ctx.scale(-1, 1);
|
||||
const dw = drawW * squish;
|
||||
const dh = drawH / squish;
|
||||
|
|
@ -2162,7 +2195,7 @@ class Tarinai {
|
|||
|
||||
if (this.favorite) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.globalAlpha = nestHideAlpha;
|
||||
const starSize = Math.max(18, this.radius * 0.88);
|
||||
const sx = this.x;
|
||||
const sy = this.y - drawH * 0.64 - 20;
|
||||
|
|
@ -2216,4 +2249,3 @@ class Tarinai {
|
|||
return distXY(this.x, this.y, x, y) < hit;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,25 +4,25 @@ const TEXT_CATALOG = {
|
|||
toolTips: {
|
||||
observe: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002",
|
||||
delete: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002",
|
||||
poke: "\u3064\u3064\u3044\u3066\u53cd\u5fdc\u3068\u5c0f\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002",
|
||||
pinch: "\u30c9\u30e9\u30c3\u30b0\u3067\u500b\u4f53\u3084\u9053\u5177\u3092\u3064\u307e\u3080\u3002",
|
||||
poke: "たりないをつつく。",
|
||||
pinch: "たりないや物を移動させる。",
|
||||
new: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002",
|
||||
zunchi: "\u6c5a\u308c\u3068\u611f\u67d3\u6e90\u3002\u305a\u3093\u3061\u75c5\u306e\u539f\u56e0\u306b\u306a\u308b\u3002",
|
||||
sweet: "\u305a\u3093\u3060\u9905\u3002\u6c17\u5206\u3092\u6574\u3048\u3001\u7206\u767a\u75c5\u3068\u55a7\u5629\u50b7\u75c5\u3092\u6cbb\u3059\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: "\u98df\u5f8c\u3057\u3070\u3089\u304f\u5a01\u5687\u3068\u55a7\u5629\u306e\u5236\u9650\u3092\u5f31\u3081\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: "\u6d88\u3048\u306a\u3044\u6c34\u5834\u3002\u6c34\u3092\u63a2\u3059\u500b\u4f53\u306e\u6cbb\u7642\u5834\u6240\u306b\u306a\u308b\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: "\u98df\u3079\u7269\u3002\u5468\u56f2\u306e\u30b9\u30c8\u30ec\u30b9\u3082\u5c11\u3057\u4e0b\u3052\u308b\u3002",
|
||||
stone: "\u843d\u4e0b\u3059\u308b\u3068\u4e8b\u6545\u30c0\u30e1\u30fc\u30b8\u3002\u7f6e\u304b\u308c\u305f\u5f8c\u306f\u4f11\u307f\u5834\u306b\u3082\u306a\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: "\u30c9\u30e9\u30c3\u30b0\u3067\u305a\u3093\u3061\u3084\u8840\u75d5\u3092\u6d17\u3044\u6d41\u3057\u3001\u305f\u308a\u306a\u3044\u3082\u6c34\u3067\u6d41\u3059\u3002",
|
||||
ball: "\u8ee2\u304c\u3063\u3066\u62bc\u3059\u3002\u4e8b\u6545\u306e\u539f\u56e0\u306b\u3082\u306a\u308b\u3002",
|
||||
firecracker: "\u7206\u767a\u3067\u5468\u56f2\u3092\u5439\u304d\u98db\u3070\u3059\u3002\u7206\u767a\u75c5\u306e\u539f\u56e0\u306b\u306a\u308b\u3002",
|
||||
fence_v: "\u7e26\u5411\u304d\u306e\u79fb\u52d5\u5236\u9650\u3002\u5bc6\u96c6\u7f6e\u304d\u306f\u5236\u9650\u3055\u308c\u308b\u3002",
|
||||
fence_h: "\u6a2a\u5411\u304d\u306e\u79fb\u52d5\u5236\u9650\u3002\u5bc6\u96c6\u7f6e\u304d\u306f\u5236\u9650\u3055\u308c\u308b\u3002"
|
||||
water_hose: "ドラッグで汚れをきれいにする。",
|
||||
ball: "たりないが遊ぶたのしいおもちゃ。",
|
||||
firecracker: "爆発を起こす。",
|
||||
fence_v: "たりないを通せんぼする。",
|
||||
fence_h: "たりないを通せんぼする。"
|
||||
},
|
||||
ecologyCards: [
|
||||
{
|
||||
|
|
@ -30,6 +30,11 @@ const TEXT_CATALOG = {
|
|||
"image": "assets/sprites/tarinai_01_smile.png",
|
||||
"text": "\u5ead\u3067\u52dd\u624b\u306b\u66ae\u3089\u3059\u5c0f\u3055\u306a\u751f\u304d\u7269\u3002\u304a\u306a\u304b\u3001\u7720\u6c17\u3001\u6c17\u5206\u3001\u30b9\u30c8\u30ec\u30b9\u3001\u4f53\u529b\u3067\u884c\u52d5\u304c\u5909\u308f\u308b\u3002"
|
||||
},
|
||||
{
|
||||
"title": "体力とストレス",
|
||||
"image": "assets/ui/ecology_hp_stress.png",
|
||||
"text": "体力はけがや衝突、爆発、空腹などで減り、0になると死亡する。ストレスは恐怖、混雑、汚れ、けんかなどで増え、高すぎると行動が乱れたり命に関わる。体力バーとストレスバーは、状態が変化した直後や観察時に表示される。"
|
||||
},
|
||||
{
|
||||
"title": "\u66ae\u3089\u3057\u3068\u6b32\u6c42",
|
||||
"image": "assets/sprites/tarinai_05_drool.png",
|
||||
|
|
@ -48,7 +53,7 @@ const TEXT_CATALOG = {
|
|||
{
|
||||
"title": "\u75c5\u6c17\u3068\u6cbb\u7642",
|
||||
"image": "assets/sprites/tarinai_24_stress_dizzy.png",
|
||||
"text": "\u305a\u3093\u3061\u75c5\u3001\u306d\u3080\u308a\u75c5\u3001\u7206\u767a\u75c5\u3001\u55a7\u5629\u50b7\u75c5\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きずつき病\u304c\u3042\u308b\u3002\u6c34\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u6c34\u3092\u3001\u305a\u3093\u3060\u9905\u3067\u6cbb\u308b\u75c5\u6c17\u306e\u500b\u4f53\u306f\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3059\u308b\u3002"
|
||||
},
|
||||
{
|
||||
"title": "\u55a7\u5629\u3068\u4e8b\u6545",
|
||||
|
|
|
|||
39
js/ui.js
|
|
@ -13,6 +13,8 @@ const ui = {
|
|||
creditsCloseBtn: document.getElementById("creditsCloseBtn"),
|
||||
toolPalette: document.getElementById("toolPalette"),
|
||||
toolCard: document.getElementById("toolCard"),
|
||||
selectedCard: document.getElementById("selectedCard"),
|
||||
selectedCloseBtn: document.getElementById("selectedCloseBtn"),
|
||||
colonyChartTabs: document.getElementById("colonyChartTabs"),
|
||||
selectedInfo: document.getElementById("selectedInfo"),
|
||||
log: document.getElementById("log"),
|
||||
|
|
@ -45,9 +47,12 @@ const uiCache = {
|
|||
archiveHtmlCache: new Map(),
|
||||
archiveRenderTimer: 0,
|
||||
archiveRenderForce: false,
|
||||
archiveRenderRunning: false,
|
||||
archiveScheduled: false,
|
||||
archivePendingOffscreen: false,
|
||||
archivePendingWhileRunning: false,
|
||||
archivePendingDirtyAfterRun: false,
|
||||
archiveLastHiddenCheckAt: -Infinity,
|
||||
archiveLastRenderedAt: 0,
|
||||
archiveLastScheduleFamilyVersion: null,
|
||||
archiveDiagnostics: { renders: 0, lastMs: 0, lastFamilies: 0, lastNodes: 0, skippedWhileRunning: 0 },
|
||||
|
|
@ -133,7 +138,8 @@ function relationIconHtml(id) {
|
|||
|
||||
function renderSelected() {
|
||||
const t = world.selected;
|
||||
if (!t || !world.tarinai.includes(t)) {
|
||||
if (!t || t.dead || !world.tarinai.includes(t)) {
|
||||
ui.selectedCard?.classList.add("hidden");
|
||||
if (uiCache.selectedEmpty) return;
|
||||
uiCache.selectedSnapshot = "";
|
||||
uiCache.selectedEmpty = true;
|
||||
|
|
@ -142,6 +148,7 @@ function renderSelected() {
|
|||
return;
|
||||
}
|
||||
|
||||
ui.selectedCard?.classList.remove("hidden");
|
||||
uiCache.selectedEmpty = false;
|
||||
const rel = t.relationSummary ? t.relationSummary() : {};
|
||||
const fightRecord = relationFightRecord(t);
|
||||
|
|
@ -167,7 +174,7 @@ function renderSelected() {
|
|||
if (t.zunchiDisease) effectBadges.push(`<span class="badge">\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}</span>`);
|
||||
if (t.sleepDisease) effectBadges.push(`<span class="badge">\u306d\u3080\u308a\u75c5</span>`);
|
||||
if (t.explosionDisease) effectBadges.push(`<span class="badge">\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}</span>`);
|
||||
if (t.fightDisease) effectBadges.push(`<span class="badge">\u55a7\u5629\u50b7\u75c5</span>`);
|
||||
if (t.fightDisease) effectBadges.push(`<span class="badge">きずつき病</span>`);
|
||||
if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}</span>`);
|
||||
if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}</span>`);
|
||||
const recordHtml = (t.records || []).slice(0, 8).map(r => `<li><span>${escapeHtml(world.clockString ? world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0) : formatRecordTime(r.time || 0))}</span><strong>${escapeHtml(r.text || "")}</strong></li>`).join("") || `<li><span>-</span><strong>\u8a18\u9332\u306a\u3057</strong></li>`;
|
||||
|
|
@ -211,7 +218,7 @@ function stateLabel(s) {
|
|||
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
|
||||
seek_water: "\u6c34\u3092\u63a2\u3059",
|
||||
zunchi_sick: "\u305a\u3093\u3061\u75c5",
|
||||
fight_sick: "\u55a7\u5629\u50b7\u75c5",
|
||||
fight_sick: "きずつき病",
|
||||
system: "\u305a\u3093\u3061\u75c5",
|
||||
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
|
||||
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
|
||||
|
|
@ -236,7 +243,7 @@ function reasonLabel(t) {
|
|||
if (t.state === "birth_ritual") return "\u5b50\u304c\u73fe\u308c\u308b\u524d\u306b\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "seek_food") return "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b";
|
||||
if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
|
||||
if (t.state === "fight_sick") return "\u55a7\u5629\u50b7\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "fight_sick") return "きずつき病\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
|
||||
if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5";
|
||||
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
|
||||
}
|
||||
|
|
@ -244,7 +251,7 @@ function reasonLabel(t) {
|
|||
function targetLabel(target) {
|
||||
if (!target) return "\u306a\u3057";
|
||||
if (target.name) return target.name;
|
||||
if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", sleep_drug: "\u306d\u3080\u308a\u85ac", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", system: "\u305a\u3093\u3061\u75c5", zunchi_sick: "\u305a\u3093\u3061\u75c5", fight_sick: "\u55a7\u5629\u50b7\u75c5", 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: "きずつき病", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
|
||||
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb";
|
||||
return "\u4e0d\u660e";
|
||||
}
|
||||
|
|
@ -364,9 +371,11 @@ function applyToolTips() {
|
|||
pop.textContent = tip;
|
||||
pop.classList.remove("hidden");
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const maxLeft = Math.max(8, window.innerWidth - 236);
|
||||
pop.style.left = `${Math.min(maxLeft, Math.max(8, rect.left + rect.width / 2 - 110))}px`;
|
||||
pop.style.top = `${Math.max(8, rect.top - pop.offsetHeight - 10)}px`;
|
||||
const popW = pop.offsetWidth || 220;
|
||||
const popH = pop.offsetHeight || 36;
|
||||
const left = rect.left - popW - 12;
|
||||
pop.style.left = `${clamp(left, 8, Math.max(8, window.innerWidth - popW - 8))}px`;
|
||||
pop.style.top = `${clamp(rect.top + rect.height / 2 - popH / 2, 8, Math.max(8, window.innerHeight - popH - 8))}px`;
|
||||
};
|
||||
const hideTip = () => pop.classList.add("hidden");
|
||||
for (const btn of ui.toolPalette.querySelectorAll(".tool[data-tool]")) {
|
||||
|
|
@ -565,6 +574,13 @@ function bindUI() {
|
|||
showToast(`${t.name}\u3092\u9078\u629e\u3057\u307e\u3057\u305f\u3002`);
|
||||
});
|
||||
|
||||
if (!uiCache.archiveAutoRefreshTimer && typeof window !== "undefined") {
|
||||
uiCache.archiveAutoRefreshTimer = window.setInterval(() => {
|
||||
if (!ui.archiveContent) return;
|
||||
scheduleArchiveWindowRender();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
ui.selectedInfo?.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-selected-action=\"favorite\"]");
|
||||
if (!btn || !world.selected || world.selected.dead) return;
|
||||
|
|
@ -574,6 +590,13 @@ function bindUI() {
|
|||
render();
|
||||
showToast(world.selected.favorite ? "\u304a\u6c17\u306b\u5165\u308a\u306b\u3057\u307e\u3057\u305f\u3002" : "\u304a\u6c17\u306b\u5165\u308a\u3092\u89e3\u9664\u3057\u307e\u3057\u305f\u3002");
|
||||
});
|
||||
ui.selectedCloseBtn?.addEventListener("click", () => {
|
||||
world.selected = null;
|
||||
uiCache.selectedSnapshot = "";
|
||||
uiCache.selectedEmpty = false;
|
||||
renderSelected();
|
||||
render();
|
||||
});
|
||||
|
||||
ui.pauseBtn?.addEventListener("click", () => {
|
||||
world.paused = !world.paused;
|
||||
|
|
|
|||
|
|
@ -21,11 +21,7 @@ function renderStats() {
|
|||
updateColonyHistory(values);
|
||||
drawColonyChart(values);
|
||||
renderSelected();
|
||||
const familyVersion = world.familyVersion || 0;
|
||||
if (uiCache.archiveLastScheduleFamilyVersion !== familyVersion) {
|
||||
uiCache.archiveLastScheduleFamilyVersion = familyVersion;
|
||||
scheduleArchiveWindowRender();
|
||||
}
|
||||
if (world.familyTreeDirty) scheduleArchiveWindowRender();
|
||||
}
|
||||
|
||||
function updateColonyHistory(values) {
|
||||
|
|
@ -199,4 +195,3 @@ function formatChartValue(key, values) {
|
|||
const v = values[key] ?? 0;
|
||||
return String(Math.round(v));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
"use strict";
|
||||
|
||||
window.TarinaiUI = { canvas, ctx, ui, renderLog, renderSelected, stateLabel, renderStats, escapeHtml, renderArchive, validateFamilyTree, resizeCanvas, applyFieldLayout, screenToWorld, selectTool, showToast, saveGame, loadGame, syncTopButtons, bindUI };
|
||||
294
js/ui_family.js
|
|
@ -8,17 +8,46 @@ function lineageEdgeFamily(family = world.family || {}) {
|
|||
return family || {};
|
||||
}
|
||||
|
||||
let lineageRelationIndexCache = { family: null, version: null, index: null };
|
||||
|
||||
function lineageBuildRelationIndex(family) {
|
||||
const known = new Set(Object.keys(family || {}));
|
||||
const parentsByChild = new Map();
|
||||
const childrenByParent = new Map();
|
||||
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);
|
||||
};
|
||||
for (const n of Object.values(family || {})) {
|
||||
if (!n?.id) continue;
|
||||
for (const parentId of n.parents || []) addParent(n.id, parentId);
|
||||
for (const childId of n.children || []) addParent(childId, n.id);
|
||||
}
|
||||
return { parentsByChild, childrenByParent };
|
||||
}
|
||||
|
||||
function lineageRelationIndex(family = world.family || {}) {
|
||||
family = lineageEdgeFamily(family);
|
||||
if (family !== (world.family || {})) return lineageBuildRelationIndex(family);
|
||||
const version = world.familyVersion || 0;
|
||||
if (lineageRelationIndexCache.family !== family || lineageRelationIndexCache.version !== version) {
|
||||
lineageRelationIndexCache = { family, version, index: lineageBuildRelationIndex(family) };
|
||||
}
|
||||
return lineageRelationIndexCache.index;
|
||||
}
|
||||
|
||||
function lineageMutualParentIds(n, family = world.family || {}, idSet = null) {
|
||||
const seen = new Set();
|
||||
const parents = [];
|
||||
if (!n?.id) return parents;
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const candidates = new Set((n.parents || []).filter(Boolean));
|
||||
// Older saves may contain only one side of the parent/child edge. For
|
||||
// rendering, recover the missing side by scanning parent.children as well.
|
||||
for (const parent of Object.values(family || {})) {
|
||||
if (!parent?.id || parent.id === n.id) continue;
|
||||
if (Array.isArray(parent.children) && parent.children.includes(n.id)) candidates.add(parent.id);
|
||||
}
|
||||
// rendering, recover the missing side from a cached relation index.
|
||||
for (const parentId of relationIndex.parentsByChild.get(n.id) || []) candidates.add(parentId);
|
||||
for (const id of candidates) {
|
||||
if (!id || id === n.id || seen.has(id)) continue;
|
||||
if (idSet && !idSet.has(id)) continue;
|
||||
|
|
@ -38,13 +67,11 @@ function lineageMutualChildIds(n, family = world.family || {}, idSet = null) {
|
|||
const seen = new Set();
|
||||
const children = [];
|
||||
if (!n?.id) return children;
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const candidates = new Set((n.children || []).filter(Boolean));
|
||||
// Same recovery as above, in the opposite direction: child.parents is enough
|
||||
// to draw a real parent/child edge even if parent.children was not persisted.
|
||||
for (const child of Object.values(family || {})) {
|
||||
if (!child?.id || child.id === n.id) continue;
|
||||
if (Array.isArray(child.parents) && child.parents.includes(n.id)) candidates.add(child.id);
|
||||
}
|
||||
for (const childId of relationIndex.childrenByParent.get(n.id) || []) candidates.add(childId);
|
||||
for (const id of candidates) {
|
||||
if (!id || id === n.id || seen.has(id)) continue;
|
||||
if (idSet && !idSet.has(id)) continue;
|
||||
|
|
@ -65,11 +92,13 @@ function lineageHasActualRelation(n, family = world.family || {}) {
|
|||
}
|
||||
|
||||
function lineageRelatedIdSet(family) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const ids = new Set();
|
||||
for (const n of Object.values(family || {}).filter(Boolean)) {
|
||||
const parents = lineageMutualParentIds(n, family);
|
||||
const children = lineageMutualChildIds(n, family);
|
||||
if (parents.length || children.length) ids.add(n.id);
|
||||
const parents = relationIndex.parentsByChild.get(n.id) || [];
|
||||
const children = relationIndex.childrenByParent.get(n.id) || [];
|
||||
if (parents.size || children.size) ids.add(n.id);
|
||||
for (const id of parents) ids.add(id);
|
||||
for (const id of children) ids.add(id);
|
||||
}
|
||||
|
|
@ -134,6 +163,8 @@ function familyDeathReasonLabel(rawReason, entity = null) {
|
|||
if (text.includes("\u3067\u8870\u5f31")) return text.length > 12 ? `${text.slice(0, 12)}\u2026` : text;
|
||||
if (/\u55a7\u5629|fight/.test(text)) return "\u55a7\u5629";
|
||||
if (/\u7206\u7af9|firecracker|explosion/.test(text)) return "\u7206\u7af9";
|
||||
if (/仕様により画面外で削除|画面外で削除|out.*bounds|offscreen/.test(text)) return "仕様により画面外で削除";
|
||||
if (/衝突|collision/.test(text)) return "衝突";
|
||||
if (/\u3064\u3064\u304b\u308c|\u3064\u3064\u304d|poke/.test(text)) return "\u3064\u3064\u304b\u308c\u3059\u304e\u305f";
|
||||
if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return "\u305a\u3093\u3061\u75c5";
|
||||
if (/\u75c5/.test(text)) {
|
||||
|
|
@ -142,8 +173,8 @@ function familyDeathReasonLabel(rawReason, entity = null) {
|
|||
}
|
||||
if (/\u98e2|\u7a7a\u8179|\u98df\u3079\u7269|hunger/.test(text)) return "\u98e2\u9913";
|
||||
if (/\u30b9\u30c8\u30ec\u30b9|stress|\u6c17\u5206/.test(text)) return "\u30b9\u30c8\u30ec\u30b9\u904e\u591a";
|
||||
if (/\u5bff\u547d/.test(text)) return "\u5bff\u547d";
|
||||
if (/\u30dc\u30fc\u30eb|\u843d\u3061|\u843d\u4e0b|\u77f3|\u9053\u5177|\u4e8b\u6545|\u5f53\u305f|\u885d\u7a81|ball|drop|stone|accident/.test(text)) return "\u4e8b\u6545";
|
||||
if (/\u5bff\u547d|天寿を全うした/.test(text)) return "天寿を全うした";
|
||||
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";
|
||||
}
|
||||
|
||||
|
|
@ -683,7 +714,39 @@ function lineageLayoutRows(rows, idSet) {
|
|||
return { positions, width, height, nodeW: NODE_W, nodeH: NODE_H, topPad: TOP_PAD, rowGap: ROW_GAP };
|
||||
}
|
||||
|
||||
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
function lineageSegmentKind(x1, y1, x2, y2) {
|
||||
if (Math.abs(y2 - y1) < 0.01) return 'h';
|
||||
if (Math.abs(x2 - x1) < 0.01) return 'v';
|
||||
return 'd';
|
||||
}
|
||||
|
||||
function lineageSegmentsFromPoints(points) {
|
||||
const segments = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const [x1, y1] = points[i - 1];
|
||||
const [x2, y2] = points[i];
|
||||
segments.push({ kind: lineageSegmentKind(x1, y1, x2, y2), x1, y1, x2, y2 });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
function lineagePathFromPoints(points) {
|
||||
if (!points.length) return "";
|
||||
return `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
|
||||
}
|
||||
|
||||
function lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY) {
|
||||
const ax = a.x + nodeW / 2;
|
||||
const bx = b.x + nodeW / 2;
|
||||
const ay = a.y + nodeH;
|
||||
const by = b.y + nodeH;
|
||||
const y = Math.max(unionY, ay + 12, by + 12);
|
||||
const unionX = Math.abs(ax - bx) < 18 ? Math.max(ax, bx) + 30 : (ax + bx) / 2;
|
||||
const points = [[ax, ay], [ax, y], [unionX, y], [bx, y], [bx, by]];
|
||||
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX, unionY: y };
|
||||
}
|
||||
|
||||
function lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
const left = a.x <= b.x ? a : b;
|
||||
const right = left === a ? b : a;
|
||||
const midYLeft = left.y + nodeH / 2 + attachOffsetA;
|
||||
|
|
@ -702,14 +765,12 @@ function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffse
|
|||
const points = [
|
||||
[x1, midYLeft], [x1 + bend, midYLeft], [x1 + bend, y], [x2 - bend, y], [x2 - bend, midYRight], [x2, midYRight]
|
||||
];
|
||||
const d = `M ${points[0][0].toFixed(1)} ${points[0][1].toFixed(1)} ` + points.slice(1).map(p => `L ${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(' ');
|
||||
const segments = [];
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const [x1s, y1s] = points[i - 1];
|
||||
const [x2s, y2s] = points[i];
|
||||
segments.push({ kind: Math.abs(y2s - y1s) < 0.01 ? 'h' : (Math.abs(x2s - x1s) < 0.01 ? 'v' : 'd'), x1: x1s, y1: y1s, x2: x2s, y2: y2s });
|
||||
}
|
||||
return { d, segments };
|
||||
return { d: lineagePathFromPoints(points), segments: lineageSegmentsFromPoints(points), unionX: (x1 + x2) / 2, unionY: y };
|
||||
}
|
||||
|
||||
function lineagePartnerPathFromPositions(a, b, nodeW, nodeH, unionY, attachOffsetA = 0, attachOffsetB = 0) {
|
||||
if (a.rowIndex !== b.rowIndex) return lineageCrossGenerationPartnerPath(a, b, nodeW, nodeH, unionY);
|
||||
return lineageSameGenerationPartnerPath(a, b, nodeW, nodeH, unionY, attachOffsetA, attachOffsetB);
|
||||
}
|
||||
|
||||
function lineageVerticalBridgePath(x, y1, y2, bridges = [], radius = 8.5) {
|
||||
|
|
@ -758,6 +819,15 @@ function lineageBuildChildPathGroup(group, nodeW, nodeH, bridgeHorizontals) {
|
|||
const bridges = lineageIntersectionYsForVertical(p.x, fromY, p.y, bridgeHorizontals);
|
||||
return { d: lineageVerticalBridgePath(p.x, fromY, p.y, bridges) };
|
||||
}
|
||||
if (childPoints.length === 1) {
|
||||
const p = childPoints[0];
|
||||
const parentBridges = lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals);
|
||||
const childBridges = lineageIntersectionYsForVertical(p.x, laneY, p.y, bridgeHorizontals);
|
||||
const parentLane = lineageVerticalBridgePath(joinX, fromY, laneY, parentBridges);
|
||||
const horizontal = ` L ${p.x.toFixed(1)} ${laneY.toFixed(1)}`;
|
||||
const childLane = lineageVerticalBridgePath(p.x, laneY, p.y, childBridges).replace(/^M\s+[-\d.]+\s+[-\d.]+/, "");
|
||||
return { d: `${parentLane}${horizontal}${childLane}` };
|
||||
}
|
||||
const minX = Math.min(joinX, ...childPoints.map(p => p.x));
|
||||
const maxX = Math.max(joinX, ...childPoints.map(p => p.x));
|
||||
let d = lineageVerticalBridgePath(joinX, fromY, laneY, lineageIntersectionYsForVertical(joinX, fromY, laneY, bridgeHorizontals));
|
||||
|
|
@ -795,6 +865,21 @@ function lineageComponentSignature(component, family) {
|
|||
.join("|");
|
||||
}
|
||||
|
||||
function lineageComponentIdentity(component) {
|
||||
return Array.from(new Set((component || []).map(n => n?.id).filter(Boolean))).sort().join(",");
|
||||
}
|
||||
|
||||
function lineageFamilyCacheKey(component, family, signature = "") {
|
||||
const uniqueComponent = Array.from(new Map((component || []).filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort);
|
||||
return `${lineageComponentIdentity(uniqueComponent)}:${signature || lineageComponentSignature(uniqueComponent, family)}`;
|
||||
}
|
||||
|
||||
function lineageHydrateFamilyHtml(html, index) {
|
||||
return html
|
||||
.split("{{LINEAGE_INDEX}}").join(String(index + 1))
|
||||
.split("{{LINEAGE_MASK_ID}}").join(`lineage-node-mask-${index}`);
|
||||
}
|
||||
|
||||
function lineageExpectedChildEdgeCount(component, family) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const idSet = new Set(component.map(x => x?.id).filter(Boolean));
|
||||
|
|
@ -805,30 +890,19 @@ function lineageExpectedChildEdgeCount(component, family) {
|
|||
|
||||
function lineageVisibleRelationSignature(family) {
|
||||
family = lineageEdgeFamily(family);
|
||||
const known = new Set(Object.keys(family));
|
||||
const parentEdges = new Map();
|
||||
const childEdges = new Map();
|
||||
const relationIndex = lineageRelationIndex(family);
|
||||
const relatedIds = new Set();
|
||||
const addEdge = (parentId, childId) => {
|
||||
if (!parentId || !childId || parentId === childId || !known.has(parentId) || !known.has(childId)) return;
|
||||
if (!parentEdges.has(childId)) parentEdges.set(childId, new Set());
|
||||
if (!childEdges.has(parentId)) childEdges.set(parentId, new Set());
|
||||
parentEdges.get(childId).add(parentId);
|
||||
childEdges.get(parentId).add(childId);
|
||||
relatedIds.add(parentId);
|
||||
for (const [childId, parents] of relationIndex.parentsByChild.entries()) {
|
||||
if (!parents.size) continue;
|
||||
relatedIds.add(childId);
|
||||
};
|
||||
for (const n of Object.values(family || {})) {
|
||||
if (!n?.id) continue;
|
||||
for (const parentId of n.parents || []) addEdge(parentId, n.id);
|
||||
for (const childId of n.children || []) addEdge(n.id, childId);
|
||||
for (const parentId of parents) relatedIds.add(parentId);
|
||||
}
|
||||
return Array.from(relatedIds)
|
||||
.sort()
|
||||
.map(id => {
|
||||
const n = family[id] || {};
|
||||
const parents = Array.from(parentEdges.get(id) || []).sort().join(",");
|
||||
const children = Array.from(childEdges.get(id) || []).sort().join(",");
|
||||
const parents = Array.from(relationIndex.parentsByChild.get(id) || []).sort().join(",");
|
||||
const children = Array.from(relationIndex.childrenByParent.get(id) || []).sort().join(",");
|
||||
return [
|
||||
id,
|
||||
n.name || "",
|
||||
|
|
@ -912,6 +986,7 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
linkGroups.push({ parents, children, parentBottom, childTop, joinX, directCouple, attachOffsets: new Map() });
|
||||
}
|
||||
lineageAssignPartnerAttachOffsets(linkGroups);
|
||||
const childLinkEdges = linkGroups.reduce((sum, group) => sum + group.children.length * group.parents.length, 0);
|
||||
|
||||
const byGap = new Map();
|
||||
for (const group of linkGroups) {
|
||||
|
|
@ -940,15 +1015,17 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
const attachA = 0;
|
||||
const attachB = 0;
|
||||
const partnerY = ((left.y + nodeH / 2) + (right.y + nodeH / 2)) / 2;
|
||||
const clearY = clamp(group.parentBottom + 22 + i * 2, partnerY + 18, group.childTop - 36);
|
||||
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB);
|
||||
if (Number.isFinite(partner.unionX)) group.joinX = partner.unionX;
|
||||
const unionY = Number.isFinite(partner.unionY) ? partner.unionY : partnerY;
|
||||
const clearY = clamp(group.parentBottom + 22 + i * 2, unionY + 10, group.childTop - 36);
|
||||
group.fromY = clearY;
|
||||
group.busY = group.children.length === 1 ? Math.min(group.childTop - 18, clearY + 28) : Math.min(group.childTop - 24, Math.max(clearY + 24, laneY + 18 + i * 3));
|
||||
const partner = lineagePartnerPathFromPositions(left, right, nodeW, nodeH, partnerY, attachA, attachB);
|
||||
if (partner.d) {
|
||||
partnerLinks.push(partner.d);
|
||||
horizontalSegments.push(...partner.segments.filter(seg => seg.kind === 'h'));
|
||||
}
|
||||
const trunk = lineageVerticalBridgePath(group.joinX, partnerY, group.fromY, []);
|
||||
const trunk = lineageVerticalBridgePath(group.joinX, unionY, group.fromY, []);
|
||||
if (trunk) childLinks.push(trunk);
|
||||
} else {
|
||||
group.fromY = group.parentBottom;
|
||||
|
|
@ -960,8 +1037,8 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
for (const groupsInGap of byGap.values()) {
|
||||
groupsInGap.sort((a, b) => a.joinX - b.joinX || a.children.length - b.children.length);
|
||||
for (const group of groupsInGap) {
|
||||
if (group.children.length > 1) {
|
||||
const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b);
|
||||
const childCenters = group.children.map(c => c.x + nodeW / 2).sort((a, b) => a - b);
|
||||
if (group.children.length > 1 || (childCenters.length === 1 && Math.abs(childCenters[0] - group.joinX) > 0.5)) {
|
||||
horizontalSegments.push({ kind: 'h', x1: Math.min(group.joinX, ...childCenters), y1: group.busY, x2: Math.max(group.joinX, ...childCenters), y2: group.busY });
|
||||
}
|
||||
const child = lineageBuildChildPathGroup(group, nodeW, nodeH, horizontalSegments);
|
||||
|
|
@ -969,7 +1046,7 @@ function lineageBuildLinks(nodes, idSet, layout) {
|
|||
}
|
||||
}
|
||||
|
||||
return { partnerLinks, childLinks, childLinkGroups: linkGroups.length };
|
||||
return { partnerLinks, childLinks, childLinkGroups: linkGroups.length, childLinkEdges };
|
||||
}
|
||||
|
||||
function lineageSplitByActualEdges(component) {
|
||||
|
|
@ -1049,17 +1126,17 @@ function buildLineageTreeLayout(component, family) {
|
|||
|
||||
const labels = generations.map((g, rowIndex) => ({ generation: g, y: layout.topPad + rowIndex * (layout.nodeH + layout.rowGap) + 18 }));
|
||||
const renderedNodes = nodes.map(n => ({ node: n, ...(layout.positions.get(n.id) || {}) })).filter(n => Number.isFinite(n.x) && Number.isFinite(n.y));
|
||||
return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups };
|
||||
return { ...layout, labels, nodes: renderedNodes, childLinks: links.childLinks, partnerLinks: links.partnerLinks, childLinkGroups: links.childLinkGroups, childLinkEdges: links.childLinkEdges };
|
||||
}
|
||||
|
||||
function lineageFamilyHtml(component, index, family, signature = "") {
|
||||
const uniqueComponent = Array.from(new Map(component.filter(Boolean).map(n => [n.id, n])).values()).sort(lineageNodeSort);
|
||||
const cacheKey = `${index}:${signature || lineageComponentSignature(uniqueComponent, family)}`;
|
||||
if (uiCache.archiveHtmlCache?.has(cacheKey)) return uiCache.archiveHtmlCache.get(cacheKey);
|
||||
const cacheKey = lineageFamilyCacheKey(uniqueComponent, family, signature);
|
||||
if (uiCache.archiveHtmlCache?.has(cacheKey)) return lineageHydrateFamilyHtml(uiCache.archiveHtmlCache.get(cacheKey), index);
|
||||
const layout = buildLineageTreeLayout(uniqueComponent, family);
|
||||
const aliveCount = uniqueComponent.filter(n => n.alive).length;
|
||||
const childEdgeCount = lineageExpectedChildEdgeCount(uniqueComponent, family);
|
||||
const maskId = `lineage-node-mask-${index}`;
|
||||
const maskId = "{{LINEAGE_MASK_ID}}";
|
||||
const nodeMaskRects = layout.nodes
|
||||
.map(p => `<rect x="${(p.x + 1).toFixed(1)}" y="${(p.y + 1).toFixed(1)}" width="${(layout.nodeW - 2).toFixed(1)}" height="${(layout.nodeH - 2).toFixed(1)}" rx="9" fill="black"></rect>`)
|
||||
.join("");
|
||||
|
|
@ -1069,8 +1146,8 @@ function lineageFamilyHtml(component, index, family, signature = "") {
|
|||
const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join("");
|
||||
const empty = !layout.childLinks.length ? `<div class="lineage-empty-family">\u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002</div>` : "";
|
||||
|
||||
const html = `<section class="lineage-family lineage-family-tree" data-lineage-child-edges="${childEdgeCount}" data-lineage-child-groups="${layout.childLinkGroups || 0}" data-lineage-child-paths="${layout.childLinks.length}">
|
||||
<h3>\u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
|
||||
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}">
|
||||
<h3>\u5bb6\u7cfb {{LINEAGE_INDEX}} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}</h3>
|
||||
<div class="lineage-tree-panel" style="width:${Math.ceil(layout.width)}px;height:${Math.ceil(layout.height)}px">
|
||||
<svg class="lineage-links" viewBox="0 0 ${Math.ceil(layout.width)} ${Math.ceil(layout.height)}" aria-hidden="true">
|
||||
<defs><mask id="${maskId}" maskUnits="userSpaceOnUse"><rect x="0" y="0" width="${Math.ceil(layout.width)}" height="${Math.ceil(layout.height)}" fill="white"></rect>${nodeMaskRects}</mask></defs>
|
||||
|
|
@ -1080,7 +1157,7 @@ function lineageFamilyHtml(component, index, family, signature = "") {
|
|||
</div>
|
||||
</section>`;
|
||||
uiCache.archiveHtmlCache?.set(cacheKey, html);
|
||||
return html;
|
||||
return lineageHydrateFamilyHtml(html, index);
|
||||
}
|
||||
|
||||
function renderArchive() {
|
||||
|
|
@ -1089,20 +1166,21 @@ function renderArchive() {
|
|||
uiCache.deferArchiveUntil = 0;
|
||||
uiCache.archiveScheduled = false;
|
||||
const familyVersion = world.familyVersion || 0;
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion) return;
|
||||
const familyDirty = Boolean(world.familyTreeDirty);
|
||||
if (uiCache.archiveRenderRunning) {
|
||||
if (familyDirty) uiCache.archivePendingDirtyAfterRun = true;
|
||||
return;
|
||||
}
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === familyVersion && !familyDirty) return;
|
||||
if (uiCache.archiveVersion && !lineageArchiveNearViewport()) {
|
||||
uiCache.archivePendingOffscreen = true;
|
||||
return;
|
||||
}
|
||||
const running = world && !world.paused;
|
||||
if (uiCache.archiveVersion && running) {
|
||||
uiCache.archivePendingWhileRunning = true;
|
||||
if (uiCache.archiveDiagnostics) uiCache.archiveDiagnostics.skippedWhileRunning = (uiCache.archiveDiagnostics.skippedWhileRunning || 0) + 1;
|
||||
lineageSetArchiveStaleStatus(true);
|
||||
return;
|
||||
const hiddenInterval = 30000;
|
||||
const hiddenNow = performance.now();
|
||||
if (hiddenNow - (uiCache.archiveLastHiddenCheckAt || -Infinity) < hiddenInterval) return;
|
||||
uiCache.archiveLastHiddenCheckAt = hiddenNow;
|
||||
}
|
||||
const now = performance.now();
|
||||
const minUpdateGap = 700;
|
||||
const minUpdateGap = 4800;
|
||||
if (uiCache.archiveVersion && !uiCache.archiveRenderForce && now - (uiCache.archiveLastRenderedAt || 0) < minUpdateGap) {
|
||||
if (!uiCache.archiveRenderTimer) {
|
||||
uiCache.archiveRenderTimer = setTimeout(() => {
|
||||
|
|
@ -1122,6 +1200,8 @@ function renderArchive() {
|
|||
const relationSignature = lineageVisibleRelationSignature(family);
|
||||
if (uiCache.archiveVersion && uiCache.archiveRelationSignature === relationSignature) {
|
||||
uiCache.archiveFamilyVersion = world.familyVersion || 0;
|
||||
world.familyTreeDirty = false;
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
return;
|
||||
}
|
||||
const ids = Object.keys(family);
|
||||
|
|
@ -1130,7 +1210,17 @@ function renderArchive() {
|
|||
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) return;
|
||||
if (uiCache.archiveVersion === version) {
|
||||
world.familyTreeDirty = false;
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
return;
|
||||
}
|
||||
const heavyArchive = ids.length > 90 || edgeCount > 160;
|
||||
if (!world.paused && heavyArchive) {
|
||||
uiCache.archivePendingWhileRunning = true;
|
||||
lineageSetArchiveStaleStatus(true);
|
||||
return;
|
||||
}
|
||||
const renderStarted = performance.now();
|
||||
lineageSetArchiveStaleStatus(false);
|
||||
if (uiCache.archiveRenderTimer) {
|
||||
|
|
@ -1143,18 +1233,22 @@ function renderArchive() {
|
|||
uiCache.archiveRelationSignature = relationSignature;
|
||||
uiCache.archiveLastRenderedAt = performance.now();
|
||||
uiCache.lastArchiveWindow = "";
|
||||
const components = liveFamilyComponents(family).flatMap(lineageSplitByActualEdges).map(c => lineageCleanComponent(c, family)).filter(c => c.length);
|
||||
const components = liveFamilyComponents(family).flatMap(lineageSplitByActualEdges).filter(c => c.length);
|
||||
uiCache.archiveRows = components;
|
||||
const signatures = components.map(component => lineageComponentSignature(component, family));
|
||||
if (!uiCache.archiveHtmlCache) uiCache.archiveHtmlCache = new Map();
|
||||
const activeCacheKeys = new Set(signatures.map((signature, index) => `${index}:${signature}`));
|
||||
const activeCacheKeys = new Set(components.map((component, index) => lineageFamilyCacheKey(component, family, signatures[index])));
|
||||
for (const key of Array.from(uiCache.archiveHtmlCache.keys())) {
|
||||
if (!activeCacheKeys.has(key)) uiCache.archiveHtmlCache.delete(key);
|
||||
}
|
||||
const token = `${version}:${Math.random().toString(36).slice(2)}`;
|
||||
uiCache.archiveRenderToken = token;
|
||||
uiCache.archiveRenderRunning = true;
|
||||
if (!components.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>`;
|
||||
uiCache.archiveRenderRunning = false;
|
||||
world.familyTreeDirty = false;
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1163,9 +1257,12 @@ function renderArchive() {
|
|||
const forest = ui.archiveContent.querySelector(".lineage-forest");
|
||||
let index = 0;
|
||||
const pump = () => {
|
||||
if (uiCache.archiveRenderToken !== token || !forest) return;
|
||||
if (uiCache.archiveRenderToken !== token || !forest) {
|
||||
uiCache.archiveRenderRunning = false;
|
||||
return;
|
||||
}
|
||||
const started = performance.now();
|
||||
let nodeBudget = 72;
|
||||
let nodeBudget = 28;
|
||||
let renderedThisSlice = 0;
|
||||
while (index < components.length) {
|
||||
const component = components[index];
|
||||
|
|
@ -1174,24 +1271,31 @@ function renderArchive() {
|
|||
nodeBudget -= Math.max(1, component.length);
|
||||
index += 1;
|
||||
renderedThisSlice += 1;
|
||||
if (index < components.length && (nodeBudget <= 0 || performance.now() - started > 14)) break;
|
||||
if (index < components.length && (nodeBudget <= 0 || performance.now() - started > 6)) break;
|
||||
}
|
||||
if (status) status.textContent = `\u5bb6\u7cfb\u56f3\u3092\u63cf\u753b\u4e2d\u2026 ${index} / ${components.length}`;
|
||||
if (index < components.length) {
|
||||
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
|
||||
schedule(pump, { timeout: 120 });
|
||||
} else if (status) {
|
||||
status.remove();
|
||||
schedule(pump, { timeout: 220 });
|
||||
} else {
|
||||
if (status) status.remove();
|
||||
const renderDirtiedAgain = uiCache.archivePendingDirtyAfterRun || (world.familyVersion || 0) !== familyVersion;
|
||||
uiCache.archiveRenderRunning = false;
|
||||
world.familyTreeDirty = renderDirtiedAgain;
|
||||
if (uiCache.archiveDiagnostics) {
|
||||
uiCache.archiveDiagnostics.renders = (uiCache.archiveDiagnostics.renders || 0) + 1;
|
||||
uiCache.archiveDiagnostics.lastMs = Math.round((performance.now() - renderStarted) * 10) / 10;
|
||||
uiCache.archiveDiagnostics.lastFamilies = components.length;
|
||||
uiCache.archiveDiagnostics.lastNodes = components.reduce((sum, component) => sum + component.length, 0);
|
||||
}
|
||||
if (renderDirtiedAgain) {
|
||||
uiCache.archivePendingDirtyAfterRun = false;
|
||||
scheduleArchiveWindowRender();
|
||||
}
|
||||
}
|
||||
};
|
||||
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
|
||||
schedule(pump, { timeout: 80 });
|
||||
schedule(pump, { timeout: 160 });
|
||||
}
|
||||
|
||||
function validateFamilyTree() {
|
||||
|
|
@ -1211,36 +1315,21 @@ function validateFamilyTree() {
|
|||
}
|
||||
document.querySelectorAll(".lineage-family-tree[data-lineage-child-edges]").forEach((section, index) => {
|
||||
const expectedEdges = Number(section.getAttribute("data-lineage-child-edges") || 0);
|
||||
const renderedEdges = Number(section.getAttribute("data-lineage-rendered-child-edges") || 0);
|
||||
const expectedGroups = Number(section.getAttribute("data-lineage-child-groups") || 0);
|
||||
const renderedPaths = Number(section.getAttribute("data-lineage-child-paths") || 0);
|
||||
const pathCount = section.querySelectorAll(".lineage-links .child-link").length;
|
||||
if (renderedEdges !== expectedEdges) {
|
||||
issues.push({ type: "render-child-edge-count-mismatch", familyIndex: index, expectedEdges, renderedEdges });
|
||||
}
|
||||
if (expectedEdges > 0 && pathCount < 1) {
|
||||
issues.push({ type: "missing-render-child-link", familyIndex: index, expectedEdges, pathCount });
|
||||
}
|
||||
if (expectedGroups > 0 && pathCount < expectedGroups) {
|
||||
issues.push({ type: "missing-render-child-link-group", familyIndex: index, expectedGroups, pathCount });
|
||||
}
|
||||
const panel = section.querySelector(".lineage-tree-panel");
|
||||
const svg = section.querySelector(".lineage-links");
|
||||
if (panel && svg) {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const nodes = Array.from(panel.querySelectorAll(".tree-node")).map(node => {
|
||||
const r = node.getBoundingClientRect();
|
||||
return {
|
||||
id: node.getAttribute("data-family-tarinai-id") || "",
|
||||
left: r.left - panelRect.left + 1,
|
||||
top: r.top - panelRect.top + 1,
|
||||
right: r.right - panelRect.left - 1,
|
||||
bottom: r.bottom - panelRect.top - 1,
|
||||
};
|
||||
});
|
||||
section.querySelectorAll(".lineage-links .child-link").forEach((path, pathIndex) => {
|
||||
if (!path.getBBox) return;
|
||||
const box = path.getBBox();
|
||||
for (const node of nodes) {
|
||||
const overlaps = box.x < node.right && box.x + box.width > node.left && box.y < node.bottom && box.y + box.height > node.top;
|
||||
if (overlaps) issues.push({ type: "child-link-bbox-overlaps-node-body", familyIndex: index, pathIndex, node: node.id });
|
||||
}
|
||||
});
|
||||
if (renderedPaths !== pathCount) {
|
||||
issues.push({ type: "render-child-path-count-mismatch", familyIndex: index, renderedPaths, pathCount });
|
||||
}
|
||||
});
|
||||
return issues;
|
||||
|
|
@ -1251,14 +1340,25 @@ function renderArchiveWindow() {
|
|||
}
|
||||
|
||||
function scheduleArchiveWindowRender() {
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0)) return;
|
||||
const familyDirty = Boolean(world.familyTreeDirty);
|
||||
if (uiCache.archiveVersion && uiCache.archiveFamilyVersion === (world.familyVersion || 0) && !familyDirty) return;
|
||||
if (uiCache.archiveRenderRunning) {
|
||||
if (familyDirty) uiCache.archivePendingDirtyAfterRun = true;
|
||||
return;
|
||||
}
|
||||
if (uiCache.archiveVersion && !lineageArchiveNearViewport()) {
|
||||
uiCache.archivePendingOffscreen = true;
|
||||
const now = performance.now();
|
||||
if (now - (uiCache.archiveLastHiddenCheckAt || -Infinity) < 30000) return;
|
||||
uiCache.archiveLastHiddenCheckAt = now;
|
||||
}
|
||||
if (uiCache.archiveRenderTimer) return;
|
||||
if (uiCache.archiveScheduled) return;
|
||||
uiCache.archiveScheduled = true;
|
||||
const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
|
||||
schedule(() => {
|
||||
renderArchive();
|
||||
}, { timeout: 700 });
|
||||
}, { timeout: 1200 });
|
||||
}
|
||||
|
||||
function schedulePendingArchiveRender() {
|
||||
|
|
|
|||
548
js/world.js
|
|
@ -1,4 +1,4 @@
|
|||
"use strict";
|
||||
"use strict";
|
||||
|
||||
class World {
|
||||
constructor() {
|
||||
|
|
@ -28,6 +28,8 @@ class World {
|
|||
this.maxGeneration = 1;
|
||||
this.family = {};
|
||||
this.familyVersion = 0;
|
||||
this.familyTreeDirty = true;
|
||||
this.familyTreeDirtyReason = "init";
|
||||
this.relationNotices = {};
|
||||
this.resolvedFightIds = {};
|
||||
this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 };
|
||||
|
|
@ -405,7 +407,16 @@ class World {
|
|||
entry.parentNames = [...(t.parentNames || entry.parentNames || [])];
|
||||
entry.children = nextChildren;
|
||||
}
|
||||
if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
if (changed) {
|
||||
this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
this.markFamilyTreeDirty("family-record");
|
||||
}
|
||||
}
|
||||
|
||||
markFamilyTreeDirty(reason = "family") {
|
||||
this.familyTreeDirty = true;
|
||||
this.familyTreeDirtyReason = reason;
|
||||
this.familyTreeDirtyAt = this.time || 0;
|
||||
}
|
||||
|
||||
normalizeFamily() {
|
||||
|
|
@ -500,7 +511,10 @@ class World {
|
|||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
if (changed) {
|
||||
this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
this.markFamilyTreeDirty("family-normalize");
|
||||
}
|
||||
this.familyCleanVersion = this.familyVersion || 0;
|
||||
return changed;
|
||||
}
|
||||
|
|
@ -509,6 +523,7 @@ class World {
|
|||
this.family = {};
|
||||
this.familyCleanVersion = 0;
|
||||
this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
this.markFamilyTreeDirty("family-reset");
|
||||
this.maxGeneration = 1;
|
||||
for (const t of this.tarinai || []) {
|
||||
t.parents = [];
|
||||
|
|
@ -552,6 +567,7 @@ class World {
|
|||
this.family[t.id].deathReason = finalReason;
|
||||
this.family[t.id].personality = t.personality || this.family[t.id].personality || "";
|
||||
this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
this.markFamilyTreeDirty("death");
|
||||
}
|
||||
}
|
||||
this.familyPrunePending = true;
|
||||
|
|
@ -597,6 +613,7 @@ class World {
|
|||
if (!remove.length) return;
|
||||
for (const id of remove) delete family[id];
|
||||
this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
this.markFamilyTreeDirty("family-prune");
|
||||
this.familyCleanVersion = this.familyVersion || 0;
|
||||
}
|
||||
|
||||
|
|
@ -687,6 +704,22 @@ class World {
|
|||
return false;
|
||||
}
|
||||
|
||||
canStartFight(t) {
|
||||
if (!t || t.dead) return false;
|
||||
if (t.sleepDisease) return false;
|
||||
if (t.explosionDisease || t.fightDisease) return true;
|
||||
if (t.zunchiDisease) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
canBeFightTarget(t) {
|
||||
return Boolean(t && !t.dead && !t.sleepDisease);
|
||||
}
|
||||
|
||||
canFightPair(actor, target) {
|
||||
return this.canStartFight(actor) && this.canBeFightTarget(target);
|
||||
}
|
||||
|
||||
spawnChild(a, b, index = 0, total = 1) {
|
||||
const inherited = Math.random() < 0.56 ? a.type : b.type;
|
||||
const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited;
|
||||
|
|
@ -735,6 +768,7 @@ class World {
|
|||
|
||||
startForcedFight(a, b) {
|
||||
if (!a || !b || a === b || a.dead || b.dead) return false;
|
||||
if (!this.canFightPair(a, b)) return false;
|
||||
const alreadyFighting = a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id);
|
||||
if (alreadyFighting) return true;
|
||||
for (const t of [a, b]) {
|
||||
|
|
@ -765,6 +799,7 @@ class World {
|
|||
const bDrug = (b.fightMochiTimer || 0) > 0.04;
|
||||
const actor = aDrug && !bDrug ? a : (!aDrug && bDrug ? b : (a.energy + a.stress * 0.36 >= b.energy + b.stress * 0.36 ? a : b));
|
||||
const target = actor === a ? b : a;
|
||||
if (!this.canFightPair(actor, target)) return false;
|
||||
actor.intimidateTimer = Math.max(actor.intimidateTimer || 0, rand(0.85, 1.35));
|
||||
actor.intimidateTargetId = target.id;
|
||||
actor.surpriseTimer = Math.max(actor.surpriseTimer || 0, 0.28);
|
||||
|
|
@ -791,6 +826,7 @@ class World {
|
|||
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 actor = scoreA >= scoreB ? a : b;
|
||||
const target = actor === a ? b : a;
|
||||
if (!this.canFightPair(actor, target)) return;
|
||||
const actorScore = actor === a ? scoreA : scoreB;
|
||||
const targetScore = actor === a ? scoreB : scoreA;
|
||||
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
|
||||
|
|
@ -849,6 +885,7 @@ class World {
|
|||
|
||||
startFight(a, b) {
|
||||
if (!a || !b || a.dead || b.dead) return;
|
||||
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return;
|
||||
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
|
||||
a.state = "fight"; b.state = "fight";
|
||||
a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0));
|
||||
|
|
@ -942,10 +979,18 @@ class World {
|
|||
t.fearTimer = Math.max(t.fearTimer, 1.7 + p);
|
||||
t.state = "panic";
|
||||
t.target = { x, y };
|
||||
const power = 170 + p * 360;
|
||||
t.vx += dx / d * power + rand(-55, 55);
|
||||
t.vy += dy / d * power + rand(-55, 55);
|
||||
t.fallTimer = Math.max(t.fallTimer, 1.25 + p * 1.35);
|
||||
let nx = dx / d;
|
||||
let ny = dy / d;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
const a = rand(0, Math.PI * 2);
|
||||
nx = Math.cos(a);
|
||||
ny = Math.sin(a);
|
||||
}
|
||||
const distanceBoost = p * p * 0.55 + p * 0.45;
|
||||
const power = (260 + distanceBoost * 820) * rand(0.78, 1.24) * (it.blastScale || 1);
|
||||
t.vx += nx * power + rand(-95, 95) * (0.6 + p);
|
||||
t.vy += ny * power + rand(-95, 95) * (0.6 + p);
|
||||
t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
|
||||
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||||
t.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
|
||||
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, 1.35 + p * 0.70);
|
||||
|
|
@ -970,11 +1015,6 @@ class World {
|
|||
ball.vx = (ball.vx || 0) + dx / 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 cap = 1250;
|
||||
if (speed > cap) {
|
||||
ball.vx = ball.vx / speed * cap;
|
||||
ball.vy = ball.vy / speed * cap;
|
||||
}
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38);
|
||||
ball.lastPokedAt = this.time || 0;
|
||||
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
|
||||
|
|
@ -1003,8 +1043,16 @@ class World {
|
|||
const q = clamp(1 - d / blastRadius, 0, 1);
|
||||
o.damage(4 + q * 15, "\u7206\u767a\u75c5");
|
||||
o.addStress ? o.addStress(16 * q, { threshold: 8 }) : (o.stress = clamp(o.stress + 16 * q, 0, 130));
|
||||
o.vx += dx / d * (120 + q * 260) + rand(-34, 34);
|
||||
o.vy += dy / d * (120 + q * 260) + rand(-34, 34);
|
||||
let nx = dx / d;
|
||||
let ny = dy / d;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
const a = rand(0, Math.PI * 2);
|
||||
nx = Math.cos(a);
|
||||
ny = Math.sin(a);
|
||||
}
|
||||
const diseaseBlast = (190 + q * 560) * rand(0.80, 1.22);
|
||||
o.vx += nx * diseaseBlast + rand(-64, 64) * (0.5 + q);
|
||||
o.vy += ny * diseaseBlast + rand(-64, 64) * (0.5 + q);
|
||||
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.2 + q);
|
||||
o.fearTimer = Math.max(o.fearTimer || 0, 1.0 + q);
|
||||
if (!o.dead && Math.random() < 0.020 * q) o.infectExplosionDisease?.(t);
|
||||
|
|
@ -1161,6 +1209,10 @@ class World {
|
|||
let bestScore = Infinity;
|
||||
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
|
||||
if (bed.dead || !this.isSleepFurniture(bed)) continue;
|
||||
if (bed.type === "nest_box" && t?.insideNestBoxId !== bed.id) {
|
||||
const occupants = this.nestBoxOccupants ? this.nestBoxOccupants(bed, Infinity) : [];
|
||||
if (occupants.length >= this.nestBoxCapacity(bed)) continue;
|
||||
}
|
||||
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
|
||||
const d = dist(t, bed);
|
||||
const occ = this.bedOccupancy(bed);
|
||||
|
|
@ -1196,6 +1248,7 @@ class World {
|
|||
loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f");
|
||||
winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1;
|
||||
loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1;
|
||||
winner.totalFightWins = (winner.totalFightWins || 0) + 1;
|
||||
loser.totalFightLosses = (loser.totalFightLosses || 0) + 1;
|
||||
if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) {
|
||||
const oldName = loser.formerName || loser.name;
|
||||
|
|
@ -1298,18 +1351,100 @@ class World {
|
|||
ball.vx = (ball.vx || 0) + nx * impulse;
|
||||
ball.vy = (ball.vy || 0) + ny * impulse;
|
||||
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
|
||||
const cap = 1250;
|
||||
if (speed > cap) {
|
||||
ball.vx = ball.vx / speed * cap;
|
||||
ball.vy = ball.vy / speed * cap;
|
||||
}
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38);
|
||||
ball.amount = Math.max(ball.amount || 999, 999);
|
||||
audio.poke();
|
||||
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" }));
|
||||
if (ball.pokeCombo >= 4 || speed > 430) this.log(`\u30dc\u30fc\u30eb\u3092\u9023\u7d9a\u3067\u3064\u3064\u304d\u3001\u52e2\u3044\u304c\u5897\u3057\u305f\u3002`, "observe");
|
||||
else this.log(`\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066\u5f3e\u3044\u305f\u3002`, "observe");
|
||||
return true;
|
||||
// 繝懊・繝ォ繧偵▽縺、縺・◆繧、繝吶Φ繝医・隕ウ蟇欟I縺ク谿九&縺ェ縺・・ return true;
|
||||
}
|
||||
|
||||
resolveBallBallCollisions(dt) {
|
||||
if ((this.itemCounts?.ball || 0) < 2) return;
|
||||
const now = this.time || 0;
|
||||
if (!this.ballCollisionMemo) this.ballCollisionMemo = new Map();
|
||||
const balls = (this.items || []).filter(it => it && !it.dead && it.type === "ball");
|
||||
for (const a of balls) {
|
||||
const ar = a.r || 18;
|
||||
const avx = a.vx || 0;
|
||||
const avy = a.vy || 0;
|
||||
const aSpeed = Math.hypot(avx, avy);
|
||||
const range = ar * 2 + Math.min(560, aSpeed * Math.max(0.016, dt || 0.016)) + 52;
|
||||
for (const b of this.nearbyItems(a.x, a.y, range) || []) {
|
||||
if (!b || b === a || b.dead || b.type !== "ball") continue;
|
||||
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
||||
if (now - (this.ballCollisionMemo.get(key) || -999) < 0.055) continue;
|
||||
const br = b.r || 18;
|
||||
const hitRadius = ar + br;
|
||||
let dx = b.x - a.x;
|
||||
let dy = b.y - a.y;
|
||||
let d = Math.hypot(dx, dy);
|
||||
let hitX = a.x;
|
||||
let hitY = a.y;
|
||||
if (d > hitRadius) {
|
||||
const ax0 = Number.isFinite(a.prevX) ? a.prevX : a.x;
|
||||
const ay0 = Number.isFinite(a.prevY) ? a.prevY : a.y;
|
||||
const bx0 = Number.isFinite(b.prevX) ? b.prevX : b.x;
|
||||
const by0 = Number.isFinite(b.prevY) ? b.prevY : b.y;
|
||||
const rvx = (a.x - ax0) - (b.x - bx0);
|
||||
const rvy = (a.y - ay0) - (b.y - by0);
|
||||
const rx = ax0 - bx0;
|
||||
const ry = ay0 - by0;
|
||||
const aa = rvx * rvx + rvy * rvy;
|
||||
if (aa <= 0.0001) continue;
|
||||
const bb = 2 * (rx * rvx + ry * rvy);
|
||||
const cc = rx * rx + ry * ry - hitRadius * hitRadius;
|
||||
const disc = bb * bb - 4 * aa * cc;
|
||||
if (disc < 0) continue;
|
||||
const u = clamp((-bb - Math.sqrt(disc)) / (2 * aa), 0, 1);
|
||||
const axHit = ax0 + (a.x - ax0) * u;
|
||||
const ayHit = ay0 + (a.y - ay0) * u;
|
||||
const bxHit = bx0 + (b.x - bx0) * u;
|
||||
const byHit = by0 + (b.y - by0) * u;
|
||||
dx = bxHit - axHit;
|
||||
dy = byHit - ayHit;
|
||||
d = Math.hypot(dx, dy);
|
||||
hitX = (axHit + bxHit) * 0.5;
|
||||
hitY = (ayHit + byHit) * 0.5;
|
||||
if (d > hitRadius + 0.5) continue;
|
||||
} else {
|
||||
hitX = (a.x + b.x) * 0.5;
|
||||
hitY = (a.y + b.y) * 0.5;
|
||||
}
|
||||
if (d < 0.001) {
|
||||
const rvx = (b.vx || 0) - (a.vx || 0);
|
||||
const rvy = (b.vy || 0) - (a.vy || 0);
|
||||
const rs = Math.hypot(rvx, rvy);
|
||||
if (rs > 0.001) { dx = rvx / rs; dy = rvy / rs; d = 1; }
|
||||
else { const ang = rand(0, Math.PI * 2); dx = Math.cos(ang); dy = Math.sin(ang); d = 1; }
|
||||
}
|
||||
const nx = dx / d;
|
||||
const ny = dy / d;
|
||||
const rvx = (b.vx || 0) - (a.vx || 0);
|
||||
const rvy = (b.vy || 0) - (a.vy || 0);
|
||||
const relNormal = rvx * nx + rvy * ny;
|
||||
if (relNormal > 0 && d >= hitRadius - 0.5) continue;
|
||||
this.ballCollisionMemo.set(key, now);
|
||||
const restitution = 0.86;
|
||||
const impulse = -(1 + restitution) * relNormal / 2;
|
||||
if (Number.isFinite(impulse)) {
|
||||
a.vx = (a.vx || 0) - impulse * nx;
|
||||
a.vy = (a.vy || 0) - impulse * ny;
|
||||
b.vx = (b.vx || 0) + impulse * nx;
|
||||
b.vy = (b.vy || 0) + impulse * ny;
|
||||
}
|
||||
const overlap = Math.max(0, hitRadius - d + 0.6);
|
||||
if (overlap > 0) {
|
||||
a.x -= nx * overlap * 0.5;
|
||||
a.y -= ny * overlap * 0.5;
|
||||
b.x += nx * overlap * 0.5;
|
||||
b.y += ny * overlap * 0.5;
|
||||
}
|
||||
const tangent = nx * ((a.vy || 0) - (b.vy || 0)) - ny * ((a.vx || 0) - (b.vx || 0));
|
||||
a.spinVelocity = clamp((a.spinVelocity || 0) - tangent / Math.max(14, ar) * 1.8, -38, 38);
|
||||
b.spinVelocity = clamp((b.spinVelocity || 0) + tangent / Math.max(14, br) * 1.8, -38, 38);
|
||||
this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(13, hitRadius * 0.38), life: 0.16, color: "rgba(255,255,255,0.44)" }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveBallInteractions(dt) {
|
||||
|
|
@ -1317,7 +1452,7 @@ class World {
|
|||
for (const ball of this.items) {
|
||||
if (!ball || ball.dead || ball.type !== "ball") continue;
|
||||
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
|
||||
const sweep = Math.min(420, ballSpeed * Math.max(0.016, dt || 0.016) + 84);
|
||||
const sweep = ballSpeed * Math.max(0.016, dt || 0.016) + 84;
|
||||
const range = (ball.r || 18) + 72 + sweep;
|
||||
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
|
||||
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
|
||||
|
|
@ -1356,22 +1491,25 @@ class World {
|
|||
const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
|
||||
const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny;
|
||||
const damage = clamp((ballSpeed - 285) / 18, 3.5, 38);
|
||||
const push = clamp(ballSpeed * 0.34, 95, 310);
|
||||
t.lastBallDamageAt = now;
|
||||
t.vx += bvx * push;
|
||||
t.vy += bvy * push;
|
||||
t.damage(damage, "\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u305f");
|
||||
t.vx = (t.vx || 0) * 0.35 + (ball.vx || 0) * 0.20;
|
||||
t.vy = (t.vy || 0) * 0.35 + (ball.vy || 0) * 0.20;
|
||||
t.damage(damage, "\u885d\u7a81");
|
||||
const timidStress = t.personality === "timid" ? 1.55 : 1.0;
|
||||
const stressGain = clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18);
|
||||
t.addStress ? t.addStress(stressGain, { threshold: 8 }) : (t.stress = clamp(t.stress + stressGain, 0, 130));
|
||||
t.state = "panic";
|
||||
t.target = { x: ball.x, y: ball.y };
|
||||
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
|
||||
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.82 : 0.38);
|
||||
t.fallMax = Math.max(t.fallMax || 0.82, t.fallTimer);
|
||||
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
|
||||
t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer);
|
||||
t.fallDir = bvx >= 0 ? 1 : -1;
|
||||
t.fearTimer = Math.max(t.fearTimer, 0.8);
|
||||
this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.6));
|
||||
t.fearTimer = Math.max(t.fearTimer, 0.95);
|
||||
t.blastSpinTimer = Math.max(t.blastSpinTimer || 0, damage > 14 ? 1.0 : 0.44);
|
||||
t.blastSpinMax = Math.max(t.blastSpinMax || 0, t.blastSpinTimer);
|
||||
this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.8));
|
||||
this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" }));
|
||||
if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u8d85\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u5f53\u305f\u3063\u3066\u30c0\u30e1\u30fc\u30b8\u3092\u53d7\u3051\u305f\u3002`, "accident");
|
||||
if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}\u306f\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident");
|
||||
ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10);
|
||||
ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10);
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38);
|
||||
|
|
@ -1387,11 +1525,6 @@ class World {
|
|||
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;
|
||||
const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
|
||||
const kickCap = 1250;
|
||||
if (kickedSpeed > kickCap) {
|
||||
ball.vx = ball.vx / kickedSpeed * kickCap;
|
||||
ball.vy = ball.vy / kickedSpeed * kickCap;
|
||||
}
|
||||
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38);
|
||||
ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding);
|
||||
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding);
|
||||
|
|
@ -1418,6 +1551,78 @@ class World {
|
|||
}
|
||||
}
|
||||
|
||||
resolveTarinaiHighSpeedCollisions(dt) {
|
||||
const threshold = 150;
|
||||
const now = this.time || 0;
|
||||
if (!this.tarinaiCollisionMemo) this.tarinaiCollisionMemo = new Map();
|
||||
for (const a of this.tarinai || []) {
|
||||
if (!a || a.dead) continue;
|
||||
const avx = a.vx || 0;
|
||||
const avy = a.vy || 0;
|
||||
const aSpeed = Math.hypot(avx, avy);
|
||||
if (aSpeed < threshold) continue;
|
||||
const px = Number.isFinite(a.prevX) ? a.prevX : a.x;
|
||||
const py = Number.isFinite(a.prevY) ? a.prevY : a.y;
|
||||
const sx = a.x - px;
|
||||
const sy = a.y - py;
|
||||
const segLenSq = sx * sx + sy * sy;
|
||||
const range = (a.radius || 22) * 2.5 + aSpeed * Math.max(0.016, dt || 0.016) + 64;
|
||||
for (const b of this.nearbyTarinai(a.x, a.y, range)) {
|
||||
if (!b || b === a || b.dead) continue;
|
||||
const key = a.id < b.id ? `${a.id}:${b.id}` : `${b.id}:${a.id}`;
|
||||
if (now - (this.tarinaiCollisionMemo.get(key) || -999) < 0.42) continue;
|
||||
const bvx = b.vx || 0;
|
||||
const bvy = b.vy || 0;
|
||||
const relVx = avx - bvx;
|
||||
const relVy = avy - bvy;
|
||||
const relSpeed = Math.hypot(relVx, relVy);
|
||||
if (relSpeed < threshold) continue;
|
||||
let hitX = a.x;
|
||||
let hitY = a.y;
|
||||
if (segLenSq > 1) {
|
||||
const u = clamp(((b.x - px) * sx + (b.y - py) * sy) / segLenSq, 0, 1);
|
||||
hitX = px + sx * u;
|
||||
hitY = py + sy * u;
|
||||
}
|
||||
const d = Math.max(0.001, distXY(hitX, hitY, b.x, b.y));
|
||||
const hitDistance = (a.radius || 22) * 0.74 + (b.radius || 22) * 0.74;
|
||||
if (d > hitDistance) continue;
|
||||
this.tarinaiCollisionMemo.set(key, now);
|
||||
const impactor = aSpeed >= Math.hypot(bvx, bvy) ? a : b;
|
||||
const target = impactor === a ? b : a;
|
||||
const ivx = impactor.vx || 0;
|
||||
const ivy = impactor.vy || 0;
|
||||
const impactSpeed = Math.hypot(ivx, ivy) || relSpeed || 1;
|
||||
let nx = impactSpeed > 0.001 ? ivx / impactSpeed : (target.x - impactor.x) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y));
|
||||
let ny = impactSpeed > 0.001 ? ivy / impactSpeed : (target.y - impactor.y) / Math.max(1, distXY(target.x, target.y, impactor.x, impactor.y));
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
const aa = rand(0, Math.PI * 2);
|
||||
nx = Math.cos(aa);
|
||||
ny = Math.sin(aa);
|
||||
}
|
||||
const damage = clamp((relSpeed - 118) / 26, 1.2, 22);
|
||||
target.vx += ivx * 0.20 + nx * 28;
|
||||
target.vy += ivy * 0.20 + ny * 28;
|
||||
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
|
||||
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
|
||||
target.damage(damage, "\u885d\u7a81");
|
||||
if (!impactor.dead) impactor.damage(damage * 0.35, "\u885d\u7a81");
|
||||
for (const t of [target, impactor]) {
|
||||
if (!t || t.dead) continue;
|
||||
t.state = "panic";
|
||||
t.target = { x: impactor.x, y: impactor.y };
|
||||
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.9 + damage * 0.035);
|
||||
t.fallTimer = Math.max(t.fallTimer || 0, 0.36 + damage * 0.025);
|
||||
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
|
||||
t.fallDir = nx >= 0 ? 1 : -1;
|
||||
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
|
||||
}
|
||||
this.effects.push(new Effect("ring", (a.x + b.x) * 0.5, (a.y + b.y) * 0.5, { size: Math.max(16, hitDistance * 0.45), life: 0.20, color: "rgba(210,75,65,0.40)" }));
|
||||
if (this.relationNotice(target.id, impactor.id, "tarinai-highspeed-collision", 2.6)) this.log(`${target.name}\u306f\u9ad8\u901f\u306e${impactor.name}\u306b\u885d\u7a81\u3057\u3066\u5f3e\u304d\u98db\u3070\u3055\u308c\u305f\u3002`, "accident");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nearest(entity, types, maxDist = Infinity) {
|
||||
let best = null, bestD = maxDist;
|
||||
const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
|
||||
|
|
@ -1430,11 +1635,12 @@ class World {
|
|||
return best;
|
||||
}
|
||||
|
||||
nearestOther(entity, maxDist = Infinity) {
|
||||
nearestOther(entity, maxDist = Infinity, predicate = null) {
|
||||
let best = null, bestD = maxDist;
|
||||
const candidates = Number.isFinite(maxDist) ? this.nearbyTarinai(entity.x, entity.y, maxDist) : this.tarinai;
|
||||
for (const o of candidates) {
|
||||
if (o === entity || o.dead) continue;
|
||||
if (predicate && !predicate(o)) continue;
|
||||
if (entity?.shouldAvoidTarget && entity.shouldAvoidTarget(o)) continue;
|
||||
const d = dist(entity, o);
|
||||
if (d < bestD) { best = o; bestD = d; }
|
||||
|
|
@ -1458,48 +1664,111 @@ class World {
|
|||
const vertical = it?.type === "fence_v";
|
||||
const horizontal = it?.type === "fence_h";
|
||||
if (!vertical && !horizontal) return null;
|
||||
const len = Math.max(82, (it.r || 42) * 1.95);
|
||||
const thick = Math.max(20, (it.r || 42) * 0.43);
|
||||
const halfW = vertical ? thick : len;
|
||||
const halfH = vertical ? len : thick;
|
||||
const len = Math.max(112, (it.r || 42) * 3.55);
|
||||
const thick = Math.max(10, (it.r || 42) * 0.31);
|
||||
const halfW = vertical ? thick / 2 : len / 2;
|
||||
const halfH = vertical ? len / 2 : thick / 2;
|
||||
return { left: it.x - halfW, right: it.x + halfW, top: it.y - halfH, bottom: it.y + halfH, vertical, horizontal };
|
||||
}
|
||||
|
||||
resolveFenceCollision(t) {
|
||||
nestBoxCapacity() {
|
||||
return 5;
|
||||
}
|
||||
|
||||
nestBoxBaseRect(it) {
|
||||
if (!it || it.dead || it.type !== "nest_box") return null;
|
||||
const r = it.r || 42;
|
||||
return {
|
||||
left: it.x - r * 1.28,
|
||||
right: it.x + r * 1.28,
|
||||
top: it.y - r * 0.80,
|
||||
bottom: it.y + r * 0.72,
|
||||
type: "nest_box",
|
||||
item: it,
|
||||
};
|
||||
}
|
||||
|
||||
nestBoxSolidRects(it) {
|
||||
const base = this.nestBoxBaseRect(it);
|
||||
if (!base) return [];
|
||||
const w = base.right - base.left;
|
||||
const h = base.bottom - base.top;
|
||||
const colW = w / 3;
|
||||
const rowH = h / 3;
|
||||
const mk = (left, right, top, bottom, cell) => ({ left, right, top, bottom, type: "nest_box", cell, item: it });
|
||||
return [
|
||||
mk(base.left, base.right, base.top, base.top + rowH, "top"),
|
||||
mk(base.left, base.left + colW, base.top + rowH, base.top + rowH * 2, "middle-left"),
|
||||
mk(base.right - colW, base.right, base.top + rowH, base.top + rowH * 2, "middle-right"),
|
||||
];
|
||||
}
|
||||
|
||||
nestBoxTopRect(it) {
|
||||
return this.nestBoxSolidRects(it)[0] || null;
|
||||
}
|
||||
|
||||
solidObstacleRects(it) {
|
||||
if (!it || it.dead) return [];
|
||||
if (this.isFenceType(it.type)) {
|
||||
const rect = this.fenceRect(it);
|
||||
return rect ? [{ ...rect, type: it.type, item: it }] : [];
|
||||
}
|
||||
if (it.type === "nest_box") return this.nestBoxSolidRects(it);
|
||||
return [];
|
||||
}
|
||||
|
||||
nearbySolidObstacleRects(x, y, radius, { include = null, exclude = null, maxChecks = CONFIG.maxFenceCollisionChecks ?? 24 } = {}) {
|
||||
const rects = [];
|
||||
let checked = 0;
|
||||
for (const it of this.nearbyItems(x, y, radius)) {
|
||||
if (!it || it === exclude || it.dead) continue;
|
||||
if (include && !include(it)) continue;
|
||||
const partRects = this.solidObstacleRects(it);
|
||||
if (!partRects.length) continue;
|
||||
checked += 1;
|
||||
for (const rect of partRects) rects.push(rect);
|
||||
if (checked >= maxChecks) break;
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
|
||||
pointInRect(x, y, r, padding = 0) {
|
||||
return Boolean(r && x >= r.left - padding && x <= r.right + padding && y >= r.top - padding && y <= r.bottom + padding);
|
||||
}
|
||||
|
||||
pushTarinaiOutOfRect(t, r, rr) {
|
||||
if (!t || !r) return false;
|
||||
const cx = clamp(t.x, r.left, r.right);
|
||||
const cy = clamp(t.y, r.top, r.bottom);
|
||||
let dx = t.x - cx;
|
||||
let dy = t.y - cy;
|
||||
let d = Math.hypot(dx, dy);
|
||||
if (d >= rr) return false;
|
||||
if (d < 0.001) {
|
||||
const left = Math.abs(t.x - r.left);
|
||||
const right = Math.abs(r.right - t.x);
|
||||
const top = Math.abs(t.y - r.top);
|
||||
const bottom = Math.abs(r.bottom - t.y);
|
||||
const m = Math.min(left, right, top, bottom);
|
||||
if (m === left) { dx = -1; dy = 0; d = 1; }
|
||||
else if (m === right) { dx = 1; dy = 0; d = 1; }
|
||||
else if (m === top) { dx = 0; dy = -1; d = 1; }
|
||||
else { dx = 0; dy = 1; d = 1; }
|
||||
}
|
||||
const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92));
|
||||
t.x += dx / d * push;
|
||||
t.y += dy / d * push;
|
||||
if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
|
||||
else t.vy *= -0.18;
|
||||
return true;
|
||||
}
|
||||
|
||||
resolveSolidObstacleCollision(t) {
|
||||
if (!t || t.dead) return;
|
||||
const rr = Math.max(8, t.radius * 0.74);
|
||||
const maxChecks = CONFIG.maxFenceCollisionChecks ?? 18;
|
||||
let checked = 0;
|
||||
let pushed = false;
|
||||
for (const it of this.nearbyItems(t.x, t.y, rr + 132)) {
|
||||
if (checked >= maxChecks) break;
|
||||
if (it.dead || !this.isFenceType(it.type)) continue;
|
||||
checked += 1;
|
||||
const r = this.fenceRect(it);
|
||||
if (!r) continue;
|
||||
const cx = clamp(t.x, r.left, r.right);
|
||||
const cy = clamp(t.y, r.top, r.bottom);
|
||||
let dx = t.x - cx;
|
||||
let dy = t.y - cy;
|
||||
let d = Math.hypot(dx, dy);
|
||||
if (d >= rr) continue;
|
||||
if (d < 0.001) {
|
||||
const left = Math.abs(t.x - r.left);
|
||||
const right = Math.abs(r.right - t.x);
|
||||
const top = Math.abs(t.y - r.top);
|
||||
const bottom = Math.abs(r.bottom - t.y);
|
||||
const m = Math.min(left, right, top, bottom);
|
||||
if (m === left) { dx = -1; dy = 0; d = 1; }
|
||||
else if (m === right) { dx = 1; dy = 0; d = 1; }
|
||||
else if (m === top) { dx = 0; dy = -1; d = 1; }
|
||||
else { dx = 0; dy = 1; d = 1; }
|
||||
}
|
||||
const push = Math.min(rr - d + 0.8, Math.max(10, rr * 0.92));
|
||||
t.x += dx / d * push;
|
||||
t.y += dy / d * push;
|
||||
pushed = true;
|
||||
if (Math.abs(dx) > Math.abs(dy)) t.vx *= -0.18;
|
||||
else t.vy *= -0.18;
|
||||
for (const rect of this.nearbySolidObstacleRects(t.x, t.y, rr + 150)) {
|
||||
if (this.pushTarinaiOutOfRect(t, rect, rr)) pushed = true;
|
||||
}
|
||||
if (pushed) {
|
||||
const pad = CONFIG.worldPadding + Math.max(2, t.radius * 0.18);
|
||||
|
|
@ -1510,6 +1779,10 @@ class World {
|
|||
}
|
||||
}
|
||||
|
||||
resolveFenceCollision(t) {
|
||||
this.resolveSolidObstacleCollision(t);
|
||||
}
|
||||
|
||||
|
||||
|
||||
segmentIntersectsRect(x1, y1, x2, y2, r) {
|
||||
|
|
@ -1529,23 +1802,18 @@ class World {
|
|||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const maxD = Math.hypot(x2 - x1, y2 - y1) / 2 + 120;
|
||||
for (const it of this.nearbyItems(cx, cy, maxD + padding + 160)) {
|
||||
if (it.dead || !this.isFenceType(it.type)) continue;
|
||||
if (distXY(cx, cy, it.x, it.y) > maxD + padding + 130) continue;
|
||||
const base = this.fenceRect(it);
|
||||
if (!base) continue;
|
||||
const r = { left: base.left - padding, right: base.right + padding, top: base.top - padding, bottom: base.bottom + padding };
|
||||
for (const rect of this.nearbySolidObstacleRects(cx, cy, maxD + padding + 170)) {
|
||||
const r = { left: rect.left - padding, right: rect.right + padding, top: rect.top - padding, bottom: rect.bottom + padding };
|
||||
if (this.segmentIntersectsRect(x1, y1, x2, y2, r)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
grassBlockedAt(x, y, self = null) {
|
||||
for (const it of this.nearbyItems(x, y, 52)) {
|
||||
for (const it of this.nearbyItems(x, y, 96)) {
|
||||
if (it === self || it.dead) continue;
|
||||
if (it.type === "fence_v" || it.type === "fence_h") {
|
||||
const r = this.fenceRect(it);
|
||||
if (r && x >= r.left - 10 && x <= r.right + 10 && y >= r.top - 10 && y <= r.bottom + 10) return true;
|
||||
for (const r of this.solidObstacleRects(it)) {
|
||||
if (this.pointInRect(x, y, r, 10)) return true;
|
||||
}
|
||||
if (it.type !== "zunchi") continue;
|
||||
const dx = (x - it.x) / Math.max(22, it.r * 1.35);
|
||||
|
|
@ -1782,9 +2050,10 @@ class World {
|
|||
return "";
|
||||
}
|
||||
|
||||
nestBoxOccupants(box) {
|
||||
nestBoxOccupants(box, limit = 5) {
|
||||
if (!box || box.dead || box.type !== "nest_box") return [];
|
||||
return this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id).slice(0, 12);
|
||||
const occupants = this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id);
|
||||
return Number.isFinite(limit) ? occupants.slice(0, Math.max(0, limit)) : occupants;
|
||||
}
|
||||
|
||||
pointerNestBoxInfo() {
|
||||
|
|
@ -1839,7 +2108,11 @@ class World {
|
|||
const perf = this.performanceLevel();
|
||||
const itemCountBefore = this.items.length;
|
||||
for (const it of this.items) it.update(dt, this);
|
||||
if (this.itemCounts?.ball) this.rebuildSpatial();
|
||||
if (this.itemCounts?.ball) {
|
||||
this.rebuildSpatial();
|
||||
this.resolveBallBallCollisions(dt);
|
||||
this.rebuildSpatial();
|
||||
}
|
||||
|
||||
for (const ef of this.effects) ef.update(dt);
|
||||
|
||||
|
|
@ -1848,6 +2121,7 @@ class World {
|
|||
}
|
||||
this.limitBallChasers();
|
||||
this.resolveBallInteractions(dt);
|
||||
this.resolveTarinaiHighSpeedCollisions(dt);
|
||||
|
||||
this.compactTimer += dt;
|
||||
const compactInterval = perf >= 2 ? 0.95 : 0.58;
|
||||
|
|
@ -1867,6 +2141,11 @@ class World {
|
|||
this.countsTimer = 0;
|
||||
}
|
||||
|
||||
if ((this.outOfBoundsCheckAt || -999) + 1.0 <= this.time) {
|
||||
this.outOfBoundsCheckAt = this.time;
|
||||
this.sanitizeOutOfBounds();
|
||||
}
|
||||
|
||||
this.drawSortTimer += dt;
|
||||
const sortInterval = perf >= 2 ? 0.16 : perf === 1 ? 0.11 : 0.07;
|
||||
if (this.drawSortTimer >= sortInterval) {
|
||||
|
|
@ -1925,6 +2204,60 @@ class World {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
sanitizeOutOfBounds() {
|
||||
const pad = Math.max(28, CONFIG.worldPadding || 30);
|
||||
const far = Math.max(360, Math.max(this.w || 1000, this.h || 720) * 0.75);
|
||||
let changedItems = false;
|
||||
const repairPoint = (obj, radius = 12) => {
|
||||
if (!obj) return "delete";
|
||||
if (!Number.isFinite(obj.x) || !Number.isFinite(obj.y)) return "delete";
|
||||
const minX = pad - radius;
|
||||
const maxX = (this.w || 1000) - pad + radius;
|
||||
const minY = pad - radius;
|
||||
const maxY = (this.h || 720) - pad + radius;
|
||||
if (obj.x >= minX && obj.x <= maxX && obj.y >= minY && obj.y <= maxY) return "ok";
|
||||
if (obj.x < -far || obj.x > (this.w || 1000) + far || obj.y < -far || obj.y > (this.h || 720) + far) return "delete";
|
||||
obj.x = clamp(obj.x, pad, (this.w || 1000) - pad);
|
||||
obj.y = clamp(obj.y, pad, (this.h || 720) - pad);
|
||||
if (Number.isFinite(obj.vx)) obj.vx *= -0.18;
|
||||
if (Number.isFinite(obj.vy)) obj.vy *= -0.18;
|
||||
return "moved";
|
||||
};
|
||||
for (const it of this.items || []) {
|
||||
const result = repairPoint(it, it.r || 12);
|
||||
if (result === "delete") { it.amount = 0; changedItems = true; }
|
||||
else if (result === "moved") changedItems = true;
|
||||
}
|
||||
for (const ef of this.effects || []) {
|
||||
const result = repairPoint(ef, ef.size || 12);
|
||||
if (result === "delete") ef.life = 0;
|
||||
}
|
||||
for (const t of this.tarinai || []) {
|
||||
if (!t || t.dead) continue;
|
||||
const result = repairPoint(t, t.radius || 22);
|
||||
if (result === "delete") {
|
||||
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.vx = 0;
|
||||
t.vy = 0;
|
||||
t.entryTimer = 0;
|
||||
t.die("莉墓ァ倥↓繧医j逕サ髱「螟悶〒蜑企勁");
|
||||
} else if (result === "moved") {
|
||||
t.x = clamp(Number.isFinite(t.x) ? t.x : this.w * 0.5, pad, this.w - pad);
|
||||
t.y = clamp(Number.isFinite(t.y) ? t.y : this.h * 0.5, pad, this.h - pad);
|
||||
t.vx = 0;
|
||||
t.vy = 0;
|
||||
t.entryTimer = 0;
|
||||
}
|
||||
}
|
||||
if (changedItems) {
|
||||
this.compactItems();
|
||||
this.updateItemCounts();
|
||||
this.rebuildSpatial();
|
||||
}
|
||||
}
|
||||
|
||||
updateWeather(dt) {
|
||||
this.nextWeatherChange -= dt;
|
||||
if (this.nextWeatherChange > 0) return;
|
||||
|
|
@ -1947,11 +2280,11 @@ class World {
|
|||
if (rect.left < CONFIG.worldPadding || rect.top < CONFIG.worldPadding || rect.right > this.w - CONFIG.worldPadding || rect.bottom > this.h - CONFIG.worldPadding) return true;
|
||||
const searchRadius = Math.max(rect.right - rect.left, rect.bottom - rect.top) * 0.5 + 72;
|
||||
for (const it of this.nearbyItems(item.x, item.y, searchRadius)) {
|
||||
if (!it || it.dead || !this.isFenceType(it.type)) continue;
|
||||
const r = this.fenceRect(it);
|
||||
if (!r) continue;
|
||||
const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom;
|
||||
if (!separated) return true;
|
||||
if (!it || it.dead) continue;
|
||||
for (const r of this.solidObstacleRects(it)) {
|
||||
const separated = rect.right + margin < r.left || rect.left - margin > r.right || rect.bottom + margin < r.top || rect.top - margin > r.bottom;
|
||||
if (!separated) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2087,16 +2420,7 @@ class World {
|
|||
py = spot.y;
|
||||
}
|
||||
const rawItem = this.applyToolSize(new Item(itemType, px, py));
|
||||
if (this.isFenceType(rawItem.type)) {
|
||||
const rect = this.fenceRect(rawItem);
|
||||
if (rect) {
|
||||
const dx = Math.max(CONFIG.worldPadding - rect.left, 0) - Math.max(rect.right - (this.w - CONFIG.worldPadding), 0);
|
||||
const dy = Math.max(CONFIG.worldPadding - rect.top, 0) - Math.max(rect.bottom - (this.h - CONFIG.worldPadding), 0);
|
||||
rawItem.x += dx;
|
||||
rawItem.y += dy;
|
||||
}
|
||||
}
|
||||
const placed = this.placeItem(rawItem, true);
|
||||
// 譟オ縺ッ隕九◆逶ョ縺ョ遏ゥ蠖「縺後ヵ繧」繝シ繝ォ繝牙、悶↓縺ッ縺ソ蜃コ縺吩ス咲スョ縺ォ縺ッ鄂ョ縺九↑縺・・ // 驟咲スョ繝励Ξ繝薙Η繝シ縺ョ襍、陦ィ遉コ縺ィ螳滄圀縺ョ驟咲スョ蜿ッ蜷ヲ繧剃ク閾エ縺輔○繧九・ const placed = this.placeItem(rawItem, true);
|
||||
if (!placed) return;
|
||||
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u3078\u3053\u9905", fight_mochi: "\u3051\u3093\u304b\u9905", water: "\u6c34", water_bowl: "\u6c34\u306e\u76bf", sleep_drug: "\u306d\u3080\u308a\u85ac", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", nest_box: "\u5de3\u7bb1", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
|
||||
this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
|
||||
|
|
@ -2117,13 +2441,22 @@ class World {
|
|||
return "note";
|
||||
}
|
||||
|
||||
shouldHideFromObservationRecords(text = "") {
|
||||
const s = String(text || "");
|
||||
return [
|
||||
"\u30dc\u30fc\u30eb\u3092\u9023\u7d9a\u3067\u3064\u3064",
|
||||
"\u30dc\u30fc\u30eb\u3092\u3064\u3064\u3044\u3066",
|
||||
"\u3092\u907f\u3051\u305f",
|
||||
].some(part => s.includes(part));
|
||||
}
|
||||
|
||||
log(text, kind = null) {
|
||||
const entry = { time: this.time, text, kind: kind || this.inferLogKind(text) };
|
||||
if (!this.eventCounters) this.eventCounters = {};
|
||||
this.eventCounters[entry.kind] = (this.eventCounters[entry.kind] || 0) + 1;
|
||||
for (const t of this.tarinai || []) {
|
||||
if (!t || !t.name || !text || !String(text).includes(t.name)) continue;
|
||||
if (t.addRecord) t.addRecord(text, entry.kind);
|
||||
if (!this.shouldHideFromObservationRecords(text) && t.addRecord) t.addRecord(text, entry.kind);
|
||||
}
|
||||
this.logs.unshift(entry);
|
||||
this.logs = this.logs.slice(0, 100);
|
||||
|
|
@ -2172,6 +2505,7 @@ class World {
|
|||
this.family = data.family || {};
|
||||
this.familyCleanVersion = null;
|
||||
this.familyVersion = (this.familyVersion || 0) + 1;
|
||||
this.markFamilyTreeDirty("load");
|
||||
this.weather = data.weather || "sunny";
|
||||
this.nextWeatherChange = data.nextWeatherChange || rand(CONFIG.weatherChangeMin, CONFIG.weatherChangeMax);
|
||||
this.relationNotices = {};
|
||||
|
|
@ -2188,4 +2522,10 @@ class World {
|
|||
}
|
||||
}
|
||||
|
||||
const world = new World();
|
||||
let world;
|
||||
try {
|
||||
world = new World();
|
||||
} catch (error) {
|
||||
if (typeof window !== "undefined") window.__worldInitError = error?.stack || String(error);
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||