diff --git a/assets/sprites/_contact_sheet.png b/assets/sprites/_contact_sheet.png deleted file mode 100644 index 8d2ed9e..0000000 Binary files a/assets/sprites/_contact_sheet.png and /dev/null differ diff --git a/assets/sprites/manifest.json b/assets/sprites/manifest.json deleted file mode 100644 index a136922..0000000 --- a/assets/sprites/manifest.json +++ /dev/null @@ -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 - } -] \ No newline at end of file diff --git a/assets/ui/ecology_hp_stress.png b/assets/ui/ecology_hp_stress.png new file mode 100644 index 0000000..b1b0a7d Binary files /dev/null and b/assets/ui/ecology_hp_stress.png differ diff --git a/assets/ui/tool_ball.png b/assets/ui/tool_ball.png new file mode 100644 index 0000000..64e5c55 Binary files /dev/null and b/assets/ui/tool_ball.png differ diff --git a/assets/ui/tool_fight_mochi.png b/assets/ui/tool_fight_mochi.png new file mode 100644 index 0000000..9c54caa Binary files /dev/null and b/assets/ui/tool_fight_mochi.png differ diff --git a/assets/ui/tool_love_mochi.png b/assets/ui/tool_love_mochi.png new file mode 100644 index 0000000..73cf643 Binary files /dev/null and b/assets/ui/tool_love_mochi.png differ diff --git a/assets/ui/tool_nest_box.png b/assets/ui/tool_nest_box.png new file mode 100644 index 0000000..44f5c92 Binary files /dev/null and b/assets/ui/tool_nest_box.png differ diff --git a/assets/ui/tool_pinch.png b/assets/ui/tool_pinch.png new file mode 100644 index 0000000..0ad3cf1 Binary files /dev/null and b/assets/ui/tool_pinch.png differ diff --git a/assets/ui/tool_sleep_drug.png b/assets/ui/tool_sleep_drug.png new file mode 100644 index 0000000..c8d0f5a Binary files /dev/null and b/assets/ui/tool_sleep_drug.png differ diff --git a/assets/ui/tool_water_bowl.png b/assets/ui/tool_water_bowl.png new file mode 100644 index 0000000..d934cfb Binary files /dev/null and b/assets/ui/tool_water_bowl.png differ diff --git a/assets/ui/tool_water_hose.png b/assets/ui/tool_water_hose.png new file mode 100644 index 0000000..31cb91a Binary files /dev/null and b/assets/ui/tool_water_hose.png differ diff --git a/css/styles.css b/css/styles.css index 0cda845..8653502 100644 --- a/css/styles.css +++ b/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%); +} diff --git a/docs/prompt.txt b/docs/prompt.txt new file mode 100644 index 0000000..dc86a4a --- /dev/null +++ b/docs/prompt.txt @@ -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. diff --git a/index.html b/index.html index 65ff9cb..1ca64b0 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,11 @@ + + + たりない観察 - +
@@ -32,6 +35,14 @@
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + + diff --git a/js/health.js b/js/health.js index 8b14f4d..7c74473 100644 --- a/js/health.js +++ b/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) { diff --git a/js/items.js b/js/items.js index 38ede23..094f29d 100644 --- a/js/items.js +++ b/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) { diff --git a/js/render.js b/js/render.js index 055d424..4270e76 100644 --- a/js/render.js +++ b/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(); } diff --git a/js/sim.js b/js/sim.js deleted file mode 100644 index da252cd..0000000 --- a/js/sim.js +++ /dev/null @@ -1,3 +0,0 @@ -"use strict"; - -window.TarinaiSim = { makeName, makeTrait, PERSONALITIES, SpatialGrid, Effect, Item, Tarinai, World, world }; diff --git a/js/tarinai.js b/js/tarinai.js index 8feeb96..84b6eb1 100644 --- a/js/tarinai.js +++ b/js/tarinai.js @@ -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; } } - diff --git a/js/text_catalog.js b/js/text_catalog.js index 1bfdb8e..1fb771b 100644 --- a/js/text_catalog.js +++ b/js/text_catalog.js @@ -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", diff --git a/js/ui.js b/js/ui.js index 8bbde11..abaa975 100644 --- a/js/ui.js +++ b/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(`\u305a\u3093\u3061\u75c5 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}`); if (t.sleepDisease) effectBadges.push(`\u306d\u3080\u308a\u75c5`); if (t.explosionDisease) effectBadges.push(`\u7206\u767a\u75c5 ${fmt(t.explosionDiseaseTimer || 0)}`); - if (t.fightDisease) effectBadges.push(`\u55a7\u5629\u50b7\u75c5`); + if (t.fightDisease) effectBadges.push(`きずつき病`); if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`\u3078\u3053\u9905 ${fmt(t.loveMochiTimer)}`); if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`\u3051\u3093\u304b\u9905 ${fmt(t.fightMochiTimer)}`); const recordHtml = (t.records || []).slice(0, 8).map(r => `
  • ${escapeHtml(world.clockString ? world.clockStringFromTime ? world.clockStringFromTime(r.time || 0) : formatRecordTime(r.time || 0) : formatRecordTime(r.time || 0))}${escapeHtml(r.text || "")}
  • `).join("") || `
  • -\u8a18\u9332\u306a\u3057
  • `; @@ -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; diff --git a/js/ui_charts.js b/js/ui_charts.js index 68250be..23996d9 100644 --- a/js/ui_charts.js +++ b/js/ui_charts.js @@ -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)); } - diff --git a/js/ui_exports.js b/js/ui_exports.js deleted file mode 100644 index 6253b33..0000000 --- a/js/ui_exports.js +++ /dev/null @@ -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 }; diff --git a/js/ui_family.js b/js/ui_family.js index d5452cb..9035671 100644 --- a/js/ui_family.js +++ b/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 => ``) .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 ? `
    \u89aa\u5b50\u95a2\u4fc2\u306e\u7dda\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    ` : ""; - const html = `
    -

    \u5bb6\u7cfb ${index + 1} / ${uniqueComponent.length}\u5339 / \u751f\u5b58 ${aliveCount}

    + const html = `
    +

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

    `; 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 = `
    \u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
    `; + uiCache.archiveRenderRunning = false; + world.familyTreeDirty = false; + uiCache.archivePendingDirtyAfterRun = false; return; } @@ -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() { diff --git a/js/world.js b/js/world.js index fbb6ffa..4ac8673 100644 --- a/js/world.js +++ b/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; +}