This commit is contained in:
33333-33333 2026-06-24 13:48:22 +09:00
commit d5c661bc22
53 changed files with 4264 additions and 950 deletions

View file

@ -1,4 +1,96 @@
# Patch Notes 15.24.12
# 15.24.35
- 持ち主不明のかんたんベッドは、最初に利用した個体の所有物になるよう変更。
- かんたんベッド同士が近すぎる位置に作られにくいよう、配置ブロックと建築位置選定を調整。
- 複製機にセットされた食べ物の種類を、食べ物探索の優先順位と接触距離ソートに反映。
# 15.24.34
- ぬいぐるみを添付スプライトに差し替え、白背景を透過化。
- たりないデータの「関係」と「家族」を1セクションに統合。
- 観察中の友達/敵対ハイライト判定を相互関係ベースに拡張。
- かんたんベッドで睡眠中の個体に、親子でも友達でもない個体が寝に来た場合は喧嘩開始。
# Patch Notes 15.24.33
## 15.24.33
- Version: `15.24.33`.
- Build: `compact-save-v2`.
- Added item-bucket dirty tracking, `ensureItemBuckets()`, `itemById()`, `liveItemsOfType()`, and `addItem()` to keep type buckets and id lookup coherent after runtime item additions.
- Routed common item creation paths through `World.addItem()` for placements, rain water, grass growth/spawn, zunchi burst/spawn, ant corpses/nests, Tarinai death traces, and constructed owned structures.
- Expanded runtime diagnostics with bucket dirty state, item id-map size, and bucket rebuild counters.
- Reworked several hot item lookups to use buckets or id-map helpers where safe.
- Updated regression checks for item-bucket helper availability and direct item push documentation.
- Added final pre-save runtime-polish report and remaining-work split note.
- Did not touch save migration, population/family limits, or family-tree UI.
# Patch Notes 15.24.31
## 15.24.31
- Version: `15.24.31`.
- Build: `social-balance-runtime-polish`.
- Fixed social-action balance regression where conflict could dominate too often after ActionSpec migration.
- Reduced normal conflict pressure, lowered fight/intimidation priority, and raised mate/bond selection weight.
- Added pair-level fight cooldown guards so one pair/individual cannot trigger repeated fight starts in the same burst.
- Shortened intimidation/defeated display timers.
- Preserved fight mochi as a strong forced exception while reducing ordinary proximity-fight probability.
- Added regression checks for social balance constants and fight re-entry guards.
- Did not touch save migration, population/family limits, or family-tree UI.
# Patch Notes 15.24.30
## 15.24.30
- Version: `15.24.30`.
- Build: `action-spec-sunbath-runtime-check`.
- Moved `sunbath` into `createSunbathActionSpec()` and registered it directly in `TARINAI_ACTION_DEFINITIONS`.
- Removed the remaining `configureTarinaiNeedActions()` late-binding bridge.
- Kept the existing sunbath body/timer subsystem intact; player-visible sunbath behavior should remain unchanged.
- Updated regression checks so late action patching is treated as a failure.
- Did not touch save migration, population/family limits, or family-tree UI.
# Patch Notes 15.24.29
## 15.24.29
- Migrated social/fear/combat/mate/birth runtime actions into ActionSpec factories.
- Kept family data, family tree UI, population limits, and save migration untouched.
- Added regression checks for pass 3 ActionSpec migration.
- Build: `action-spec-social-runtime-check`.
# Patch Notes 15.24.28
## 15.24.28
- Added centralized behavior-text composition through `composeTarinaiBehaviorText()` and `patchBehaviorTextFromComposer()`.
- Added `textTarinaiAction()` to the ActionSpec lifecycle helpers.
- Kept existing explicit live behavior text as the default display fallback so player-facing wording does not churn during normal observation.
- Added a player-visible diff check comparing the current build against the initial 15.24.20 ZIP.
- Kept save migration, population/family caps, and family-tree UI out of scope.
# Patch Notes 15.24.25
## 15.24.25
- Added `scripts/regression_check.py` and `docs/REGRESSION_CHECKLIST.md` to freeze the current generated-file, syntax, and ActionSpec lifecycle baseline.
- Expanded `js/tarinai_action_spec.js` with explicit lifecycle helpers: `selectTarinaiActionTarget`, `canStartTarinaiAction`, `startTarinaiAction`, `tickTarinaiAction`, `finishTarinaiAction`, and `failTarinaiAction`.
- Migrated need-action definitions from legacy `condition/run/update` shape toward explicit `selectTarget/canStart/start/tick` ActionSpec fields.
- Routed priority/action eligibility through `canStartTarinaiAction()` while preserving legacy fallback behavior.
- Added finish/fail lifecycle calls for completed or failed action ticks.
- Kept save migration, population caps, family caps, and family-tree UI out of scope.
# Patch Notes 15.24.23
## 15.24.23
- Added coarse-grained spatial invalidation via `markSpatialDirty()` + `ensureSpatial()` for the main update loop.
- Reduced redundant spatial rebuilds around item, ball, ant, and Tarinai update phases while keeping direct `rebuildSpatial()` calls compatible.
- Added per-type item buckets in `updateItemCounts()` and switched ball-ball collision setup to `itemsOfType("ball")`.
- Reworked render layering into a cached dynamic render stack so expensive y-sort work runs on a short interval or count/signature change, not every paint.
- Kept population and family-tree UI behavior unchanged.
# Patch Notes 15.24.22
- Added `activeBehavior` as the authoritative runtime behavior layer above legacy state timers.
- Added `forcedBehaviorQueue` and `forceBehavior()` hook for external forced actions such as love mochi, fight mochi, and drugs.
@ -9,7 +101,21 @@
- Reduced direct old-style birth starts: proximity now raises mate pressure; ritual starts when mate behavior is active or forced.
- UI action text now prefers `activeBehavior` over stale thoughts or intent strings.
## 15.24.14
## 15.24.22
- `js/tarinai_action_spec.js` を追加し、行動を `ActionSpec` として登録できる共通層を導入。
- 既存の `condition` / `run` / `update` を維持しつつ、`canStart` / `start` / `tick` / `finish` / `fail` / `text` に段階移行できる互換ラッパーを追加。
- `TARINAI_ACTIONS``TARINAI_ACTION_DEFINITIONS` から登録生成する形に変更。既存参照は維持。
- 行動開始・継続処理を `startTarinaiAction()` / `tickTarinaiAction()` 経由へ移行。
- `behavior``specId` / `specKind` を保持し、表示・旧API互換と行動仕様の対応を追いやすくした。
## 15.24.21
- 外部強制行動を queued / active / retry / expire の状態機械に拡張。
- 強制行動の source / forcedReasonText を activeBehavior に保持し、旧タイマー同期で失われにくくした。
- 関係欲求の開始判定を social 大分類ではなく bond / family / mate / conflict のサブ欲求に寄せた。
- 食事行動を activeBehavior 内で対象接近から摂食完了まで完結するよう整理。
- 行動選択時に高い未選択欲求がある場合、「眠たいけど、食べている」のように対立理由を表示。
## 15.24.21
- 外部強制行動を強化。薬・餅から `forceBehavior()` 経由で優先割込み、対象再探索、失敗時再試行を行うようにした。
- へこ餅、けんか餅、ねむり薬の強制行動優先度を更新。
- 喧嘩対象が Item 等になった場合の `relationTo is not a function` エラーを防止。

View file

@ -1,6 +1,6 @@
# Tarinai Colony Observation Game
Buildless single-page browser simulation. Open `index.html`; no package manager, transpiler, server API, or module loader is required. Runtime order is explicit `<script defer>` order generated from `app_manifest.json`. Current inspected version: `15.20.16`.
Buildless single-page browser simulation. Open `index.html`; no package manager, transpiler, server API, or module loader is required. Runtime order is explicit `<script defer>` order generated from `app_manifest.json`. Current inspected version: `15.24.33`.
## Edit contract
@ -16,6 +16,9 @@ Buildless single-page browser simulation. Open `index.html`; no package manager,
- `docs/ARCHITECTURE.md`: runtime architecture, file ownership, system boundaries.
- `docs/CONTENT_GUIDE.md`: item visuals, UI/field visual parity, audio, bubble text inventory.
- `docs/MAINTENANCE.md`: release process, validation, debugging, cache/version rules.
- `docs/RUNTIME_POLISH_PRE_SAVE_REPORT.md`: 15.24.32 runtime polish summary before save migration.
- `docs/COMPACT_SAVE_V2_REPORT.md`: 15.24.33 compact save v2 summary.
- `docs/REMAINING_WORK_SPLIT.md`: work intentionally split after 15.24.33.
- `docs/prompt.txt`: untouched user prompt/context archive.
## Player-facing surface
@ -41,6 +44,7 @@ Buildless single-page browser simulation. Open `index.html`; no package manager,
- `WeatherSystem`: `js/weather_system.js`; weather state transitions and rain-spawned water items.
- `Audio`: `js/sound_pack.js` owns sound metadata; `js/audio.js` loads samples, applies settings, enforces cooldowns, and runs synthetic fallbacks.
- `Performance`: `js/performance_manager.js`; FPS quality level, ant stride, effect budget, terrain redraw cadence.
- `Runtime item lookup`: `World.addItem()`, `itemsOfType()`, `itemById()`, and `ensureItemBuckets()` keep item caches coherent for population-scale runs.
## Current high-risk areas

View file

@ -1,5 +1,5 @@
{
"version": "15.24.14",
"version": "15.24.35",
"css": [
"css/base.css",
"css/layout.css",
@ -25,10 +25,13 @@
"js/audio.js",
"js/render.js",
"js/sim_core.js",
"js/structures.js",
"js/items.js",
"js/ants.js",
"js/health.js",
"js/tarinai.js",
"js/tarinai_action_spec.js",
"js/tarinai_behavior_state.js",
"js/tarinai_identity_social.js",
"js/tarinai_action_state.js",
"js/tarinai_disease_nest.js",

BIN
assets/objects/plushie.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

View file

@ -478,14 +478,18 @@ h1 {
#signboardEditorText {
width: 100%;
box-sizing: border-box;
resize: vertical;
resize: none;
min-height: 88px;
max-height: 88px;
overflow: hidden;
white-space: pre;
border-radius: 12px;
border: 1px solid rgba(84,68,48,0.18);
background: rgba(255,255,255,0.84);
padding: 10px 11px;
color: var(--ink);
font: 14px/1.55 ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
font: 15px/1.55 ui-rounded, "Hiragino Maru Gothic ProN", "Yu Gothic", sans-serif;
letter-spacing: 0.03em;
outline: none;
}
#signboardEditorText:focus {

View file

@ -1,6 +1,6 @@
# Architecture
Build target: static browser page. No bundler. Globals and ordered deferred scripts are intentional. Current inspected version: `15.20.16`.
Build target: static browser page. No bundler. Globals and ordered deferred scripts are intentional. Current inspected version: `15.24.33`.
## Load order
@ -12,6 +12,7 @@ The old `css/styles.css` compatibility shim and `js/ui_family.js` split marker h
- `World`: constructor in `js/world.js`; methods split into view, family/social, combat/effects, environment, spatial/budget, ants, update, placement/log, bootstrap.
- `Tarinai`: constructor in `js/tarinai.js`; methods split into identity/social, disease/nest, item effects, needs/items, social/move/life, render.
- Behavior text is composed from `behavior`, needs, tied needs, ActionSpec metadata, and forced-request cause metadata; stored explicit live text remains the display fallback to avoid player-visible wording churn.
- `Item`: item state, lifecycle, scaling, and field rendering in `js/items.js`.
- `Effect`: transient visuals in `js/sim_core.js`; combat/weather/food effects are spawned through `World` helpers.
- `TarinaiEvents`: global event bus for logs, terrain dirtiness, audio failures, phase/performance changes.
@ -46,7 +47,7 @@ The old `css/styles.css` compatibility shim and `js/ui_family.js` split marker h
`main.js` starts asset preload, world bootstrap, animation loop, deferred image loading, service-worker registration. `World.update(dt)` advances time, weather, items, ants, Tarinai, effects, budgets, terrain dirtiness. `render()` draws cached background/terrain, visible entities, weather overlay, cards, HUD, and debug overlays.
World time is `CONFIG.dayLength` seconds per day. Version `15.20.16` uses 120 seconds/day and weather changes every 46-96 seconds. The phase boundary rules live in `World.phaseName()`.
World time is `CONFIG.dayLength` seconds per day. Version `15.24.33` uses 120 seconds/day and weather changes every 46-96 seconds. The phase boundary rules live in `World.phaseName()`.
Domain events are emitted at central mutation points: placement, eating, decay, effect apply/remove, disease infect/cure, damage/death/birth, fight hit/loss/end, ant spawn/grab/carry/death, weather changes, and rain item drops. Logs/audio still use direct calls where they already existed; do not add duplicate log or sound side effects when subscribing to these events.
@ -57,3 +58,20 @@ Ant behavior remains intentionally conservative. `ANT_EXTENSION_HOOKS` names fut
Use `World.nearby*` helpers rather than full-array scans in hot paths. Terrain cache uses dirty/version flags; do not reintroduce every-frame full terrain hashing. Performance degradation should be centralized in `performance_manager.js`.
Family tree rendering is intentionally asynchronous and cached. `ui_family_async.js` chunks component discovery with `requestIdleCallback` when available; `ui_family_render.js` hydrates cached HTML, keeps archive dirty/version state, and exposes `validateFamilyTree()` plus `familyTreeDiagnostics()` for browser-console checks.
## Basic ActionSpec migration
Version `15.24.33` keeps low-risk need actions, social/fear/combat/mate/birth actions, and sunbath registered through ActionSpec factories. The former configureTarinaiNeedActions late-binding bridge has been removed; the existing sunbath subsystem remains as the body-level timer implementation called by the ActionSpec.
## Runtime diagnostics
`World.runtimeDiagnostics()` exposes spatial rebuild counters, draw-list status, item bucket counts, and behavior counts. `?debug=1` renders these values in the existing debug overlay for manual browser regression checks.
## Social balance guards
Version `15.24.33` adds explicit guardrails around combat-heavy social behavior. Normal conflict must be dominant over bond/mate/family pressure before `fight_rival` can start, and `World.startFight()` / `startConflict()` / `startForcedFight()` use pair-level cooldowns plus already-fighting checks to prevent burst re-entry. Fight mochi remains a strong forced exception, but ordinary proximity conflict is intentionally lower than mate/friend pressure.
## Pre-save runtime polish
Version `15.24.33` keeps item type buckets and item id lookup as runtime caches owned by `World.updateItemCounts()`. Runtime item additions should go through `World.addItem(item, reason)` when practical so spatial, terrain, and item-bucket invalidation stay synchronized. Save migration is still intentionally deferred until this runtime shape has been browser-observed.

View file

@ -0,0 +1,37 @@
# Compact Save V2 Report — 15.24.33
## 方針
15.24.33 では旧セーブ互換を捨て、保存形式を `v: 2` に作り直した。
目的はハッシュテキストを短くすることであり、旧 `TARINAI_SAVE_V1:` / `tarinai_save_slot_v1_` は読み込み対象外。
## 削除した保存対象
- ログ履歴。
- 一時エフェクト。
- UI選択状態。
- 描画用キャッシュ、spatial/grid、bucket、terrain cache。
- `needDisplay` / `needRawBase` / `previousNeeds` など、読み込み後に再計算または近似できる値。
- 短命な表示タイマー、吹き出し文、内部デバッグ計測値。
- 死亡者を含む完全な家系アーカイブ履歴。生存個体の親子関係は index 参照として保存し、読み込み後に `recordFamily()` で再構築する。
## 残した保存対象
- world の field / day / time / weather / camera / generation などの進行状態。
- 生存中たりないの名前、見た目、性格、遺伝、位置、主要欲求、病気、親子参照、友好・恐怖関係。
- 生存中アイテムの type / 位置 / 量 / type別に必要な追加状態。
- アリの巣・アリの主要状態。
- 構造物の owner / hp / attachment などのプレイに関わる状態。
## ハッシュテキスト
- Prefix: `TN2!`
- Codec: `D` = `CompressionStream("deflate-raw")``R` = fallback raw bytes。
- Text encoding: printable ASCII 33〜126 のうち `*`, `'`, `_` を除いた91文字集合。
- Binary-to-text は base91 相当の13/14bit詰め。
## 注意
- 旧セーブは読めない。これは意図した仕様。
- 家系図の死亡者履歴は保存しない。読み込み後の家系図は生存個体ベースになる。
- `CompressionStream` 非対応環境では raw bytes を同じ91文字集合で符号化する。短縮率は下がるが読み書きは維持する。

View file

@ -0,0 +1,52 @@
# Player-visible diff report: 15.24.20 initial ZIP vs 15.24.33
Comparison target: `tarinai_ui_revision_v15_24_20_pin_social_sign_polish(1).zip` → current build `15.24.33`.
## Verdict
No large UI/tool/item/save/family-tree specification change was detected. The intentional player-visible behavior change in this build is limited to social-action balance: ordinary fights and intimidation should occur less often, while friendship and reproduction should no longer be crowded out by conflict.
## Evidence
| Surface | Result | Notes |
|---|---|---|
| Top-level visible text in `index.html` | unchanged | Script tags/version params changed; visible UI copy is still the same. |
| Assets | unchanged | No sprite, object, UI icon, or sound asset change was introduced in this pass. |
| CSS | unchanged | No style-file change in this pass. |
| Tool/item definitions | unchanged | `js/data.js` tool/item definitions were not changed. |
| Tool UI binding | unchanged | No tool-list or operation binding change in this pass. |
| Item behavior/render definitions | unchanged | `items.js` was not changed. |
| Save/snapshot files | unchanged | Save migration remains intentionally deferred. |
| Family-tree UI modules | unchanged | Family-tree UI remains out of scope. |
| Tarinai social behavior | intentionally tuned | Conflict pressure and repeated-fight gates were adjusted to restore friend/mate/birth viability. |
## Files added since the original internal-structure pass
- `docs/REGRESSION_CHECKLIST.md`
- `docs/SOCIAL_BALANCE_FIX_REPORT.md`
- `js/tarinai_action_spec.js`
- `js/tarinai_behavior_state.js`
- `scripts/regression_check.py`
## Changed files with possible player-visible risk
| File | Risk | Assessment |
|---|---|---|
| `js/tarinai_needs_items.js` | Behavior selection and observation text | Social conflict is now less dominant; mate/friend actions are easier to select. |
| `js/world_family_social.js` | Fight/intimidation timing | Pair-level fight re-entry guard added; intimidation and fight durations shortened. |
| `js/tarinai_social_move_life.js` | Proximity conflict pressure | Ordinary close-contact fight probability reduced; fight mochi remains strong. |
| `js/render.js` | Draw ordering | Dynamic render stack remains cached for performance; possible minor y-sort delay under dense scenes. |
| `js/world_update.js` / `js/world_spatial_budget.js` | Simulation lookup performance | Spatial rebuild timing remains dirty/ensure style. |
| `service-worker.js` / `index.html` / `app_manifest.json` / `js/version.js` | Loading/cache | Version and script query params changed only. |
## Explicitly not changed
- No population cap was added.
- No family or generation cap was added.
- Family-tree UI was not modified.
- No tool category, tool name, item sprite, CSS layout, or field choice change was introduced.
- Save migration was not implemented in this pass.
## Residual risk
The remaining visible risk is balance rather than UI specification: social behavior should now produce fewer conflict loops, but exact rates still require browser playtesting with dense populations and multiple personality mixes.

View file

@ -0,0 +1,45 @@
# Regression Checklist
Version: 15.24.33
This pass freezes the current internal baseline before continuing deeper behavior refactors.
## Scope
- Static loading order must remain generated from `app_manifest.json`.
- `index.html` and `service-worker.js` must match the manifest after `scripts/generate_app_files.py`.
- JavaScript syntax must pass `node --check`.
- `ActionSpec` lifecycle helpers must support `selectTarget / canStart / start / tick / finish / fail`.
- Save-data migration is intentionally out of scope for this pass.
- Population caps and family-tree UI are intentionally out of scope.
## Command
```bash
python3 scripts/regression_check.py
```
Expected result: all checks print `[OK]` and exit with status 0.
## 15.24.28 runtime diagnostics
When opening with `?debug=1`, verify the overlay reports spatial rebuilds, draw-list state, item bucket/id-map state, forced/locked behavior counts, and top behavior ids. These are observation aids only and do not change player controls or simulation rules.
## Social balance regression checks
For `15.24.33+`, also verify:
- Ordinary conflict does not crowd out `approach_friend` / `approach_mate` in a neutral population.
- Fight mochi still forces conflict, but one individual should not start several fights at once.
- Intimidation resolves quickly enough that it does not visually lock the colony.
- With `?debug=1`, behavior counts should show non-conflict social actions appearing over time in a dense but non-drugged population.
## 15.24.33 runtime item-bucket checks
- Runtime-created items should go through `World.addItem()` where safe.
- `World.itemsOfType()` should call `ensureItemBuckets()` before returning a bucket.
- `World.itemById()` should not return dead items.
- The debug overlay should expose item bucket dirty state, id-map size, and bucket rebuild counters.
- Save migration remains out of scope until this runtime polish is observed in-browser.

View file

@ -0,0 +1,46 @@
# Remaining Work Split — after 15.24.32
## Bundled in 15.24.32
- Performance/runtime polish that does not alter save semantics.
- Item bucket/id-map coherence.
- Direct item creation helper consolidation.
- Regression guard expansion.
- Documentation update for the pre-save state.
## Still intentionally split
### 1. Browser long-run verification
Static checks pass, but long-run behavior still needs a real browser observation pass:
- 10+ minutes normal observation.
- Food/water/medicine/mochi placement.
- Friend formation, mate approach, birth ritual.
- Conflict/fight/intimidation cooldown behavior.
- Large population stress check with `?debug=1`.
### 2. Save migration
Do after the runtime shape is stable.
Target migration scope:
- Convert legacy `intent/currentAction/activeBehavior/activeForcedBehavior` save fields into `behavior`.
- Avoid saving transient display text and short-lived timers where possible.
- Keep old saves readable.
- Keep new saves simpler.
### 3. Compatibility-layer shrink
Only after save migration:
- Mark legacy behavior fields as deprecated runtime views.
- Remove direct write paths where no longer needed.
- Keep read-only compatibility only if old save support requires it.
## Not in scope by user preference
- Population cap.
- Family/generation cap.
- Family-tree UI rewrite.

View file

@ -0,0 +1,63 @@
# Runtime Polish Pre-Save Report — 15.24.32
This pass bundles the remaining low-risk internal cleanup that can be done before save-data migration.
## Scope
Included:
- Runtime item lookup coherence.
- Item bucket/id-map helper expansion.
- Common runtime item creation paths routed through `World.addItem()`.
- Regression guard updates.
- Final pre-save documentation.
Explicitly excluded:
- Save-data migration.
- Population or family caps.
- Family-tree UI performance work.
- Player-facing tool/UI redesign.
## What changed internally
`World.updateItemCounts()` now rebuilds both:
- `itemTypeBuckets: Map<string, Item[]>`
- `itemIdMap: Map<string, Item>`
The following helpers are available:
```txt
markItemBucketsDirty(reason)
ensureItemBuckets(reason)
addItem(item, reason, opts)
itemById(id)
itemsOfType(type)
liveItemsOfType(type, predicate)
```
This keeps high-frequency code from repeatedly scanning `world.items` for common lookups.
## Runtime creation paths routed through addItem
Common additions now dirty item buckets/spatial/terrain consistently:
- Player placement.
- Rain water drop.
- Grass spawn and grass seed growth.
- Zunchi burst/spawn.
- Ant corpse and ant nest creation.
- Tarinai death trace/splat/grass.
- Built owned structures.
- Field reset grass/stone.
## Player-visible impact
No intentional change to normal UI, tools, item definitions, population/family rules, family-tree UI, or save/load controls.
The only visible change is in `?debug=1`: item bucket dirty state, id-map size, and bucket rebuild counters are now exposed through runtime diagnostics.
## Remaining split
Save migration is still separated because it changes persistence semantics. It should run after this runtime structure has been observed in browser for basic stability.

View file

@ -0,0 +1,28 @@
# Social Balance Fix Report — 15.24.31
## Reported symptoms
- Tarinai rarely reproduced or formed friendships.
- Fights could start in rapid consecutive bursts.
- Intimidation lasted too long.
## Main cause
The ActionSpec social migration preserved the old combat hooks, but conflict pressure and same-priority social selection made `fight_rival` too competitive against `approach_friend` and `approach_mate`. Friend contact also routed into fight behavior whenever nearby conflict was detected, even when bond or mate pressure was stronger.
## Fixes
- Reduced ordinary conflict pressure and carried conflict weight.
- Lowered `fight_rival` and `intimidate_enemy` priority/weight.
- Raised `approach_mate` and `approach_friend` priority/weight.
- Required non-forced conflict to be genuinely dominant over bond/mate/family pressure.
- Added pair-level fight cooldown and already-fighting guards in `World.startFight()`, `startConflict()`, and `startForcedFight()`.
- Shortened intimidation and intimidation-defeat timers.
- Kept fight mochi as a high-priority forced behavior.
## Not changed
- No population cap.
- No family-generation cap.
- No family-tree UI work.
- No save migration.

View file

@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 viewBox=%270 0 64 64%27%3E%3Ctext y=%2752%27 font-size=%2752%27%3E%E3%81%9F%3C/text%3E%3C/svg%3E" />
<link rel="stylesheet" href="css/base.css?v=15.24.14" />
<link rel="stylesheet" href="css/layout.css?v=15.24.14" />
<link rel="stylesheet" href="css/panel.css?v=15.24.14" />
<link rel="stylesheet" href="css/components.css?v=15.24.14" />
<link rel="stylesheet" href="css/mobile.css?v=15.24.14" />
<link rel="stylesheet" href="css/base.css?v=15.24.35" />
<link rel="stylesheet" href="css/layout.css?v=15.24.35" />
<link rel="stylesheet" href="css/panel.css?v=15.24.35" />
<link rel="stylesheet" href="css/components.css?v=15.24.35" />
<link rel="stylesheet" href="css/mobile.css?v=15.24.35" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -183,67 +183,69 @@
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=15.24.14" defer></script>
<script src="js/event_bus.js?v=15.24.14" defer></script>
<script src="js/performance_manager.js?v=15.24.14" defer></script>
<script src="js/registry_base.js?v=15.24.14" defer></script>
<script src="js/disease_registry.js?v=15.24.14" defer></script>
<script src="js/effect_registry.js?v=15.24.14" defer></script>
<script src="js/sound_pack.js?v=15.24.14" defer></script>
<script src="js/data.js?v=15.24.14" defer></script>
<script src="js/item_visual_registry.js?v=15.24.14" defer></script>
<script src="js/food_registry.js?v=15.24.14" defer></script>
<script src="js/input_mode_manager.js?v=15.24.14" defer></script>
<script src="js/math.js?v=15.24.14" defer></script>
<script src="js/physics_helpers.js?v=15.24.14" defer></script>
<script src="js/assets.js?v=15.24.14" defer></script>
<script src="js/audio.js?v=15.24.14" defer></script>
<script src="js/render.js?v=15.24.14" defer></script>
<script src="js/sim_core.js?v=15.24.14" defer></script>
<script src="js/structures.js?v=15.24.14" defer></script>
<script src="js/items.js?v=15.24.14" defer></script>
<script src="js/ants.js?v=15.24.14" defer></script>
<script src="js/health.js?v=15.24.14" defer></script>
<script src="js/tarinai.js?v=15.24.14" defer></script>
<script src="js/tarinai_identity_social.js?v=15.24.14" defer></script>
<script src="js/tarinai_action_state.js?v=15.24.14" defer></script>
<script src="js/tarinai_disease_nest.js?v=15.24.14" defer></script>
<script src="js/tarinai_item_effects.js?v=15.24.14" defer></script>
<script src="js/tarinai_needs_items.js?v=15.24.14" defer></script>
<script src="js/tarinai_social_move_life.js?v=15.24.14" defer></script>
<script src="js/tarinai_render.js?v=15.24.14" defer></script>
<script src="js/world.js?v=15.24.14" defer></script>
<script src="js/world_view.js?v=15.24.14" defer></script>
<script src="js/family_graph.js?v=15.24.14" defer></script>
<script src="js/world_family_social.js?v=15.24.14" defer></script>
<script src="js/world_combat_effects.js?v=15.24.14" defer></script>
<script src="js/world_environment.js?v=15.24.14" defer></script>
<script src="js/world_spatial_budget.js?v=15.24.14" defer></script>
<script src="js/world_ants_system.js?v=15.24.14" defer></script>
<script src="js/weather_system.js?v=15.24.14" defer></script>
<script src="js/world_update.js?v=15.24.14" defer></script>
<script src="js/world_placement_log.js?v=15.24.14" defer></script>
<script src="js/world_bootstrap.js?v=15.24.14" defer></script>
<script src="js/text_catalog.js?v=15.24.14" defer></script>
<script src="js/ui.js?v=15.24.14" defer></script>
<script src="js/ui_log.js?v=15.24.14" defer></script>
<script src="js/ui_helpers.js?v=15.24.14" defer></script>
<script src="js/ui_selected.js?v=15.24.14" defer></script>
<script src="js/snapshot_system.js?v=15.24.14" defer></script>
<script src="js/save_system.js?v=15.24.14" defer></script>
<script src="js/patch_helpers.js?v=15.24.14" defer></script>
<script src="js/freeze_system.js?v=15.24.14" defer></script>
<script src="js/relation_event_system.js?v=15.24.14" defer></script>
<script src="js/ui_layout_dialogs.js?v=15.24.14" defer></script>
<script src="js/ui_tools.js?v=15.24.14" defer></script>
<script src="js/ui_bind.js?v=15.24.14" defer></script>
<script src="js/ui_charts.js?v=15.24.14" defer></script>
<script src="js/ui_family_data.js?v=15.24.14" defer></script>
<script src="js/ui_family_async.js?v=15.24.14" defer></script>
<script src="js/ui_family_layout.js?v=15.24.14" defer></script>
<script src="js/ui_family_paths.js?v=15.24.14" defer></script>
<script src="js/ui_family_render.js?v=15.24.14" defer></script>
<script src="js/main.js?v=15.24.14" defer></script>
<script src="js/debug_tools.js?v=15.24.14" defer></script>
<script src="js/version.js?v=15.24.35" defer></script>
<script src="js/event_bus.js?v=15.24.35" defer></script>
<script src="js/performance_manager.js?v=15.24.35" defer></script>
<script src="js/registry_base.js?v=15.24.35" defer></script>
<script src="js/disease_registry.js?v=15.24.35" defer></script>
<script src="js/effect_registry.js?v=15.24.35" defer></script>
<script src="js/sound_pack.js?v=15.24.35" defer></script>
<script src="js/data.js?v=15.24.35" defer></script>
<script src="js/item_visual_registry.js?v=15.24.35" defer></script>
<script src="js/food_registry.js?v=15.24.35" defer></script>
<script src="js/input_mode_manager.js?v=15.24.35" defer></script>
<script src="js/math.js?v=15.24.35" defer></script>
<script src="js/physics_helpers.js?v=15.24.35" defer></script>
<script src="js/assets.js?v=15.24.35" defer></script>
<script src="js/audio.js?v=15.24.35" defer></script>
<script src="js/render.js?v=15.24.35" defer></script>
<script src="js/sim_core.js?v=15.24.35" defer></script>
<script src="js/structures.js?v=15.24.35" defer></script>
<script src="js/items.js?v=15.24.35" defer></script>
<script src="js/ants.js?v=15.24.35" defer></script>
<script src="js/health.js?v=15.24.35" defer></script>
<script src="js/tarinai.js?v=15.24.35" defer></script>
<script src="js/tarinai_action_spec.js?v=15.24.35" defer></script>
<script src="js/tarinai_behavior_state.js?v=15.24.35" defer></script>
<script src="js/tarinai_identity_social.js?v=15.24.35" defer></script>
<script src="js/tarinai_action_state.js?v=15.24.35" defer></script>
<script src="js/tarinai_disease_nest.js?v=15.24.35" defer></script>
<script src="js/tarinai_item_effects.js?v=15.24.35" defer></script>
<script src="js/tarinai_needs_items.js?v=15.24.35" defer></script>
<script src="js/tarinai_social_move_life.js?v=15.24.35" defer></script>
<script src="js/tarinai_render.js?v=15.24.35" defer></script>
<script src="js/world.js?v=15.24.35" defer></script>
<script src="js/world_view.js?v=15.24.35" defer></script>
<script src="js/family_graph.js?v=15.24.35" defer></script>
<script src="js/world_family_social.js?v=15.24.35" defer></script>
<script src="js/world_combat_effects.js?v=15.24.35" defer></script>
<script src="js/world_environment.js?v=15.24.35" defer></script>
<script src="js/world_spatial_budget.js?v=15.24.35" defer></script>
<script src="js/world_ants_system.js?v=15.24.35" defer></script>
<script src="js/weather_system.js?v=15.24.35" defer></script>
<script src="js/world_update.js?v=15.24.35" defer></script>
<script src="js/world_placement_log.js?v=15.24.35" defer></script>
<script src="js/world_bootstrap.js?v=15.24.35" defer></script>
<script src="js/text_catalog.js?v=15.24.35" defer></script>
<script src="js/ui.js?v=15.24.35" defer></script>
<script src="js/ui_log.js?v=15.24.35" defer></script>
<script src="js/ui_helpers.js?v=15.24.35" defer></script>
<script src="js/ui_selected.js?v=15.24.35" defer></script>
<script src="js/snapshot_system.js?v=15.24.35" defer></script>
<script src="js/save_system.js?v=15.24.35" defer></script>
<script src="js/patch_helpers.js?v=15.24.35" defer></script>
<script src="js/freeze_system.js?v=15.24.35" defer></script>
<script src="js/relation_event_system.js?v=15.24.35" defer></script>
<script src="js/ui_layout_dialogs.js?v=15.24.35" defer></script>
<script src="js/ui_tools.js?v=15.24.35" defer></script>
<script src="js/ui_bind.js?v=15.24.35" defer></script>
<script src="js/ui_charts.js?v=15.24.35" defer></script>
<script src="js/ui_family_data.js?v=15.24.35" defer></script>
<script src="js/ui_family_async.js?v=15.24.35" defer></script>
<script src="js/ui_family_layout.js?v=15.24.35" defer></script>
<script src="js/ui_family_paths.js?v=15.24.35" defer></script>
<script src="js/ui_family_render.js?v=15.24.35" defer></script>
<script src="js/main.js?v=15.24.35" defer></script>
<script src="js/debug_tools.js?v=15.24.35" defer></script>
</body>
</html>

View file

@ -344,10 +344,10 @@ class AntActor {
const corpse = new Item("ant_corpse", this.x, this.y);
corpse.amount = 24;
corpse.workerSprite = this.workerSpriteId();
this.world.items.push(corpse);
this.world.addItem?.(corpse, "ant-corpse") || this.world.items.push(corpse);
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
if (home) syncAntNestCount(this.world, home);
this.world.updateItemCounts?.();
this.world.ensureItemBuckets?.("ant-corpse");
this.world.drawListDirty = true;
}
@ -370,11 +370,9 @@ class AntActor {
base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT);
if (!force && this.world.placementBlocked?.(base)) return null;
audio.antNest?.();
this.world.items.push(base);
this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1;
this.world.markTerrainDirty?.("ant-nest-built");
this.world.addItem?.(base, "ant-nest-built") || this.world.items.push(base);
this.world.emit?.("ant:nest-built", { nest: base, queen: this });
this.world.rebuildSpatial?.(true);
this.world.ensureSpatial?.("ant-nest-built");
this.world.drawListDirty = true;
this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event");
return base;

View file

@ -44,6 +44,7 @@ const DECOR_ASSETS = [
{ id: "pushpin_stuck", path: "assets/objects/pushpin_stuck.webp" },
{ id: "oshibyo", path: "assets/objects/oshibyo.webp" },
{ id: "oshibyo_stuck", path: "assets/objects/oshibyo_stuck.webp" },
{ id: "plushie", path: "assets/objects/plushie.webp" },
];
const TOOL_DEFINITIONS = Object.freeze({
@ -75,9 +76,9 @@ const TOOL_DEFINITIONS = Object.freeze({
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.webp", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.webp", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.webp", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.webp", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026文字×3行まで\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "食べ物や薬をセットすると、自動供給してくれる。" },
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, iconText: "\u26bd", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 30, amount: 999, icon: "assets/ui/tool_signboard.webp", collisionShape: "circle", tooltip: "\u8a2d\u7f6e\u5f8c\u306b\u89b3\u5bdf\u30c4\u30fc\u30eb\u3067\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u6587\u5b57\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u30026\u6587\u5b57\u00d73\u884c\u307e\u3067\u3002" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "\u8907\u88fd\u6a5f", placeable: true, scalable: false, radius: 34, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "\u98df\u3079\u7269\u3084\u85ac\u3092\u30bb\u30c3\u30c8\u3059\u308b\u3068\u3001\u81ea\u52d5\u4f9b\u7d66\u3057\u3066\u304f\u308c\u308b\u3002" },
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.webp", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "\u843d\u3068\u3059\u3068\u3053\u308d\u304c\u308a\u3001\u305f\u308a\u306a\u3044\u306b\u523a\u3055\u308b\u3068\u30d1\u30cb\u30c3\u30af\u3068\u7d99\u7d9a\u30c0\u30e1\u30fc\u30b8\u3092\u4e0e\u3048\u308b\u3002" },
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "\u304a\u3057\u308a\u92f2", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "\u523a\u3055\u308b\u3068\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u308b\u3002\u3064\u307e\u3093\u3067\u629c\u304f\u3068\u6e9c\u307e\u3063\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3076\u3002" },
@ -133,11 +134,11 @@ function pinBehaviorFor(type = "") {
const TOOL_CATEGORIES = Object.freeze([
{ id: "operate", label: "\u64cd\u4f5c\u30fb\u89b3\u5bdf", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269\u30fb\u6c34\u30fb\u8349", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] },
{ id: "medicine", label: "\u85ac\u30fb\u500b\u4f53\u52b9\u679c", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "\u751f\u6d3b\u30fb\u751f\u304d\u7269", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator"] },
{ id: "hazard", label: "\u5371\u967a\u30fb\u969c\u5bb3\u7269", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "fence_v", "fence_h"] },
{ id: "operate", label: "\u64cd\u4f5c", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
{ id: "food", label: "\u98df\u3079\u7269", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice", "duplicator"] },
{ id: "medicine", label: "\u85ac", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "\u751f\u6d3b", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ball", "signboard", "fence_v", "fence_h"] },
{ id: "hazard", label: "\u5371\u967a", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo", "ant_nest"] },
]);
function toolCategories() {
@ -171,7 +172,7 @@ function itemAmountFor(type = "", fallback = 80) {
}
const TOOL_SIZE_ORDER = Object.freeze(["small", "medium", "large"]);
const TOOL_SIZE_LABELS = Object.freeze({ small: "小", medium: "中", large: "大" });
const TOOL_SIZE_LABELS = Object.freeze({ small: "\u5c0f", medium: "\u4e2d", large: "\u5927" });
const TOOL_SIZE_SCALES = Object.freeze({ small: 0.68, medium: 1.0, large: 1.48 });
function normalizedToolSizeFor(worldRef = null, type = "") {
const def = itemDefinition(type);
@ -255,7 +256,7 @@ const CONFIG = {
weatherChangeMin: 46,
weatherChangeMax: 96,
bubbleLimit: 32,
grassLimit: 180,
grassLimit: 360,
highPopulationMode: 70,
ultraPopulationMode: 120,
};

View file

@ -21,13 +21,19 @@
const w = window.world;
const perf = window.TarinaiPerformance?.debugSnapshot(w) || {};
const input = window.TarinaiInputMode?.snapshot?.() || {};
const diag = w?.lastRuntimeDiagnostics || w?.runtimeDiagnostics?.() || {};
const behaviorCounts = diag.behavior?.counts || {};
const topBehaviors = Object.entries(behaviorCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, n]) => `${id}:${n}`).join(", ");
panel.textContent = [
`tarinai v${window.TARINAI_VERSION || "?"}`,
`fps ${perf.fps ?? window.__tarinaiFps ?? "?"} perf ${perf.level ?? "?"}`,
`items ${(w?.items || []).length} tarinai ${(w?.tarinai || []).length} ants ${(w?.ants || []).length}`,
`effects ${(w?.effects || []).length}`,
`terrain v${w?.terrainVersion ?? 0} spatial v${w?.spatialVersion ?? 0}`,
`drawList ${(w?.drawList || []).length} logs ${(w?.logs || []).length}`,
`spatial rebuild ${diag.spatial?.rebuildsThisFrame ?? 0}/frame total:${diag.spatial?.rebuildsTotal ?? 0} dirty:${diag.spatial?.dirtyMarksThisFrame ?? 0} reason:${diag.spatial?.dirtyReason || diag.spatial?.lastRebuildReason || "-"}`,
`drawList ${(w?.drawList || []).length} dirty:${Boolean(diag.render?.drawListDirty)} logs ${(w?.logs || []).length}`,
`item buckets:${diag.items?.bucketTypes ?? 0} dirty:${Boolean(diag.items?.bucketsDirty)} id-map:${diag.items?.idMapSize ?? 0} rebuilds:${diag.items?.bucketRebuildsTotal ?? 0}`,
`behavior forced:${diag.behavior?.forced ?? 0} locked:${diag.behavior?.locked ?? 0} top ${topBehaviors || "-"}`,
`dpr ${uiCache?.canvasDpr || "?"} cache ${window.TARINAI_APP?.cacheName || "?"}`,
`sw ${navigator.serviceWorker?.controller ? "controlled" : "uncontrolled"} soundPack ${window.TarinaiAudio?.soundPackVersion || "?"}`,
`input ${input.currentMode || "?"} touchFirst:${Boolean(input.touchFirst)} coarse:${Boolean(input.pointerCoarse)} hover:${Boolean(input.hasHover)} compact:${Boolean(input.isCompactViewport)} uaMobile:${Boolean(input.userAgentMobile)}`,

View file

@ -111,8 +111,8 @@
const nutrition = Number(context.nutrition) || 0;
if (typeof applyNeedRelief === "function") applyNeedRelief(tarinai, { fulfill: -(context.source === "eating" ? nutrition * 0.5 : 12), social: -4 });
if (context.source === "eating") {
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, reasonText: "へこ餅で繁殖できる相手を探している" });
if (tarinai.forceBehavior) tarinai.forceBehavior("approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "approach_mate", { source: "love_mochi", priority: 150, ttl: 24, searchRange: 820, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
}
return true;
},
@ -125,8 +125,8 @@
if (typeof applyNeedShock === "function") applyNeedShock(tarinai, { safety: context.source === "eating" ? nutrition * 0.5 : 16 });
if (context.source === "eating") {
tarinai.fearTimer = Math.max(tarinai.fearTimer || 0, 0.32);
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, reasonText: "けんか餅で気に入らない相手を探している" });
if (tarinai.forceBehavior) tarinai.forceBehavior("fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "fight_rival", { source: "fight_mochi", priority: 180, ttl: 24, searchRange: 820, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
}
return true;
},
@ -141,8 +141,8 @@
tarinai.fightMochiTimer = Math.max(0, (tarinai.fightMochiTimer || 0) - 2.5);
const baseChance = Math.min(0.98, 0.35 + nutrition * 0.075);
const sleepChance = tarinai.diseaseChance ? tarinai.diseaseChance(baseChance) : baseChance;
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, reasonText: "ねむり薬で眠ろうとしている" });
if (tarinai.forceBehavior) tarinai.forceBehavior("sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
else if (typeof queueForcedTarinaiBehavior === "function") queueForcedTarinaiBehavior(tarinai, "sleep_anywhere", { source: "sleep_drug", priority: 145, ttl: 14, duration: 8, minDuration: 8, causeText: "\u306d\u3080\u308a\u85ac\u306e\u52b9\u679c" });
if (!tarinai.sleepDisease && Math.random() < Math.min(0.98, sleepChance)) return tarinai.infectSleepDisease?.(context.item || null) || true;
return true;
}

View file

@ -47,8 +47,8 @@
.frozen-panel { grid-column: 1 / -1; margin-top: 8px; padding: 7px 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); min-width: 0; }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:800; font-size:12px; color:#4f5f70; margin-bottom:5px; }
.frozen-panel-toggle { width:100%; border:0; background:transparent; color:inherit; font:inherit; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0; cursor:pointer; text-align:left; }
.frozen-panel-toggle::after { content:"折り畳み"; font-size:11px; opacity:.62; font-weight:700; }
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"展開"; }
.frozen-panel-toggle::after { content:"\u6298\u308a\u7573\u307f"; font-size:11px; opacity:.62; font-weight:700; }
.frozen-panel.collapsed .frozen-panel-toggle::after { content:"\u5c55\u958b"; }
.frozen-panel-count { font-size:12px; opacity:.72; }
.frozen-list { display:flex; gap:6px; overflow-x:auto; padding-bottom:2px; }
.frozen-panel.collapsed .frozen-list { display:none; }
@ -162,8 +162,8 @@
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
global.showToast?.(`${t.name}を冷凍保存しました。`);
worldRef.log?.(`${t.name}をフィールド外で冷凍保存した。`, "observe", { participants: [t] });
global.showToast?.(`${t.name}\u3092\u51b7\u51cd\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3057\u305f\u3002`, "observe", { participants: [t] });
global.renderStats?.();
global.render?.();
return true;
@ -194,18 +194,18 @@
seen.add(key);
unique.push(loss);
}
const relPriority = unique.some(l => l.relation === "親" || l.relation === "子") ? 1 : 0;
const relPriority = unique.some(l => l.relation === "\u89aa" || l.relation === "\u5b50") ? 1 : 0;
const stress = Math.min(36, 10 + unique.length * 6 + relPriority * 8);
if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" });
if (t.enterPanic) t.enterPanic({ reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, fear: 2.4 + Math.min(2.0, unique.length * 0.35), stress, cause: "pending_relation_death" });
else {
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: stress * 1.8, social: stress * 1.2 });
t.fearTimer = Math.max(t.fearTimer || 0, 2.4 + Math.min(2.0, unique.length * 0.35));
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "誰か"}がいないことに気づいた`, wake: true });
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination() : null, reason: `${unique[0]?.name || "\u8ab0\u304b"}\u304c\u3044\u306a\u3044\u3053\u3068\u306b\u6c17\u3065\u3044\u305f`, wake: true });
}
t.bubble?.("いない", 1.1, "rgba(60,50,68,0.82)");
t.recordChangeCause?.("冷凍中の\u8a03\u5831", "ストレス", { value: stress });
t.addRecord?.(`冷凍中に${unique.map(l => l.name || "誰か").slice(0, 3).join("、")}が死亡していたことに気づいた。`, "death");
worldRef.log?.(`${t.name}は解凍時に身近な個体の死に気づき、パニックになった。`, "death", { participants: [t] });
t.bubble?.("\u3044\u306a\u3044", 1.1, "rgba(60,50,68,0.82)");
t.recordChangeCause?.("\u51b7\u51cd\u4e2d\u306e\u8a03\u5831", "\u30b9\u30c8\u30ec\u30b9", { value: stress });
t.addRecord?.(`\u51b7\u51cd\u4e2d\u306b${unique.map(l => l.name || "\u8ab0\u304b").slice(0, 3).join("\u3001")}\u304c\u6b7b\u4ea1\u3057\u3066\u3044\u305f\u3053\u3068\u306b\u6c17\u3065\u3044\u305f\u3002`, "death");
worldRef.log?.(`${t.name}\u306f\u89e3\u51cd\u6642\u306b\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u304d\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death", { participants: [t] });
}
function thawFrozen(freezeId, x, y, worldRef = global.world) {
@ -230,9 +230,9 @@
t.panicTarget = null;
t.insideNestBoxId = "";
t.sleeping = false;
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("解凍されて少し驚いている");
if (t.state === "sleep" || t.state === "seek_bed") t.goIdle?.("\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
t.thought = "解凍されて少し驚いている";
t.thought = "\u89e3\u51cd\u3055\u308c\u3066\u5c11\u3057\u9a5a\u3044\u3066\u3044\u308b";
t.frozenAt = 0;
updateLiveIdAfterThaw(worldRef, t);
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
@ -247,8 +247,8 @@
worldRef.rebuildSpatial?.(true);
saveFrozenStore(worldRef);
renderFrozenPanel(worldRef);
global.showToast?.(`${t.name}を解凍しました。`);
worldRef.log?.(`${t.name}を解凍してフィールドに戻した。`, "birth", { participants: [t] });
global.showToast?.(`${t.name}\u3092\u89e3\u51cd\u3057\u307e\u3057\u305f\u3002`);
worldRef.log?.(`${t.name}\u3092\u89e3\u51cd\u3057\u3066\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3057\u305f\u3002`, "birth", { participants: [t] });
global.renderStats?.();
global.render?.();
return true;
@ -284,7 +284,7 @@
if (!card) return;
pendingThawId = card.dataset.frozenId || "";
for (const c of panel.querySelectorAll(".frozen-card")) c.classList.toggle("pending-thaw", c === card);
global.showToast?.("フィールドをクリックすると解凍します。ドラッグ&ドロップでも配置できます。");
global.showToast?.("\u30d5\u30a3\u30fc\u30eb\u30c9\u3092\u30af\u30ea\u30c3\u30af\u3059\u308b\u3068\u89e3\u51cd\u3057\u307e\u3059\u3002\u30c9\u30e9\u30c3\u30b0&\u30c9\u30ed\u30c3\u30d7\u3067\u3082\u914d\u7f6e\u3067\u304d\u307e\u3059\u3002");
});
}
const canvas = global.canvas || document.getElementById("gameCanvas");

View file

@ -134,7 +134,7 @@ const HEALTH = (() => {
if (actualLoss > 0.01) {
cause = rememberDamage(t, actualLoss, reason);
t.world?.emit?.("tarinai:damaged", { tarinai: t, amount: actualLoss, cause, reason });
const plushie = (t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
const plushie = (t.world?.itemsOfType?.("plushie") || t.world?.items || []).find(item => item && !item.dead && item.isStructure && item.type === "plushie" && item.ownerId === t.id && item.carriedById === t.id);
if (plushie && actualLoss >= 1) plushie.damage(actualLoss, t.world, null);
if (cause === CAUSES.fight || /\u55a7\u5629|fight/.test(String(reason || ""))) t.recordBleedExposure();
audio.damage?.(actualLoss);

View file

@ -210,7 +210,7 @@ function drawConsumableFieldSprite(ctx, type = "", r = 10, seed = 1, opts = {})
ctx.font = `${Math.round(localR * 1.15)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("", 0, 1);
ctx.fillText("\u2764", 0, 1);
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.beginPath();
ctx.ellipse(-localR * 0.28, -localR * 0.18, localR * 0.23, localR * 0.10, -0.35, 0, Math.PI * 2);
@ -356,7 +356,7 @@ function drawToolItemPreview(ctx, type = "", opts = {}) {
wear: 0,
text: type === "signboard" ? "" : undefined,
storedFoodType: type === "duplicator" ? "sweet" : "",
storedFoodLabel: type === "duplicator" ? "ずんだ餅" : "",
storedFoodLabel: type === "duplicator" ? "\u305a\u3093\u3060\u9905" : "",
pinState: "loose",
spin: type === "pushpin" || type === "oshibyo" ? -0.55 : 0,
zunchiVariant: "zunchi",
@ -467,10 +467,12 @@ class Item {
this.lastPokeAngle = rand(0, Math.PI * 2);
}
if (type === "zunchi") {
this.amount = Math.max(Number(this.amount || 0) || 0, 240);
this.stage = "fresh";
this.stageTimer = 0;
this.fertility = 0;
this.freshness = 1;
this.spawnGrace = 1.2;
this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
}
if (type === "ant_nest") {
@ -528,6 +530,7 @@ class Item {
}
update(dt, worldRef = world) {
this.age += dt;
if (this.type === "zunchi") this.spawnGrace = Math.max(0, (this.spawnGrace || 0) - dt);
if (this.dropTimer > 0) {
const beforeDrop = this.dropTimer;
this.dropTimer = Math.max(0, this.dropTimer - dt);
@ -687,11 +690,11 @@ class Item {
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
t.fallDir = nx < 0 ? -1 : 1;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "高速ずんちにぶつかって吹き飛んでいる", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" });
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" });
else {
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5);
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "高速ずんちにぶつかって吹き飛んでいる", wake: true });
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "\u9ad8\u901f\u305a\u3093\u3061\u306b\u3076\u3064\u304b\u3063\u3066\u5439\u304d\u98db\u3093\u3067\u3044\u308b", wake: true });
if (t.addStress) t.addStress(7, { threshold: 8, duration: 3.2 });
}
worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65);
@ -924,11 +927,14 @@ class Item {
tryStickPushpin(worldRef) {
if (!worldRef?.tarinai?.length) return false;
if (this.pinState === "lodged") return false;
const now = worldRef.time || 0;
if ((this.noStickUntil || 0) > now) return false;
let best = null;
let bestD = Infinity;
const hitRange = Math.max(10, (this.r || 8) * 1.25);
for (const t of worldRef.tarinai) {
if (!t || t.dead) continue;
if (this.noStickTargetId && this.noStickTargetId === t.id && (this.noStickTargetUntil || 0) > now) continue;
const d = distXY(this.x, this.y, t.x, t.y);
const hit = (t.radius || 16) * 0.86 + hitRange;
if (d <= hit && d < bestD) { best = t; bestD = d; }
@ -948,7 +954,10 @@ class Item {
this.pinState = "lodged";
this.deletable = false;
this.pinTargetId = t.id;
this.pinAttachAngle = isOshibyo ? 0.36 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1));
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = faceDir;
this.pinVisualSide = visualSide;
this.pinAttachAngle = isOshibyo ? visualSide * 0.36 : Math.atan2(dy || -1, dx || visualSide);
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.72 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.40 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
this.pinDamageTick = 0;
@ -962,23 +971,23 @@ class Item {
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
if (isOshibyo) {
t.thought = "おしり鋲が刺さってずんちが出なくなった";
worldRef.log?.(`${t.name}におしり鋲が刺さった。`, "accident", { participants: [t] });
t.thought = "\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u3066\u305a\u3093\u3061\u304c\u51fa\u306a\u304f\u306a\u3063\u305f";
worldRef.log?.(`${t.name}\u306b\u304a\u3057\u308a\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }));
return true;
}
if (t.enterPanic) {
t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
} else {
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true });
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
if (t.addStress) t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 });
}
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲");
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "\u753b\u92f2");
if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) {
this.pinLogAt = worldRef.time || 0;
worldRef.log?.(`${t.name}に画鋲が刺さった。`, "accident", { participants: [t] });
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
}
worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }));
return true;
@ -994,29 +1003,32 @@ class Item {
const isOshibyo = Boolean(behavior?.blocksZunchi);
t.stuckPushpinId = this.id;
this.deletable = false;
this.x = isOshibyo ? t.x + (t.radius || 16) * 0.66 : t.x + (t.radius || 16) * 0.36;
const faceDir = t.facingDir ? t.facingDir() : (t.facing || 1);
const visualSide = faceDir;
this.pinVisualSide = visualSide;
this.x = isOshibyo ? t.x + visualSide * (t.radius || 16) * 0.72 : t.x + visualSide * (t.radius || 16) * 0.36;
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.42 : t.y - (t.radius || 16) * 0.04;
this.prevX = this.x;
this.prevY = this.y;
this.spin = isOshibyo ? 0.52 : 0.28;
this.spin = visualSide * (isOshibyo ? 0.52 : 0.28);
this.vx = t.vx || 0;
this.vy = t.vy || 0;
if (isOshibyo) {
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "おしり鋲でずんちが溜まってつらい";
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044";
return;
}
this.pinDamageTick = (this.pinDamageTick || 0) + dt;
while (this.pinDamageTick >= 0.55) {
this.pinDamageTick -= 0.55;
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "画鋲");
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "\u753b\u92f2");
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
if (t.addStress) t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 });
if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
}
t.sleeping = false;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
if (t.enterPanic) t.enterPanic({ threat: this, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
else {
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true });
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
@ -1038,6 +1050,16 @@ class Item {
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null;
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
if (reason === "pinch") {
const now = worldRef?.time || 0;
this.noStickUntil = now + 1.0;
this.noStickTargetId = t?.id || "";
this.noStickTargetUntil = now + 1.0;
} else if (t) {
const now = worldRef?.time || 0;
this.noStickTargetId = t.id || "";
this.noStickTargetUntil = now + 0.55;
}
this.pinState = "loose";
this.pinTargetId = "";
this.pinDamageTick = 0;
@ -1104,14 +1126,14 @@ class Item {
this.fertilityBoost = clamp(fertility, 0, 1.4);
const weatherBoost = weather === "sunny" ? 0.09 : weather === "light_rain" ? 0.42 : 0.08;
const dryStress = weather === "sunny" && light > 0.78 && waterNear < 0.04 && this.fertilityBoost < 0.12 ? 0.012 : 0;
const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * 0.5;
const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * (2 / 3);
this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88);
this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1);
const grassLimit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96);
if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2);
if (spawned > 0) this.growth = rand(0.52, 0.68);
this.seedTimer = rand(18, 34);
this.seedTimer = rand(9, 17);
}
if (this.age > (this.lifeSpan || 640) * 0.82) {
const fade = clamp((this.age - (this.lifeSpan || 640) * 0.82) / Math.max((this.lifeSpan || 640) * 0.18, 1), 0, 1);
@ -1123,10 +1145,10 @@ class Item {
}
this.amount = clamp(this.growth * 120 * this.health, 0, 130);
if (this.age > (this.lifeSpan || 640) * 1.05) this.amount -= dt * (1.2 + (this.wither || 0) * 24);
this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * 0.5;
this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * (2 / 3);
if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
this.seedTimer = rand(24, 54);
if (Math.random() < 0.42 + this.fertilityBoost * 0.18) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1);
this.seedTimer = rand(12, 27);
if (Math.random() < 0.62 + this.fertilityBoost * 0.22) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1);
}
}
seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) {
@ -1141,8 +1163,7 @@ class Item {
const sprout = new Item("grass", spot.x, spot.y);
sprout.growth = rand(0.08, 0.18);
sprout.amount = sprout.growth * 120;
worldRef.items.push(sprout);
if (worldRef.itemCounts) worldRef.itemCounts.grass = (worldRef.itemCounts.grass || 0) + 1;
worldRef.addItem?.(sprout, "grass-seed") || worldRef.items.push(sprout);
made += 1;
}
return made;
@ -1159,21 +1180,22 @@ class Item {
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
}
this.stageTimer += dt * 2.0 * (rainBoost + waterBoost * 1.3);
const decayDt = (this.spawnGrace || 0) > 0 ? 0 : dt;
if (this.stage === "fresh") {
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
this.amount -= dt * 0.05 * decayBoost;
this.amount -= decayDt * 0.05 * decayBoost;
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
} else if (this.stage === "dry") {
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
this.amount -= dt * 0.09 * decayBoost;
this.amount -= decayDt * 0.09 * decayBoost;
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
} else if (this.stage === "decomposing") {
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
this.amount -= dt * 0.12 * decayBoost;
this.amount -= decayDt * 0.12 * decayBoost;
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
} else if (this.stage === "fertile_soil") {
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
this.amount -= dt * 0.26 * decayBoost;
this.amount -= decayDt * 0.26 * decayBoost;
}
}
get dead() {
@ -1185,6 +1207,10 @@ class Item {
const warm = lightState.warmth;
const styleNight = lightState.light < 0.42;
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
if (isPinType(this.type) && this.pinState === "lodged") {
const owner = world?.tarinai?.find?.(o => o && !o.dead && o.id === this.pinTargetId) || null;
if (owner?.insideNestBoxId) return;
}
const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null;
const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1);
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
@ -1274,6 +1300,7 @@ class Item {
const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin");
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
ctx.save();
if (stuck) ctx.scale((this.pinVisualSide || 1) < 0 ? -1 : 1, 1);
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)";
ctx.shadowBlur = 3;
@ -1546,7 +1573,7 @@ class Item {
ctx.font = `bold ${Math.max(14, Math.round(this.r * 0.44))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("", 0, -this.r * 0.58);
ctx.fillText("\u2026", 0, -this.r * 0.58);
}
ctx.restore();
} else if (this.type === "ant_nest") {

View file

@ -10,31 +10,35 @@
const observerFamily = observer.familyKey || observer.archiveKey || observer.id || "";
const deadId = dead.id || dead.releasedLiveId || "";
const observerId = observer.id || observer.liveId || "";
if (deadFamily && (observer.parents || []).includes(deadFamily)) return "";
if (deadFamily && (observer.children || []).includes(deadFamily)) return "";
if (observerFamily && (dead.parents || []).includes(observerFamily)) return "";
if (observerFamily && (dead.children || []).includes(observerFamily)) return "";
if (deadFamily && (observer.parents || []).includes(deadFamily)) return "\u89aa";
if (deadFamily && (observer.children || []).includes(deadFamily)) return "\u5b50";
if (observerFamily && (dead.parents || []).includes(observerFamily)) return "\u5b50";
if (observerFamily && (dead.children || []).includes(observerFamily)) return "\u89aa";
const rel = deadId ? observer.relationships?.[deadId] : null;
const reverse = observerId ? dead.relationships?.[observerId] : null;
if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "友達";
if ((rel?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14) || (reverse?.affinity || 0) >= (global.FRIEND_AFFINITY_THRESHOLD || 14)) return "\u53cb\u9054";
return "";
}
function applyLiveDeathShock(observer, dead, relation, worldRef) {
const strong = relation === "親" || relation === "子";
if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("身近な個体の死に気づいた");
const strong = relation === "\u89aa" || relation === "\u5b50";
if (observer.state === "sleep" || observer.state === "seek_bed") observer.goIdle?.("\u8eab\u8fd1\u306a\u500b\u4f53\u306e\u6b7b\u306b\u6c17\u3065\u3044\u305f");
const stress = strong ? 18 : 11;
const fear = strong ? 2.1 : 1.45;
if (observer.enterPanic) observer.enterPanic({ threat: dead, reason: `${relation}が死んだ`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" });
if (observer.enterPanic) {
const relLabel = relation || "\u8eab\u8fd1\u306a\u500b\u4f53";
observer.lastPanicDetail = `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u305f`;
observer.enterPanic({ threat: dead, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relLabel}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, fear, stress, cause: "relation_death", bubble: "!!", bubbleColor: "rgba(60,50,68,0.82)" });
}
else {
observer.sleeping = false;
observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${relation}が死んだ`, wake: true });
observer.setActionState?.("panic", { target: observer.panicDestination ? observer.panicDestination(dead) : null, reason: `${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff08${relation || "\u8eab\u8fd1\u306a\u500b\u4f53"}\uff09\u306e\u6b7b\u306b\u6c17\u4ed8\u3044\u3066\u3001\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b\u3002`, wake: true });
observer.fearTimer = Math.max(observer.fearTimer || 0, fear);
if (typeof applyNeedShock === "function") applyNeedShock(observer, { safety: stress * 1.6, social: stress * 1.4 });
observer.bubble?.("!!", 1.0, "rgba(60,50,68,0.82)");
}
observer.recordChangeCause?.(`${relation}の死亡`, "ストレス", { value: stress });
observer.addRecord?.(`${dead.name || "身近な個体"}の死に気づいてパニックになった。`, "death");
observer.recordChangeCause?.(`${relation}\u306e\u6b7b\u4ea1`, "\u30b9\u30c8\u30ec\u30b9", { value: stress });
observer.addRecord?.(`${dead.name || "\u8eab\u8fd1\u306a\u500b\u4f53"}\u306e\u6b7b\u306b\u6c17\u3065\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u305f\u3002`, "death");
}
function notifyDeathToRelations(dead, reason = "") {
@ -59,16 +63,16 @@
if (!duplicate) {
entry.pendingLosses.unshift({
relation,
name: dead.name || "たりない",
name: dead.name || "\u305f\u308a\u306a\u3044",
familyKey: deadFamily,
liveId: deadId,
reason: reason || dead.deathReason || "死亡",
reason: reason || dead.deathReason || "\u6b7b\u4ea1",
time: worldRef.time || 0,
});
if (entry.pendingLosses.length > MAX_PENDING_LOSSES) entry.pendingLosses.length = MAX_PENDING_LOSSES;
}
}
if (liveHits) worldRef.log?.(`${dead.name || "たりない"}の死に、身近な個体が動揺した。`, "death", { participants: [dead] });
if (liveHits) worldRef.log?.(`${dead.name || "\u305f\u308a\u306a\u3044"}\u306e\u6b7b\u306b\u3001\u8eab\u8fd1\u306a\u500b\u4f53\u304c\u52d5\u63fa\u3057\u305f\u3002`, "death", { participants: [dead] });
global.TarinaiFreezeSystem?.saveFrozenStore?.(worldRef);
global.TarinaiFreezeSystem?.renderFrozenPanel?.(worldRef);
}

View file

@ -348,6 +348,55 @@ function isEntityVisibleInRect(entity, rect, extra = 0) {
return x + r >= rect.left && x - r <= rect.right && y + r >= rect.top && y - r <= rect.bottom;
}
function renderStackSignature(worldRef) {
return [
(worldRef.items || []).length,
(worldRef.tarinai || []).length,
(worldRef.ants || []).length,
worldRef.terrainVersion || 0,
].join(":");
}
function ensureWorldRenderStack(worldRef) {
if (!worldRef._renderStack) {
worldRef._renderStack = { signature: "", backItems: [], layered: [], carriedPlushies: [], lodgedPins: [] };
}
const stack = worldRef._renderStack;
const signature = renderStackSignature(worldRef);
if (!worldRef.drawListDirty && stack.signature === signature) return stack;
stack.backItems.length = 0;
stack.layered.length = 0;
stack.carriedPlushies.length = 0;
stack.lodgedPins.length = 0;
for (const it of worldRef.items || []) {
if (!it || it.dead || isCachedTerrainItem(it)) continue;
if (isPinType(it.type) && it.pinState === "lodged") {
stack.lodgedPins.push(it);
continue;
}
if (it.isStructure && it.type === "plushie" && it.carriedById) {
stack.carriedPlushies.push(it);
continue;
}
if (isBehindSpriteLayerItem(it)) stack.backItems.push(it);
else stack.layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
}
for (const t of worldRef.tarinai || []) {
if (t && !t.dead) stack.layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
}
for (const a of worldRef.ants || []) {
if (a && !a.dead) stack.layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
}
stack.backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
stack.layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
stack.signature = signature;
worldRef.drawListDirty = false;
return stack;
}
function ensureBackgroundCache(w, h, lighting) {
const key = backgroundCacheKey(world, lighting);
if (!backgroundCache.canvas) {
@ -756,19 +805,12 @@ function render() {
drawPlacementPreview(ctx, world);
if (terrainLayer) ctx.drawImage(terrainLayer, 0, 0, sceneW, sceneH);
// Low bedding is drawn one step behind the live sprite stack.
const backItems = [];
const layered = [];
for (const it of world.items) {
if (!it || it.dead || isCachedTerrainItem(it)) continue;
if (isPinType(it.type) && it.pinState === "lodged") continue;
if (it.isStructure && it.type === "plushie" && it.carriedById) continue;
if (!isEntityVisibleInRect(it, visibleRect)) continue;
if (isBehindSpriteLayerItem(it)) backItems.push(it);
else layered.push({ entity: it, y: renderLayerSortY(it), rank: renderLayerKindRank(it) });
// Dynamic render stacks are sorted on a short interval rather than every paint.
// Visibility is still checked per frame, so camera movement remains responsive.
const renderStack = ensureWorldRenderStack(world);
for (const it of renderStack.backItems) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
backItems.sort((a, b) => renderLayerSortY(a) - renderLayerSortY(b) || (a.x || 0) - (b.x || 0));
for (const it of backItems) it.draw(ctx, world.time, lighting);
ctx.save();
ctx.lineWidth = 1.15;
@ -786,20 +828,15 @@ function render() {
ctx.setLineDash([]);
ctx.restore();
for (const t of world.tarinai || []) {
if (t && !t.dead && isEntityVisibleInRect(t, visibleRect)) layered.push({ entity: t, y: renderLayerSortY(t), rank: renderLayerKindRank(t) });
for (const entry of renderStack.layered) {
if (isEntityVisibleInRect(entry.entity, visibleRect)) entry.entity.draw(ctx, world.time, lighting);
}
for (const a of world.ants || []) {
if (a && !a.dead && isEntityVisibleInRect(a, visibleRect)) layered.push({ entity: a, y: renderLayerSortY(a), rank: renderLayerKindRank(a) });
}
layered.sort((a, b) => a.y - b.y || a.rank - b.rank || ((a.entity.x || 0) - (b.entity.x || 0)));
for (const entry of layered) entry.entity.draw(ctx, world.time, lighting);
for (const it of world.items) {
if (it.isStructure && it.type === "plushie" && it.carriedById && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
for (const it of renderStack.carriedPlushies) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const it of world.items) {
if (isPinType(it.type) && it.pinState === "lodged" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
for (const it of renderStack.lodgedPins) {
if (isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const ef of world.effects) {
if (isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);

View file

@ -1,57 +1,152 @@
"use strict";
(function (global) {
const STORAGE_PREFIX = "tarinai_save_slot_v1_";
const EXPORT_PREFIX = "TARINAI_SAVE_V1:";
const STORAGE_PREFIX = "tarinai_save_slot_v2_";
const EXPORT_PREFIX = "TN2!";
const SLOT_COUNT = 9;
const Snapshot = global.TarinaiSnapshot;
if (!Snapshot) throw new Error("TarinaiSnapshot is not available for save_system.js");
const createSnapshot = (...args) => Snapshot.createSnapshot(...args);
const restoreSnapshot = (...args) => Snapshot.restoreSnapshot(...args);
function bytesToBase64(bytes) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
const SAVE_TEXT_ALPHABET = (() => {
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (ch === "*" || ch === "'" || ch === "_") continue;
out += ch;
}
return out;
})();
const SAVE_TEXT_REVERSE = (() => {
const map = new Map();
for (let i = 0; i < SAVE_TEXT_ALPHABET.length; i++) map.set(SAVE_TEXT_ALPHABET[i], i);
return map;
})();
if (SAVE_TEXT_ALPHABET.length !== 91) throw new Error("save alphabet must contain 91 printable ASCII chars");
function base91Encode(bytes) {
let b = 0;
let n = 0;
let out = "";
for (const byte of bytes || []) {
b |= (byte & 255) << n;
n += 8;
if (n > 13) {
let v = b & 8191;
if (v > 88) {
b >>= 13;
n -= 13;
} else {
v = b & 16383;
b >>= 14;
n -= 14;
}
out += SAVE_TEXT_ALPHABET[v % 91] + SAVE_TEXT_ALPHABET[Math.floor(v / 91)];
}
}
if (n) {
out += SAVE_TEXT_ALPHABET[b % 91];
if (n > 7 || b > 90) out += SAVE_TEXT_ALPHABET[Math.floor(b / 91)];
}
return out;
}
function base64ToBytes(text) {
let b64 = String(text || "").trim().replace(/^#+/, "").replace(EXPORT_PREFIX, "").replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
function base91Decode(text) {
const clean = String(text || "").trim();
let v = -1;
let b = 0;
let n = 0;
const out = [];
for (const ch of clean) {
const c = SAVE_TEXT_REVERSE.get(ch);
if (c == null) continue;
if (v < 0) {
v = c;
} else {
v += c * 91;
b |= v << n;
n += (v & 8191) > 88 ? 13 : 14;
do {
out.push(b & 255);
b >>= 8;
n -= 8;
} while (n > 7);
v = -1;
}
}
if (v >= 0) out.push((b | (v << n)) & 255);
return new Uint8Array(out);
}
async function compressBytes(bytes) {
if (typeof CompressionStream === "function") {
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
function encodeSnapshot(snapshot) {
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
return EXPORT_PREFIX + bytesToBase64(bytes);
async function decompressBytes(bytes, codec = "R") {
if (codec === "D") {
if (typeof DecompressionStream !== "function") throw new Error("deflate decoder is not available in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
return bytes;
}
function decodeSnapshot(text) {
async function encodeSnapshot(snapshot) {
const compact = snapshot && snapshot.v === 2 ? snapshot : createSnapshot(global.world);
const json = JSON.stringify(compact);
const bytes = new TextEncoder().encode(json);
let codec = "R";
let packed = bytes;
try {
const compressed = await compressBytes(bytes);
if (compressed.length < bytes.length) {
codec = "D";
packed = compressed;
}
} catch (error) {
console.warn("save compression fallback", error);
}
return `${EXPORT_PREFIX}${codec}${base91Encode(packed)}`;
}
async function decodeSnapshot(text) {
const raw = String(text || "").trim();
if (!raw) throw new Error("empty import text");
if (raw.startsWith("{") || raw.startsWith("[")) return JSON.parse(raw);
const bytes = base64ToBytes(raw);
if (raw.startsWith("{") || raw.startsWith("[")) {
const data = JSON.parse(raw);
if (data?.v !== 2) throw new Error("unsupported save version");
return data;
}
if (!raw.startsWith(EXPORT_PREFIX)) throw new Error("unsupported save text");
const codec = raw.charAt(EXPORT_PREFIX.length) || "R";
const payload = raw.slice(EXPORT_PREFIX.length + 1);
const packed = base91Decode(payload);
const bytes = await decompressBytes(packed, codec);
const json = new TextDecoder().decode(bytes);
return JSON.parse(json);
const data = JSON.parse(json);
if (data?.v !== 2) throw new Error("unsupported save version");
return data;
}
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
function saveSlot(slot) {
async function saveSlot(slot) {
const snapshot = createSnapshot(global.world);
localStorage.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
}
function loadSlot(slot) {
async function loadSlot(slot) {
const raw = localStorage.getItem(slotKey(slot));
if (!raw) throw new Error("empty slot");
return restoreSnapshot(JSON.parse(raw), global.world);
const snapshot = JSON.parse(raw);
if (snapshot?.v !== 2) throw new Error("unsupported slot version");
return restoreSnapshot(snapshot, global.world);
}
function deleteSlot(slot) { localStorage.removeItem(slotKey(slot)); }
@ -66,10 +161,14 @@
function formatSlotSummary(snapshot) {
if (!snapshot) return "空き";
const d = snapshot.summary || {};
const date = snapshot.createdAt ? new Date(snapshot.createdAt).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "日時不明";
return `${date} / ${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""} / ${d.population || 0}`;
if (!snapshot) return "\u7a7a\u304d";
const m = snapshot.m || [];
const date = snapshot.c ? new Date(snapshot.c).toLocaleString("ja-JP", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }) : "\u65e5\u6642\u4e0d\u660e";
const day = m[0] || 1;
const population = m[1] || 0;
const fieldType = m[2] || "\u5ead";
const timeText = Number.isFinite(m[3]) ? `${Math.floor((m[3] / 10) % 24).toString().padStart(2, "0")}:00` : "";
return `${date} / ${fieldType} / ${day}\u65e5\u76ee ${timeText} / ${population}\u5339`;
}
function ensureDialog() {
@ -141,7 +240,7 @@
openDialog();
});
const dialog = ensureDialog();
dialog.addEventListener("click", (e) => {
dialog.addEventListener("click", async (e) => {
if (e.target === dialog) { closeDialog(); return; }
const saveBtn = e.target.closest("[data-save-slot]");
const loadBtn = e.target.closest("[data-load-slot]");
@ -149,61 +248,61 @@
if (saveBtn) {
const slot = Number(saveBtn.dataset.saveSlot);
try {
saveSlot(slot);
await saveSlot(slot);
renderSlotList();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u4fdd\u5b58しました。`);
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u306b\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
global.showToast?.("\u4fdd\u5b58に失敗しました。空き容量を確認してください。");
global.showToast?.("\u4fdd\u5b58\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u7a7a\u304d\u5bb9\u91cf\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
}
return;
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みます。現在の状態は上書きされます。`)) return;
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002`)) return;
try {
loadSlot(slot);
await loadSlot(slot);
closeDialog();
global.audio?.notify?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}を読み込みました。`);
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002`);
} catch (error) {
console.warn(error);
global.showToast?.("読み込みに失敗しました。");
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
}
return;
}
if (deleteBtn) {
const slot = Number(deleteBtn.dataset.deleteSlot);
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u524a\u9664しますか`)) return;
if (!global.confirm?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3059\u304b\uff1f`)) return;
deleteSlot(slot);
renderSlotList();
global.audio?.delete?.();
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u524a\u9664しました。`);
global.showToast?.(`\u30b9\u30ed\u30c3\u30c8${slot}\u3092\u524a\u9664\u3057\u307e\u3057\u305f\u3002`);
return;
}
if (e.target.closest("#saveCloseBtn")) closeDialog();
if (e.target.closest("#saveExportBtn")) {
const area = dialog.querySelector("#saveHashText");
if (area) {
area.value = encodeSnapshot(createSnapshot(global.world));
area.value = await encodeSnapshot(createSnapshot(global.world));
area.focus();
area.select();
}
global.showToast?.("現在の状態をハッシュテキストに書き出しました。");
global.showToast?.("\u73fe\u5728\u306e\u72b6\u614b\u3092\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u306b\u66f8\u304d\u51fa\u3057\u307e\u3057\u305f\u3002");
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; }
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307fます。現在の状態は上書きされます。")) return;
if (!text.trim()) { global.showToast?.("\u8aad\u307f\u8fbc\u3080\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8cbc\u308a\u4ed8\u3051\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
if (!global.confirm?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u3092\u8aad\u307f\u8fbc\u307f\u307e\u3059\u3002\u73fe\u5728\u306e\u72b6\u614b\u306f\u4e0a\u66f8\u304d\u3055\u308c\u307e\u3059\u3002")) return;
try {
restoreSnapshot(decodeSnapshot(text), global.world);
restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog();
global.showToast?.("ハッシュテキストから読み込みました。");
global.showToast?.("\u30cf\u30c3\u30b7\u30e5\u30c6\u30ad\u30b9\u30c8\u304b\u3089\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
} catch (error) {
console.warn(error);
global.showToast?.("読み込みに失敗しました。テキストを確認してください。");
global.showToast?.("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002\u30c6\u30ad\u30b9\u30c8\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
}
}
});

View file

@ -1,7 +1,8 @@
"use strict";
(function (global) {
const SNAPSHOT_VERSION = 1;
const SNAPSHOT_VERSION = 2;
const NEED_KEYS = ["food", "sleep", "health", "safety", "social", "fulfill"];
const DEFAULT_SKIP = new Set(["world", "target", "panicTarget", "targetRef", "targetEntity", "source"]);
function isPlainObject(value) {
@ -56,6 +57,63 @@
return target;
}
function q(value, scale = 1, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? Math.round(n * scale) : fallback;
}
function u(value, scale = 1, fallback = 0) {
const n = Number(value);
return Number.isFinite(n) ? n / scale : fallback;
}
function compactNumberArray(obj, keys, scale = 1) {
return keys.map(k => q(obj?.[k], scale));
}
function expandNumberArray(arr, keys, scale = 1) {
const out = {};
if (!Array.isArray(arr)) return out;
keys.forEach((key, i) => { out[key] = u(arr[i], scale); });
return out;
}
function needsToArray(needs = {}) {
return NEED_KEYS.map(k => q(needs[k], 1));
}
function arrayToNeeds(arr = []) {
const out = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
NEED_KEYS.forEach((k, i) => { out[k] = u(arr[i], 1); });
return out;
}
function flagPack(t = {}) {
let flags = 0;
if (t.dead) flags |= 1 << 0;
if (t.favorite) flags |= 1 << 1;
if (t.zunchiDisease) flags |= 1 << 2;
if (t.sleepDisease) flags |= 1 << 3;
if (t.explosionDisease) flags |= 1 << 4;
if (t.fightDisease) flags |= 1 << 5;
if (t.isZunchiSlave) flags |= 1 << 6;
if (t.zunchiSlaveLocked) flags |= 1 << 7;
if (t.birthRitualLeader) flags |= 1 << 8;
return flags;
}
function flagApply(t, flags = 0) {
t.dead = !!(flags & (1 << 0));
t.favorite = !!(flags & (1 << 1));
t.zunchiDisease = !!(flags & (1 << 2));
t.sleepDisease = !!(flags & (1 << 3));
t.explosionDisease = !!(flags & (1 << 4));
t.fightDisease = !!(flags & (1 << 5));
t.isZunchiSlave = !!(flags & (1 << 6));
t.zunchiSlaveLocked = !!(flags & (1 << 7));
t.birthRitualLeader = !!(flags & (1 << 8));
}
function refFor(worldRef, value) {
if (!value || typeof value !== "object") return null;
if ((worldRef?.tarinai || []).includes(value)) return { kind: "tarinai", id: value.id || "", token: value.liveToken || 0, familyKey: value.familyKey || "" };
@ -75,22 +133,209 @@
}
function worldData(worldRef) {
const keys = [
"w", "h", "viewportW", "viewportH", "time", "day", "paused", "speed", "tool", "toolSize", "toolSizes", "frozenTarinai",
"fieldType", "fieldZoom", "cameraX", "cameraY", "weather", "nextWeatherChange", "deadCount",
"liveIdNext", "liveIdSerial", "lastBirthAt", "maxGeneration", "family", "familyVersion", "familyTreeDirtyReason",
"relationNotices", "resolvedFightIds", "eventCounters", "foodSpoilagePenalty", "foodSpoilageEvents",
"lastPhase", "colonyMood", "lastColonyMoodDay", "antUpdatePhase", "shakeTimer", "shakeDuration", "shakeStrength"
return {
fieldType: worldRef?.fieldType || "garden",
time: Number(worldRef?.time) || 0,
day: Number(worldRef?.day) || 1,
cameraX: Number(worldRef?.cameraX) || 0,
cameraY: Number(worldRef?.cameraY) || 0,
weather: worldRef?.weather || "sunny",
nextWeatherChange: Number(worldRef?.nextWeatherChange) || 0,
deadCount: Number(worldRef?.deadCount) || 0,
liveIdNext: Number(worldRef?.liveIdNext) || 1,
liveIdSerial: Number(worldRef?.liveIdSerial) || 1,
lastBirthAt: Number(worldRef?.lastBirthAt) || -999,
maxGeneration: Number(worldRef?.maxGeneration) || 1,
colonyMood: worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed",
};
}
function compactWorld(worldRef) {
const mood = worldRef?.colonyMood?.id || worldRef?.colonyMood || "relaxed";
return [
worldRef?.fieldType || "garden",
q(worldRef?.time, 10),
q(worldRef?.day, 1, 1),
q(worldRef?.cameraX, 1),
q(worldRef?.cameraY, 1),
worldRef?.weather || "sunny",
q(worldRef?.nextWeatherChange, 10),
q(worldRef?.deadCount, 1),
q(worldRef?.liveIdNext, 1, 1),
q(worldRef?.liveIdSerial, 1, 1),
q(worldRef?.lastBirthAt, 10, -9990),
q(worldRef?.maxGeneration, 1, 1),
mood,
];
const out = {};
for (const key of keys) {
const cloned = clonePlain(worldRef?.[key]);
if (cloned !== undefined) out[key] = cloned;
}
function applyCompactWorld(worldRef, arr = []) {
worldRef.fieldType = arr[0] || "garden";
worldRef.time = u(arr[1], 10);
worldRef.day = u(arr[2], 1, 1);
worldRef.cameraX = u(arr[3], 1);
worldRef.cameraY = u(arr[4], 1);
worldRef.weather = arr[5] || "sunny";
worldRef.nextWeatherChange = u(arr[6], 10);
worldRef.deadCount = u(arr[7], 1);
worldRef.liveIdNext = Math.max(1, u(arr[8], 1, 1));
worldRef.liveIdSerial = Math.max(1, u(arr[9], 1, 1));
worldRef.lastBirthAt = u(arr[10], 10, -999);
worldRef.maxGeneration = Math.max(1, u(arr[11], 1, 1));
const moodId = arr[12] || "relaxed";
worldRef.colonyMood = worldRef.colonyMoodDefinition?.(moodId) || worldRef.colonyMoodDefinition?.("relaxed") || { id: moodId, label: moodId, effects: { personality: {} } };
}
function compactBehavior(b = null, tarinaiIndex = new Map(), itemIndex = new Map()) {
if (!b || typeof b !== "object") return null;
const ref = b.targetRef || b.target || null;
let target = null;
if (ref?.kind === "tarinai") target = ["t", tarinaiIndex.get(ref.id) ?? -1];
else if (ref?.kind === "item") target = ["i", itemIndex.get(ref.id) ?? -1];
else if (Number.isFinite(ref?.x) && Number.isFinite(ref?.y)) target = ["p", q(ref.x, 1), q(ref.y, 1)];
return [b.id || "", b.need || "", b.subNeed || "", b.phase || "", b.forced ? 1 : 0, target].filter((v, i) => i < 5 || v);
}
function expandBehavior(arr = null) {
if (!Array.isArray(arr) || !arr[0]) return null;
return {
id: arr[0] || "",
need: arr[1] || "",
subNeed: arr[2] || "",
phase: arr[3] || "",
forced: !!arr[4],
};
}
function compactRelationships(t, tarinaiIndex) {
const out = [];
for (const [id, rel] of Object.entries(t.relationships || {})) {
const idx = tarinaiIndex.get(id);
if (!Number.isInteger(idx) || idx < 0) continue;
const affinity = Number(rel?.affinity) || 0;
const fear = Number(rel?.fear) || 0;
const wins = Number(rel?.fightsWon) || 0;
const losses = Number(rel?.fightsLost) || 0;
if (Math.abs(affinity) < 1 && fear < 1 && wins < 1 && losses < 1) continue;
out.push([idx, q(affinity, 10), q(fear, 10), q(wins, 1), q(losses, 1)]);
}
return out;
}
function expandRelationships(rows = [], tarinaiList = [], worldRef = null) {
const out = {};
for (const row of rows || []) {
if (!Array.isArray(row)) continue;
const other = tarinaiList[row[0]];
if (!other?.id) continue;
out[other.id] = {
affinity: u(row[1], 10),
fear: u(row[2], 10),
fightsWon: u(row[3], 1),
fightsLost: u(row[4], 1),
lastEvent: (u(row[3], 1) || u(row[4], 1)) ? "fight" : "",
lastTime: worldRef?.time || 0,
};
}
return out;
}
function compactTarinai(t, idx, familyIndex, tarinaiIndex, itemIndex) {
const parentIdx = (t.parents || []).map(id => familyIndex.get(id)).filter(Number.isInteger);
const childIdx = (t.children || []).map(id => familyIndex.get(id)).filter(Number.isInteger);
const timers = [
q(t.reproductionTimer, 10), q(t.fightCooldown, 10), q(t.loveMochiTimer, 10), q(t.fightMochiTimer, 10),
q(t.zunchiDiseaseSeverity, 10), q(t.zunchiStain, 10), q(t.postBirthPeaceTimer, 10), q(t.awakeLockTimer, 10),
q(t.itemEffectTimers?.laxative, 10), q(t.itemEffectTimers?.ammo, 10), q(t.mercuryLifeMultiplier, 100),
];
const modes = [t.powerItemMode || "", t.sizeItemMode || "", t.lifeItemMode || ""];
const pinIdx = itemIndex.get(t.stuckPushpinId) ?? -1;
const nestIdx = itemIndex.get(t.insideNestBoxId) ?? -1;
return [
t.name || "", t.type || "smile", t.birthPersonality || null, t.currentPersonality || null, t.genetics || null,
q(t.x, 1), q(t.y, 1), q(t.vx, 10), q(t.vy, 10), q(t.scale, 1000), q(t.adultScale, 1000),
q(t.age, 10), q(t.lifeSpan, 10), q(t.birthTime, 10), q(t.generation, 1, 1), t.hasPaired ? 1 : 0,
q(t.hunger, 1), q(t.loneliness, 1), q(t.energy, 1), q(t.circadianSleepPressure, 1), needsToArray(t.needs || t.needRaw || {}),
t.state || "idle", flagPack(t), parentIdx, childIdx, compactRelationships(t, tarinaiIndex), timers, modes,
pinIdx, nestIdx, q(t.totalFightWins, 1), q(t.totalFightLosses, 1), t.deathReason || "", compactBehavior(t.behavior, tarinaiIndex, itemIndex),
];
}
function itemExtra(item, tarinaiIndex = new Map()) {
const type = item?.type || "";
if (item?.isStructure) return ["S", q(item.hp, 10), q(item.maxHp, 10), q(item.attachment, 10), q(item.usedCount, 1), tarinaiIndex.get(item.ownerId) ?? -1, item.carriedById ? (tarinaiIndex.get(item.carriedById) ?? -1) : -1, item.onHead ? 1 : 0];
if (type === "grass") return [q(item.growth, 1000), q(item.health, 1000), q(item.seedTimer, 10), q(item.eatenAmount, 10), q(item.fertilityBoost, 1000), q(item.lifeSpan, 10), q(item.wither, 1000)];
if (type === "bed") return [q(item.comfort, 1000), q(item.wear, 1000)];
if (type === "signboard") return [item.text || ""];
if (type === "duplicator") return [item.storedFoodType || "", item.storedFoodLabel || ""];
if (type === "ball") return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000)];
if (type === "zunchi") return [item.stage || "fresh", q(item.stageTimer, 10), q(item.fertility, 1000), q(item.freshness, 1000), item.zunchiVariant || ""];
if (type === "ant_nest") return [q(item.antCount, 1), Array.isArray(item.antWorkers) ? item.antWorkers.map(v => q(v, 10)) : [], q(item.queenSpawnAt, 10)];
if (type === "ant_corpse") return [item.workerSprite || "ant_worker", q(item.decayTimer, 10)];
if (type === "firecracker") return [q(item.fuseTimer, 10), q(item.fuseMax, 10)];
if (typeof isPinType === "function" && isPinType(type)) return [q(item.vx, 10), q(item.vy, 10), q(item.spin, 1000), q(item.spinVelocity, 1000), item.pinState || "loose", tarinaiIndex.get(item.pinTargetId) ?? -1, q(item.pinAttachAngle, 1000), q(item.pinAttachDistance, 10), q(item.pinOffsetY, 10)];
if (typeof isServingFoodType === "function" && isServingFoodType(type)) return [item.toolSize || "medium", q(item.foodServingsRemaining ?? item.amount, 10), q(item.foodServingsMax, 10)];
return null;
}
function compactItem(item, index, tarinaiIndex = new Map()) {
return [
item?.type || "", q(item?.x, 1), q(item?.y, 1), q(item?.r, 10), q(item?.amount, 10), q(item?.age, 10), q(item?.seed, 10), item?.dead ? 1 : 0, itemExtra(item, tarinaiIndex),
];
}
function applyItemExtra(item, extra, tarinaiList = []) {
if (!Array.isArray(extra)) return;
const type = item.type || "";
if (extra[0] === "S") {
item.isStructure = true;
item.hp = u(extra[1], 10);
item.maxHp = u(extra[2], 10);
item.attachment = u(extra[3], 10);
item.usedCount = u(extra[4], 1);
item.ownerId = tarinaiList[extra[5]]?.id || "";
item.carriedById = tarinaiList[extra[6]]?.id || "";
item.onHead = !!extra[7];
const def = global.StructureRegistry?.get?.(type);
if (def) {
item.label = def.label;
item.roles = clonePlain(def.roles) || item.roles || {};
item.needEffects = clonePlain(def.needEffects) || item.needEffects || {};
}
return;
}
if (type === "grass") {
item.growth = u(extra[0], 1000); item.health = u(extra[1], 1000); item.seedTimer = u(extra[2], 10); item.eatenAmount = u(extra[3], 10); item.fertilityBoost = u(extra[4], 1000); item.lifeSpan = u(extra[5], 10); item.wither = u(extra[6], 1000);
} else if (type === "bed") {
item.comfort = u(extra[0], 1000); item.wear = u(extra[1], 1000);
} else if (type === "signboard") {
item.text = String(extra[0] || "");
} else if (type === "duplicator") {
item.storedFoodType = extra[0] || ""; item.storedFoodLabel = extra[1] || "";
item.roles.food = Boolean(item.storedFoodType);
} else if (type === "ball") {
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000);
} else if (type === "zunchi") {
item.stage = extra[0] || "fresh"; item.stageTimer = u(extra[1], 10); item.fertility = u(extra[2], 1000); item.freshness = u(extra[3], 1000); item.zunchiVariant = extra[4] || item.zunchiVariant || "zunchi";
} else if (type === "ant_nest") {
item.antCount = u(extra[0], 1); item.antWorkers = Array.isArray(extra[1]) ? extra[1].map(v => u(v, 10)) : []; item.queenSpawnAt = u(extra[2], 10);
} else if (type === "ant_corpse") {
item.workerSprite = extra[0] || "ant_worker"; item.decayTimer = u(extra[1], 10);
} else if (type === "firecracker") {
item.fuseTimer = u(extra[0], 10); item.fuseMax = u(extra[1], 10);
} else if (typeof isPinType === "function" && isPinType(type)) {
item.vx = u(extra[0], 10); item.vy = u(extra[1], 10); item.spin = u(extra[2], 1000); item.spinVelocity = u(extra[3], 1000); item.pinState = extra[4] || "loose"; item.pinTargetId = tarinaiList[extra[5]]?.id || ""; item.pinAttachAngle = u(extra[6], 1000); item.pinAttachDistance = u(extra[7], 10); item.pinOffsetY = u(extra[8], 10);
} else if (typeof isServingFoodType === "function" && isServingFoodType(type)) {
item.toolSize = extra[0] || "medium"; item.foodServingsRemaining = u(extra[1], 10); item.foodServingsMax = u(extra[2], 10); item.amount = item.foodServingsRemaining;
}
}
function compactAnt(ant, itemIndex = new Map(), tarinaiIndex = new Map()) {
return [ant.kind || "worker", itemIndex.get(ant.homeId) ?? -1, tarinaiIndex.get(ant.targetId) ?? -1, q(ant.x, 1), q(ant.y, 1), q(ant.vx, 10), q(ant.vy, 10), q(ant.hp, 10), q(ant.maxHp, 10), ant.state || "search", q(ant.age, 10), q(ant.seed, 10), q(ant.returnTimer, 10), q(ant.foundingX, 1), q(ant.foundingY, 1), q(ant.foundingDelayUntil, 10), q(ant.foundingAttempts, 1), ant.dead ? 1 : 0];
}
function snapshotEntity(worldRef, entity, skip = new Set(["world", "target", "panicTarget", "targetRef"])) {
// Compatibility helper for non-save subsystems such as freeze. The exported save format no longer uses this broad snapshot.
const data = copyOwnData(entity, skip);
const targetRef = refFor(worldRef, entity?.target);
const panicTargetRef = refFor(worldRef, entity?.panicTarget);
@ -103,25 +348,27 @@
function createSnapshot(worldRef = global.world) {
if (!worldRef) throw new Error("world is not ready");
const selectedRef = refFor(worldRef, worldRef.selected);
const tarinaiLive = (worldRef.tarinai || []).filter(Boolean);
const itemsLive = (worldRef.items || []).filter(Boolean).filter(it => it && !it.dead);
const tarinaiIndex = new Map();
const familyIndex = new Map();
tarinaiLive.forEach((t, i) => {
if (t.id) tarinaiIndex.set(t.id, i);
if (t.familyKey) familyIndex.set(t.familyKey, i);
});
const itemIndex = new Map();
itemsLive.forEach((it, i) => { if (it.id) itemIndex.set(it.id, i); });
const now = Date.now();
return {
version: SNAPSHOT_VERSION,
app: "tarinai_colony_game",
createdAt: Date.now(),
summary: {
day: worldRef.day || 1,
time: worldRef.clockString ? worldRef.clockString() : "",
fieldType: worldRef.fieldType || "garden",
population: (worldRef.tarinai || []).filter(t => t && !t.dead).length,
items: (worldRef.items || []).filter(it => it && !it.dead).length,
},
world: worldData(worldRef),
tarinai: (worldRef.tarinai || []).filter(Boolean).map(t => snapshotEntity(worldRef, t)),
items: (worldRef.items || []).filter(Boolean).map(it => snapshotEntity(worldRef, it)),
ants: (worldRef.ants || []).filter(Boolean).map(a => snapshotEntity(worldRef, a)),
effects: (worldRef.effects || []).filter(Boolean).map(ef => snapshotEntity(worldRef, ef)),
logs: clonePlain((worldRef.logs || []).slice(0, 100)) || [],
selectedRef,
v: SNAPSHOT_VERSION,
a: "tcg",
c: now,
m: [worldRef.day || 1, (worldRef.tarinai || []).filter(t => t && !t.dead).length, worldRef.fieldType || "garden", q(worldRef.time || 0, 10), itemsLive.length],
w: compactWorld(worldRef),
t: tarinaiLive.map((t, i) => compactTarinai(t, i, familyIndex, tarinaiIndex, itemIndex)),
i: itemsLive.map((it, i) => compactItem(it, i, tarinaiIndex)),
n: (worldRef.ants || []).filter(a => a && !a.dead).map(a => compactAnt(a, itemIndex, tarinaiIndex)),
s: null,
};
}
@ -141,11 +388,52 @@
return t;
}
function expandTarinai(row, index, worldRef) {
const TarinaiClass = global.Tarinai || (typeof Tarinai !== "undefined" ? Tarinai : null);
if (!TarinaiClass) throw new Error("Tarinai is not available");
const opts = {
familyKey: `sf${index}`,
id: `st${index}`,
liveToken: 1,
name: row[0] || undefined,
type: row[1] || "smile",
birthPersonality: row[2] || undefined,
currentPersonality: row[3] || undefined,
genetics: row[4] || undefined,
x: u(row[5], 1), y: u(row[6], 1), vx: u(row[7], 10), vy: u(row[8], 10),
scale: u(row[9], 1000, 0.28), adultScale: u(row[10], 1000, 0.28),
age: u(row[11], 10), lifeSpan: u(row[12], 10, 1600), birthTime: u(row[13], 10), generation: Math.max(1, u(row[14], 1, 1)),
hasPaired: !!row[15], hunger: u(row[16], 1), loneliness: u(row[17], 1), energy: u(row[18], 1), circadianSleepPressure: u(row[19], 1),
needs: arrayToNeeds(row[20]), state: row[21] || "idle", behavior: expandBehavior(row[33]),
};
const t = new TarinaiClass(worldRef, opts);
t.familyKey = `sf${index}`;
t.id = `st${index}`;
t.liveToken = 1;
t.needRaw = { ...t.needs };
t.needDisplay = { ...t.needs };
t.previousNeeds = { ...t.needs };
flagApply(t, Number(row[22]) || 0);
t.__savedParentIdx = Array.isArray(row[23]) ? row[23] : [];
t.__savedChildIdx = Array.isArray(row[24]) ? row[24] : [];
t.__savedRelationships = Array.isArray(row[25]) ? row[25] : [];
const timers = Array.isArray(row[26]) ? row[26] : [];
t.reproductionTimer = u(timers[0], 10); t.fightCooldown = u(timers[1], 10); t.loveMochiTimer = u(timers[2], 10); t.fightMochiTimer = u(timers[3], 10);
t.zunchiDiseaseSeverity = u(timers[4], 10); t.zunchiStain = u(timers[5], 10); t.postBirthPeaceTimer = u(timers[6], 10); t.awakeLockTimer = u(timers[7], 10);
t.itemEffectTimers = { ...(t.itemEffectTimers || {}), laxative: u(timers[8], 10), ammo: u(timers[9], 10) };
t.mercuryLifeMultiplier = u(timers[10], 100, t.mercuryLifeMultiplier || 1);
const modes = Array.isArray(row[27]) ? row[27] : [];
t.powerItemMode = modes[0] || ""; t.sizeItemMode = modes[1] || ""; t.lifeItemMode = modes[2] || "";
t.__savedPinIdx = row[28]; t.__savedNestIdx = row[29];
t.totalFightWins = u(row[30], 1); t.totalFightLosses = u(row[31], 1); t.deathReason = row[32] || "";
if (typeof t.refreshEffectiveSize === "function") t.refreshEffectiveSize();
return t;
}
function restoreSnapshot(snapshot, worldRef = global.world) {
if (!snapshot || typeof snapshot !== "object") throw new Error("invalid save data");
if (!snapshot || typeof snapshot !== "object" || snapshot.v !== SNAPSHOT_VERSION) throw new Error("invalid v2 save data");
if (!worldRef) throw new Error("world is not ready");
const data = snapshot.version ? snapshot : JSON.parse(JSON.stringify(snapshot));
const fieldType = data.world?.fieldType || "garden";
const fieldType = snapshot.w?.[0] || "garden";
if (typeof applyFieldLayout === "function") applyFieldLayout(fieldType);
if (typeof worldRef.reset === "function") worldRef.reset(0, fieldType);
@ -153,82 +441,83 @@
worldRef.items = [];
worldRef.ants = [];
worldRef.effects = [];
worldRef.logs = Array.isArray(data.logs) ? clonePlain(data.logs) || [] : [];
worldRef.logs = [];
worldRef.selected = null;
applyOwnData(worldRef, data.world || {}, new Set(["tarinai", "items", "ants", "effects", "logs", "selected", "liveTarinai", "events", "spatial"]));
worldRef.events = global.TarinaiEvents || null;
worldRef.liveTarinai = new Map();
worldRef.liveIdFree = [];
applyCompactWorld(worldRef, snapshot.w || []);
const items = Array.isArray(data.items) ? data.items : [];
for (const saved of items) {
if (!saved || !saved.type) continue;
const item = new Item(saved.type, Number(saved.x) || 0, Number(saved.y) || 0);
applyOwnData(item, saved, new Set(["target", "targetRef", "world"]));
const tarRows = Array.isArray(snapshot.t) ? snapshot.t : [];
for (let idx = 0; idx < tarRows.length; idx++) {
const t = expandTarinai(tarRows[idx] || [], idx, worldRef);
worldRef.tarinai.push(t);
if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 1, target: t });
}
for (const [idx, t] of worldRef.tarinai.entries()) {
t.parents = (t.__savedParentIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean);
t.children = (t.__savedChildIdx || []).map(i => worldRef.tarinai[i]?.familyKey).filter(Boolean);
delete t.__savedParentIdx; delete t.__savedChildIdx;
}
const itemRows = Array.isArray(snapshot.i) ? snapshot.i : [];
for (let idx = 0; idx < itemRows.length; idx++) {
const row = itemRows[idx] || [];
const type = row[0] || "";
if (!type) continue;
let item = null;
if (Array.isArray(row[8]) && row[8][0] === "S" && global.StructureRegistry?.get?.(type)) {
const owner = worldRef.tarinai[row[8][5]] || null;
item = global.StructureRegistry.create(type, owner, u(row[1], 1), u(row[2], 1), worldRef);
} else {
item = new Item(type, u(row[1], 1), u(row[2], 1));
}
item.id = `si${idx}`;
item.r = u(row[3], 10, item.r || 12);
item.amount = u(row[4], 10, item.amount || 0);
item.age = u(row[5], 10, 0);
item.seed = u(row[6], 10, item.seed || 0);
item.dead = !!row[7];
applyItemExtra(item, row[8], worldRef.tarinai);
worldRef.items.push(item);
}
const tarinaiList = Array.isArray(data.tarinai) ? data.tarinai : [];
for (const saved of tarinaiList) {
if (!saved) continue;
const t = restoreTarinaiData(worldRef, saved, { clearTransient: false });
worldRef.tarinai.push(t);
if (t.id) worldRef.liveTarinai.set(t.id, { token: t.liveToken || 0, target: t });
for (const t of worldRef.tarinai) {
if (Number.isInteger(t.__savedPinIdx) && t.__savedPinIdx >= 0) t.stuckPushpinId = worldRef.items[t.__savedPinIdx]?.id || null;
if (Number.isInteger(t.__savedNestIdx) && t.__savedNestIdx >= 0) t.insideNestBoxId = worldRef.items[t.__savedNestIdx]?.id || null;
t.relationships = expandRelationships(t.__savedRelationships || [], worldRef.tarinai, worldRef);
delete t.__savedPinIdx; delete t.__savedNestIdx; delete t.__savedRelationships;
}
const ants = Array.isArray(data.ants) ? data.ants : [];
for (const saved of ants) {
if (!saved) continue;
const ant = new AntActor(worldRef, saved);
applyOwnData(ant, saved, new Set(["world", "targetRef"]));
ant.world = worldRef;
const antRows = Array.isArray(snapshot.n) ? snapshot.n : [];
for (let idx = 0; idx < antRows.length; idx++) {
const row = antRows[idx] || [];
const ant = new AntActor(worldRef, {
id: `sa${idx}`,
kind: row[0] || "worker",
homeId: worldRef.items[row[1]]?.id || "",
targetId: worldRef.tarinai[row[2]]?.id || "",
targetToken: worldRef.tarinai[row[2]]?.liveToken || 0,
x: u(row[3], 1), y: u(row[4], 1), vx: u(row[5], 10), vy: u(row[6], 10), hp: u(row[7], 10), maxHp: u(row[8], 10), state: row[9] || "search", age: u(row[10], 10), seed: u(row[11], 10), returnTimer: u(row[12], 10), foundingX: u(row[13], 1), foundingY: u(row[14], 1), foundingDelayUntil: u(row[15], 10), foundingAttempts: u(row[16], 1), dead: !!row[17],
});
ant.targetRef = ant.targetId ? worldRef.liveTarinaiById?.(ant.targetId, ant.targetToken) || null : null;
worldRef.ants.push(ant);
}
const effects = Array.isArray(data.effects) ? data.effects : [];
for (const saved of effects) {
if (!saved || !saved.type) continue;
const ef = new Effect(saved.type, Number(saved.x) || 0, Number(saved.y) || 0, saved);
applyOwnData(ef, saved, new Set(["world", "target", "targetRef"]));
worldRef.effects.push(ef);
}
const itemById = new Map(worldRef.items.map(it => [it.id, it]));
const antById = new Map(worldRef.ants.map(a => [a.id, a]));
const tarinaiById = new Map(worldRef.tarinai.map(t => [t.id, t]));
const resolve = (ref) => {
if (!ref) return null;
if (ref.kind === "item") return itemById.get(ref.id) || null;
if (ref.kind === "ant") return antById.get(ref.id) || null;
if (ref.kind === "tarinai") return tarinaiById.get(ref.id) || worldRef.tarinai.find(t => t.familyKey === ref.familyKey) || null;
return resolveRef(worldRef, ref);
};
for (const [i, saved] of tarinaiList.entries()) {
const t = worldRef.tarinai[i];
if (!t) continue;
t.target = resolve(saved.__targetRef);
t.panicTarget = resolve(saved.__panicTargetRef);
}
for (const [i, saved] of ants.entries()) {
const ant = worldRef.ants[i];
if (!ant) continue;
ant.targetRef = resolve(saved.__targetRefObject);
}
worldRef.selected = resolve(data.selectedRef);
worldRef.family = clonePlain(data.world?.family) || worldRef.family || {};
worldRef.relationNotices = clonePlain(data.world?.relationNotices) || {};
worldRef.resolvedFightIds = clonePlain(data.world?.resolvedFightIds) || {};
worldRef.eventCounters = clonePlain(data.world?.eventCounters) || {};
worldRef.colonyMood = clonePlain(data.world?.colonyMood) || worldRef.colonyMoodDefinition?.("relaxed") || { id: "relaxed", label: "のんびり", effects: { personality: {} } };
worldRef.family = {};
worldRef.familyVersion = (worldRef.familyVersion || 0) + 1;
for (const t of worldRef.tarinai) worldRef.recordFamily?.(t);
worldRef.familyCleanVersion = null;
worldRef.familyTreeDirty = true;
worldRef.drawListDirty = true;
worldRef.terrainDirty = true;
worldRef.relationNotices = {};
worldRef.resolvedFightIds = {};
worldRef.eventCounters = {};
if (typeof worldRef.updateItemCounts === "function") worldRef.updateItemCounts();
if (typeof worldRef.updateEffectCounts === "function") worldRef.updateEffectCounts();
if (typeof worldRef.rebuildSpatial === "function") worldRef.rebuildSpatial(true);
if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load");
if (typeof worldRef.markTerrainDirty === "function") worldRef.markTerrainDirty("load-v2");
if (typeof worldRef.clampCamera === "function") worldRef.clampCamera();
if (typeof renderLog === "function") renderLog(worldRef.logs || []);
if (typeof resetArchiveRenderState === "function") resetArchiveRenderState();

View file

@ -78,7 +78,7 @@
ctx.translate(this.x, this.y);
const night = (lighting?.light || 1) < 0.42;
if (this.type === "grass_bed") {
// かんたんベッド: 緑色の小さい干草寝床。通常の干草寝床より一回り小さく、草束感を強める。
// \u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9: \u7dd1\u8272\u306e\u5c0f\u3055\u3044\u5e72\u8349\u5bdd\u5e8a\u3002\u901a\u5e38\u306e\u5e72\u8349\u5bdd\u5e8a\u3088\u308a\u4e00\u56de\u308a\u5c0f\u3055\u304f\u3001\u8349\u675f\u611f\u3092\u5f37\u3081\u308b\u3002
const grassFill = night ? "rgba(78,132,67,0.90)" : "rgba(84,177,72,0.93)";
const grassStroke = night ? "rgba(39,78,43,0.76)" : "rgba(45,112,48,0.78)";
const strawStroke = night ? "rgba(168,207,119,0.42)" : "rgba(205,234,128,0.66)";
@ -106,13 +106,13 @@
ctx.ellipse(0, this.r * 0.22, this.r * 1.28, this.r * 0.28, -0.06, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "plushie") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : global.images?.get?.("smile");
const img = typeof getRenderableImage === "function" ? getRenderableImage("plushie", "plushie") : global.images?.get?.("plushie");
ctx.rotate(Math.sin(time * 2 + this.seed) * 0.05);
if (img) {
const w = this.r * 2.2;
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("smile") : null;
const h = w * (metrics?.ratio || 0.92);
ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h);
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("plushie") : null;
const w = this.r * 3.0;
const h = w * (metrics?.ratio || 0.74);
ctx.drawImage(img, -w * 0.58, -h * 0.60, w, h);
} else {
ctx.fillStyle = "#f7dfc0";
ctx.strokeStyle = "rgba(86,62,48,0.70)";
@ -178,16 +178,18 @@
roles: { sleepPlace: true, ownedStructure: true },
needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 },
create(owner, x, y, world) { return new TarinaiStructure(this, owner, x, y, world); },
onUse(user, structure) {
onUse(user, structure, worldRef) {
if (user?.id && !structure.ownerId) {
structure.ownerId = user.id;
structure.ownerName = user.name || "";
structure.attachment = Math.max(structure.attachment || 0, 24);
worldRef?.markItemBucketsDirty?.("grass-bed-owner-adopted");
worldRef?.markTerrainDirty?.("grass-bed-owner-adopted");
worldRef?.emit?.("structure:ownerAdopted", { structure, owner: user });
}
const ownerUse = user?.id && user.id === structure.ownerId;
if (ownerUse) structure.attachment = Math.min(100, (structure.attachment || 0) + 2);
structure.usedCount = (structure.usedCount || 0) + 1;
if (user) {
user.needShock = user.needShock || {};
user.needShock.sleep = Math.min(0, (user.needShock.sleep || 0) - (ownerUse ? 18 : 8));
user.needShock.safety = Math.min(0, (user.needShock.safety || 0) - (ownerUse ? 12 : 4));
user.needShock.fulfill = Math.min(0, (user.needShock.fulfill || 0) - (ownerUse ? 18 : 6));
}
return true;
},
onDestroyed(structure, world, breaker) {

View file

@ -67,11 +67,13 @@ class Tarinai { constructor(world, opts = {}) {
this.lastSatisfactionSource = opts.lastSatisfactionSource || "";
this.stress = typeof calculateStressFromNeeds === "function" ? calculateStressFromNeeds(this.needRaw || this.needs) : 0;
this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...defaultNeeds, ...opts.previousNeeds } : { ...this.needs };
this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : null;
this.currentAction = opts.currentAction && typeof opts.currentAction === "object" ? { ...opts.currentAction } : null;
this.activeBehavior = opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null;
this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : [];
this.need = "";
this.state = opts.state || "idle";
this.behaviorSerial = opts.behaviorSerial ?? 0;
this.behavior = typeof globalThis.normalizeTarinaiBehaviorState === "function"
? globalThis.normalizeTarinaiBehaviorState(opts, this)
: (opts.behavior && typeof opts.behavior === "object" ? { ...opts.behavior } : (opts.activeBehavior && typeof opts.activeBehavior === "object" ? { ...opts.activeBehavior } : null));
this.forcedBehaviorQueue = Array.isArray(opts.forcedBehaviorQueue) ? opts.forcedBehaviorQueue.map(v => ({ ...(v || {}) })).slice(-8) : [];
this.actionCooldowns = opts.actionCooldowns && typeof opts.actionCooldowns === "object" ? { ...opts.actionCooldowns } : {};
this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...opts.needShock } : {};
this.buildPlan = opts.buildPlan && typeof opts.buildPlan === "object" ? { ...opts.buildPlan } : null;
@ -79,8 +81,6 @@ class Tarinai { constructor(world, opts = {}) {
this.hpBarTimer = opts.hpBarTimer ?? 0;
this.stressBarTimer = opts.stressBarTimer ?? 0;
this.affection = opts.affection ?? rand(0, 30);
this.need = "";
this.state = opts.state || "idle";
this.dead = !!opts.dead;
this.deathReason = opts.deathReason || "";
this.lastDamageCause = opts.lastDamageCause || "";

201
js/tarinai_action_spec.js Normal file
View file

@ -0,0 +1,201 @@
"use strict";
(function (global) {
const registry = new Map();
function isObject(value) {
return Boolean(value && typeof value === "object");
}
function finiteOr(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function toList(value) {
if (Array.isArray(value)) return value.filter(Boolean).map(String);
if (value == null || value === "") return [];
return [String(value)];
}
function hasOwnFn(obj, key) {
return typeof obj?.[key] === "function";
}
function resolveActionSpec(action) {
if (!action) return null;
return typeof action === "string" ? getTarinaiActionSpec(action) : action;
}
function buildActionContext(tarinai, world, ctx = {}, spec = null) {
const base = isObject(ctx) ? { ...ctx } : {};
if (base.target === undefined && spec && hasOwnFn(spec, "selectTarget")) {
base.target = spec.selectTarget(tarinai, world, base);
}
return base;
}
function normalizeTarinaiActionSpec(input = {}) {
if (!isObject(input)) throw new TypeError("ActionSpec must be an object");
const id = String(input.id || input.actionId || "").trim();
if (!id) throw new TypeError("ActionSpec.id is required");
// Public lifecycle: canStart -> selectTarget -> start -> tick -> finish/fail -> text.
// Legacy hooks (`condition`, `run`, `update`) remain accepted so old code can be
// migrated incrementally, but newly migrated actions should use the lifecycle names.
const spec = {
...input,
id,
kind: String(input.kind || "need_action"),
need: String(input.need || "fulfill"),
subNeed: String(input.subNeed || ""),
label: String(input.label || input.phrase || id),
phrase: String(input.phrase || input.label || id),
weight: finiteOr(input.weight, 1),
priority: finiteOr(input.priority, 0),
tags: toList(input.tags),
timerBacked: Boolean(input.timerBacked),
interruptible: input.interruptible !== false,
};
const originalCanStart = hasOwnFn(input, "canStart") ? input.canStart : null;
const originalCondition = hasOwnFn(input, "condition") ? input.condition : null;
const originalSelectTarget = hasOwnFn(input, "selectTarget") ? input.selectTarget : null;
const originalStart = hasOwnFn(input, "start") ? input.start : null;
const originalRun = hasOwnFn(input, "run") ? input.run : null;
const originalTick = hasOwnFn(input, "tick") ? input.tick : null;
const originalUpdate = hasOwnFn(input, "update") ? input.update : null;
const originalFinish = hasOwnFn(input, "finish") ? input.finish : null;
const originalFail = hasOwnFn(input, "fail") ? input.fail : null;
const originalText = hasOwnFn(input, "text") ? input.text : null;
spec.selectTarget = function selectTarget(tarinai, world, ctx = {}) {
if (originalSelectTarget) return originalSelectTarget.call(spec, tarinai, world, ctx);
return ctx.target ?? tarinai?.target ?? null;
};
spec.canStart = function canStart(tarinai, world, ctx = {}) {
if (originalCanStart) return Boolean(originalCanStart.call(spec, tarinai, world, ctx));
if (originalCondition) return Boolean(originalCondition.call(spec, tarinai, world, ctx));
return true;
};
spec.start = function start(tarinai, world, ctx = {}) {
if (originalStart) return originalStart.call(spec, tarinai, world, ctx);
if (originalRun) return originalRun.call(spec, tarinai, world, ctx);
return true;
};
spec.tick = function tick(tarinai, world, dt = 0, needs = null, ctx = {}) {
if (originalTick) return originalTick.call(spec, tarinai, world, dt, needs, ctx);
if (originalUpdate) return originalUpdate.call(spec, tarinai, world, dt, needs, ctx);
return undefined;
};
spec.finish = function finish(tarinai, world, ctx = {}) {
return originalFinish ? originalFinish.call(spec, tarinai, world, ctx) : true;
};
spec.fail = function fail(tarinai, world, ctx = {}) {
return originalFail ? originalFail.call(spec, tarinai, world, ctx) : false;
};
spec.text = function text(tarinai, world, ctx = {}) {
if (originalText) return String(originalText.call(spec, tarinai, world, ctx) || "");
if (ctx.phase === "finished") return spec.phrase;
return spec.label;
};
Object.defineProperty(spec, "__actionSpec", {
value: true,
enumerable: false,
configurable: false,
});
return spec;
}
function registerTarinaiActionSpecs(definitions = []) {
const specs = [];
for (const definition of definitions || []) {
const spec = normalizeTarinaiActionSpec(definition);
registry.set(spec.id, spec);
specs.push(spec);
}
return specs;
}
function registerTarinaiActionSpec(definition = {}) {
const spec = normalizeTarinaiActionSpec(definition);
registry.set(spec.id, spec);
return spec;
}
function getTarinaiActionSpec(id = "") {
return registry.get(String(id || "")) || null;
}
function listTarinaiActionSpecs() {
return Array.from(registry.values());
}
function selectTarinaiActionTarget(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return null;
return hasOwnFn(spec, "selectTarget") ? spec.selectTarget(tarinai, world, ctx) : (ctx.target ?? tarinai?.target ?? null);
}
function canStartTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return false;
return hasOwnFn(spec, "canStart") ? Boolean(spec.canStart(tarinai, world, isObject(ctx) ? ctx : {})) : true;
}
function startTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return false;
const canCtx = isObject(ctx) ? ctx : {};
if (hasOwnFn(spec, "canStart") && !spec.canStart(tarinai, world, canCtx)) return false;
const nextCtx = buildActionContext(tarinai, world, canCtx, spec);
return hasOwnFn(spec, "start") ? spec.start(tarinai, world, nextCtx) : spec.run?.(tarinai, world, nextCtx);
}
function tickTarinaiAction(action, tarinai, world, dt = 0, needs = null, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return undefined;
const nextCtx = buildActionContext(tarinai, world, ctx, spec);
return hasOwnFn(spec, "tick") ? spec.tick(tarinai, world, dt, needs, nextCtx) : spec.update?.(tarinai, world, dt, needs, nextCtx);
}
function finishTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
return hasOwnFn(spec, "finish") ? spec.finish(tarinai, world, ctx) : true;
}
function failTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
return hasOwnFn(spec, "fail") ? spec.fail(tarinai, world, ctx) : false;
}
function textTarinaiAction(action, tarinai, world, ctx = {}) {
const spec = resolveActionSpec(action);
if (!spec) return "";
return hasOwnFn(spec, "text") ? String(spec.text(tarinai, world, isObject(ctx) ? ctx : {}) || "") : String(spec.label || spec.phrase || spec.id || "");
}
Object.assign(global, {
normalizeTarinaiActionSpec,
createTarinaiActionSpec: normalizeTarinaiActionSpec,
registerTarinaiActionSpec,
registerTarinaiActionSpecs,
getTarinaiActionSpec,
listTarinaiActionSpecs,
selectTarinaiActionTarget,
canStartTarinaiAction,
startTarinaiAction,
tickTarinaiAction,
finishTarinaiAction,
failTarinaiAction,
textTarinaiAction,
TarinaiActionSpecRegistry: registry,
});
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -24,13 +24,13 @@
},
beginEating(item = null, reason = "") {
const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}を食べている` : "食べている");
const text = reason || (item?.type && typeof toolLabel === "function" ? `${toolLabel(item.type)}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b");
const ok = this.setActionState("eat", {
target: item || null,
reason: text,
sleeping: false,
});
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "食べている", reasonText: text, target: item || null, phase: "perform", source: "behavior" });
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "food", actionId: "eat_food", actionLabel: "\u98df\u3079\u3066\u3044\u308b", reasonText: text, target: item || null, phase: "perform", source: "behavior" });
return ok;
},
@ -48,8 +48,8 @@
},
goIdle(reason = "") {
if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "待っている");
return this.setActionState("idle", { target: null, reason: reason || this.thought || "待っている", sleeping: false });
if (typeof clearActiveBehavior === "function") clearActiveBehavior(this, reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b");
return this.setActionState("idle", { target: null, reason: reason || this.thought || "\u5f85\u3063\u3066\u3044\u308b", sleeping: false });
},
startSleeping(target = null, reason = "") {
@ -64,10 +64,8 @@
targetId: target?.id || null,
};
}
this.intent = this.intent || { need: "sleep", tiedNeeds: ["sleep"], actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: reason || "眠っている", reasonText: "ねむいので、眠っている。", startedAt: now };
this.currentAction = this.currentAction || { id: this.intent.actionId || "sleep", need: "sleep", startedAt: this.sleepSession.startedAt, minDuration: this.sleepSession.minDuration, lockSeconds: this.sleepSession.minDuration };
const text = reason || (target?.type === "nest_box" ? "巣箱の中で眠っている" : "眠っている");
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "眠っている", reasonText: text, target, phase: "perform", source: "behavior" });
const text = reason || (target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b");
if (typeof setLiveActionText === "function") setLiveActionText(this, { need: "sleep", actionId: target ? "sleep_in_bed" : "sleep_anywhere", actionLabel: "\u7720\u3063\u3066\u3044\u308b", reasonText: text, target, phase: "perform", source: "behavior" });
return this.setActionState("sleep", { target, reason: text, sleeping: true });
},
@ -82,7 +80,7 @@
currentBehaviorText() {
if (typeof activeBehaviorText === "function") return activeBehaviorText(this);
return String(this.activeBehavior?.presentText || this.activeBehavior?.reasonText || this.thought || "").trim();
return String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(this) : "") || this.thought || "").trim();
},
enterPanic(opts = {}) {
@ -100,18 +98,23 @@
if (healthShock > 0) this.needShock.health = Math.max(this.needShock.health || 0, healthShock);
}
let handled = false;
if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "パニックになっている");
if (typeof triggerNeedShockReaction === "function") handled = triggerNeedShockReaction(this, threat, opts.reason || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b");
if (!handled) {
let target = null;
if ("target" in opts) target = opts.target;
else if (opts.destination) target = opts.destination;
else if (this.panicDestination) target = this.panicDestination(threat, !!opts.forceDestination);
const panicReason = opts.reason || this.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
this.setActionState("panic", {
target,
reason: opts.reason || this.thought || "パニックになっている",
reason: panicReason,
wake: opts.wake !== false,
sleeping: false,
});
if (typeof setLiveActionText === "function") {
const causeText = this.lastPanicDetail || (String(panicReason).includes("\u3001") ? String(panicReason).split("\u3001")[0].replace(/\u3066$/, "\u305f") : "\u6016\u3044");
setLiveActionText(this, { need: "safety", actionId: "panic_escape", actionLabel: "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", reasonText: panicReason, causeText, target, phase: "perform", source: "behavior" });
}
this.fearTimer = Math.max(this.fearTimer || 0, 2.4);
}
if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer);

View file

@ -0,0 +1,417 @@
"use strict";
(function (global) {
const Tarinai = global.Tarinai;
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_behavior_state.js");
function isObject(value) {
return Boolean(value && typeof value === "object");
}
function finiteOr(value, fallback) {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
function localBehaviorTargetId(target = null) {
if (!target) return null;
if (target.id != null) return target.id;
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `pos:${Math.round(target.x)},${Math.round(target.y)}`;
return null;
}
function nowFor(tarinai = null) {
return finiteOr(tarinai?.world?.time, 0);
}
function hidden(obj, key, value) {
if (!obj) return value;
Object.defineProperty(obj, key, {
value,
writable: true,
configurable: true,
enumerable: false,
});
return value;
}
function actionIdFrom(input = {}, legacyIntent = null, legacyAction = null, previous = null) {
return String(input.id || input.actionId || legacyIntent?.actionId || legacyAction?.id || previous?.id || "");
}
function normalizeForcedRequestValue(value = null, fallback = null) {
const src = isObject(value) ? value : {};
const prev = isObject(fallback) ? fallback : {};
const uid = String(src.uid || src.forcedId || prev.uid || prev.forcedId || "");
const id = String(src.id || src.actionId || prev.id || prev.actionId || "");
if (!uid && !id) return null;
const request = {
uid,
id,
source: String(src.source || prev.source || "external"),
priority: finiteOr(src.priority, finiteOr(prev.priority, 0)),
status: String(src.status || prev.status || "active"),
reasonText: String(src.reasonText || src.forcedReasonText || prev.reasonText || prev.forcedReasonText || ""),
causeText: String(src.causeText || prev.causeText || ""),
targetId: src.targetId !== undefined ? src.targetId : (prev.targetId !== undefined ? prev.targetId : null),
createdAt: finiteOr(src.createdAt, finiteOr(prev.createdAt, 0)),
startedAt: finiteOr(src.startedAt, finiteOr(src.activeSince, finiteOr(prev.startedAt, finiteOr(prev.activeSince, 0)))),
activeSince: finiteOr(src.activeSince, finiteOr(src.startedAt, finiteOr(prev.activeSince, finiteOr(prev.startedAt, 0)))),
expiresAt: Number.isFinite(Number(src.expiresAt)) ? Number(src.expiresAt) : (Number.isFinite(Number(prev.expiresAt)) ? Number(prev.expiresAt) : undefined),
minDuration: finiteOr(src.minDuration, finiteOr(prev.minDuration, 0)),
duration: finiteOr(src.duration, finiteOr(prev.duration, 0)),
attempt: finiteOr(src.attempt ?? src.attempts, finiteOr(prev.attempt ?? prev.attempts, 0)),
failReason: String(src.failReason || prev.failReason || ""),
};
return request;
}
function normalizeBehaviorValue(input, tarinai = null, previous = null) {
if (!isObject(input)) return null;
const legacyIntent = isObject(input.intent) ? input.intent : null;
const legacyAction = isObject(input.currentAction) ? input.currentAction : null;
const now = nowFor(tarinai);
const id = actionIdFrom(input, legacyIntent, legacyAction, previous) || String(tarinai?.state || "idle");
const need = String(input.need || legacyIntent?.need || legacyAction?.need || previous?.need || "fulfill");
const label = String(input.label || input.actionLabel || legacyIntent?.actionLabel || previous?.label || id || "行動している");
const reasonText = String(input.reasonText || input.presentText || legacyIntent?.reasonText || previous?.reasonText || label || "");
const target = input.target !== undefined ? input.target : tarinai?.target;
const startedAt = finiteOr(input.startedAt ?? legacyIntent?.startedAt ?? legacyAction?.startedAt, finiteOr(previous?.startedAt, now));
const minDuration = finiteOr(input.minDuration ?? legacyAction?.minDuration, finiteOr(previous?.minDuration, 0.45));
const lockSeconds = finiteOr(input.lockSeconds ?? legacyAction?.lockSeconds, finiteOr(previous?.lockSeconds, 1.0));
const tiedNeeds = Array.isArray(input.tiedNeeds) && input.tiedNeeds.length
? [...input.tiedNeeds]
: (Array.isArray(legacyIntent?.tiedNeeds) && legacyIntent.tiedNeeds.length
? [...legacyIntent.tiedNeeds]
: (Array.isArray(previous?.tiedNeeds) && previous.tiedNeeds.length ? [...previous.tiedNeeds] : [need]));
const behavior = {
id,
need,
subNeed: String(input.subNeed || legacyAction?.subNeed || previous?.subNeed || ""),
phase: String(input.phase || legacyAction?.phase || previous?.phase || (String(tarinai?.state || "") === "idle" ? "idle" : "active")),
state: String(input.state || tarinai?.state || previous?.state || "idle"),
label,
presentText: String(input.presentText || reasonText || ""),
reasonText,
targetId: input.targetId !== undefined ? input.targetId : localBehaviorTargetId(target),
tiedNeeds,
startedAt,
updatedAt: finiteOr(input.updatedAt, now),
minDuration,
lockSeconds,
deadlineAt: Number.isFinite(Number(input.deadlineAt)) ? Number(input.deadlineAt) : previous?.deadlineAt,
source: String(input.source || previous?.source || "need"),
sourceReasonText: String(input.sourceReasonText || input.forcedReasonText || previous?.sourceReasonText || previous?.forcedReasonText || ""),
forcedReasonText: String(input.forcedReasonText || input.sourceReasonText || previous?.forcedReasonText || previous?.sourceReasonText || ""),
causeText: String(input.causeText || legacyIntent?.causeText || previous?.causeText || ""),
forced: Boolean(input.forced ?? previous?.forced ?? false),
forcedId: String(input.forcedId || previous?.forcedId || ""),
priority: finiteOr(input.priority, finiteOr(previous?.priority, 0)),
specId: String(input.specId || input.actionSpecId || previous?.specId || id),
specKind: String(input.specKind || previous?.specKind || "need_action"),
};
const forcedRequest = normalizeForcedRequestValue(
input.forcedRequest || input.request || null,
behavior.forced || behavior.forcedId || previous?.forcedRequest
? { ...(previous?.forcedRequest || {}), uid: behavior.forcedId || previous?.forcedRequest?.uid || "", id: behavior.id, source: behavior.source, priority: behavior.priority, reasonText: behavior.forcedReasonText || behavior.sourceReasonText, causeText: behavior.causeText, targetId: behavior.targetId }
: null
);
behavior.forcedRequest = forcedRequest;
if (forcedRequest) {
behavior.forced = true;
behavior.forcedId = forcedRequest.uid || behavior.forcedId;
behavior.source = forcedRequest.source || behavior.source || "external";
behavior.priority = finiteOr(forcedRequest.priority, behavior.priority);
behavior.forcedReasonText = behavior.forcedReasonText || forcedRequest.reasonText || "";
behavior.sourceReasonText = behavior.sourceReasonText || forcedRequest.reasonText || behavior.forcedReasonText || "";
behavior.causeText = behavior.causeText || forcedRequest.causeText || "";
}
if (Number.isFinite(Number(input.serial))) behavior.serial = Number(input.serial);
return syncLegacyViews(behavior);
}
function behaviorFromLegacyIntent(intent, currentAction = null, tarinai = null, previous = null) {
if (!isObject(intent) && !isObject(currentAction)) return null;
const now = nowFor(tarinai);
return normalizeBehaviorValue({
id: currentAction?.id || intent?.actionId || tarinai?.state || "idle",
need: intent?.need || currentAction?.need || previous?.need || "fulfill",
subNeed: currentAction?.subNeed || previous?.subNeed || "",
phase: currentAction?.phase || previous?.phase || "active",
state: tarinai?.state || previous?.state || "idle",
label: intent?.actionLabel || previous?.label || "行動している",
presentText: intent?.reasonText || previous?.presentText || "",
reasonText: intent?.reasonText || previous?.reasonText || intent?.actionLabel || "",
causeText: intent?.causeText || previous?.causeText || "",
tiedNeeds: Array.isArray(intent?.tiedNeeds) && intent.tiedNeeds.length ? [...intent.tiedNeeds] : undefined,
startedAt: intent?.startedAt ?? currentAction?.startedAt ?? now,
minDuration: currentAction?.minDuration,
lockSeconds: currentAction?.lockSeconds,
priority: previous?.priority,
source: previous?.source || "legacy",
forced: previous?.forced,
forcedId: previous?.forcedId,
sourceReasonText: previous?.sourceReasonText,
forcedReasonText: previous?.forcedReasonText,
}, tarinai, previous);
}
function syncLegacyViews(behavior) {
if (!isObject(behavior)) return null;
const intent = {
need: behavior.need || "fulfill",
tiedNeeds: Array.isArray(behavior.tiedNeeds) && behavior.tiedNeeds.length ? [...behavior.tiedNeeds] : [behavior.need || "fulfill"],
actionId: behavior.id || "idle",
actionLabel: behavior.label || behavior.presentText || behavior.reasonText || "行動している",
reasonText: behavior.reasonText || behavior.presentText || behavior.label || "",
causeText: behavior.causeText || "",
startedAt: finiteOr(behavior.startedAt, 0),
};
const currentAction = {
id: behavior.id || "idle",
need: behavior.need || intent.need,
subNeed: behavior.subNeed || "",
startedAt: finiteOr(behavior.startedAt, 0),
minDuration: finiteOr(behavior.minDuration, 0.45),
lockSeconds: finiteOr(behavior.lockSeconds, 1.0),
phase: behavior.phase || "active",
specId: behavior.specId || behavior.id || "idle",
specKind: behavior.specKind || "need_action",
};
const forcedRequest = normalizeForcedRequestValue(behavior.forcedRequest || null,
behavior.forced || behavior.forcedId ? { uid: behavior.forcedId || "", id: behavior.id || "", source: behavior.source || "external", priority: finiteOr(behavior.priority, 0), reasonText: behavior.forcedReasonText || behavior.sourceReasonText || "", causeText: behavior.causeText || "", targetId: behavior.targetId } : null);
behavior.forcedRequest = forcedRequest;
const activeForcedBehavior = forcedRequest
? { uid: forcedRequest.uid || behavior.forcedId || "", id: forcedRequest.id || behavior.id || "", source: forcedRequest.source || behavior.source || "external", priority: finiteOr(forcedRequest.priority, finiteOr(behavior.priority, 0)) }
: null;
hidden(behavior, "_intent", intent);
hidden(behavior, "_currentAction", currentAction);
hidden(behavior, "_activeForcedBehavior", activeForcedBehavior);
return behavior;
}
function normalizeBehaviorState(opts = {}, tarinai = null) {
const previous = isObject(tarinai?.behavior) ? tarinai.behavior : null;
let behavior = normalizeBehaviorValue(opts.behavior || opts.activeBehavior || null, tarinai, previous);
if (!behavior) behavior = behaviorFromLegacyIntent(opts.intent || null, opts.currentAction || null, tarinai, previous);
if (!behavior) return null;
if (isObject(opts.intent)) mergeIntent(tarinai, opts.intent, behavior);
if (isObject(opts.currentAction)) mergeCurrentAction(tarinai, opts.currentAction, behavior);
if (isObject(opts.activeForcedBehavior)) mergeActiveForcedBehavior(tarinai, opts.activeForcedBehavior, behavior);
return syncLegacyViews(behavior);
}
function ensureBehavior(tarinai) {
if (!tarinai) return null;
if (isObject(tarinai.behavior)) return syncLegacyViews(tarinai.behavior);
return null;
}
function assignBehavior(tarinai, value) {
if (!tarinai) return null;
const next = normalizeBehaviorValue(value, tarinai, tarinai.behavior || null);
tarinai.behavior = next;
return next;
}
function getTarinaiBehavior(tarinai) {
return ensureBehavior(tarinai);
}
function getTarinaiBehaviorId(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.id || fallback || "");
}
function getTarinaiBehaviorNeed(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.need || fallback || "");
}
function getTarinaiBehaviorTiedNeeds(tarinai, fallbackNeed = "fulfill") {
const b = ensureBehavior(tarinai);
if (Array.isArray(b?.tiedNeeds) && b.tiedNeeds.length) return [...b.tiedNeeds];
return [String(b?.need || fallbackNeed || "fulfill")];
}
function getTarinaiBehaviorText(tarinai, fallback = "") {
const b = ensureBehavior(tarinai);
return String(b?.presentText || b?.reasonText || b?.label || fallback || "").trim();
}
function patchTarinaiBehavior(tarinai, fields = {}) {
if (!tarinai || !isObject(fields)) return ensureBehavior(tarinai);
const previous = ensureBehavior(tarinai) || {};
const next = normalizeBehaviorValue({ ...previous, ...fields }, tarinai, previous);
tarinai.behavior = next;
return next;
}
function clearTarinaiBehavior(tarinai, opts = {}) {
if (!tarinai) return null;
tarinai.behavior = null;
if (opts.clearTarget) tarinai.target = null;
if (opts.reason) tarinai.thought = String(opts.reason || "");
return null;
}
function setTarinaiForcedBehavior(tarinai, value = null) {
return mergeActiveForcedBehavior(tarinai, value || null);
}
function mergeIntent(tarinai, value, behavior = null) {
if (!tarinai || !isObject(value)) return null;
const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({
id: value.actionId || tarinai.state || "idle",
need: value.need || "fulfill",
label: value.actionLabel || value.reasonText || "行動している",
reasonText: value.reasonText || value.actionLabel || "",
tiedNeeds: value.tiedNeeds,
causeText: value.causeText || "",
startedAt: value.startedAt,
source: "intent",
}, tarinai, null);
if (!b) return null;
if (value.actionId != null) b.id = String(value.actionId || b.id || "idle");
if (value.need != null) b.need = String(value.need || b.need || "fulfill");
if (value.actionLabel != null) b.label = String(value.actionLabel || b.label || "");
if (value.reasonText != null) {
b.reasonText = String(value.reasonText || "");
b.presentText = b.presentText || b.reasonText;
}
if (value.causeText != null) b.causeText = String(value.causeText || "");
if (Array.isArray(value.tiedNeeds) && value.tiedNeeds.length) b.tiedNeeds = [...value.tiedNeeds];
if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt);
if (value.specId != null) b.specId = String(value.specId || b.id || "idle");
if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action");
tarinai.behavior = syncLegacyViews(b);
return tarinai.behavior._intent;
}
function mergeCurrentAction(tarinai, value, behavior = null) {
if (!tarinai || !isObject(value)) return null;
const b = behavior || ensureBehavior(tarinai) || normalizeBehaviorValue({
id: value.id || tarinai.state || "idle",
need: value.need || "fulfill",
subNeed: value.subNeed || "",
startedAt: value.startedAt,
minDuration: value.minDuration,
lockSeconds: value.lockSeconds,
phase: value.phase,
source: "currentAction",
}, tarinai, null);
if (!b) return null;
if (value.id != null) b.id = String(value.id || b.id || "idle");
if (value.need != null) b.need = String(value.need || b.need || "fulfill");
if (value.subNeed != null) b.subNeed = String(value.subNeed || "");
if (value.phase != null) b.phase = String(value.phase || b.phase || "active");
if (Number.isFinite(Number(value.startedAt))) b.startedAt = Number(value.startedAt);
if (Number.isFinite(Number(value.minDuration))) b.minDuration = Number(value.minDuration);
if (Number.isFinite(Number(value.lockSeconds))) b.lockSeconds = Number(value.lockSeconds);
if (value.specId != null) b.specId = String(value.specId || b.id || "idle");
if (value.specKind != null) b.specKind = String(value.specKind || b.specKind || "need_action");
tarinai.behavior = syncLegacyViews(b);
return tarinai.behavior._currentAction;
}
function mergeActiveForcedBehavior(tarinai, value, behavior = null) {
if (!tarinai) return null;
const b = behavior || ensureBehavior(tarinai);
if (!b) return null;
if (!value) {
b.forced = false;
b.forcedId = "";
b.forcedRequest = null;
b.sourceReasonText = b.sourceReasonText || "";
b.forcedReasonText = b.forcedReasonText || "";
tarinai.behavior = syncLegacyViews(b);
return null;
}
const request = normalizeForcedRequestValue(value, { uid: b.forcedId || "", id: b.id || "idle", source: b.source || "external", priority: b.priority || 0, reasonText: b.forcedReasonText || b.sourceReasonText || "", causeText: b.causeText || "", targetId: b.targetId });
b.forced = true;
b.forcedRequest = request;
b.forcedId = String(request?.uid || value.uid || value.forcedId || b.forcedId || "");
if (request?.id || value.id != null) b.id = String(request?.id || value.id || b.id || "idle");
b.source = String(request?.source || value.source || b.source || "external");
b.priority = finiteOr(request?.priority, finiteOr(value.priority, finiteOr(b.priority, 0)));
b.forcedReasonText = b.forcedReasonText || request?.reasonText || "";
b.sourceReasonText = b.sourceReasonText || request?.reasonText || b.forcedReasonText || "";
b.causeText = b.causeText || request?.causeText || "";
tarinai.behavior = syncLegacyViews(b);
return tarinai.behavior._activeForcedBehavior;
}
function getTarinaiForcedBehavior(tarinai) {
const b = ensureBehavior(tarinai);
if (!b || !b.forced) return null;
return b.forcedRequest || b._activeForcedBehavior || null;
}
function isTarinaiBehaviorForced(tarinai) {
const b = ensureBehavior(tarinai);
return Boolean(b?.forced || b?.forcedId || b?.forcedRequest);
}
Object.defineProperties(Tarinai.prototype, {
activeBehavior: {
configurable: true,
get() {
return ensureBehavior(this);
},
set(value) {
if (!value) { clearTarinaiBehavior(this); return; }
assignBehavior(this, value);
},
},
intent: {
configurable: true,
get() {
const b = ensureBehavior(this);
return b ? b._intent : null;
},
set(value) {
if (!value) { clearTarinaiBehavior(this); return; }
mergeIntent(this, value);
},
},
currentAction: {
configurable: true,
get() {
const b = ensureBehavior(this);
return b ? b._currentAction : null;
},
set(value) {
if (!value) { clearTarinaiBehavior(this); return; }
mergeCurrentAction(this, value);
},
},
activeForcedBehavior: {
configurable: true,
get() {
const b = ensureBehavior(this);
return b ? b._activeForcedBehavior : null;
},
set(value) {
mergeActiveForcedBehavior(this, value || null);
},
},
});
global.normalizeTarinaiBehaviorState = normalizeBehaviorState;
global.normalizeTarinaiBehaviorValue = normalizeBehaviorValue;
global.syncTarinaiBehaviorLegacyViews = syncLegacyViews;
global.getTarinaiBehavior = getTarinaiBehavior;
global.currentTarinaiBehavior = getTarinaiBehavior;
global.getTarinaiBehaviorId = getTarinaiBehaviorId;
global.getTarinaiBehaviorNeed = getTarinaiBehaviorNeed;
global.getTarinaiBehaviorTiedNeeds = getTarinaiBehaviorTiedNeeds;
global.getTarinaiBehaviorText = getTarinaiBehaviorText;
global.patchTarinaiBehavior = patchTarinaiBehavior;
global.setTarinaiBehavior = assignBehavior;
global.clearTarinaiBehavior = clearTarinaiBehavior;
global.normalizeTarinaiForcedRequest = normalizeForcedRequestValue;
global.getTarinaiForcedBehavior = getTarinaiForcedBehavior;
global.isTarinaiBehaviorForced = isTarinaiBehaviorForced;
global.setTarinaiForcedBehavior = setTarinaiForcedBehavior;
global.clearTarinaiForcedBehavior = function clearTarinaiForcedBehavior(tarinai) { return setTarinaiForcedBehavior(tarinai, null); };
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -85,7 +85,7 @@
this.sleepDiseaseCooldown = 8;
this.sleepDiseaseAnchorX = this.x;
this.sleepDiseaseAnchorY = this.y;
this.startSleeping(null, "ねむり病で眠り続けている");
this.startSleeping(null, "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b");
this.fightTimer = 0;
this.intimidateTimer = 0;
this.fightTargetIds = [];
@ -104,7 +104,7 @@
this.sleepDiseaseCooldown = 14;
this.sleepDiseaseAnchorX = null;
this.sleepDiseaseAnchorY = null;
this.goIdle(reason || "ねむり病が治った");
this.goIdle(reason || "\u306d\u3080\u308a\u75c5\u304c\u6cbb\u3063\u305f");
this.sleeping = false;
this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, 10);
audio.heal?.();
@ -181,7 +181,7 @@
const beingDraggedByAnt = Boolean((this.antDraggedTimer || 0) > 0 && this.antDraggedBy);
if (!Number.isFinite(this.sleepDiseaseAnchorX)) this.sleepDiseaseAnchorX = this.x;
if (!Number.isFinite(this.sleepDiseaseAnchorY)) this.sleepDiseaseAnchorY = this.y;
this.startSleeping(null, beingDraggedByAnt ? "眠ったままアリに運ばれている" : "ねむり病で眠り続けている");
this.startSleeping(null, beingDraggedByAnt ? "\u7720\u3063\u305f\u307e\u307e\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b" : "\u306d\u3080\u308a\u75c5\u3067\u7720\u308a\u7d9a\u3051\u3066\u3044\u308b");
this.vx = 0;
this.vy = 0;
if (beingDraggedByAnt) {
@ -211,8 +211,8 @@
wantsNestBox(box = null) {
if (!box || box.dead || box.type !== "nest_box") return false;
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) return true;
if (!(this.state === "sleep" || this.state === "seek_bed")) return false;
if (this.insideNestBoxId === box.id && (this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return true;
if (!(this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) return false;
return this.target === box || this.insideNestBoxId === box.id;
},
@ -228,7 +228,7 @@
}
this.insideNestBoxId = box.id;
this.target = box;
if (this.state === "seek_bed") this.startSleeping(box, "巣箱の中で眠っている");
if (this.state === "seek_bed") this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
if (this.state === "sleep") this.sleeping = true;
const inner = this.world?.nestBoxInnerPoint ? this.world.nestBoxInnerPoint(box, this) : { x: box.x, y: box.y };
const pull = clamp(dt * (alreadyInside ? 10.0 : 13.5), 0, 1);
@ -278,8 +278,8 @@
}
if (currentBox) {
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity)) {
this.startSleeping(box, "巣箱の中で眠っている");
if ((this.world?.time || 0) < (this.nestBoxStayUntil || -Infinity) && (this.state === "sleep" || this.state === "seek_bed" || this.sleeping)) {
this.startSleeping(box, "\u5de3\u7bb1\u306e\u4e2d\u3067\u7720\u3063\u3066\u3044\u308b");
this.sleeping = true;
this.target = currentBox;
}

View file

@ -371,7 +371,7 @@
this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 8;
this.target = null;
this.goIdle?.("柵に阻まれて行き先を変えている");
this.goIdle?.("\u67f5\u306b\u963b\u307e\u308c\u3066\u884c\u304d\u5148\u3092\u5909\u3048\u3066\u3044\u308b");
this.wanderAngle += rand(-1.4, 1.4);
return;
}
@ -379,7 +379,7 @@
this.targetGiveupKey = key;
this.targetGiveupUntil = this.world.time + 12;
this.target = null;
this.goIdle?.("辿り着けず、あきらめた");
this.goIdle?.("\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f");
this.wanderAngle += rand(-2.2, 2.2);
}
},

View file

@ -218,14 +218,22 @@
applyFoodHungerRelief(amount = 0) {
const raw = Math.max(0, Number(amount) || 0);
const actual = this.hasItemEffect("laxative") ? raw * (EFFECT_REGISTRY?.modifier?.("laxative", "hungerReliefMultiplier", 0) ?? 0) : raw;
if (actual > 0.2) this.lastAteAt = this.world?.time || 0;
if (actual >= 6) {
const now = this.world?.time || 0;
if (actual > 0.2) this.lastAteAt = now;
if (actual >= 3.5) {
const day = Number(this.world?.config?.dayLength || CONFIG?.dayLength || 120) || 120;
const base = day / 4;
const base = day / 3.65;
const jitter = typeof stableUnit === "function" ? (0.82 + stableUnit(this.familyKey || this.id, "meal-cooldown") * 0.36) : 1;
this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, (this.world?.time || 0) + base * jitter);
this.mealCooldownUntil = Math.max(this.mealCooldownUntil || 0, now + base * jitter);
this.needSatisfaction = this.needSatisfaction || (typeof createDefaultNeeds === "function" ? createDefaultNeeds() : {});
this.needSatisfaction.food = Math.max(this.needSatisfaction.food || 0, Math.min(88, actual * 1.85));
this.actionCooldowns = this.actionCooldowns || {};
this.actionCooldowns.food = Math.max(this.actionCooldowns.food || 0, 5.2);
}
this.hunger -= actual;
if (actual >= 3.5 && this.hunger < 68 && (typeof getTarinaiBehaviorNeed === "function" ? getTarinaiBehaviorNeed(this) : this.behavior?.need) === "food" && !((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(this) : this.behavior)?.forced)) {
this.intentLockTimer = Math.min(this.intentLockTimer || 0, 0.25);
}
return actual;
},
@ -267,7 +275,7 @@
if (!this.world || this.dead) return;
if (this.hasLodgedPinEffect?.("blocksZunchi")) {
this.oshiriByoZunchiStock = Math.min(24, Math.max(0, this.oshiriByoZunchiStock || 0) + 1);
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "おしり鋲でずんちが溜まってつらい" : "おしり鋲でずんちを我慢している";
this.thought = (this.oshiriByoZunchiStock || 0) >= 6 ? "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u304c\u6e9c\u307e\u3063\u3066\u3064\u3089\u3044" : "\u304a\u3057\u308a\u92f2\u3067\u305a\u3093\u3061\u3092\u6211\u6162\u3057\u3066\u3044\u308b";
return;
}
const side = this.facingDir ? this.facingDir() : (this.facing || 1);

File diff suppressed because it is too large Load diff

View file

@ -300,16 +300,6 @@
drawDawnRimLight(ctx, dw, dh, lightState);
if (faceRight) ctx.scale(-1, 1);
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) {
ctx.save();
ctx.globalAlpha = 0.80;
ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.fillStyle = "rgba(42,36,29,0.72)";
const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?");
ctx.fillText(mark, 0, -drawH * 0.72);
ctx.restore();
}
ctx.restore();

View file

@ -22,8 +22,8 @@
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * 36, safety: dt * 5 }, o);
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * 30, safety: dt * 5 }, this);
if (typeof queueForcedTarinaiBehavior === "function") {
queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${o.name || "相手"}と喧嘩しようとしている` });
queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 98, ttl: 8, reasonText: `${this.name || "相手"}と喧嘩しようとしている` });
queueForcedTarinaiBehavior(this, "fight_rival", { target: o, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
queueForcedTarinaiBehavior(o, "fight_rival", { target: this, source: "fight_mochi", priority: 180, ttl: 8, causeText: "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c" });
}
}
@ -136,14 +136,14 @@
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 (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) {
if (this.world.canFightPair?.(this, o) && d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * (battleDrug ? 0.23 : 0.085) * personalityRisk * fearBrake * friendBrake * conflictBias) {
this.conflictTargetId = o.id;
o.conflictTargetId = this.id;
const urge = 48 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 12 : 0);
const urge = 34 + (battleDrug ? 34 : 0) + (mixedZunchiSlave ? 10 : 0);
this.conflictUrge = Math.max(this.conflictUrge || 0, urge);
o.conflictUrge = Math.max(o.conflictUrge || 0, urge * 0.82);
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * 0.32, safety: dt * (battleDrug ? 4 : 1.6) }, o);
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * 0.24, safety: dt * (battleDrug ? 4 : 1.6) }, this);
if (typeof applyNeedShock === "function") applyNeedShock(this, { social: dt * urge * (battleDrug ? 0.32 : 0.18), safety: dt * (battleDrug ? 4 : 0.9) }, o);
if (typeof applyNeedShock === "function") applyNeedShock(o, { social: dt * urge * (battleDrug ? 0.24 : 0.14), safety: dt * (battleDrug ? 4 : 0.9) }, this);
}
const compatibleBirthStatus = this.isZunchiSlave === o.isZunchiSlave;
@ -156,10 +156,10 @@
applyNeedShock(o, { social: dt * (loveDrug ? 34 : 14) }, this);
}
if (loveDrug && typeof queueForcedTarinaiBehavior === "function") {
queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${o.name || "相手"}と繁殖したくて近づいている` });
queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 92, ttl: 10, reasonText: `${this.name || "相手"}と繁殖したくて近づいている` });
queueForcedTarinaiBehavior(this, "approach_mate", { target: o, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
queueForcedTarinaiBehavior(o, "approach_mate", { target: this, source: "love_mochi", priority: 150, ttl: 10, causeText: "\u3078\u3053\u9905\u306e\u52b9\u679c" });
}
const mateIntent = this.activeBehavior?.id === "approach_mate" || o.activeBehavior?.id === "approach_mate" || this.intent?.actionId === "approach_mate" || o.intent?.actionId === "approach_mate";
const mateIntent = (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(this) : this.behavior?.id) === "approach_mate" || (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(o) : o.behavior?.id) === "approach_mate";
if (mateIntent && Math.random() < dt * (loveDrug ? 0.78 : 0.30)) {
this.world.startBirthRitual(this, o);
}
@ -197,7 +197,7 @@
const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0;
const crowdedSleep = bedCrowd > 5;
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100);
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) }); else if (typeof applyNeedRelief === "function") applyNeedRelief(this, { sleep: -dt * 3 * bedComfort, safety: -dt * 1.5 * bedComfort });
if (crowdedSleep && typeof applyNeedShock === "function") applyNeedShock(this, { safety: dt * Math.max(1, bedCrowd - 5) });
this.hunger = clamp(this.hunger + dt * 0.018, 0, 115);
if (this.target && !this.target.dead && this.target.type === "nest_box") {
this.updateNestBoxPresence(dt);
@ -276,9 +276,9 @@
const d = Math.hypot(dx, dy) || 1;
if (this.state === "wander" && this.target?.detour && d < Math.max(22, (this.radius || 20) * 0.9)) {
if (typeof applyNeedSatisfaction === "function") applyNeedSatisfaction(this, { fulfill: 6 }, "wander");
this.currentAction = null;
if (typeof clearTarinaiBehavior === "function") clearTarinaiBehavior(this, { reason: this.thought }); else this.behavior = null;
this.intentLockTimer = 0;
this.goIdle?.("少し歩いた");
this.goIdle?.("\u5c11\u3057\u6b69\u3044\u305f");
return;
}
if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) {
@ -406,11 +406,11 @@
this.dead = true;
this.deathReason = finalReason;
this.world.deadCount += 1;
this.world.items.push(new Item("trace", this.x, this.y));
this.world.items.push(new Item("splat", this.x, this.y));
this.world.addItem?.(new Item("trace", this.x, this.y), "tarinai-death-trace") || this.world.items.push(new Item("trace", this.x, this.y));
this.world.addItem?.(new Item("splat", this.x, this.y), "tarinai-death-splat") || this.world.items.push(new Item("splat", this.x, this.y));
if (Math.random() < 0.55) {
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
if (spot) this.world.items.push(new Item("grass", spot.x, spot.y));
if (spot) this.world.addItem?.(new Item("grass", spot.x, spot.y), "tarinai-death-grass") || this.world.items.push(new Item("grass", spot.x, spot.y));
}
this.world.markDead(this, finalReason);
this.releasedLiveToken = this.liveToken || 0;

View file

@ -53,34 +53,34 @@ const TEXT_CATALOG = {
TEXT_CATALOG.stateLabels = Object.freeze({
idle: "待機",
seek_food: "食べ物へ向かう",
seek_bed: "寝床へ向かう",
play_ball: "ボール遊び",
seek_water: "水を探す",
zunchi_sick: "ずんち病",
fight_sick: "きずつき病",
system: "ずんち病",
seek_friend: "仲間を探す",
follow_parent: "親についていく",
cursor_friend: "カーソルについていく",
cursor_enemy: "カーソルを避ける",
fight: "喧嘩中",
sleep: "睡眠中",
eat: "食事中",
panic: "パニック",
intimidate: "威嚇中",
birth_ritual: "誕生前",
sunbath: "日光浴中",
ant_attack: "アリ攻撃中",
ant_intimidate: "アリ威嚇中",
frozen: "冷凍中",
idle: "\u5f85\u6a5f",
seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
seek_bed: "\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
play_ball: "\u30dc\u30fc\u30eb\u904a\u3073",
seek_water: "\u6c34\u3092\u63a2\u3059",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
system: "\u305a\u3093\u3061\u75c5",
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_enemy: "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u308b",
fight: "\u55a7\u5629\u4e2d",
sleep: "\u7761\u7720\u4e2d",
eat: "\u98df\u4e8b\u4e2d",
panic: "\u30d1\u30cb\u30c3\u30af",
intimidate: "\u5a01\u5687\u4e2d",
birth_ritual: "\u8a95\u751f\u524d",
sunbath: "\u65e5\u5149\u6d74\u4e2d",
ant_attack: "\u30a2\u30ea\u653b\u6483\u4e2d",
ant_intimidate: "\u30a2\u30ea\u5a01\u5687\u4e2d",
frozen: "\u51b7\u51cd\u4e2d",
});
TEXT_CATALOG.targetLabelOverrides = Object.freeze({
system: "ずんち病",
zunchi_sick: "ずんち病",
fight_sick: "きずつき病",
system: "\u305a\u3093\u3061\u75c5",
zunchi_sick: "\u305a\u3093\u3061\u75c5",
fight_sick: "\u304d\u305a\u3064\u304d\u75c5",
});
// Backward-compatible alias. Tool/item labels come from TOOL_DEFINITIONS via toolLabel().
TEXT_CATALOG.targetLabels = TEXT_CATALOG.targetLabelOverrides;
@ -90,53 +90,53 @@ TEXT_CATALOG.stateLabel = function stateLabelFromCatalog(s) {
};
TEXT_CATALOG.targetLabel = function targetLabelFromCatalog(target) {
if (!target) return "なし";
if (!target) return "\u306a\u3057";
if (target.name) return target.name;
if (target.kind === "queen") return "女王アリ";
if (target.kind === "worker") return "働きアリ";
if (target.kind === "queen") return "\u5973\u738b\u30a2\u30ea";
if (target.kind === "worker") return "\u50cd\u304d\u30a2\u30ea";
if (target.type) {
const type = String(target.type || "");
if (TEXT_CATALOG.targetLabelOverrides?.[type]) return TEXT_CATALOG.targetLabelOverrides[type];
const label = typeof toolLabel === "function" ? toolLabel(type) : "";
return label || type;
}
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "位置";
return "不明";
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u4f4d\u7f6e";
return "\u4e0d\u660e";
};
TEXT_CATALOG.reasonLabel = function reasonLabelFromCatalog(t) {
if (!t) return "なし";
if ((t.pinchThoughtTimer || 0) > 0) return "つままれている";
const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim();
if (!t) return "\u306a\u3057";
if ((t.pinchThoughtTimer || 0) > 0) return "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
const behaviorText = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).trim();
if (behaviorText) return behaviorText;
const target = TEXT_CATALOG.targetLabel(t.target);
if (t.state === "eat") return target !== "なし" ? `${target}を食べている` : "食べている";
if (t.state === "sleep") return t.target?.type === "nest_box" ? "巣箱の中で体力を回復している" : "体力を回復している";
if (t.state === "panic") return t.target ? `${target}から逃げている` : "怖がっている";
if (t.state === "intimidate") return target !== "なし" ? `${target}を威嚇している` : "相手を威嚇している";
if (t.state === "ant_intimidate") return "アリを威嚇している";
if (t.state === "ant_attack") return "アリを攻撃している";
if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}と繁殖の前ぶれをしている` : "繁殖の前ぶれをしている";
if (t.state === "sunbath") return "晴れた日なたでストレスを下げている";
if (t.state === "frozen") return "フィールド外で冷凍保存されている";
if (t.state === "seek_food") return target !== "なし" ? `${target}を探している` : "お腹がすいている";
if (t.state === "seek_water") return t.explosionDisease ? "爆発病で水を探している" : "ずんち病で水を探している";
if (t.state === "seek_bed") return target !== "なし" ? `${target}へ向かっている` : "眠る場所を探している";
if (t.state === "play_ball") return "ボールが気になっている";
if (t.state === "seek_friend" && t.intent?.actionId === "approach_mate") return t.target?.name ? `${t.target.name}に繁殖のため近づいている` : "繁殖できる相手を探している";
if (t.state === "seek_friend") return "仲間を探している";
if (t.state === "follow_parent") return "親についていく";
if (t.state === "cursor_friend") return "カーソルについていく";
if (t.state === "cursor_enemy") return "カーソルを避けている";
if (t.state === "fight") return target !== "なし" ? `${target}と喧嘩している` : "喧嘩している";
if (t.state === "fight_sick") return "きずつき病で当てもなく彷徨っている";
if (t.state === "zunchi_sick" || t.state === "system") return "ずんち病で調子が悪い";
if ((t.hurtTimer || 0) > 0.08) return "痛みで混乱している";
if ((t.fearTimer || 0) > 0.08) return "怖がっている";
if ((t.grassEatTimer || 0) > 0.04) return "草を食べている";
if (t.state === "eat") return target !== "\u306a\u3057" ? `${target}\u3092\u98df\u3079\u3066\u3044\u308b` : "\u98df\u3079\u3066\u3044\u308b";
if (t.state === "sleep") return t.target?.type === "nest_box" ? "\u5de3\u7bb1\u306e\u4e2d\u3067\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b" : "\u4f53\u529b\u3092\u56de\u5fa9\u3057\u3066\u3044\u308b";
if (t.state === "panic") return t.target ? `${target}\u304b\u3089\u9003\u3052\u3066\u3044\u308b` : "\u6016\u304c\u3063\u3066\u3044\u308b";
if (t.state === "intimidate") return target !== "\u306a\u3057" ? `${target}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b` : "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "ant_intimidate") return "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
if (t.state === "ant_attack") return "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
if (t.state === "birth_ritual") return t.target?.name ? `${t.target.name}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` : "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b";
if (t.state === "sunbath") return "\u6674\u308c\u305f\u65e5\u306a\u305f\u3067\u30b9\u30c8\u30ec\u30b9\u3092\u4e0b\u3052\u3066\u3044\u308b";
if (t.state === "frozen") return "\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u3067\u51b7\u51cd\u4fdd\u5b58\u3055\u308c\u3066\u3044\u308b";
if (t.state === "seek_food") return target !== "\u306a\u3057" ? `${target}\u3092\u63a2\u3057\u3066\u3044\u308b` : "\u304a\u8179\u304c\u3059\u3044\u3066\u3044\u308b";
if (t.state === "seek_water") return t.explosionDisease ? "\u7206\u767a\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b" : "\u305a\u3093\u3061\u75c5\u3067\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "seek_bed") return target !== "\u306a\u3057" ? `${target}\u3078\u5411\u304b\u3063\u3066\u3044\u308b` : "\u7720\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "play_ball") return "\u30dc\u30fc\u30eb\u304c\u6c17\u306b\u306a\u3063\u3066\u3044\u308b";
if (t.state === "seek_friend" && (typeof getTarinaiBehaviorId === "function" ? getTarinaiBehaviorId(t) : t.behavior?.id) === "approach_mate") return t.target?.name ? `${t.target.name}\u306b\u7e41\u6b96\u306e\u305f\u3081\u8fd1\u3065\u3044\u3066\u3044\u308b` : "\u7e41\u6b96\u3067\u304d\u308b\u76f8\u624b\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "seek_friend") return "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b";
if (t.state === "follow_parent") return "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f";
if (t.state === "cursor_friend") return "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f";
if (t.state === "cursor_enemy") return "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b";
if (t.state === "fight") return target !== "\u306a\u3057" ? `${target}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` : "\u55a7\u5629\u3057\u3066\u3044\u308b";
if (t.state === "fight_sick") return "\u304d\u305a\u3064\u304d\u75c5\u3067\u5f53\u3066\u3082\u306a\u304f\u5f77\u5fa8\u3063\u3066\u3044\u308b";
if (t.state === "zunchi_sick" || t.state === "system") return "\u305a\u3093\u3061\u75c5\u3067\u8abf\u5b50\u304c\u60aa\u3044";
if ((t.hurtTimer || 0) > 0.08) return "\u75db\u307f\u3067\u6df7\u4e71\u3057\u3066\u3044\u308b";
if ((t.fearTimer || 0) > 0.08) return "\u6016\u304c\u3063\u3066\u3044\u308b";
if ((t.grassEatTimer || 0) > 0.04) return "\u8349\u3092\u98df\u3079\u3066\u3044\u308b";
const thought = String(t.thought || "").trim();
if (thought) return thought;
return "周囲を見ている";
return "\u5468\u56f2\u3092\u898b\u3066\u3044\u308b";
};
function stateLabel(s) { return TEXT_CATALOG.stateLabel(s); }

View file

@ -189,7 +189,7 @@ function bindUI() {
document.addEventListener("click", (e) => {
if (!isActivePlacementTool()) return;
if (clickedInsideGameOrToolUi(e.target)) return;
selectTool("observe"); // 道具設置モードを解除
selectTool("observe"); // \u9053\u5177\u8a2d\u7f6e\u30e2\u30fc\u30c9\u3092\u89e3\u9664
});
ui.creditsBtn?.addEventListener("click", () => { audio.uiClick?.(); openCreditsDialog(); });
ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog);
@ -380,9 +380,9 @@ function bindUI() {
target.y = uiCache.grabOrigin.y;
target.vx = uiCache.grabOrigin.vx || 0;
target.vy = uiCache.grabOrigin.vy || 0;
target.thought = "フィールドに戻された";
target.thought = "\u30d5\u30a3\u30fc\u30eb\u30c9\u306b\u623b\u3055\u308c\u305f";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
showToast("フィールド外なので元の位置に戻しました。");
showToast("\u30d5\u30a3\u30fc\u30eb\u30c9\u5916\u306a\u306e\u3067\u5143\u306e\u4f4d\u7f6e\u306b\u623b\u3057\u307e\u3057\u305f\u3002");
uiCache.grabOrigin = null;
return true;
}
@ -403,10 +403,12 @@ function bindUI() {
uiCache.grabStartX = e.touches?.[0]?.clientX || 0;
uiCache.grabStartY = e.touches?.[0]?.clientY || 0;
if (uiCache.grabKind === "tarinai") {
const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id);
if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch");
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
@ -540,7 +542,7 @@ function bindUI() {
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("つままれている");
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 0.8);
} else if (target.type === "ball") {
target.prevX = target.x - dx;
@ -658,10 +660,12 @@ function bindUI() {
uiCache.grabStartX = e.clientX;
uiCache.grabStartY = e.clientY;
if (uiCache.grabKind === "tarinai") {
const lodged = target.currentLodgedPin?.() || (world.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === target.id);
if (lodged?.detachPushpin) lodged.detachPushpin(world, "pinch");
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("つままれている");
if (target.state === "sleep" || target.state === "seek_bed") target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
target.thought = "つままれている";
target.thought = "\u3064\u307e\u307e\u308c\u3066\u3044\u308b";
target.pinchThoughtTimer = Math.max(target.pinchThoughtTimer || 0, 1.2);
} else {
if (isPinType(target.type) && target.pinState === "lodged" && target.detachPushpin) target.detachPushpin(world, "pinch");
@ -761,7 +765,7 @@ function bindUI() {
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.goIdle?.("つままれている");
target.goIdle?.("\u3064\u307e\u307e\u308c\u3066\u3044\u308b");
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;

View file

@ -18,7 +18,7 @@ function renderStats() {
};
setTextIfChanged(ui.statPop, "pop", values.pop);
setTextIfChanged(ui.statDead, "dead", values.dead);
setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "のんびり");
setTextIfChanged(ui.statColonyMood, "colonyMood", world.colonyMood?.label || "\u306e\u3093\u3073\u308a");
updateColonyHistory(values);
drawColonyChart(values);
renderSelected();

View file

@ -169,20 +169,20 @@ function selectedInfoRowHtml(label, valueHtml, metric = "") {
function selectedInventoryItems(t) {
const worldRef = t?.world || world;
const labels = { grass_bed: "かんたんベッド", plushie: "ぬいぐるみ" };
const labels = { grass_bed: "\u304b\u3093\u305f\u3093\u30d9\u30c3\u30c9", plushie: "\u306c\u3044\u3050\u308b\u307f" };
const items = (worldRef?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id);
const counts = new Map();
for (const it of items) {
const label = labels[it.type] || toolLabel(it.type) || it.type;
counts.set(label, (counts.get(label) || 0) + 1);
}
return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} ×${count}` : label);
return Array.from(counts.entries()).map(([label, count]) => count > 1 ? `${label} \u00d7${count}` : label);
}
function selectedInventoryHtml(t) {
const entries = selectedInventoryItems(t);
if (!entries.length) return `<div class="info-row"><span>所持品</span><strong>なし</strong></div>`;
return `<div class="info-row"><span>所持品</span><strong>${entries.map(v => `<span class="personality-tag">${escapeHtml(v)}</span>`).join("")}</strong></div>`;
if (!entries.length) return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>\u306a\u3057</strong></div>`;
return `<div class="info-row"><span>\u6240\u6301\u54c1</span><strong>${entries.map(v => `<span class="personality-tag">${escapeHtml(v)}</span>`).join("")}</strong></div>`;
}
function selectedStateText(t) {
@ -216,9 +216,10 @@ function renderSelected() {
].join(",");
const itemEffectSummary = t.activeItemEffectSummary ? t.activeItemEffectSummary() : [];
const needKeys = typeof TARINAI_NEED_KEYS !== "undefined" ? TARINAI_NEED_KEYS : ["food", "sleep", "health", "safety", "social", "fulfill"];
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
const snapshot = [
t.id, t.type, t.name, generationDisplay, t.state, t.thought, targetLabel(t.target),
t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "", t.activeBehavior?.id || "", t.activeBehavior?.presentText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
behaviorState?.need || "", behaviorState?.id || "", behaviorState?.label || "", behaviorState?.reasonText || "", behaviorState?.id || "", behaviorState?.presentText || "", needKeys.map(key => `${key}:${t.needs?.[key] ?? 0}`).join(","),
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
fmt(t.energy), fmt(t.stress), fmt(t.mood),
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
@ -273,7 +274,7 @@ function renderSelected() {
const needRowsHtml = needKeys.map(key => {
const label = (typeof TARINAI_NEED_LABELS !== "undefined" && TARINAI_NEED_LABELS[key]) || key;
const value = typeof getNeedDisplayValue === "function" ? getNeedDisplayValue(t.needs?.[key] || 0) : Math.round((t.needs?.[key] || 0) / 10);
const chosen = t.intent?.need === key ? ` data-chosen-need="true"` : "";
const chosen = behaviorState?.need === key ? ` data-chosen-need="true"` : "";
return `<div class="need-cell"${chosen}><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
}).join("");
const healthHtml = [
@ -286,13 +287,11 @@ function renderSelected() {
? itemEffectSummary.map(e => `<div class="info-row"><span>${escapeHtml(e.label)}</span><strong>${escapeHtml(e.detail)}</strong></div>`).join("")
: `<div class="info-row"><span>\u30a2\u30a4\u30c6\u30e0\u52b9\u679c</span><strong>\u306a\u3057</strong></div>`;
const relationHtml = `
<div class="info-row relation-row relation-list-row"><span>\u53cb\u9054</span><strong>${relationListHtml(t, "friend")}</strong></div>
<div class="info-row relation-row relation-list-row"><span>\u6575\u5bfe</span><strong>${relationListHtml(t, "enemy")}</strong></div>
<div class="info-row"><span>\u3051\u3093\u304b\u52dd\u7387</span><strong>${escapeHtml(fightWinRateLabel(t))}</strong></div>
`;
const familyHtml = `
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
<div class="info-row relation-row relation-list-row"><span>友達</span><strong>${relationListHtml(t, "friend")}</strong></div>
<div class="info-row relation-row relation-list-row"><span>敵対</span><strong>${relationListHtml(t, "enemy")}</strong></div>
<div class="info-row"><span></span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "")}</strong></div>
<div class="info-row"><span></span><strong>${t.children?.length || 0}</strong></div>
<div class="info-row"><span>けんか勝率</span><strong>${escapeHtml(fightWinRateLabel(t))}</strong></div>
`;
ui.selectedInfo.innerHTML = `
<div class="selected-name-editor">
@ -304,8 +303,7 @@ function renderSelected() {
${selectedDataCategoryHtml("health", "\u6b32\u6c42", healthHtml)}
${selectedDataCategoryHtml("effects", "\u30a2\u30a4\u30c6\u30e0\u52b9\u679c", itemEffectHtml)}
${selectedDataCategoryHtml("personality", "\u6027\u683c", personalityHtml)}
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2", relationHtml)}
${selectedDataCategoryHtml("family", "\u5bb6\u65cf", familyHtml)}
${selectedDataCategoryHtml("relation", "\u95a2\u4fc2\u3068\u5bb6\u65cf", relationHtml)}
${selectedDataCategoryHtml("inventory", "\u6240\u6301\u54c1", selectedInventoryHtml(t))}
${selectedDataCategoryHtml("records", "\u8a18\u9332", `<div class="selected-records"><ol>${recordHtml}</ol></div>`)}
</div>
@ -325,19 +323,20 @@ function stateLabel(s) {
}
function reasonLabel(t) {
if (!t) return "なし";
const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String(t.activeBehavior?.presentText || t.activeBehavior?.reasonText || "").trim();
if (!t) return "\u306a\u3057";
const behavior = typeof activeBehaviorText === "function" ? activeBehaviorText(t) : String((typeof getTarinaiBehaviorText === "function" ? getTarinaiBehaviorText(t) : (t.behavior?.presentText || t.behavior?.reasonText || ""))).trim();
if (behavior) return behavior;
const state = String(t.state || "");
const live = window.TEXT_CATALOG?.reasonLabel?.(t) || "";
const liveStates = new Set(["birth_ritual", "eat", "sleep", "panic", "fight", "intimidate", "ant_attack", "ant_intimidate", "frozen"]);
if (live && (liveStates.has(state) || (t.birthRitualTimer || 0) > 0.04 || (t.eatTimer || 0) > 0.04 || (t.fightTimer || 0) > 0.04 || (t.intimidateTimer || 0) > 0.04)) return live;
if (t?.intent?.reasonText) return t.intent.reasonText;
return live || "なし";
const behaviorState = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t?.behavior;
if (behaviorState?.reasonText) return behaviorState.reasonText;
return live || "\u306a\u3057";
}
function targetLabel(target) {
return window.TEXT_CATALOG?.targetLabel?.(target) || "なし";
return window.TEXT_CATALOG?.targetLabel?.(target) || "\u306a\u3057";
}
function fightWinRateLabel(t) {

View file

@ -4,7 +4,7 @@ const SCALABLE_TOOLS = new Set(scalableToolIds());
const SIZE_ORDER = typeof TOOL_SIZE_ORDER !== "undefined" ? TOOL_SIZE_ORDER : ["small", "medium", "large"];
const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "小", medium: "中", large: "大" };
const SIZE_LABELS = typeof TOOL_SIZE_LABELS !== "undefined" ? TOOL_SIZE_LABELS : { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
function renderToolPalette() {
if (!ui?.toolPalette || typeof toolCategories !== "function") return;
@ -41,6 +41,7 @@ function renderToolPalette() {
function attachToolSpritePreview(btn, def, toolId) {
const itemType = def?.itemType || toolId;
if (!btn || !itemType || !def?.placeable) return;
if (toolId === "ball" || itemType === "ball") return;
const drawPreview = window.drawToolItemPreview || window.drawConsumableFieldSprite;
if (typeof drawPreview !== "function") return;
const canvas = document.createElement("canvas");

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "15.24.14";
const APP_BUILD = "forced-behavior-sleep-rhythm";
const APP_VERSION = "15.24.35";
const APP_BUILD = "owned-bed-duplicator-priority";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -67,7 +67,7 @@ class World { constructor() {
this.drawList = [];
this.itemCounts = {};
this.effectCounts = {};
this.colonyMood = { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } };
this.colonyMood = { id: "relaxed", label: "\u306e\u3093\u3073\u308a", description: "\u4f55\u3082\u7121\u3057", effects: { personality: {} } };
this.lastColonyMoodDay = 0;
this.foodSpoilagePenalty = 0;
this.foodSpoilageEvents = 0;

View file

@ -6,7 +6,7 @@
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
updateAnts(dt) {
if (!this.ants) this.ants = [];
const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
const nests = this.itemsOfType?.("ant_nest") || (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
for (const nest of nests) {
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;

View file

@ -79,8 +79,8 @@
if (t.addStress) t.addStress(22 * p, { threshold: 8 });
t.hurtTimer = Math.max(t.hurtTimer, 1.5 + p * 1.8);
t.surpriseTimer = Math.max(t.surpriseTimer, 0.85);
if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "爆竹の爆風でパニックになっている", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "爆竹の爆風でパニックになっている", wake: true }); }
if (t.enterPanic) t.enterPanic({ target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.7 + p, wake: true, cause: "firecracker_blast" });
else { t.fearTimer = Math.max(t.fearTimer, 1.7 + p); t.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); }
let nx = dx / d;
let ny = dy / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
@ -94,7 +94,7 @@
panic: true,
target: { x, y },
fearTimer: 1.7 + p,
thought: "爆竹の爆風でパニックになっている",
thought: "\u7206\u7af9\u306e\u7206\u98a8\u3067\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
});
t.fallTimer = Math.max(t.fallTimer, 1.45 + p * 1.85);
t.fallMax = Math.max(t.fallMax || 0, t.fallTimer);
@ -181,12 +181,12 @@
panic: true,
target: { x, y },
fearTimer: 1.7 + q,
thought: "爆発病の爆発に巻き込まれている",
thought: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b",
});
o.hurtTimer = Math.max(o.hurtTimer || 0, 1.5 + q * 1.8);
o.surpriseTimer = Math.max(o.surpriseTimer || 0, 0.85);
if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "爆発病の爆発に巻き込まれている", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "爆発病の爆発に巻き込まれている", wake: true }); }
if (o.enterPanic) o.enterPanic({ target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", fear: 1.7 + q, wake: true, cause: "explosion_disease_blast" });
else { o.fearTimer = Math.max(o.fearTimer || 0, 1.7 + q); o.setActionState?.("panic", { target: { x, y }, reason: "\u7206\u767a\u75c5\u306e\u7206\u767a\u306b\u5dfb\u304d\u8fbc\u307e\u308c\u3066\u3044\u308b", wake: true }); }
o.fallTimer = Math.max(o.fallTimer || 0, 1.45 + q * 1.85);
o.fallMax = Math.max(o.fallMax || 0, o.fallTimer);
o.fallDir = (dx >= 0 ? 1 : -1) * (Math.random() < 0.5 ? 1 : -1);
@ -279,13 +279,12 @@
it.vy = Math.sin(a) * rand(200, 480) - rand(30, 160);
it.stage = "fresh";
it.burstFromOshibyo = true;
this.items.push(it);
this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
this.addItem?.(it, "zunchi-burst") || this.items.push(it);
}
this.effects?.push(new Effect("zunchi_miasma", sx, sy, { size: 28, life: 0.65, color: "rgba(74,91,50,0.48)" }));
this.log?.(`${source?.name || "たりない"}から溜まっていたずんちが弾け飛んだ。`, "accident", { participants: source?.name ? [source] : [] });
this.log?.(`${source?.name || "\u305f\u308a\u306a\u3044"}\u304b\u3089\u6e9c\u307e\u3063\u3066\u3044\u305f\u305a\u3093\u3061\u304c\u5f3e\u3051\u98db\u3093\u3060\u3002`, "accident", { participants: source?.name ? [source] : [] });
audio.place?.("zunchi");
this.rebuildSpatial?.(true);
this.ensureSpatial?.("zunchi-burst");
},
spawnZunchi(x, y) {
@ -300,8 +299,7 @@
}
const it = new Item("zunchi", clamp(x, 40, this.w - 40), clamp(y, 40, this.h - 40));
it.amount = 260;
this.items.push(it);
this.itemCounts.zunchi = (this.itemCounts.zunchi || 0) + 1;
this.addItem?.(it, "zunchi-spawn") || this.items.push(it);
audio.place?.("zunchi");
},
@ -439,8 +437,8 @@
entity.vy = (Number.isFinite(entity.vy) ? entity.vy : 0) + vy;
}
if (options.panic && entity instanceof Tarinai) {
if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "パニックになっている", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "パニックになっている", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
if (entity.enterPanic) entity.enterPanic({ target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: options.fearTimer ?? 0.55, wake: true, cause: options.cause || "impulse" });
else { entity.setActionState?.("panic", { target: options.target || null, reason: options.thought || "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); entity.fearTimer = Math.max(entity.fearTimer || 0, options.fearTimer ?? 0.55); }
}
return true;
},
@ -492,8 +490,8 @@
victim.fightTimer = Math.min(victim.fightTimer || 0, rand(0.20, 0.42));
attacker.fightTimer = Math.min(attacker.fightTimer || 0, rand(0.20, 0.42));
this.emit?.("fight:lost", { winner: attacker, loser: victim, damage });
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "喧嘩に負けてパニックになっている", fear: 1.1, wake: true, cause: "fight_lost" });
else { victim.setActionState?.("panic", { target: attacker, reason: "喧嘩に負けてパニックになっている", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
if (victim.enterPanic) victim.enterPanic({ target: attacker, threat: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 1.1, wake: true, cause: "fight_lost" });
else { victim.setActionState?.("panic", { target: attacker, reason: "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true }); victim.fearTimer = Math.max(victim.fearTimer || 0, 1.1 * (profile.fear || 1)); }
const dx = victim.x - attacker.x;
const dy = victim.y - attacker.y;
const d = Math.hypot(dx, dy) || 1;
@ -579,9 +577,9 @@
t.fearTimer = Math.max(t.fearTimer || 0, 4.0 + p * 2.2);
if (typeof applyNeedShock === "function") applyNeedShock(t, { safety: 18 + p * 30, health: 8 + p * 15 });
t.adjustPersonality?.("openness", -(0.020 + p * 0.030), "after being struck by genkotsu.");
t.thought = "げんこつがこわい";
t.thought = "\u3052\u3093\u3053\u3064\u304c\u3053\u308f\u3044";
} else {
t.thought = isStone ? "石が落ちてきてパニックになっている" : "落ちてきた道具に当たって混乱している";
t.thought = isStone ? "\u77f3\u304c\u843d\u3061\u3066\u304d\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b";
}
t.hurtTimer = Math.max(t.hurtTimer, isGenkotsu ? 3.4 : (isStone ? 2.7 : 1.25));
t.fallTimer = Math.max(t.fallTimer, isGenkotsu ? 0.86 : (isStone ? 0.62 : 0.26));
@ -641,7 +639,7 @@
return dist(a, ball) + ap - (dist(b, ball) + bp);
});
for (const t of arr.slice(5)) {
t.goIdle("ボールが混み合っているので眺めている");
t.goIdle("\u30dc\u30fc\u30eb\u304c\u6df7\u307f\u5408\u3063\u3066\u3044\u308b\u306e\u3067\u773a\u3081\u3066\u3044\u308b");
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45);
}
}
@ -682,8 +680,9 @@
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");
const balls = this.itemsOfType?.("ball") || (this.items || []).filter(it => it && !it.dead && it.type === "ball");
for (const a of balls) {
if (!a || a.dead || a.type !== "ball") continue;
const ar = a.r || 18;
const avx = a.vx || 0;
const avy = a.vy || 0;
@ -816,14 +815,14 @@
panic: true,
target: { x: ball.x, y: ball.y },
fearTimer: 0.95,
thought: "高速のボールにぶつかってパニックになっている",
thought: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
});
this.applyImpactDamage(t, damage, "\u885d\u7a81");
const sensitiveHit = t.shouldApplyPersonalityBehavior?.("neuroticism", 1) ? 1.35 : (t.shouldApplyPersonalityBehavior?.("neuroticism", -1) ? 0.82 : 1.0);
const stressGain = clamp(damage * 0.55 * sensitiveHit, 3, sensitiveHit > 1 ? 24 : 18);
if (t.addStress) t.addStress(stressGain, { threshold: 8 });
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "高速のボールにぶつかってパニックになっている", wake: true });
if (t.enterPanic) t.enterPanic({ target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.95, stress: 0, wake: true, cause: "fast_ball_hit" });
else t.setActionState?.("panic", { target: { x: ball.x, y: ball.y }, reason: "\u9ad8\u901f\u306e\u30dc\u30fc\u30eb\u306b\u3076\u3064\u304b\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", wake: true });
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.98 : 0.46);
t.fallMax = Math.max(t.fallMax || 0.98, t.fallTimer);
@ -929,7 +928,7 @@
panic: true,
target: { x: impactor.x, y: impactor.y },
fearTimer: 0.55,
thought: "高速でぶつかって混乱している",
thought: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b",
});
impactor.vx = ivx * 0.72 - nx * Math.min(56, relSpeed * 0.10);
impactor.vy = ivy * 0.72 - ny * Math.min(56, relSpeed * 0.10);
@ -937,8 +936,8 @@
if (!impactor.dead) this.applyImpactDamage(impactor, damage * 0.35, "\u885d\u7a81");
for (const t of [target, impactor]) {
if (!t || t.dead) continue;
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "高速でぶつかって混乱している", wake: true });
if (t.enterPanic) t.enterPanic({ target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", fear: 0.75, wake: true, cause: "tarinai_highspeed_collision" });
else t.setActionState?.("panic", { target: { x: impactor.x, y: impactor.y }, reason: "\u9ad8\u901f\u3067\u3076\u3064\u304b\u3063\u3066\u6df7\u4e71\u3057\u3066\u3044\u308b", wake: true });
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);

View file

@ -39,6 +39,7 @@
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
this.fightPairCooldowns = {};
this.foodSpoilagePenalty = 0;
this.foodSpoilageEvents = 0;
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
@ -62,8 +63,8 @@
generation: 1,
});
}
for (let i = 0; i < grassCount; i++) this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)));
this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
for (let i = 0; i < grassCount; i++) this.addItem?.(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)), "reset-grass") || this.items.push(new Item("grass", rand(70, this.w - 70), rand(70, this.h - 70)));
this.addItem?.(new Item("stone", this.w * 0.68, this.h * 0.32), "reset-stone") || this.items.push(new Item("stone", this.w * 0.68, this.h * 0.32));
this.updateItemCounts();
this.updateEffectCounts();
this.markTerrainDirty?.("reset");
@ -342,7 +343,7 @@
if (!other || other === dead || other.dead) continue;
if (other.target === dead || other.target?.id === deadId) {
other.target = null;
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("参照していた相手がいなくなった");
if (["seek_friend", "panic", "fight", "intimidate"].includes(other.state)) other.goIdle?.("\u53c2\u7167\u3057\u3066\u3044\u305f\u76f8\u624b\u304c\u3044\u306a\u304f\u306a\u3063\u305f");
changed = true;
}
if (other.panicTarget === dead || other.panicTarget?.id === deadId) { other.panicTarget = null; changed = true; }
@ -428,11 +429,17 @@
b.birthRitualRole = 1;
a.birthRitualLeader = true;
b.birthRitualLeader = false;
a.setActionState("birth_ritual", { target: b, reason: "繁殖の前ぶれをしている" });
b.setActionState("birth_ritual", { target: a, reason: "繁殖の前ぶれをしている" });
a.setActionState("birth_ritual", { target: b, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" });
b.setActionState("birth_ritual", { target: a, reason: "\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b" });
if (typeof setLiveActionText === "function") {
setLiveActionText(a, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${b.name || "相手"}と繁殖の前ぶれをしている`, target: b });
setLiveActionText(b, { need: "social", actionId: "birth_ritual", actionLabel: "繁殖の前ぶれをしている", reasonText: `${a.name || "相手"}と繁殖の前ぶれをしている`, target: a });
const aBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(a) : a.behavior;
const bBehavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(b) : b.behavior;
const aLoveCause = (aBehavior?.source === "love_mochi" || (a.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${b.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const bLoveCause = (bBehavior?.source === "love_mochi" || (b.loveMochiTimer || 0) > 0.04) ? "\u3078\u3053\u9905\u306e\u52b9\u679c" : `${a.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u306a\u308b`;
const aText = typeof buildReasonText === "function" ? buildReasonText("social", aBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, a, this, { causeText: aLoveCause }) : `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`;
const bText = typeof buildReasonText === "function" ? buildReasonText("social", bBehavior?.tiedNeeds || ["social"], { id: "birth_ritual", need: "social", subNeed: "mate", label: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b` }, b, this, { causeText: bLoveCause }) : `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`;
setLiveActionText(a, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${b.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: aText, causeText: aLoveCause, target: b });
setLiveActionText(b, { need: "social", subNeed: "mate", actionId: "birth_ritual", actionLabel: `${a.name || "\u76f8\u624b"}\u3068\u7e41\u6b96\u306e\u524d\u3076\u308c\u3092\u3057\u3066\u3044\u308b`, reasonText: bText, causeText: bLoveCause, target: a });
}
a.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
b.reproductionTimer = CONFIG.reproductionCooldown + rand(2, 10);
@ -447,8 +454,8 @@
a.birthRitualTimer = b.birthRitualTimer = 0;
a.birthPartnerId = b.birthPartnerId = null;
a.birthRitualLeader = b.birthRitualLeader = false;
a.goIdle("誕生の前ぶれが終わった");
b.goIdle("誕生の前ぶれが終わった");
a.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
b.goIdle("\u8a95\u751f\u306e\u524d\u3076\u308c\u304c\u7d42\u308f\u3063\u305f");
if (reproductionBlockedByDisease || (!!a.isZunchiSlave !== !!b.isZunchiSlave)) {
a.birthRitualRole = b.birthRitualRole = 0;
return null;
@ -579,11 +586,39 @@
return child;
},
fightPairKey(a, b) {
const aid = a?.id || a?.familyKey || "";
const bid = b?.id || b?.familyKey || "";
if (!aid || !bid) return "";
return String(aid) < String(bid) ? `${aid}:${bid}` : `${bid}:${aid}`;
},
fightPairCooldownRemaining(a, b) {
const key = this.fightPairKey?.(a, b);
if (!key) return 0;
const until = Number(this.fightPairCooldowns?.[key] || 0) || 0;
return Math.max(0, until - (this.time || 0));
},
markFightPairCooldown(a, b, seconds = 3.2) {
const key = this.fightPairKey?.(a, b);
if (!key) return;
this.fightPairCooldowns = this.fightPairCooldowns || {};
this.fightPairCooldowns[key] = Math.max(Number(this.fightPairCooldowns[key] || 0) || 0, (this.time || 0) + Math.max(0.2, Number(seconds) || 3.2));
},
isAlreadyFightingPair(a, b) {
if (!a || !b) return false;
return a.fightTimer > 0.04 && b.fightTimer > 0.04 && ((a.fightTargetIds || []).includes(b.id) || a.fightTargetId === b.id) && ((b.fightTargetIds || []).includes(a.id) || b.fightTargetId === a.id);
},
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);
const alreadyFighting = this.isAlreadyFightingPair?.(a, b);
if (alreadyFighting) return true;
if ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04) return false;
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
for (const t of [a, b]) {
t.birthRitualTimer = 0;
t.birthRitualMax = 0;
@ -601,7 +636,7 @@
t.goIdle("");
}
this.startFightMochiIntimidation(a, b);
this.startFight(a, b);
this.startFight(a, b, { forced: true, initiator: a });
return true;
},
@ -629,33 +664,36 @@
},
startConflict(a, b) {
if (!a || !b || a.dead || b.dead) return;
if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return;
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) { this.startForcedFight(a, b); return; }
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return;
if (!a || !b || a.dead || b.dead) return false;
if (typeof a.relationTo !== "function" || typeof b.relationTo !== "function") return false;
if ((a.fightMochiTimer || 0) > 0.04 || (b.fightMochiTimer || 0) > 0.04) return this.startForcedFight(a, b);
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return false;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
if (this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return false;
const aAggressiveIntent = a.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const bAggressiveIntent = b.shouldApplyPersonalityBehavior?.("aggression", 1) ? 1 : 0;
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + aAggressiveIntent * 8 + ((typeof b.relationTo === "function" ? (b.relationTo(a.id).fear || 0) : 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + bAggressiveIntent * 8 + ((typeof a.relationTo === "function" ? (a.relationTo(b.id).fear || 0) : 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;
if (!this.canFightPair(actor, target)) return false;
const actorScore = actor === a ? scoreA : scoreB;
const targetScore = actor === a ? scoreB : scoreA;
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04;
const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90);
if (canIntimidate && Math.random() < intimidationChance) {
this.startIntimidation(actor, target, actorScore, targetScore);
return;
return this.startIntimidation(actor, target, actorScore, targetScore);
}
this.startFight(a, b);
return this.startFight(actor, target, { initiator: actor });
},
startIntimidation(actor, target, actorScore = 0, targetScore = 0) {
if (!actor || !target || actor.dead || target.dead) return false;
if ((actor.fightTimer || 0) > 0.04 || (target.fightTimer || 0) > 0.04) return false;
if ((actor.intimidateTimer || 0) > 0.04 || (target.intimidateTimer || 0) > 0.04) return false;
if (this.fightPairCooldownRemaining?.(actor, target) > 0.04) return false;
if (actor.lowHealthSprite && actor.lowHealthSprite()) return false;
const fearLoad = ((typeof target.relationTo === "function" ? (target.relationTo(actor.id).fear || 0) : 0) * 0.72) + target.personalityProfile().fear * 8;
const actorAggression = Math.max(0, actor.currentPersonality?.aggression || 0);
@ -670,17 +708,17 @@
target.surpriseTimer = Math.max(target.surpriseTimer, 0.32);
actor.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
target.fightCooldown = CONFIG.fightCooldown + rand(1, 4);
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(2.45, 3.35));
actor.intimidateTimer = Math.max(actor.intimidateTimer, rand(1.05, 1.65));
actor.intimidateTargetId = target.id;
actor.setActionState("intimidate", { target, reason: "相手を威嚇している" });
if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "威嚇している", reasonText: `${target.name || "相手"}を威嚇している`, target, phase: "perform", source: "behavior" });
actor.setActionState("intimidate", { target, reason: "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b" });
if (typeof setLiveActionText === "function") setLiveActionText(actor, { need: "social", subNeed: "conflict", actionId: "intimidate_enemy", actionLabel: "\u5a01\u5687\u3057\u3066\u3044\u308b", reasonText: `${target.name || "\u76f8\u624b"}\u3092\u5a01\u5687\u3057\u3066\u3044\u308b`, target, phase: "perform", source: "behavior" });
this.effects.push(new Effect("ring", actor.x, actor.y - actor.radius * 0.65, {
life: 0.42,
size: actor.radius * 0.55,
color: "rgba(145, 106, 55, 0.70)",
}));
this.spawnBubble(actor.x, actor.y - actor.radius * 1.30, "\u306f\u3046\u30fc\uff01", "rgba(92,62,34,0.82)");
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.65, 2.45));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(0.95, 1.55));
target.fearTimer = Math.max(target.fearTimer, 0.65 * target.personalityProfile().fear);
if (Math.random() >= successChance) {
actor.adjustPersonality?.("aggression", -0.018, "after failed intimidation");
@ -688,20 +726,20 @@
actor.intimidateTimer = 0;
actor.intimidateTargetId = null;
target.intimidatedTimer = 0;
if (actor.state === "intimidate") actor.goIdle("威嚇に失敗した");
if (target.state === "panic") target.goIdle("威嚇から戻った");
if (actor.state === "intimidate") actor.goIdle("\u5a01\u5687\u306b\u5931\u6557\u3057\u305f");
if (target.state === "panic") target.goIdle("\u5a01\u5687\u304b\u3089\u623b\u3063\u305f");
actor.spriteLockUntil = 0;
target.spriteLockUntil = 0;
this.startFight(actor, target);
this.startFight(actor, target, { initiator: actor });
return true;
}
actor.adjustPersonality?.("aggression", 0.018, "after successful intimidation");
target.defeatedById = actor.id;
target.fightWinnerId = actor.id;
target.defeatedTimer = Math.max(target.defeatedTimer, rand(2.8, 4.2));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(2.4, 3.4));
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "威嚇されて逃げている", fear: 1.35, wake: true, cause: "intimidated" });
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "威嚇されて逃げている", wake: true }); }
target.defeatedTimer = Math.max(target.defeatedTimer, rand(1.8, 2.8));
target.intimidatedTimer = Math.max(target.intimidatedTimer || 0, rand(1.2, 2.0));
if (target.enterPanic) target.enterPanic({ threat: actor, target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", fear: 1.35, wake: true, cause: "intimidated" });
else { target.fearTimer = Math.max(target.fearTimer, 1.35 * target.personalityProfile().fear); target.setActionState?.("panic", { target: actor, reason: "\u5a01\u5687\u3055\u308c\u3066\u9003\u3052\u3066\u3044\u308b", wake: true }); }
this.spawnBubble(target.x, target.y - target.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
target.vx += (target.x < actor.x ? -1 : 1) * rand(26, 54);
target.vy += rand(-18, 18);
@ -711,21 +749,41 @@
this.log(`${actor.name}\u306f${target.name}\u3092\u5a01\u5687\u3057\u3066\u8ffd\u3044\u6255\u3063\u305f\u3002`, "fight", { participants: [actor, target] });
actor.lastLog = target.lastLog = this.time;
}
this.markFightPairCooldown?.(actor, target, 2.8);
return true;
},
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.setActionState("fight", { target: b, reason: `${b.name || "相手"}と喧嘩している` });
b.setActionState("fight", { target: a, reason: `${a.name || "相手"}と喧嘩している` });
startFight(a, b, opts = {}) {
if (!a || !b || a.dead || b.dead) return false;
if (!this.canFightPair(a, b) && !this.canFightPair(b, a)) return false;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return false;
if (this.isAlreadyFightingPair?.(a, b)) return true;
const allowForcedStart = Boolean(opts?.forced);
if (!allowForcedStart && ((a.fightTimer || 0) > 0.04 || (b.fightTimer || 0) > 0.04 || (a.intimidateTimer || 0) > 0.04 || (b.intimidateTimer || 0) > 0.04)) return false;
if (!allowForcedStart && this.fightPairCooldownRemaining?.(a, b) > 0.04) return false;
this.markFightPairCooldown?.(a, b, 3.4);
const initiator = opts?.initiator === b ? b : a;
const receiver = initiator === a ? b : a;
const causeFor = (self, other) => {
if (((typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(self) : self.behavior)?.source === "fight_mochi") || (self.fightMochiTimer || 0) > 0.04) return "\u3051\u3093\u304b\u9905\u306e\u52b9\u679c";
if (self === receiver && other === initiator) return `${other.name || "\u76f8\u624b"}\u306b\u3051\u3093\u304b\u3092\u58f2\u3089\u308c\u305f`;
return `${other.name || "\u76f8\u624b"}\u304c\u6c17\u306b\u5165\u3089\u306a\u3044`;
};
const textFor = (self, other) => typeof buildReasonText === "function"
? buildReasonText("social", (typeof getTarinaiBehaviorTiedNeeds === "function" ? getTarinaiBehaviorTiedNeeds(self, "social") : self.behavior?.tiedNeeds) || ["social"], { id: "fight_rival", need: "social", subNeed: "conflict", label: self === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b` }, self, this, { causeText: causeFor(self, other) })
: `${other.name || "\u76f8\u624b"}\u3068\u55a7\u5629\u3057\u3066\u3044\u308b`;
const reasonA = textFor(a, b);
const reasonB = textFor(b, a);
a.counterAttackFromId = a === receiver ? initiator.id : null;
b.counterAttackFromId = b === receiver ? initiator.id : null;
a.setActionState("fight", { target: b, reason: reasonA });
b.setActionState("fight", { target: a, reason: reasonB });
if (typeof setLiveActionText === "function") {
setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${b.name || "相手"}と喧嘩している`, target: b, phase: "perform", source: "behavior" });
setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: "喧嘩している", reasonText: `${a.name || "相手"}と喧嘩している`, target: a, phase: "perform", source: "behavior" });
setLiveActionText(a, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: a === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonA, causeText: causeFor(a, b), target: b, phase: "perform", source: "behavior" });
setLiveActionText(b, { need: "social", subNeed: "conflict", actionId: "fight_rival", actionLabel: b === receiver ? "\u53cd\u6483\u3057\u3066\u3044\u308b" : "\u55a7\u5629\u3057\u3066\u3044\u308b", reasonText: reasonB, causeText: causeFor(b, a), target: a, phase: "perform", source: "behavior" });
}
a.fightTimer = Math.max(a.fightTimer, rand(4.5, 7.0));
b.fightTimer = Math.max(b.fightTimer, rand(4.5, 7.0));
a.fightTimer = Math.max(a.fightTimer, rand(3.2, 4.8));
b.fightTimer = Math.max(b.fightTimer, rand(3.2, 4.8));
a.nextHeadbutt = this.time + rand(0.08, 0.18);
b.nextHeadbutt = a.nextHeadbutt;
a.fightCooldown = CONFIG.fightCooldown + rand(1, 5);
@ -761,6 +819,7 @@
this.log(`${a.name}\u3068${b.name}\u304c\u5c0f\u3055\u306a\u55a7\u5629\u3092\u3057\u305f\u3002`, "fight", { participants: [a, b] });
a.lastLog = b.lastLog = this.time;
}
return true;
}
}));
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -18,10 +18,10 @@
<div class="signboard-editor-panel" role="dialog" aria-modal="true" aria-labelledby="signboardEditorTitle">
<div class="signboard-editor-titlebar">
<h2 id="signboardEditorTitle">\u770b\u677f</h2>
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">×</button>
<button id="signboardEditorClose" class="signboard-editor-close" type="button" aria-label="\u9589\u3058\u308b">\u00d7</button>
</div>
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT}" rows="3" spellcheck="false"></textarea>
<div class="signboard-editor-meta"><span>6文字×3行まで</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
<textarea id="signboardEditorText" maxlength="${SIGNBOARD_TEXT_LIMIT + SIGNBOARD_LINES - 1}" rows="3" cols="6" wrap="off" spellcheck="false" inputmode="text"></textarea>
<div class="signboard-editor-meta"><span>6\u6587\u5b57\u00d73\u884c\u307e\u3067</span><span id="signboardEditorCount">0/${SIGNBOARD_TEXT_LIMIT}</span></div>
<div class="signboard-editor-actions">
<button id="signboardEditorClear" class="btn" type="button">\u6d88\u3059</button>
<button id="signboardEditorCancel" class="btn" type="button">\u30ad\u30e3\u30f3\u30bb\u30eb</button>
@ -40,18 +40,14 @@
function sanitizeSignboardText(value = "") {
const normalized = String(value || "").replace(/\r/g, "").replace(/[\t ]+/g, " ");
const rawLines = normalized.split("\n");
const rawLines = normalized.split("\n").slice(0, SIGNBOARD_LINES);
const out = [];
for (const rawLine of rawLines) {
let rest = String(rawLine || "").trim();
if (!rest && out.length < SIGNBOARD_LINES) { out.push(""); continue; }
while (rest && out.length < SIGNBOARD_LINES) {
out.push(sliceGraphemes(rest, SIGNBOARD_CHARS_PER_LINE));
rest = Array.from(rest).slice(SIGNBOARD_CHARS_PER_LINE).join("");
}
if (out.length >= SIGNBOARD_LINES) break;
for (let i = 0; i < SIGNBOARD_LINES; i += 1) {
const rawLine = rawLines[i];
if (rawLine == null) break;
out.push(sliceGraphemes(String(rawLine), SIGNBOARD_CHARS_PER_LINE));
}
return out.slice(0, SIGNBOARD_LINES).join("\n").replace(/\n+$/g, "").trim();
return out.join("\n").replace(/\n+$/g, "");
}
function signboardVisibleLength(text = "") {
@ -88,9 +84,39 @@
dialog.__worldRef = worldRef;
if (area) {
area.value = sanitizeSignboardText(sign.text || "");
area.onkeydown = (e) => {
const value = String(area.value || "");
const start = Number(area.selectionStart || 0);
const end = Number(area.selectionEnd || start);
const before = value.slice(0, start);
const selected = value.slice(start, end);
const lineIndex = before.split("\n").length - 1;
const lines = value.split("\n");
const currentLine = lines[lineIndex] || "";
const replacing = selected.length > 0;
if (e.key === "Enter") {
if (lines.length >= SIGNBOARD_LINES) e.preventDefault();
return;
}
if (e.key && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
const selectedSameLine = !selected.includes("\n");
const selectedCount = selectedSameLine ? Array.from(selected).length : 0;
if (!replacing && Array.from(currentLine).length >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
else if (replacing && selectedSameLine && Array.from(currentLine).length - selectedCount >= SIGNBOARD_CHARS_PER_LINE) e.preventDefault();
}
};
area.onpaste = (e) => {
e.preventDefault();
const text = e.clipboardData?.getData?.("text") || "";
const start = Number(area.selectionStart || 0);
const end = Number(area.selectionEnd || start);
const next = sanitizeSignboardText((area.value || "").slice(0, start) + text + (area.value || "").slice(end));
area.value = next;
syncCount();
};
area.oninput = () => {
const clean = sanitizeSignboardText(area.value || "");
if (area.value !== clean && signboardVisibleLength(area.value || "") > SIGNBOARD_TEXT_LIMIT) area.value = clean;
if (area.value !== clean) area.value = clean;
syncCount();
};
setTimeout(() => { area.focus(); area.select(); syncCount(); }, 0);
@ -219,6 +245,14 @@
}
}
}
if (item.type === "grass_bed") {
const spacing = Math.max(46, (item.r || 18) * 3.0);
for (const it of this.nearbyItems(item.x, item.y, spacing + 70) || []) {
if (!it || it === item || it.dead || it.type !== "grass_bed") continue;
const required = spacing + Math.max(0, ((it.r || 18) - (item.r || 18)) * 0.5);
if (distXY(item.x, item.y, it.x, it.y) < required) return true;
}
}
return false;
},
@ -302,6 +336,10 @@
if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; }
}
if (foundItem && isPinType(foundItem.type) && foundItem.pinState === "lodged") return foundItem;
if (foundT) {
const lodged = foundT.currentLodgedPin?.() || (this.items || []).find(it => it && isPinType(it.type) && it.pinState === "lodged" && it.pinTargetId === foundT.id);
if (lodged && lodged.detachPushpin) return lodged;
}
if (foundT && foundItem) {
const td = distXY(x, y, foundT.x, foundT.y);
return td <= foundItemD + 8 ? foundT : foundItem;
@ -332,13 +370,11 @@
item.dropTimer = item.dropMax;
item.dropImpactDone = false;
}
this.items.push(item);
this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
this.markTerrainDirty?.(`place:${item.type}`);
this.addItem?.(item, `place:${item.type}`) || this.items.push(item);
this.emit("tool:place", { item, type: item.type, dropped });
this.emit("tool:placed", { item, type: item.type, dropped, tool: item.type });
if (dropped) audio.place?.(item.type);
this.rebuildSpatial(true);
this.ensureSpatial?.(`place:${item.type}`);
return item;
},

View file

@ -25,6 +25,49 @@
return window.TarinaiPerformance?.quality?.(this) || { level: this.performanceLevel(), antStride: 1, effectLimit: CONFIG.effectLimit ?? 96 };
},
runtimeDiagnostics() {
const behaviorCounts = {};
let forcedBehaviors = 0;
let lockedBehaviors = 0;
for (const t of this.tarinai || []) {
if (!t || t.dead) continue;
const behavior = typeof currentTarinaiBehavior === "function" ? currentTarinaiBehavior(t) : t.behavior;
const id = behavior?.id || t.state || "none";
behaviorCounts[id] = (behaviorCounts[id] || 0) + 1;
if (behavior?.forced) forcedBehaviors += 1;
if ((t.intentLockTimer || 0) > 0.01) lockedBehaviors += 1;
}
return {
spatial: {
version: this.spatialVersion || 0,
dirty: Boolean(this.spatialDirty),
dirtyReason: this.spatialDirtyReason || this.lastSpatialDirtyReason || "",
dirtyMarksThisFrame: this.spatialDirtyMarksThisFrame || 0,
dirtyMarksTotal: this.spatialDirtyMarksTotal || 0,
rebuildsThisFrame: this.spatialRebuildsThisFrame || 0,
rebuildsTotal: this.spatialRebuildsTotal || 0,
lastRebuildReason: this.lastSpatialRebuildReason || "",
},
render: {
drawList: (this.drawList || []).length,
drawListDirty: Boolean(this.drawListDirty),
drawSortTimer: Number(this.drawSortTimer || 0),
},
items: {
total: (this.items || []).length,
bucketTypes: this.itemTypeBuckets?.size || 0,
bucketsDirty: Boolean(this.itemBucketsDirty),
idMapSize: this.itemIdMap?.size || 0,
bucketRebuildsTotal: this.itemBucketRebuildsTotal || 0,
},
behavior: {
forced: forcedBehaviors,
locked: lockedBehaviors,
counts: behaviorCounts,
},
};
},
grassLoadLevel() {
const grass = this.itemCounts?.grass || 0;
const perf = this.performanceLevel();
@ -52,40 +95,124 @@
return this.drawList;
},
ensureSpatial(reason = "read") {
if (!this.spatial) return false;
if (!this.spatialDirty && (this.spatialVersion || 0) > 0) return false;
return this.rebuildSpatial(true, reason);
},
nearbyItems(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-items");
return this.spatial.nearby(this.spatial.itemCells, x, y, radius, this.spatialItemScratch, precise);
},
nearbyTarinai(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-tarinai");
return this.spatial.nearby(this.spatial.tarinaiCells, x, y, radius, this.spatialTarinaiScratch, precise);
},
nearbyAnts(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-ants");
return this.spatial.nearby(this.spatial.antCells, x, y, radius, this.spatialAntScratch, precise);
},
nearbyObstacles(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-obstacles");
return this.spatial.nearby(this.spatial.obstacleCells, x, y, radius, this.spatialObstacleScratch, precise);
},
nearbyFood(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-food");
return this.spatial.nearby(this.spatial.foodCells, x, y, radius, this.spatialFoodScratch, precise);
},
nearbyHazards(x, y, radius, precise = false) {
this.ensureSpatial?.("nearby-hazards");
return this.spatial.nearby(this.spatial.hazardCells, x, y, radius, this.spatialHazardScratch, precise);
},
rebuildSpatial(force = false) {
rebuildSpatial(force = false, reason = "manual") {
// Direct calls keep their old eager semantics. update() uses markSpatialDirty + ensureSpatial
// so repeated rebuild requests in the same phase are collapsed to one rebuild.
if (!force && this.spatialDirty === false && reason !== "manual") return false;
this.spatial.rebuild(this.items, this.tarinai, this.ants || []);
this.spatialDirty = false;
this.spatialDirtyReason = "";
this.spatialVersion = (this.spatialVersion || 0) + 1;
this.spatialRebuildsThisFrame = (this.spatialRebuildsThisFrame || 0) + 1;
this.spatialRebuildsTotal = (this.spatialRebuildsTotal || 0) + 1;
this.lastSpatialRebuildReason = reason;
return true;
},
updateItemCounts() {
markItemBucketsDirty(reason = "manual") {
this.itemBucketsDirty = true;
this.itemBucketsDirtyReason = reason;
this.lastItemBucketsDirtyReason = reason;
this.itemBucketDirtyMarksTotal = (this.itemBucketDirtyMarksTotal || 0) + 1;
},
ensureItemBuckets(reason = "read") {
if (!this.itemTypeBuckets || !this.itemIdMap || this.itemBucketsDirty) {
this.updateItemCounts(reason);
return true;
}
return false;
},
addItem(item, reason = "add-item", opts = {}) {
if (!item) return null;
this.items.push(item);
this.markItemBucketsDirty?.(reason);
this.markSpatialDirty?.(reason);
if (opts.terrain !== false) this.markTerrainDirty?.(reason);
if (opts.countNow !== false && item.type) this.itemCounts[item.type] = (this.itemCounts[item.type] || 0) + 1;
return item;
},
itemById(id) {
if (id == null || id === "") return null;
this.ensureItemBuckets?.("item-by-id");
const item = this.itemIdMap?.get?.(id) || null;
return item && !item.dead ? item : null;
},
itemsOfType(type) {
this.ensureItemBuckets?.(`items-of-type:${type}`);
const bucket = this.itemTypeBuckets?.get?.(type);
return bucket || [];
},
liveItemsOfType(type, predicate = null) {
const bucket = this.itemsOfType(type);
if (typeof predicate !== "function") return bucket;
const out = [];
for (const item of bucket) if (item && !item.dead && predicate(item)) out.push(item);
return out;
},
updateItemCounts(reason = "manual") {
const counts = this.itemCounts;
if (!this.itemTypeBuckets) this.itemTypeBuckets = new Map();
if (!this.itemIdMap) this.itemIdMap = new Map();
for (const key of Object.keys(counts)) counts[key] = 0;
for (const it of this.items) counts[it.type] = (counts[it.type] || 0) + 1;
this.itemTypeBuckets.clear();
this.itemIdMap.clear();
for (const it of this.items) {
if (!it || it.dead) continue;
counts[it.type] = (counts[it.type] || 0) + 1;
if (it.id != null) this.itemIdMap.set(it.id, it);
let bucket = this.itemTypeBuckets.get(it.type);
if (!bucket) {
bucket = [];
this.itemTypeBuckets.set(it.type, bucket);
}
bucket.push(it);
}
this.itemBucketsDirty = false;
this.itemBucketsDirtyReason = "";
this.itemBucketRebuildsTotal = (this.itemBucketRebuildsTotal || 0) + 1;
this.lastItemBucketRebuildReason = reason;
},
updateEffectCounts() {
@ -108,6 +235,7 @@
if (changed) {
this.markSpatialDirty?.("compact-items");
this.markTerrainDirty?.("compact-items");
this.markItemBucketsDirty?.("compact-items");
}
return changed;
},
@ -115,11 +243,11 @@
enforceItemBudget() {
const pruneOldest = (type, limit) => {
if (!Number.isFinite(limit) || limit < 0) return;
let count = 0;
for (const it of this.items) if (it.type === type && !it.dead) count += 1;
const live = this.liveItemsOfType?.(type) || (this.items || []).filter(it => it && it.type === type && !it.dead);
const count = live.length;
if (count <= limit) return;
const victims = this.items
.filter(it => it.type === type && !it.dead)
const victims = live
.slice()
.sort((a, b) => (b.age || 0) - (a.age || 0))
.slice(0, count - limit);
for (const it of victims) it.amount = 0;
@ -131,8 +259,8 @@
const fenceLimit = this.fenceLimit ? this.fenceLimit() : (CONFIG.fenceLimit ?? 72);
const liveFences = this.liveFenceCount ? this.liveFenceCount() : 0;
if (liveFences > fenceLimit) {
const victims = this.items
.filter(it => this.isFenceType?.(it.type) && !it.dead)
const victims = (this.items || [])
.filter(it => it && this.isFenceType?.(it.type) && !it.dead)
.sort((a, b) => (b.age || 0) - (a.age || 0))
.slice(0, liveFences - fenceLimit);
for (const it of victims) it.amount = 0;
@ -141,8 +269,8 @@
const limit = CONFIG.itemLimit ?? Infinity;
if (!Number.isFinite(limit) || this.items.length <= limit) return;
const priority = { trace: 1, splat: 2, zunchi: 3, water: 4, grass: 5, sweet: 6, firecracker: 7, fence_v: 8, fence_h: 8 };
const removable = this.items
.filter(it => !it.dead && (priority[it.type] || 99) < 99)
const removable = (this.items || [])
.filter(it => it && !it.dead && (priority[it.type] || 99) < 99)
.sort((a, b) => (priority[a.type] || 99) - (priority[b.type] || 99) || (b.age || 0) - (a.age || 0));
let extra = this.items.length - limit;
for (const it of removable) {

View file

@ -43,15 +43,15 @@
colonyMoodDefinition(id = "relaxed") {
const defs = {
calm: { id: "calm", label: "おだやか", description: "社交性プラス", effects: { personality: { sociability: 0.42 } } },
prickly: { id: "prickly", label: "ピリピリ", description: "攻撃性プラス", effects: { personality: { aggression: 0.46 } } },
stinky: { id: "stinky", label: "くさい", description: "開放性マイナス", effects: { personality: { openness: -0.44 } } },
weaklings: { id: "weaklings", label: "ざこだらけ", description: "ずんちどれい以外のストレス減少", effects: { personality: {} } },
zunda_party: { id: "zunda_party", label: "ずんだぱーてぃー", description: "社交性と開放性プラス。ストレス減少", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
ball_party: { id: "ball_party", label: "ぼーるぱーてぃー", description: "社交性と開放性プラス。ストレス減少", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
sun_party: { id: "sun_party", label: "おひさまぱーてぃー", description: "日光浴頻度増加", effects: { personality: {} } },
ant_overrun: { id: "ant_overrun", label: "ありだらけ", description: "アリを見るとパニック", effects: { personality: {} } },
relaxed: { id: "relaxed", label: "のんびり", description: "何も無し", effects: { personality: {} } },
calm: { id: "calm", label: "\u304a\u3060\u3084\u304b", description: "\u793e\u4ea4\u6027\u30d7\u30e9\u30b9", effects: { personality: { sociability: 0.42 } } },
prickly: { id: "prickly", label: "\u30d4\u30ea\u30d4\u30ea", description: "\u653b\u6483\u6027\u30d7\u30e9\u30b9", effects: { personality: { aggression: 0.46 } } },
stinky: { id: "stinky", label: "\u304f\u3055\u3044", description: "\u958b\u653e\u6027\u30de\u30a4\u30ca\u30b9", effects: { personality: { openness: -0.44 } } },
weaklings: { id: "weaklings", label: "\u3056\u3053\u3060\u3089\u3051", description: "\u305a\u3093\u3061\u3069\u308c\u3044\u4ee5\u5916\u306e\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: {} } },
zunda_party: { id: "zunda_party", label: "\u305a\u3093\u3060\u3071\u30fc\u3066\u3043\u30fc", description: "\u793e\u4ea4\u6027\u3068\u958b\u653e\u6027\u30d7\u30e9\u30b9\u3002\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
ball_party: { id: "ball_party", label: "\u307c\u30fc\u308b\u3071\u30fc\u3066\u3043\u30fc", description: "\u793e\u4ea4\u6027\u3068\u958b\u653e\u6027\u30d7\u30e9\u30b9\u3002\u30b9\u30c8\u30ec\u30b9\u6e1b\u5c11", effects: { personality: { sociability: 0.38, openness: 0.38 } } },
sun_party: { id: "sun_party", label: "\u304a\u3072\u3055\u307e\u3071\u30fc\u3066\u3043\u30fc", description: "\u65e5\u5149\u6d74\u983b\u5ea6\u5897\u52a0", effects: { personality: {} } },
ant_overrun: { id: "ant_overrun", label: "\u3042\u308a\u3060\u3089\u3051", description: "\u30a2\u30ea\u3092\u898b\u308b\u3068\u30d1\u30cb\u30c3\u30af", effects: { personality: {} } },
relaxed: { id: "relaxed", label: "\u306e\u3093\u3073\u308a", description: "\u4f55\u3082\u7121\u3057", effects: { personality: {} } },
};
return defs[id] || defs.relaxed;
},
@ -76,7 +76,7 @@
const nextId = candidates.length ? candidates[Math.floor(Math.random() * candidates.length)] : "relaxed";
this.colonyMood = this.colonyMoodDefinition(nextId);
this.lastColonyMoodDay = this.day || 1;
this.log?.(`今日のコロニーの雰囲気は「${this.colonyMood.label}」。`, "event");
this.log?.(`\u4eca\u65e5\u306e\u30b3\u30ed\u30cb\u30fc\u306e\u96f0\u56f2\u6c17\u306f\u300c${this.colonyMood.label}\u300d\u3002`, "event");
return this.colonyMood;
},
@ -86,12 +86,47 @@
const tFamilyKey = this.tarinaiFamilyKey(t);
if ((s.parents || []).includes(tFamilyKey)) return "parent";
if ((s.children || []).includes(tFamilyKey)) return "child";
const friendThreshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14;
const rel = s.relationTo ? s.relationTo(t.id) : null;
if (rel && (rel.affinity || 0) >= FRIEND_AFFINITY_THRESHOLD) return "friend";
if (rel && (rel.fear || 0) >= 8) return "enemy";
const reverse = t.relationTo ? t.relationTo(s.id) : null;
if ((rel && (rel.affinity || 0) >= friendThreshold) || (reverse && (reverse.affinity || 0) >= friendThreshold)) return "friend";
if ((rel && (rel.fear || 0) >= 8) || (reverse && (reverse.fear || 0) >= 8)) return "enemy";
return "";
},
resolveOwnedBedSleepConflicts() {
const alive = (this.tarinai || []).filter(t => t && !t.dead);
if (alive.length < 2) return 0;
const friendThreshold = typeof FRIEND_AFFINITY_THRESHOLD === "number" ? FRIEND_AFFINITY_THRESHOLD : 14;
const isFriendPair = (a, b) => {
const relAB = a?.relationTo ? a.relationTo(b?.id) : null;
const relBA = b?.relationTo ? b.relationTo(a?.id) : null;
return Boolean((relAB && (relAB.affinity || 0) >= friendThreshold) || (relBA && (relBA.affinity || 0) >= friendThreshold));
};
const bedSleepers = new Map();
for (const sleeper of alive) {
if (!(sleeper.sleeping || sleeper.state === "sleep")) continue;
const bed = sleeper.target;
if (!bed || bed.dead || bed.type !== "grass_bed") continue;
if (distXY(sleeper.x, sleeper.y, bed.x, bed.y) > Math.max((bed.r || 18) * 1.55, 32)) continue;
if (!bedSleepers.has(bed.id)) bedSleepers.set(bed.id, []);
bedSleepers.get(bed.id).push(sleeper);
}
let conflicts = 0;
for (const intruder of alive) {
if (!(intruder.state === "seek_bed" || intruder.state === "sleep")) continue;
const bed = intruder.target;
if (!bed || bed.dead || bed.type !== "grass_bed") continue;
const sleepers = bedSleepers.get(bed.id) || [];
if (!sleepers.length) continue;
if (distXY(intruder.x, intruder.y, bed.x, bed.y) > Math.max((bed.r || 18) * 2.2, 54)) continue;
const defender = sleepers.find(other => other && other !== intruder && !this.areParentChild?.(intruder, other) && !isFriendPair(intruder, other));
if (!defender) continue;
if (this.startForcedFight?.(defender, intruder)) conflicts += 1;
}
return conflicts;
},
nestBoxOccupants(box, limit = 5) {
if (!box || box.dead || box.type !== "nest_box") return [];
const occupants = this.tarinai.filter(t => t && !t.dead && t.insideNestBoxId === box.id);
@ -188,24 +223,30 @@
this.updateWeather(dt);
this.foodSpoilagePenalty = Math.max(0, (this.foodSpoilagePenalty || 0) - dt * 0.010);
this.updateTerrainDirtyState?.(dt);
this.rebuildSpatial();
this.spatialRebuildsThisFrame = 0;
this.spatialDirtyMarksThisFrame = 0;
this.ensureSpatial?.("update-start");
const perf = this.performanceLevel();
const itemCountBefore = this.items.length;
for (const it of this.items) it.update(dt, this);
if (this.items.length) this.markSpatialDirty?.("items-updated");
if (this.itemCounts?.ball) {
this.rebuildSpatial();
this.ensureSpatial?.("ball-ball-collisions");
this.resolveBallBallCollisions(dt);
this.rebuildSpatial();
this.markSpatialDirty?.("ball-ball-collisions");
}
this.updateAnts?.(dt);
if ((this.ants || []).length) this.markSpatialDirty?.("ants-updated");
for (const ef of this.effects) ef.update(dt);
for (const t of this.tarinai) {
t.update(dt);
}
this.rebuildSpatial();
this.resolveOwnedBedSleepConflicts?.();
if (this.tarinai.length) this.markSpatialDirty?.("tarinai-updated");
this.ensureSpatial?.("post-tarinai-update");
this.limitBallChasers();
this.resolveBallInteractions(dt);
this.resolveTarinaiHighSpeedCollisions(dt);
@ -259,7 +300,7 @@
const grassCount = this.itemCounts.grass || 0;
if (grassCount < Math.min(54, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 180)) && Math.random() < dt * 0.082) {
if (grassCount < Math.min(108, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 360)) && Math.random() < dt * 0.109) {
const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), {
allowOriginal: true,
minRadius: 28,
@ -270,9 +311,7 @@
const sprout = new Item("grass", spot.x, spot.y);
sprout.amount = rand(30, 62);
sprout.growth = rand(0.18, 0.38);
this.items.push(sprout);
this.itemCounts.grass = grassCount + 1;
this.markTerrainDirty?.("grass-spawn");
this.addItem?.(sprout, "grass-spawn") || this.items.push(sprout);
}
}
@ -281,12 +320,12 @@
// share the rain overlay renderer.
if (window.TarinaiWeatherSystem?.shouldDropRainWater?.(this, dt)) {
const drop = window.TarinaiWeatherSystem.createRainWaterItem(this);
this.items.push(drop);
this.itemCounts.water = (this.itemCounts.water || 0) + 1;
this.markTerrainDirty?.("rain-water");
this.addItem?.(drop, "rain-water") || this.items.push(drop);
this.emit?.("rain:itemDropped", { item: drop, type: "water" });
}
this.lastRuntimeDiagnostics = this.runtimeDiagnostics?.() || null;
},
sanitizeOutOfBounds() {
@ -360,7 +399,8 @@
if (changedItems) {
this.compactItems();
this.updateItemCounts();
this.rebuildSpatial();
this.markSpatialDirty?.("sanitize-out-of-bounds");
this.ensureSpatial?.("sanitize-out-of-bounds");
}
},

View file

@ -42,6 +42,9 @@
markSpatialDirty(reason = "manual") {
this.spatialDirty = true;
this.spatialDirtyReason = reason;
this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1;
this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1;
this.lastSpatialDirtyReason = reason;
},
markTerrainDirty(reason = "manual") {

Binary file not shown.

View file

@ -4,21 +4,34 @@ This project intentionally stays buildless; this script is a lightweight manifes
"""
from __future__ import annotations
from pathlib import Path
import json, re
import json
import re
ROOT = Path(__file__).resolve().parents[1]
manifest = json.loads((ROOT / "app_manifest.json").read_text(encoding="utf-8"))
version = manifest["version"]
css_files = manifest["css"]
js_files = manifest["js"]
index = ROOT / "index.html"
s = index.read_text(encoding="utf-8")
css_block = "".join([f'\n <link rel="stylesheet" href="{f}?v={version}" />' for f in css_files])
s = re.sub(r'\s*<link rel="stylesheet" href="css/[^\"]+" />(?:\s*<link rel="stylesheet" href="css/[^\"]+" />)*', css_block, s, count=1)
s = re.sub(
r'\s*<link rel="stylesheet" href="css/[^\"]+" />(?:\s*<link rel="stylesheet" href="css/[^\"]+" />)*',
css_block,
s,
count=1,
)
script_block = "\n".join([f' <script src="{f}?v={version}" defer></script>' for f in js_files])
s = re.sub(r'\n\s*<script src="js/version\.js\?v=[^\"]+" defer></script>[\s\S]*?\n\s*<script src="js/debug_tools\.js\?v=[^\"]+" defer></script>', "\n" + script_block, s, count=1)
s = re.sub(
r'\n\s*<script src="js/version\.js\?v=[^\"]+" defer></script>[\s\S]*?\n\s*<script src="js/debug_tools\.js\?v=[^\"]+" defer></script>',
"\n" + script_block,
s,
count=1,
)
index.write_text(s, encoding="utf-8")
core_names = [Path(f).stem for f in js_files]
css_entries = ",\n ".join([f'`./{f}?${{v}}`' for f in css_files])
js_names = ", ".join([json.dumps(n, ensure_ascii=False) for n in core_names])
(ROOT / "service-worker.js").write_text(f'''"use strict";\n\nconst APP_VERSION = "{version}";\nconst CACHE_NAME = `tarinai-colony-${{APP_VERSION}}`;\nconst v = `v=${{APP_VERSION}}`;\n// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.\nconst coreScriptNames = [\n {js_names}\n];\nconst CORE_ASSETS = [\n "./",\n "./index.html",\n {css_entries},\n ...coreScriptNames.map(name => `./js/${{name}}.js?${{v}}`),\n];\n\nself.addEventListener("install", (event) => {{\n event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CORE_ASSETS)).catch(() => undefined));\n self.skipWaiting();\n}});\n\nself.addEventListener("activate", (event) => {{\n event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))));\n self.clients.claim();\n}});\n\nfunction isStaticAsset(request) {{\n const url = new URL(request.url);\n return /\\.(?:js|css|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(url.pathname);\n}}\n\nself.addEventListener("fetch", (event) => {{\n const request = event.request;\n if (request.method !== "GET") return;\n const url = new URL(request.url);\n if (url.origin !== self.location.origin) return;\n if (/service-worker\\.js$/i.test(url.pathname)) return;\n if (request.mode === "navigate" || /index\\.html$/i.test(url.pathname)) {{\n event.respondWith(fetch(request).then(response => {{\n const copy = response.clone();\n caches.open(CACHE_NAME).then(cache => cache.put(request, copy));\n return response;\n }}).catch(() => caches.match(request).then(cached => cached || caches.match("./index.html"))));\n return;\n }}\n if (isStaticAsset(request)) {{\n event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => {{\n const copy = response.clone();\n caches.open(CACHE_NAME).then(cache => cache.put(request, copy));\n return response;\n }})));\n }}\n}});\n''', encoding="utf-8")
(ROOT / "service-worker.js").write_text(f'''"use strict";\n\nconst APP_VERSION = "{version}";\nconst CACHE_NAME = `tarinai-colony-${{APP_VERSION}}`;\nconst v = `v=${{APP_VERSION}}`;\n// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.\nconst coreScriptNames = [\n {js_names}\n];\nconst CORE_ASSETS = [\n "./",\n "./index.html",\n {css_entries},\n ...coreScriptNames.map(name => `./js/${{name}}.js?${{v}}`),\n];\n\nself.addEventListener("install", (event) => {{\n event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CORE_ASSETS)).catch(() => undefined));\n self.skipWaiting();\n}});\n\nself.addEventListener("activate", (event) => {{\n event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)))));\n self.clients.claim();\n}});\n\nfunction isStaticAsset(request) {{\n const url = new URL(request.url);\n return /\\.(?:js|css|webp|png|jpg|jpeg|gif|svg|mp3|wav|woff2?)$/i.test(url.pathname);\n}}\n\nfunction cacheableResponse(response) {{\n return response && response.status === 200 && response.type !== "opaque";\n}}\n\nfunction putCacheSafely(request, response) {{\n if (!cacheableResponse(response)) return Promise.resolve(false);\n return caches.open(CACHE_NAME).then(cache => cache.put(request, response.clone())).then(() => true).catch(() => false);\n}}\n\nself.addEventListener("fetch", (event) => {{\n const request = event.request;\n if (request.method !== "GET") return;\n const url = new URL(request.url);\n if (url.origin !== self.location.origin) return;\n if (/service-worker\\.js$/i.test(url.pathname)) return;\n if (request.mode === "navigate" || /index\\.html$/i.test(url.pathname)) {{\n event.respondWith(fetch(request).then(response => {{\n putCacheSafely(request, response);\n return response;\n }}).catch(() => caches.match(request).then(cached => cached || caches.match("./index.html"))));\n return;\n }}\n if (isStaticAsset(request)) {{\n event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => {{\n putCacheSafely(request, response);\n return response;\n }})));\n }}\n}});\n''', encoding="utf-8")

408
scripts/regression_check.py Normal file
View file

@ -0,0 +1,408 @@
#!/usr/bin/env python3
"""Lightweight regression guard for the buildless Tarinai app.
Checks static-file consistency, generated-load order, syntax, and the ActionSpec
registry contract. This intentionally avoids save-data migration checks; save
compatibility was intentionally dropped in compact save v2.
"""
from __future__ import annotations
from pathlib import Path
import json
import re
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "app_manifest.json"
def fail(message: str) -> None:
print(f"[FAIL] {message}", file=sys.stderr)
raise SystemExit(1)
def ok(message: str) -> None:
print(f"[OK] {message}")
def read_text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def manifest() -> dict:
try:
data = json.loads(read_text(MANIFEST))
except Exception as exc: # pragma: no cover - diagnostic path
fail(f"cannot read app_manifest.json: {exc}")
for key in ("version", "css", "js"):
if key not in data:
fail(f"manifest missing {key}")
return data
def check_files_exist(data: dict) -> None:
missing = [p for p in [*data["css"], *data["js"]] if not (ROOT / p).exists()]
if missing:
fail("manifest references missing files: " + ", ".join(missing))
ok("manifest file references exist")
def check_versions(data: dict) -> None:
version = str(data["version"])
version_js = read_text(ROOT / "js/version.js")
service_worker = read_text(ROOT / "service-worker.js")
if f'APP_VERSION = "{version}"' not in version_js:
fail("js/version.js APP_VERSION does not match manifest")
if f'APP_VERSION = "{version}"' not in service_worker:
fail("service-worker.js APP_VERSION does not match manifest")
ok(f"version synchronized: {version}")
def check_index_order(data: dict) -> None:
index = read_text(ROOT / "index.html")
css = re.findall(r'<link rel="stylesheet" href="([^"]+)"\s*/>', index)
scripts = re.findall(r'<script src="([^"]+)" defer></script>', index)
css_clean = [entry.split("?", 1)[0] for entry in css if entry.startswith("css/")]
script_clean = [entry.split("?", 1)[0] for entry in scripts if entry.startswith("js/")]
if css_clean[: len(data["css"])] != data["css"]:
fail("index.html CSS order differs from manifest")
if script_clean[: len(data["js"])] != data["js"]:
fail("index.html JS order differs from manifest")
ok("index.html load order matches manifest")
def check_service_worker_order(data: dict) -> None:
sw = read_text(ROOT / "service-worker.js")
match = re.search(r"const coreScriptNames = \[\s*(.*?)\s*\];", sw, re.S)
if not match:
fail("service-worker.js coreScriptNames not found")
names = re.findall(r'"([^"]+)"', match.group(1))
expected = [Path(p).stem for p in data["js"]]
if names != expected:
fail("service-worker.js coreScriptNames differs from manifest JS order")
ok("service-worker.js core script order matches manifest")
def node_check(paths: list[Path]) -> None:
node = "node"
for path in paths:
result = subprocess.run([node, "--check", str(path)], cwd=ROOT, text=True, capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail(f"node --check failed: {path.relative_to(ROOT)}")
ok(f"node --check passed for {len(paths)} files")
def action_spec_smoke() -> None:
source = read_text(ROOT / "js/tarinai_action_spec.js")
js = f"""
const vm = require('vm');
const context = {{ console, globalThis: null }};
context.globalThis = context;
context.window = context;
vm.createContext(context);
vm.runInContext({source!r}, context, {{ filename: 'tarinai_action_spec.js' }});
const action = context.registerTarinaiActionSpec({{
id: 'smoke_action',
need: 'fulfill',
label: 'smoke',
selectTarget() {{ return {{ id: 'target-1' }}; }},
canStart(t, world, ctx) {{ const target = ctx.target || this.selectTarget(t, world, ctx); return target && target.id === 'target-1'; }},
start(t, world, ctx) {{ t.startedWith = ctx.target.id; return true; }},
tick() {{ return 'finished'; }},
finish(t) {{ t.finished = true; return true; }},
}});
const subject = {{}};
if (!context.canStartTarinaiAction('smoke_action', subject, {{}})) throw new Error('canStart failed');
if (!context.startTarinaiAction(action, subject, {{}})) throw new Error('start failed');
if (subject.startedWith !== 'target-1') throw new Error('selectTarget/start context failed');
if (context.tickTarinaiAction('smoke_action', subject, {{}}, 0.1, {{}}) !== 'finished') throw new Error('tick failed');
if (!context.finishTarinaiAction('smoke_action', subject, {{}}, {{ phase: 'finished' }})) throw new Error('finish failed');
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("ActionSpec smoke test failed")
ok("ActionSpec lifecycle smoke test passed")
def behavior_state_smoke() -> None:
source = read_text(ROOT / "js/tarinai_behavior_state.js")
js = f"""
const vm = require('vm');
const context = {{ console, globalThis: null }};
context.globalThis = context;
context.window = context;
context.Tarinai = class Tarinai {{}};
vm.createContext(context);
vm.runInContext({source!r}, context, {{ filename: 'tarinai_behavior_state.js' }});
const t = new context.Tarinai();
t.world = {{ time: 12 }};
t.state = 'seek_food';
context.setTarinaiBehavior(t, {{ id: 'eat_food', need: 'food', label: 'eat', reasonText: 'hungry', tiedNeeds: ['food', 'sleep'], forced: true, forcedId: 'f1', source: 'test', forcedRequest: {{ uid: 'f1', id: 'eat_food', source: 'test', priority: 99, reasonText: 'forced hungry', status: 'active' }} }});
if (context.getTarinaiBehaviorId(t) !== 'eat_food') throw new Error('behavior id mismatch');
if (context.getTarinaiBehaviorNeed(t) !== 'food') throw new Error('behavior need mismatch');
if (!context.isTarinaiBehaviorForced(t)) throw new Error('forced flag not visible');
const forced = context.getTarinaiForcedBehavior(t);
if (!forced || forced.uid !== 'f1' || forced.priority !== 99) throw new Error('forced request not normalized');
if (t.intent.actionId !== 'eat_food' || t.currentAction.need !== 'food' || t.activeForcedBehavior.uid !== 'f1') throw new Error('legacy views not synced');
context.setTarinaiForcedBehavior(t, null);
if (context.currentTarinaiBehavior(t).forced || context.getTarinaiForcedBehavior(t)) throw new Error('forced flag not cleared');
context.clearTarinaiBehavior(t);
if (context.currentTarinaiBehavior(t) !== null) throw new Error('behavior not cleared');
"""
with tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix=".js", delete=False) as f:
f.write(js)
tmp = Path(f.name)
try:
result = subprocess.run(["node", str(tmp)], cwd=ROOT, text=True, capture_output=True)
finally:
tmp.unlink(missing_ok=True)
if result.returncode != 0:
sys.stderr.write(result.stdout)
sys.stderr.write(result.stderr)
fail("Behavior state smoke test failed")
ok("behavior state smoke test passed")
def check_legacy_behavior_refs() -> None:
allowed = {"js/tarinai.js", "js/tarinai_behavior_state.js"}
tokens = re.compile(r"\b(activeBehavior|currentAction|activeForcedBehavior|intent)\b")
offenders = []
for path in (ROOT / "js").glob("*.js"):
rel = path.relative_to(ROOT).as_posix()
if rel in allowed:
continue
for i, line in enumerate(read_text(path).splitlines(), start=1):
if tokens.search(line) and "intentLockTimer" not in line and "//" not in line:
offenders.append(f"{rel}:{i}")
if offenders:
fail("legacy behavior fields outside compatibility layer: " + ", ".join(offenders[:20]))
ok("legacy behavior fields isolated to compatibility layer")
def check_basic_action_spec_migration() -> None:
source = read_text(ROOT / "js/tarinai_needs_items.js")
required = {
"eat_food": "createConsumableActionSpec",
"drink_water": "createConsumableActionSpec",
"use_medicine": "createConsumableActionSpec",
"sleep_in_bed": "createSleepInBedActionSpec",
"sleep_build_grass_bed": "createSleepBuildGrassBedActionSpec",
"sleep_anywhere": "createSleepAnywhereActionSpec",
"rest_to_recover": "createRestActionSpec",
"sunbath": "createSunbathActionSpec",
"wander_lightly": "createWanderActionSpec",
}
for action_id, factory in required.items():
if factory not in source:
fail(f"basic ActionSpec factory missing for {action_id}: {factory}")
forbidden_late_ticks = [
'set("eat_food",',
'set("drink_water",',
'set("use_medicine",',
'set("sunbath",',
]
offenders = [token for token in forbidden_late_ticks if token in source]
if offenders:
fail("basic actions still patched in configureTarinaiNeedActions: " + ", ".join(offenders))
ok("basic need actions migrated into ActionSpec factories")
def check_social_action_spec_migration() -> None:
source = read_text(ROOT / "js/tarinai_needs_items.js")
required_factories = [
"createPanicEscapeActionSpec",
"createFleeActionSpec",
"createApproachFriendActionSpec",
"createApproachFamilyActionSpec",
"createApproachMateActionSpec",
"createFightRivalActionSpec",
"createIntimidateEnemyActionSpec",
"createBirthRitualActionSpec",
]
for factory in required_factories:
if factory not in source:
fail(f"social/combat ActionSpec factory missing: {factory}")
definitions_match = re.search(r"const TARINAI_ACTION_DEFINITIONS = \[(.*?)\];", source, re.S)
if not definitions_match:
fail("TARINAI_ACTION_DEFINITIONS block missing")
definitions = definitions_match.group(1)
for factory in required_factories:
if f"{factory}()" not in definitions:
fail(f"{factory} is not registered in TARINAI_ACTION_DEFINITIONS")
if "function configureTarinaiNeedActions" in source:
fail("configureTarinaiNeedActions should not be needed after ActionSpec runtime migration")
forbidden_late_ticks = [
'set("panic_escape",',
'set("flee",',
'set("approach_friend",',
'set("approach_parent_or_child",',
'set("approach_mate",',
'set("fight_rival",',
'set("intimidate_enemy",',
'set("sunbath",',
"birthRitualAction",
"TARINAI_ACTIONS.push",
]
offenders = [token for token in forbidden_late_ticks if token in source]
if offenders:
fail("actions still patched late: " + ", ".join(offenders))
ok("social/fear/combat/mate/birth/sunbath actions migrated into ActionSpec factories")
def check_runtime_diagnostics_hooks() -> None:
spatial_budget = read_text(ROOT / "js/world_spatial_budget.js")
debug_tools = read_text(ROOT / "js/debug_tools.js")
world_update = read_text(ROOT / "js/world_update.js")
if "runtimeDiagnostics()" not in spatial_budget:
fail("World.runtimeDiagnostics hook missing")
if "lastRuntimeDiagnostics" not in world_update:
fail("world update does not publish lastRuntimeDiagnostics")
if "spatial rebuild" not in debug_tools or "behavior forced" not in debug_tools:
fail("debug overlay is missing runtime diagnostic lines")
ok("runtime diagnostics hooks present")
def check_social_balance_guards() -> None:
needs = read_text(ROOT / "js/tarinai_needs_items.js")
world_social = read_text(ROOT / "js/world_family_social.js")
social_move = read_text(ROOT / "js/tarinai_social_move_life.js")
required_need_tokens = [
"fight_rival: 56",
"intimidate_enemy: 42",
"approach_mate: 68",
"approach_friend: 66",
"return ({ conflict: 34, mate: 24, family: 12, bond: 14",
"if (subNeed !== \"conflict\" && subValue >= threshold) return true",
"subValue >= threshold && subValue >= socialPull * 0.82",
]
for token in required_need_tokens:
if token not in needs:
fail(f"social balance guard token missing: {token}")
if "canStart: basicCanStartWithTarget,\n start(t, world, ctx = {}) {\n const rival" in needs:
fail("intimidate_enemy reverted to ungated basicCanStart")
required_world_tokens = [
"fightPairCooldowns",
"fightPairKey(a, b)",
"fightPairCooldownRemaining(a, b)",
"markFightPairCooldown(a, b",
"isAlreadyFightingPair(a, b)",
"if (this.isAlreadyFightingPair?.(a, b)) return true",
]
for token in required_world_tokens:
if token not in world_social:
fail(f"fight re-entry guard missing: {token}")
if "dt * (battleDrug ? 0.23 : 0.085)" not in social_move:
fail("ordinary proximity fight probability guard missing")
ok("social balance and fight re-entry guards present")
def check_item_bucket_polish() -> None:
spatial_budget = read_text(ROOT / "js/world_spatial_budget.js")
required_tokens = [
"markItemBucketsDirty(reason",
"ensureItemBuckets(reason",
"addItem(item",
"itemById(id)",
"itemsOfType(type)",
"liveItemsOfType(type",
"this.itemIdMap = new Map()",
"bucketRebuildsTotal",
]
for token in required_tokens:
if token not in spatial_budget:
fail(f"item bucket polish helper missing: {token}")
if "return item && !item.dead ? item : null" not in spatial_budget:
fail("itemById must not return dead items")
debug_tools = read_text(ROOT / "js/debug_tools.js")
if "bucket" not in debug_tools.lower() and "id-map" not in debug_tools.lower():
fail("debug overlay is missing item bucket/id-map diagnostics")
routed_files = {
"js/world_placement_log.js": "addItem?.(item",
"js/world_update.js": "addItem?.(drop",
"js/world_combat_effects.js": "addItem?.(it",
"js/ants.js": "addItem?.(corpse",
"js/tarinai_social_move_life.js": "addItem?.(new Item(\"trace\"",
"js/tarinai_needs_items.js": "addItem?.(structure",
}
for rel, token in routed_files.items():
if token not in read_text(ROOT / rel):
fail(f"runtime item creation path is not routed through addItem: {rel}")
ok("runtime item bucket/id-map polish present")
def check_compact_save_v2() -> None:
snapshot = read_text(ROOT / "js/snapshot_system.js")
save = read_text(ROOT / "js/save_system.js")
required_snapshot = [
"const SNAPSHOT_VERSION = 2",
"function compactWorld",
"function compactTarinai",
"function compactItem",
"function compactAnt",
"logs = []",
"worldRef.family = {}",
"recordFamily",
]
for token in required_snapshot:
if token not in snapshot:
fail(f"compact save v2 snapshot token missing: {token}")
forbidden_snapshot = [
"logs: clonePlain",
"effects: (worldRef.effects",
"summary:",
"app: \"tarinai_colony_game\"",
]
offenders = [token for token in forbidden_snapshot if token in snapshot]
if offenders:
fail("old broad snapshot fields still present: " + ", ".join(offenders))
required_save = [
'STORAGE_PREFIX = "tarinai_save_slot_v2_"',
'EXPORT_PREFIX = "TN2!"',
"SAVE_TEXT_ALPHABET",
"base91Encode",
"base91Decode",
'CompressionStream("deflate-raw")',
"DecompressionStream",
"async function encodeSnapshot",
"async function decodeSnapshot",
]
for token in required_save:
if token not in save:
fail(f"compact save v2 encoder token missing: {token}")
if "TARINAI_SAVE_V1" in save or "tarinai_save_slot_v1" in save:
fail("old save v1 prefix still present")
ok("compact save v2 snapshot and encoder present")
def main() -> None:
data = manifest()
check_files_exist(data)
check_versions(data)
check_index_order(data)
check_service_worker_order(data)
node_check([*(ROOT / "js").glob("*.js"), ROOT / "service-worker.js"])
action_spec_smoke()
behavior_state_smoke()
check_basic_action_spec_migration()
check_social_action_spec_migration()
check_runtime_diagnostics_hooks()
check_social_balance_guards()
check_item_bucket_polish()
check_compact_save_v2()
check_legacy_behavior_refs()
if __name__ == "__main__":
main()

View file

@ -1,11 +1,11 @@
"use strict";
const APP_VERSION = "15.24.14";
const APP_VERSION = "15.24.35";
const CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const v = `v=${APP_VERSION}`;
// Generated from app_manifest.json. Run scripts/generate_app_files.py after changing static files.
const coreScriptNames = [
"version", "event_bus", "performance_manager", "registry_base", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "render", "sim_core", "items", "ants", "health", "tarinai", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "save_system", "patch_helpers", "freeze_system", "relation_event_system", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
"version", "event_bus", "performance_manager", "registry_base", "disease_registry", "effect_registry", "sound_pack", "data", "item_visual_registry", "food_registry", "input_mode_manager", "math", "physics_helpers", "assets", "audio", "render", "sim_core", "structures", "items", "ants", "health", "tarinai", "tarinai_action_spec", "tarinai_behavior_state", "tarinai_identity_social", "tarinai_action_state", "tarinai_disease_nest", "tarinai_item_effects", "tarinai_needs_items", "tarinai_social_move_life", "tarinai_render", "world", "world_view", "family_graph", "world_family_social", "world_combat_effects", "world_environment", "world_spatial_budget", "world_ants_system", "weather_system", "world_update", "world_placement_log", "world_bootstrap", "text_catalog", "ui", "ui_log", "ui_helpers", "ui_selected", "snapshot_system", "save_system", "patch_helpers", "freeze_system", "relation_event_system", "ui_layout_dialogs", "ui_tools", "ui_bind", "ui_charts", "ui_family_data", "ui_family_async", "ui_family_layout", "ui_family_paths", "ui_family_render", "main", "debug_tools"
];
const CORE_ASSETS = [
"./",