fulfillment

This commit is contained in:
33333-33333 2026-06-23 11:27:55 +09:00
commit 2e90add2e1
67 changed files with 5427 additions and 690 deletions

View file

@ -121,3 +121,128 @@
- Removed the remaining synchronous family-tree DFS fallback blocks in `ui_family_data.js` and `ui_family_render.js`; both now use `TarinaiFamilyGraph.connectedComponents()`.
- Simplified the remaining manual ant proximity fallback in `tarinai_needs_items.js` to `world.nearbyAnts()`.
- Verification: all `js/*.js` and `service-worker.js` pass `node --check`.
## 15.25.0 save/load UI and pin UI fix
- Fixed `Item.draw()` crash caused by an undefined `isOshibyo` local in the pin drawing branch.
- Reworked the save dialog into a 3x3 slot grid and added per-slot canvas thumbnails.
- Added compressed `TARINAI_SAVE_V2:` hash export/import using browser gzip streams when available; legacy `TARINAI_SAVE_V1:` imports remain supported.
- Replaced browser confirm prompts in save/load/import/delete flows with in-game save dialog confirmation UI.
- Added missing tool UI icons for `pushpin` and `oshibyo` and updated their tooltips.
- Renamed tool categories: 操作, 飲食, 薬, 生活, 危険物. Moved freeze to 操作 and fences to 生活.
- Made the frozen individual panel initially collapsed.
- Made colony mood span the full colony UI width with a compact height.
- Moved push notification cards inside the top of the Event UI.
- Bumped static cache version to `15.25.0`.
## v15.26.0 save/sunbath/signboard/freeze UI patch
- ずんちどれいの食対象を、ずんだ餅・草・水系を除く食べ物全般へ拡張。
- 看板を大型化し、観察/看板ツールでクリック編集、配置直後編集に対応。
- 日光浴の開始処理が呼ばれていなかった問題を修正し、発生確率と継続時間を大幅に増加。
- フィールド内の非地形オブジェクト・たりない・アリをY座標で統合ソートし、下側が前に出る描画順へ調整。
- 冷凍ボタンを操作カテゴリから廃止し、冷凍個体一覧を操作カテゴリ内部へ移動。
- セーブスロットを小型化し、サムネイルをスロット全面背景として表示。
- ハッシュ書き出しをV3コンパクト形式へ変更。イベントログ、エフェクト、吹き出し等の非本体情報は保存対象外。
- α版のため、セーブ/ハッシュ互換性は当面保証しない旨をコードコメントに明記。
## v15.27.0 hash V4 / layer / warning patch
- ハッシュ書き出しを `TARINAI_SAVE_V4:` に更新。V3の密な配列形式から、短い疎配列形式へ変更。
- V4ではゲームプレイ本体に必要な world / tarinai / item / ant / frozen 状態のみを保存し、ログ、イベント表示、エフェクト、吹き出し、サムネイル等は保存対象外。
- アイテム種別・スプライト種別は固定辞書の数値コードで保存し、初期状態で全アイテムを1つずつ置いたケースでも短くなるようにした。
- アイテムは `type,x,y` をヘッダ化し、その他はデフォルト値から変化した値だけを保存する方式へ変更。
- 干草寝床の描画レイヤーを同じY座標の他オブジェクトより少し後ろへ調整。
- ロード/インポート/削除の上書き警告を中央の大きなゲーム内UIとして表示。
- キャッシュ対策としてバージョンを `15.27.0` へ更新。
## v15.28.0 machine / pin placement patch
- おしり鋲を約半分の表示サイズに縮小し、刺さり位置をたりないスプライト右側へ移動。
- 画鋲/おしり鋲の刺さり表示は、たりないのスプライト反転に合わせて左右反転するよう調整。
- 新アイテム「複製機」を追加。消費アイテムツールで複製機をクリックするとスロットにセットし、下側の皿へ無限補充する。
- 新アイテム「大砲」を追加。消費アイテムツールで大砲をクリックするとスロットにセットし、クリック位置で360度向きを変更し、セット品を撃ち出す。
- 大砲弾として飛んだ消費アイテムは、高速時にたりないへ当たるとボール同様に吹き飛ばす。
- 複製機/大砲の道具UIアイコンを追加。
- セーブV4のアイテム種別表・追加キーへ複製機/大砲および機械スロット情報を追加。
- キャッシュ対策としてバージョンを `15.28.0` へ更新。
## v15.29.0 asset / save compression patch
- 全たりないスプライトを 512x468 以下に収まるよう縮小し、WebP を再圧縮。
- `ALPHA_BOUNDS` を現行画像寸法と透過領域から再生成。
- 音声サンプルを軽量 MP3 に置き換え、WAV を配布対象から除外。
- セーブハッシュ内の座標系数値は小数点第一位までに丸めるよう変更。
- コメントは削除せず、JS minify は行っていない。
- キャッシュ対策としてバージョンを `15.29.0` へ更新。
## v15.30.0 save hash v5 compact patch
- ハッシュ書き出しを `TARINAI_SAVE_V5:` に更新。
- V5はトップレベルを短い配列形式にし、world / たりない / item / ant / 冷凍個体をゲームプレイに必要な値だけで再パック。
- たりないの性格・遺伝・関係性・効果タイマー・状態/欲求/アイテム種別を短い配列または数値コード化。
- 初期値・決定論的に再生成できる値・UI/演出専用値はハッシュ保存対象から除外。
- 座標は引き続き小数点第一位まで保存。
- V4/V3/V2/V1の読み込み互換は一応残すが、α版のため今後の互換性保証はしない。
- キャッシュ対策としてバージョンを `15.30.0` へ更新。
## 15.31.0 - Hash V6 compaction
- Added `TARINAI_SAVE_V6:` export format.
- Further shortened hash text while preserving gameplay state.
- Removed individual observation history/name fields from hash exports:
- names
- former names
- parent display names
- personality daily history
- mystery drug history
- recent change records
- per-individual observation records
- Replaced long family keys in hash payload with compact numeric family references.
- Encoded live IDs like `t12` as numeric IDs in hash payload and restored them on load.
- Packed tarinai core gameplay stats into fixed compact rows.
- Kept gameplay-critical data:
- needs/status
- position and movement-relevant values
- personality and genetics
- parent/child links
- relationships
- diseases
- zunchi slave state
- item effects
- oshibyo/zunchi stock
- sunbath/fear/fall gameplay timers
- items, machines, ants, frozen tarinai
- Flattened item base records remains active from V6 export.
- Hash remains ASCII base64url + gzip for copy/paste and editor durability.
## 15.32.0 - Hash V7 base91/name preservation
- Added `TARINAI_SAVE_V7:` hash export.
- V7 uses gzip plus a 91-character printable ASCII transport alphabet instead of base64url.
- V7 keeps individual names in compact hash exports.
- V7 excludes dead tarinai rows from compact hash exports so family graph history does not revive/save dead individuals.
- V7 filters parent/child and relationship links to living or frozen individuals; pending frozen death notices remain gameplay data.
- V7 stores selected numeric tarinai stats as relative deltas from common defaults while preserving the existing compact numeric precision.
- V1-V6 import support remains.
## 15.33.0 - Hash V8 current-only compact save
- Hash exports now use `TARINAI_SAVE_V8:` only. Old hash import compatibility was intentionally removed for the alpha save format.
- Added an untouched-initial-colony sentinel. Initial colonies now export as a compact reset marker plus names instead of full entity/item rows, targeting roughly 100 characters for the initial state depending on generated names.
- Frozen individual save data no longer preserves pending death notices or relationship/memory records.
- Dead family nodes remain omitted from compact hash saves.
- Individual names are preserved in V8 compact saves.
- V8 keeps printable ASCII transport using the 91-character alphabet, with an explicit compressed/uncompressed mode marker.
## 15.34.0 - Hash V9 binary compact format
- Removed the V8 initial-state-only save sentinel. All states now use the same compact format.
- Export hash format is now `TARINAI_SAVE_V9:` and legacy hash imports are rejected during alpha.
- Replaced JSON-based compact hashes with a dedicated binary packet plus printable ASCII Base94 transport.
- Stopped saving elapsed day count in compact hash; loaded compact hashes restore day as 1.
- Kept custom individual names, while default four-character names made only from `は / う / ぅ / っ` are packed as 2-bit character codes.
- Replaced saved tarinai/item IDs and family keys with array-index references; IDs are regenerated on load.
- Uses default-relative numeric deltas, optional timer tags, boolean bit fields, and 0.1-unit integer coordinates.
- Keeps gameplay-critical tarinai needs, personality, genetics, family links, relationships, item states, machines, ants, and frozen individuals; frozen pending-loss memory remains omitted.
## 15.35.0 - Hash V10 compact packet
- Changed exported hash prefix to `TARINAI-SAVE-V10:` so the full hash text no longer uses `_` in its version marker.
- Excluded `\\`, `*`, `'`, and `_` from the printable ASCII transport alphabet.
- Replaced the V9 generic array/value packet with a V10 field-specific binary packet for common gameplay data.
- Kept gameplay-critical state while reducing overhead for names, world data, tarinai core rows, item rows, ants, and frozen individuals.
- Kept elapsed day count out of the hash; loads restore to day 1.

View file

@ -1,5 +1,5 @@
{
"version": "15.24.0",
"version": "15.35.0",
"css": [
"css/base.css",
"css/layout.css",
@ -20,6 +20,7 @@
"js/food_registry.js",
"js/input_mode_manager.js",
"js/math.js",
"js/need_behavior_system.js",
"js/physics_helpers.js",
"js/assets.js",
"js/audio.js",
@ -74,7 +75,6 @@
"assets/ui/*.webp",
"assets/objects/*.webp",
"assets/sprites/*.webp",
"assets/sounds/*.wav",
"assets/sounds/*.mp3"
],
"notes": "Source of truth for static loading order; generated files are index.html script/link tags and service-worker.js cache list."

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

BIN
assets/ui/tool_cannon.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
assets/ui/tool_oshibyo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -400,15 +400,15 @@ h1 {
.tool[data-tool="water_bowl"] { --tool-icon: url("../assets/ui/tool_water_bowl.webp"); }
/* Generated parameter item tool icons. */
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); }
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.33.0"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.33.0"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.33.0"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.33.0"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.33.0"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.33.0"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.33.0"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.33.0"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.33.0"); }
.tool[data-tool="freeze"] { --tool-icon: none; }
.tool[data-tool="freeze"]::before {
content: "🧊";
@ -418,3 +418,11 @@ h1 {
text-align: center;
}
.tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; }
/* v15.28.0: missing pin tool icons. */
.tool[data-tool="pushpin"] { --tool-icon: url("../assets/ui/tool_pushpin.webp?v=15.33.0"); }
.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp?v=15.33.0"); }
/* v15.28.0: machine tool icons. */
.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp?v=15.33.0"); }
.tool[data-tool="cannon"] { --tool-icon: url("../assets/ui/tool_cannon.webp?v=15.33.0"); }

View file

@ -454,6 +454,14 @@
.selected-info .info-row[data-metric="zunchi"] { background: rgba(112, 142, 70, 0.10); }
.selected-info .info-row[data-metric="mood"] { background: rgba(222, 196, 86, 0.10); }
.selected-info .info-row[data-metric="lack"] { background: rgba(142, 112, 184, 0.10); }
.selected-info .selected-need-row.active {
background: rgba(124, 198, 70, 0.18);
box-shadow: inset 3px 0 0 rgba(99, 166, 52, 0.78);
}
.selected-info .selected-need-mark {
color: rgba(72, 126, 42, 0.95);
font-size: 0.82em;
}
.selected-personality-changes {
align-items: start;
}
@ -621,3 +629,179 @@
justify-content: flex-start;
}
}
/* v15.25.0: save menu grid, thumbnails, compact import/export confirmation. */
.save-dialog-panel {
width: min(860px, calc(100vw - 32px));
max-height: min(88vh, 790px);
}
.save-slot-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 14px 0;
}
.save-slot-card {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 6px;
min-height: 190px;
padding: 9px;
border: 1px solid rgba(115, 91, 62, 0.20);
border-radius: 14px;
background: rgba(255, 255, 255, 0.58);
box-shadow: 0 2px 8px rgba(68, 52, 33, 0.06);
}
.save-slot-card.empty {
background: rgba(250, 247, 240, 0.54);
}
.save-slot-thumb {
height: 82px;
border-radius: 10px;
overflow: hidden;
background: linear-gradient(180deg, rgba(231, 221, 204, 0.80), rgba(245, 240, 230, 0.90));
border: 1px solid rgba(115, 91, 62, 0.14);
display: grid;
place-items: center;
}
.save-slot-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.save-slot-thumb-empty {
font-size: 11px;
letter-spacing: .04em;
color: rgba(88, 72, 54, 0.48);
font-weight: 800;
}
.save-slot-title {
font-weight: 800;
color: #4f3928;
line-height: 1.15;
}
.save-slot-meta {
color: #6d5a46;
font-size: 12px;
line-height: 1.35;
min-height: 48px;
}
.save-slot-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
justify-content: stretch;
}
.save-slot-actions .btn.mini {
min-width: 0;
padding-inline: 5px;
}
.save-confirm-box {
margin-top: 10px;
padding: 10px;
border-radius: 14px;
border: 1px solid rgba(169, 90, 73, 0.26);
background: rgba(255, 245, 236, 0.96);
box-shadow: inset 0 0 0 1px rgba(255,255,255,.62);
}
.save-confirm-box.hidden { display: none; }
.save-confirm-message { font-size: 13px; line-height: 1.45; color: #4f3928; }
.save-confirm-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }
.save-hash-text { min-height: 82px; }
@media (max-width: 740px) {
.save-slot-grid { grid-template-columns: 1fr; }
.save-slot-card { min-height: 0; }
.save-slot-thumb { height: 96px; }
}
/* v15.25.0: compact full-width colony mood. */
.colony-stats .stat:nth-child(3) {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 9px;
min-height: 28px;
text-align: left;
}
.colony-stats .stat:nth-child(3) span { font-size: 10px; }
.colony-stats .stat:nth-child(3) strong { font-size: 14px; line-height: 1.15; text-align: right; }
/* v15.25.0: event push cards are contained at the top of the event UI. */
.log-push-overlay {
position: static;
z-index: auto;
width: 100%;
margin: 0 0 8px;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.log-push-toast {
box-shadow: 0 6px 18px rgba(46, 34, 24, 0.13);
padding: 8px 10px 8px 13px;
}
/* v15.28.0: smaller save slots with full-card thumbnails. */
.save-dialog-panel { width: min(760px, calc(100vw - 32px)); max-height: min(88vh, 760px); }
.save-slot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; }
.save-slot-card { position: relative; overflow: hidden; min-height: 126px; padding: 7px; grid-template-rows: auto 1fr auto; gap: 4px; background: rgba(255,255,255,.64); }
.save-slot-thumb { position: absolute; inset: 0; height: auto; border: 0; border-radius: inherit; z-index: 0; opacity: .88; }
.save-slot-thumb::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,250,241,.42), rgba(255,250,241,.78)); }
.save-slot-thumb img { object-fit: cover; }
.save-slot-thumb-empty { width:100%; height:100%; display:grid; place-items:center; background:rgba(240,234,222,.80); }
.save-slot-title, .save-slot-meta, .save-slot-actions { position: relative; z-index: 1; }
.save-slot-title { padding: 3px 6px; border-radius: 999px; background: rgba(255,251,244,.76); width: fit-content; }
.save-slot-meta { min-height: 34px; font-size: 11px; line-height: 1.25; padding: 5px 6px; border-radius: 10px; background: rgba(255,251,244,.70); }
.save-slot-actions { gap: 4px; }
.save-slot-actions .btn.mini { padding: 5px 4px; font-size: 11px; }
@media (max-width: 740px) { .save-slot-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } .save-slot-card { min-height: 118px; } }
@media (max-width: 520px) { .save-slot-grid { grid-template-columns: 1fr; } .save-slot-card { min-height: 128px; } }
/* v15.28.0: frozen list lives inside the Operation tool category. */
.tool-category[data-tool-category="operate"] .frozen-panel { grid-column: 1 / -1; margin: 4px 0 0; }
/* v15.28.0: large centered load/overwrite warning. */
.save-confirm-box {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 12000;
width: min(560px, calc(100vw - 32px));
margin: 0;
padding: 22px 24px;
border-radius: 22px;
border: 2px solid rgba(169, 90, 73, 0.34);
background: rgba(255, 248, 239, 0.98);
box-shadow: 0 24px 70px rgba(42, 30, 18, 0.30), inset 0 0 0 1px rgba(255,255,255,.74);
}
.save-confirm-box::before {
content: "";
position: fixed;
left: -100vw;
right: -100vw;
top: -100vh;
bottom: -100vh;
background: rgba(30, 22, 14, 0.24);
z-index: -1;
}
.save-confirm-message {
font-size: 18px;
line-height: 1.55;
font-weight: 800;
color: #4a2f23;
}
.save-confirm-actions {
margin-top: 18px;
gap: 12px;
}
.save-confirm-actions .btn {
min-width: 118px;
padding: 10px 16px;
font-size: 15px;
}

View file

@ -180,15 +180,15 @@
}
/* Item icons and mobile input mode */
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.20.16"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.20.16"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.20.16"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.20.16"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.20.16"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.20.16"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.20.16"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.20.16"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.20.16"); }
.tool[data-tool="laxative"] { --tool-icon: url("../assets/ui/tool_laxative.webp?v=15.30.0"); }
.tool[data-tool="protein"] { --tool-icon: url("../assets/ui/tool_protein.webp?v=15.30.0"); }
.tool[data-tool="niteropu"] { --tool-icon: url("../assets/ui/tool_niteropu.webp?v=15.30.0"); }
.tool[data-tool="ammo"] { --tool-icon: url("../assets/ui/tool_ammo.webp?v=15.30.0"); }
.tool[data-tool="mystery_drug"] { --tool-icon: url("../assets/ui/tool_mystery_drug.webp?v=15.30.0"); }
.tool[data-tool="mercury"] { --tool-icon: url("../assets/ui/tool_mercury.webp?v=15.30.0"); }
.tool[data-tool="giant_drug"] { --tool-icon: url("../assets/ui/tool_giant_drug.webp?v=15.30.0"); }
.tool[data-tool="dwarf_drug"] { --tool-icon: url("../assets/ui/tool_dwarf_drug.webp?v=15.30.0"); }
.tool[data-tool="zunda_juice"] { --tool-icon: url("../assets/ui/tool_zunda_juice.webp?v=15.30.0"); }
.mobile-mode-controls {
display: none;
position: absolute;
@ -239,3 +239,21 @@ body.touch-mobile-ui.mobile-mode-family .lineage-main-card { z-index: 9; }
text-align: center;
}
.tool[data-tool="freeze"]::after { content: "🧊"; background: none; font-size: 52px; right: -10px; left: auto; opacity: 0.08; display:flex; align-items:center; justify-content:flex-end; }
/* v15.28.0: keep push notifications inside the event card on mobile too. */
.log-push-overlay {
position: static;
z-index: auto;
width: 100%;
margin: 0 0 8px;
display: flex;
flex-direction: column;
gap: 6px;
pointer-events: none;
}
.tool[data-tool="pushpin"] { --tool-icon: url("../assets/ui/tool_pushpin.webp?v=15.30.0"); }
.tool[data-tool="oshibyo"] { --tool-icon: url("../assets/ui/tool_oshibyo.webp?v=15.30.0"); }
/* v15.28.0: machine tool icons. */
.tool[data-tool="duplicator"] { --tool-icon: url("../assets/ui/tool_duplicator.webp?v=15.30.0"); }
.tool[data-tool="cannon"] { --tool-icon: url("../assets/ui/tool_cannon.webp?v=15.30.0"); }

File diff suppressed because it is too large Load diff

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.0" />
<link rel="stylesheet" href="css/layout.css?v=15.24.0" />
<link rel="stylesheet" href="css/panel.css?v=15.24.0" />
<link rel="stylesheet" href="css/components.css?v=15.24.0" />
<link rel="stylesheet" href="css/mobile.css?v=15.24.0" />
<link rel="stylesheet" href="css/base.css?v=15.35.0" />
<link rel="stylesheet" href="css/layout.css?v=15.35.0" />
<link rel="stylesheet" href="css/panel.css?v=15.35.0" />
<link rel="stylesheet" href="css/components.css?v=15.35.0" />
<link rel="stylesheet" href="css/mobile.css?v=15.35.0" />
</head>
<body>
<div id="loadingScreen" class="loading-screen" aria-live="polite">
@ -99,6 +99,7 @@
<section class="card log-card collapsible-card" data-card-key="events">
<button class="panel-card-toggle" type="button" aria-expanded="true"><span class="panel-card-title">&#x30A4;&#x30D9;&#x30F3;&#x30C8;</span><span class="panel-card-state">&#x6298;&#x308A;&#x7573;&#x307F;</span></button>
<div class="panel-card-body">
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<div id="log" class="log"></div>
<div class="log-push-controls" id="logPushControls" aria-label="&#x30D7;&#x30C3;&#x30B7;&#x30E5;&#x901A;&#x77E5;&#x8A2D;&#x5B9A;">
<label class="push-master"><input id="logPushEnabled" type="checkbox"> &#x30D7;&#x30C3;&#x30B7;&#x30E5;&#x901A;&#x77E5;</label>
@ -182,67 +183,67 @@
</div>
</div>
<div id="logPushOverlay" class="log-push-overlay" aria-live="polite" aria-atomic="false"></div>
<script src="js/version.js?v=15.24.0" defer></script>
<script src="js/event_bus.js?v=15.24.0" defer></script>
<script src="js/performance_manager.js?v=15.24.0" defer></script>
<script src="js/registry_base.js?v=15.24.0" defer></script>
<script src="js/disease_registry.js?v=15.24.0" defer></script>
<script src="js/effect_registry.js?v=15.24.0" defer></script>
<script src="js/sound_pack.js?v=15.24.0" defer></script>
<script src="js/data.js?v=15.24.0" defer></script>
<script src="js/item_visual_registry.js?v=15.24.0" defer></script>
<script src="js/food_registry.js?v=15.24.0" defer></script>
<script src="js/input_mode_manager.js?v=15.24.0" defer></script>
<script src="js/math.js?v=15.24.0" defer></script>
<script src="js/physics_helpers.js?v=15.24.0" defer></script>
<script src="js/assets.js?v=15.24.0" defer></script>
<script src="js/audio.js?v=15.24.0" defer></script>
<script src="js/render.js?v=15.24.0" defer></script>
<script src="js/sim_core.js?v=15.24.0" defer></script>
<script src="js/items.js?v=15.24.0" defer></script>
<script src="js/ants.js?v=15.24.0" defer></script>
<script src="js/health.js?v=15.24.0" defer></script>
<script src="js/tarinai.js?v=15.24.0" defer></script>
<script src="js/tarinai_identity_social.js?v=15.24.0" defer></script>
<script src="js/tarinai_action_state.js?v=15.24.0" defer></script>
<script src="js/tarinai_disease_nest.js?v=15.24.0" defer></script>
<script src="js/tarinai_item_effects.js?v=15.24.0" defer></script>
<script src="js/tarinai_needs_items.js?v=15.24.0" defer></script>
<script src="js/tarinai_social_move_life.js?v=15.24.0" defer></script>
<script src="js/tarinai_render.js?v=15.24.0" defer></script>
<script src="js/world.js?v=15.24.0" defer></script>
<script src="js/world_view.js?v=15.24.0" defer></script>
<script src="js/family_graph.js?v=15.24.0" defer></script>
<script src="js/world_family_social.js?v=15.24.0" defer></script>
<script src="js/world_combat_effects.js?v=15.24.0" defer></script>
<script src="js/world_environment.js?v=15.24.0" defer></script>
<script src="js/world_spatial_budget.js?v=15.24.0" defer></script>
<script src="js/world_ants_system.js?v=15.24.0" defer></script>
<script src="js/weather_system.js?v=15.24.0" defer></script>
<script src="js/world_update.js?v=15.24.0" defer></script>
<script src="js/world_placement_log.js?v=15.24.0" defer></script>
<script src="js/world_bootstrap.js?v=15.24.0" defer></script>
<script src="js/text_catalog.js?v=15.24.0" defer></script>
<script src="js/ui.js?v=15.24.0" defer></script>
<script src="js/ui_log.js?v=15.24.0" defer></script>
<script src="js/ui_helpers.js?v=15.24.0" defer></script>
<script src="js/ui_selected.js?v=15.24.0" defer></script>
<script src="js/snapshot_system.js?v=15.24.0" defer></script>
<script src="js/save_system.js?v=15.24.0" defer></script>
<script src="js/patch_helpers.js?v=15.24.0" defer></script>
<script src="js/freeze_system.js?v=15.24.0" defer></script>
<script src="js/relation_event_system.js?v=15.24.0" defer></script>
<script src="js/ui_layout_dialogs.js?v=15.24.0" defer></script>
<script src="js/ui_tools.js?v=15.24.0" defer></script>
<script src="js/ui_bind.js?v=15.24.0" defer></script>
<script src="js/ui_charts.js?v=15.24.0" defer></script>
<script src="js/ui_family_data.js?v=15.24.0" defer></script>
<script src="js/ui_family_async.js?v=15.24.0" defer></script>
<script src="js/ui_family_layout.js?v=15.24.0" defer></script>
<script src="js/ui_family_paths.js?v=15.24.0" defer></script>
<script src="js/ui_family_render.js?v=15.24.0" defer></script>
<script src="js/main.js?v=15.24.0" defer></script>
<script src="js/debug_tools.js?v=15.24.0" defer></script>
<script src="js/version.js?v=15.35.0" defer></script>
<script src="js/event_bus.js?v=15.35.0" defer></script>
<script src="js/performance_manager.js?v=15.35.0" defer></script>
<script src="js/registry_base.js?v=15.35.0" defer></script>
<script src="js/disease_registry.js?v=15.35.0" defer></script>
<script src="js/effect_registry.js?v=15.35.0" defer></script>
<script src="js/sound_pack.js?v=15.35.0" defer></script>
<script src="js/data.js?v=15.35.0" defer></script>
<script src="js/item_visual_registry.js?v=15.35.0" defer></script>
<script src="js/food_registry.js?v=15.35.0" defer></script>
<script src="js/input_mode_manager.js?v=15.35.0" defer></script>
<script src="js/math.js?v=15.35.0" defer></script>
<script src="js/need_behavior_system.js?v=15.35.0" defer></script>
<script src="js/physics_helpers.js?v=15.35.0" defer></script>
<script src="js/assets.js?v=15.35.0" defer></script>
<script src="js/audio.js?v=15.35.0" defer></script>
<script src="js/render.js?v=15.35.0" defer></script>
<script src="js/sim_core.js?v=15.35.0" defer></script>
<script src="js/items.js?v=15.35.0" defer></script>
<script src="js/ants.js?v=15.35.0" defer></script>
<script src="js/health.js?v=15.35.0" defer></script>
<script src="js/tarinai.js?v=15.35.0" defer></script>
<script src="js/tarinai_identity_social.js?v=15.35.0" defer></script>
<script src="js/tarinai_action_state.js?v=15.35.0" defer></script>
<script src="js/tarinai_disease_nest.js?v=15.35.0" defer></script>
<script src="js/tarinai_item_effects.js?v=15.35.0" defer></script>
<script src="js/tarinai_needs_items.js?v=15.35.0" defer></script>
<script src="js/tarinai_social_move_life.js?v=15.35.0" defer></script>
<script src="js/tarinai_render.js?v=15.35.0" defer></script>
<script src="js/world.js?v=15.35.0" defer></script>
<script src="js/world_view.js?v=15.35.0" defer></script>
<script src="js/family_graph.js?v=15.35.0" defer></script>
<script src="js/world_family_social.js?v=15.35.0" defer></script>
<script src="js/world_combat_effects.js?v=15.35.0" defer></script>
<script src="js/world_environment.js?v=15.35.0" defer></script>
<script src="js/world_spatial_budget.js?v=15.35.0" defer></script>
<script src="js/world_ants_system.js?v=15.35.0" defer></script>
<script src="js/weather_system.js?v=15.35.0" defer></script>
<script src="js/world_update.js?v=15.35.0" defer></script>
<script src="js/world_placement_log.js?v=15.35.0" defer></script>
<script src="js/world_bootstrap.js?v=15.35.0" defer></script>
<script src="js/text_catalog.js?v=15.35.0" defer></script>
<script src="js/ui.js?v=15.35.0" defer></script>
<script src="js/ui_log.js?v=15.35.0" defer></script>
<script src="js/ui_helpers.js?v=15.35.0" defer></script>
<script src="js/ui_selected.js?v=15.35.0" defer></script>
<script src="js/snapshot_system.js?v=15.35.0" defer></script>
<script src="js/save_system.js?v=15.35.0" defer></script>
<script src="js/patch_helpers.js?v=15.35.0" defer></script>
<script src="js/freeze_system.js?v=15.35.0" defer></script>
<script src="js/relation_event_system.js?v=15.35.0" defer></script>
<script src="js/ui_layout_dialogs.js?v=15.35.0" defer></script>
<script src="js/ui_tools.js?v=15.35.0" defer></script>
<script src="js/ui_bind.js?v=15.35.0" defer></script>
<script src="js/ui_charts.js?v=15.35.0" defer></script>
<script src="js/ui_family_data.js?v=15.35.0" defer></script>
<script src="js/ui_family_async.js?v=15.35.0" defer></script>
<script src="js/ui_family_layout.js?v=15.35.0" defer></script>
<script src="js/ui_family_paths.js?v=15.35.0" defer></script>
<script src="js/ui_family_render.js?v=15.35.0" defer></script>
<script src="js/main.js?v=15.35.0" defer></script>
<script src="js/debug_tools.js?v=15.35.0" defer></script>
</body>
</html>

View file

@ -50,7 +50,7 @@ const TOOL_DEFINITIONS = Object.freeze({
observe: { id: "observe", label: "\u89b3\u5bdf", placeable: false, scalable: false, icon: "assets/ui/tool_observe.webp", tooltip: "\u500b\u4f53\u3092\u9078\u629e\u3057\u3001\u72b6\u614b\u30fb\u6027\u683c\u30fb\u75c5\u6c17\u30fb\u304a\u6c17\u306b\u5165\u308a\u3092\u78ba\u8a8d\u3059\u308b\u3002" },
delete: { id: "delete", label: "\u524a\u9664", placeable: false, scalable: false, icon: "assets/ui/tool_delete.webp", tooltip: "\u7f6e\u3044\u305f\u9053\u5177\u3084\u6c5a\u308c\u3092\u6d88\u3059\u3002" },
poke: { id: "poke", label: "\u3064\u3064\u304f", placeable: false, scalable: false, iconText: "\u{1F448}", tooltip: "\u305f\u308a\u306a\u3044\u3092\u3064\u3064\u304f\u3002\u30dc\u30fc\u30eb\u3082\u3064\u3064\u3044\u3066\u8ee2\u304c\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "\u305f\u308a\u306a\u3044\u3084\u7269\u3092\u3064\u307e\u3093\u3067\u79fb\u52d5\u3055\u305b\u308b\u3002" },
pinch: { id: "pinch", label: "\u3064\u307e\u3080", placeable: false, scalable: false, iconText: "\u{1F90F}", tooltip: "たりないや物を移動させる。冷凍庫にも持っていける。" },
new: { id: "new", label: "\u8ffd\u52a0", placeable: false, scalable: false, icon: "assets/ui/tool_new.webp", tooltip: "\u753b\u9762\u5916\u304b\u3089\u65b0\u3057\u3044\u305f\u308a\u306a\u3044\u3092\u547c\u3076\u3002" },
water_hose: { id: "water_hose", label: "\u6d17\u6d44", placeable: false, scalable: false, iconText: "\u{1F6BF}", tooltip: "\u30c9\u30e9\u30c3\u30b0\u3067\u6c5a\u308c\u3092\u304d\u308c\u3044\u306b\u3059\u308b\u3002" },
freeze: { id: "freeze", label: "冷凍", placeable: false, scalable: false, iconText: "\u{1F9CA}", tooltip: "つまむでたりないを冷凍個体UIまで運ぶと冷凍保存する。" },
@ -77,10 +77,12 @@ const TOOL_DEFINITIONS = Object.freeze({
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: 24, amount: 999, iconText: "\u{1FAA7}", collisionShape: "circle", tooltip: "\u77ed\u3044\u30c6\u30ad\u30b9\u30c8\u3092\u66f8\u304d\u8fbc\u3081\u308b\u770b\u677f\u3002\u305f\u308a\u306a\u3044\u306b\u5bfe\u3059\u308b\u52b9\u679c\u306f\u7121\u3057" },
signboard: { id: "signboard", itemType: "signboard", label: "\u770b\u677f", placeable: true, scalable: false, radius: 42, amount: 999, iconText: "\u{1FAA7}", collisionShape: "circle", tooltip: "短いテキストを書き込める大きめの看板。クリックで編集できる。たりないに対する効果は無し" },
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: "おしり鋲", placeable: true, scalable: false, radius: 9, amount: 999, icon: "assets/objects/oshibyo.webp", tooltip: "刺さるとずんちが出なくなる。つまんで抜くと溜まったずんちが弾け飛ぶ。" },
pushpin: { id: "pushpin", itemType: "pushpin", label: "\u753b\u92f2", placeable: true, scalable: false, radius: 8, amount: 999, icon: "assets/ui/tool_pushpin.webp", tooltip: "転がってたりないに刺さる。" },
oshibyo: { id: "oshibyo", itemType: "oshibyo", label: "おしり鋲", placeable: true, scalable: false, radius: 5, amount: 999, icon: "assets/ui/tool_oshibyo.webp", tooltip: "転がってずんちのおでかけを防止。" },
duplicator: { id: "duplicator", itemType: "duplicator", label: "複製機", placeable: true, scalable: false, radius: 44, amount: 999, icon: "assets/ui/tool_duplicator.webp", collisionShape: "circle", tooltip: "セットした消費アイテムを減らさず皿に出し続ける。" },
cannon: { id: "cannon", itemType: "cannon", label: "大砲", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_cannon.webp", collisionShape: "circle", tooltip: "セットした消費アイテムを向いている方向へ撃ち出す。クリック位置で向きを変える。" },
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
fence_h: { id: "fence_h", itemType: "fence_h", label: "\u6a2a\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_h.webp", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
water: { id: "water", itemType: "water", label: "\u6c34", placeable: false, scalable: false, radius: 12, amount: 64, simulationOnly: true },
@ -92,13 +94,14 @@ const TOOL_DEFINITIONS = Object.freeze({
const ITEM_TRAITS = Object.freeze({
obstacle: new Set(["stone", "ball", "nest_box", "bed", "fence_v", "fence_h"]),
obstacle: new Set(["stone", "ball", "nest_box", "bed", "fence_v", "fence_h", "duplicator", "cannon"]),
food_interest: new Set(["sweet", "love_mochi", "fight_mochi", "grass", "water", "water_bowl", "ant_corpse", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice", "sleep_drug"]),
hazard: new Set(["firecracker", "genkotsu", "pushpin", "oshibyo", "sleep_drug", "zunchi", "splat", "mystery_drug", "laxative", "niteropu", "mercury", "giant_drug", "dwarf_drug"]),
pin: new Set(["pushpin", "oshibyo"]),
kinematic: new Set(["ball", "pushpin", "oshibyo", "zunchi"]),
machine: new Set(["duplicator", "cannon"]),
sleepFurniture: new Set(["bed", "nest_box"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "pushpin", "oshibyo"]),
draggable: new Set(["ball", "stone", "bed", "nest_box", "signboard", "pushpin", "oshibyo", "duplicator", "cannon"]),
});
function itemHasTrait(type = "", trait = "") {
@ -113,6 +116,13 @@ function itemTraitsFor(type = "") {
function isPinType(type = "") { return itemHasTrait(type, "pin"); }
function isSleepFurnitureType(type = "") { return itemHasTrait(type, "sleepFurniture"); }
function isMachineType(type = "") { return itemHasTrait(type, "machine"); }
function isMachineConsumableType(type = "") {
const t = String(type || "");
if (!t) return false;
if (typeof isServingFoodType === "function" && isServingFoodType(t)) return true;
return ["grass", "water", "water_bowl", "zunchi", "ant_corpse"].includes(t);
}
function isLodgedPin(item = null) { return Boolean(item && !item.dead && isPinType(item.type) && item.pinState === "lodged"); }
function lodgedPinFor(tarinai = null, worldRef = null) {
if (!tarinai?.stuckPushpinId) return null;
@ -124,7 +134,7 @@ function hasLodgedPinEffect(tarinai = null, effectKey = "") { return Boolean(lod
const PIN_BEHAVIORS = Object.freeze({
pushpin: { type: "pushpin", looseAsset: "pushpin", lodgedAsset: "pushpin_stuck", attachMode: "impact", panicOnAttach: true, damageOnAttach: 6, damagePerTick: 1.4, stressOnAttach: 18, stressPerTick: 2.6, blocksZunchi: false, burstZunchiOnDetach: false },
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true },
oshibyo: { type: "oshibyo", looseAsset: "oshibyo", lodgedAsset: "oshibyo_stuck", attachMode: "butt", panicOnAttach: false, damageOnAttach: 0, damagePerTick: 0, stressOnAttach: 0, stressPerTick: 0, blocksZunchi: true, burstZunchiOnDetach: true, scale: 0.50 },
});
function pinBehaviorFor(type = "") {
@ -133,12 +143,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"] },
{ 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: "freeze", label: "冷凍", themeClass: "tool-category-freeze", order: 99, toolIds: ["freeze"] },
{ id: "operate", label: "操作", themeClass: "tool-category-operate", order: 10, toolIds: ["observe", "delete", "poke", "pinch", "new", "water_hose"] },
{ id: "food", label: "飲食", themeClass: "tool-category-food", order: 20, toolIds: ["sweet", "love_mochi", "fight_mochi", "grass", "water_bowl", "zunda_juice"] },
{ id: "medicine", label: "薬", themeClass: "tool-category-medicine", order: 30, toolIds: ["sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug"] },
{ id: "habitat", label: "生活", themeClass: "tool-category-habitat", order: 40, toolIds: ["zunchi", "bed", "nest_box", "ant_nest", "ball", "signboard", "duplicator", "cannon", "fence_v", "fence_h"] },
{ id: "hazard", label: "危険物", themeClass: "tool-category-hazard", order: 50, toolIds: ["stone", "genkotsu", "firecracker", "pushpin", "oshibyo"] },
]);
function toolCategories() {
@ -194,12 +203,12 @@ function toolTipsFromDefinitions() {
const ALPHA_BOUNDS = {
smile: { x: 18, y: 18, w: 476, h: 432, imageW: 512, imageH: 468 },
angry: { x: 16, y: 16, w: 480, h: 432, imageW: 512, imageH: 464 },
teary: { x: 17, y: 17, w: 478, h: 438, imageW: 512, imageH: 472 },
jito: { x: 18, y: 18, w: 476, h: 451, imageW: 512, imageH: 487 },
teary: { x: 17, y: 17, w: 474, h: 434, imageW: 508, imageH: 468 },
jito: { x: 18, y: 18, w: 456, h: 432, imageW: 492, imageH: 468 },
drool: { x: 18, y: 18, w: 476, h: 425, imageW: 512, imageH: 461 },
cry: { x: 15, y: 15, w: 482, h: 448, imageW: 512, imageH: 478 },
sleep: { x: 18, y: 18, w: 475, h: 439, imageW: 511, imageH: 475 },
pokan: { x: 18, y: 18, w: 476, h: 433, imageW: 512, imageH: 469 },
cry: { x: 15, y: 15, w: 471, h: 438, imageW: 501, imageH: 468 },
sleep: { x: 18, y: 18, w: 467, h: 432, imageW: 503, imageH: 468 },
pokan: { x: 18, y: 17, w: 475, h: 434, imageW: 511, imageH: 468 },
stretch: { x: 15, y: 15, w: 482, h: 354, imageW: 512, imageH: 384 },
hurt: { x: 17, y: 21, w: 478, h: 432, imageW: 512, imageH: 468 },
weak: { x: 53, y: 18, w: 405, h: 435, imageW: 512, imageH: 468 },
@ -218,17 +227,22 @@ const ALPHA_BOUNDS = {
stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 },
zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 },
birth_ritual: { x: 0, y: 0, w: 512, h: 456, imageW: 512, imageH: 456 },
zunchi_slave: { x: 17, y: 22, w: 477, h: 431, imageW: 512, imageH: 468 },
hungry_70: { x: 0, y: 0, w: 430, h: 366, imageW: 430, imageH: 366 },
zunchi_slave_alt: { x: 42, y: 22, w: 425, h: 426, imageW: 512, imageH: 468 },
zunchi_slave_alt: { x: 33, y: 56, w: 446, h: 402, imageW: 512, imageH: 468 },
sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 },
sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 },
zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 },
ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 },
ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 },
genkotsu: { x: 8, y: 8, w: 220, h: 283, imageW: 236, imageH: 299 },
sunbath_1: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
sunbath_2: { x: 69, y: 18, w: 374, h: 432, imageW: 512, imageH: 468 },
sunbath_3: { x: 71, y: 18, w: 369, h: 432, imageW: 512, imageH: 468 },
pushpin: { x: 6, y: 6, w: 82, h: 186, imageW: 95, imageH: 200 },
pushpin_stuck: { x: 7, y: 7, w: 135, h: 135, imageW: 150, imageH: 150 },
oshibyo: { x: 0, y: 0, w: 198, h: 449, imageW: 198, imageH: 449 },
oshibyo_stuck: { x: 0, y: 0, w: 247, h: 247, imageW: 247, imageH: 247 },
};
const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"];

View file

@ -44,8 +44,12 @@
const style = document.createElement("style");
style.id = "freezeSystemStyles";
style.textContent = `
.frozen-panel { margin-top: 10px; padding: 10px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; font-weight:700; font-size:13px; color:#4f5f70; margin-bottom:8px; }
.frozen-panel { margin-top: 10px; padding: 8px; border: 1px solid rgba(82, 63, 43, 0.18); border-radius: 14px; background: rgba(244, 249, 255, 0.72); }
.frozen-panel-title { display:flex; align-items:center; justify-content:space-between; gap:8px; width:100%; border:0; background:transparent; padding:0; font-weight:700; font-size:13px; color:#4f5f70; margin:0; cursor:pointer; text-align:left; }
.frozen-panel-title::before { content:"▸"; width:14px; color:rgba(79,95,112,.74); }
.frozen-panel:not(.collapsed) .frozen-panel-title::before { content:"▾"; }
.frozen-panel-body { margin-top:8px; }
.frozen-panel.collapsed .frozen-panel-body { display:none; }
.frozen-panel-count { font-size:12px; opacity:.72; }
.frozen-list { display:grid; grid-template-columns: 1fr; gap:7px; }
.frozen-card { display:flex; align-items:center; gap:8px; width:100%; border:1px solid rgba(72, 98, 124, .20); border-radius:12px; padding:7px; background:rgba(255,255,255,.78); cursor:grab; color:#354657; text-align:left; box-shadow: 0 2px 7px rgba(41,53,65,.06); }
@ -64,16 +68,23 @@
function ensurePanel() {
ensureStyles();
let panel = document.getElementById("frozenPanel");
if (panel) return panel;
if (panel) {
const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body');
if (operateBody && panel.parentNode !== operateBody) operateBody.appendChild(panel);
return panel;
}
panel = document.createElement("section");
panel.id = "frozenPanel";
panel.className = "frozen-panel";
panel.classList.add("collapsed");
panel.innerHTML = `
<div class="frozen-panel-title"><span>冷凍個体</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></div>
<div id="frozenList" class="frozen-list"></div>
<button class="frozen-panel-title" type="button" aria-expanded="false"><span>冷凍個体</span><span id="frozenPanelCount" class="frozen-panel-count">0</span></button>
<div class="frozen-panel-body"><div id="frozenList" class="frozen-list"></div></div>
`;
const operateBody = document.querySelector('[data-tool-category="operate"] .tool-category-body');
const anchor = global.ui?.toolPalette || document.getElementById("toolPalette");
if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
if (operateBody) operateBody.appendChild(panel);
else if (anchor?.parentNode) anchor.parentNode.insertBefore(panel, anchor.nextSibling);
else document.body.appendChild(panel);
return panel;
}
@ -246,6 +257,12 @@
const panel = ensurePanel();
if (panel.dataset.freezeBound !== "1") {
panel.dataset.freezeBound = "1";
panel.addEventListener("click", (e) => {
const title = e.target.closest(".frozen-panel-title");
if (!title) return;
const collapsed = !panel.classList.toggle("collapsed");
title.setAttribute("aria-expanded", collapsed ? "true" : "false");
});
panel.addEventListener("dragstart", (e) => {
const card = e.target.closest(".frozen-card[data-frozen-id]");
if (!card) return;

View file

@ -174,6 +174,43 @@ function drawBulletSprite(ctx, r) {
ctx.restore();
}
function machineDishPosition(machine) {
const r = machine?.r || 42;
return { x: machine.x, y: machine.y + r * 1.18 };
}
function machineMuzzlePosition(machine) {
const r = machine?.r || 42;
const a = Number.isFinite(machine?.cannonAngle) ? machine.cannonAngle : 0;
return { x: machine.x + Math.cos(a) * r * 0.98, y: machine.y + Math.sin(a) * r * 0.98 };
}
function makeMachineConsumableCopy(type, x, y, source = null, opts = {}) {
const it = new Item(type, x, y);
it.machineProduced = true;
it.machineProducerId = source?.id || "";
it.sourceMachineType = source?.type || "";
it.noDecay = true;
if (opts.infiniteServing) it.infiniteServing = true;
if (opts.projectile) {
it.cannonProjectile = true;
it.projectileSourceId = source?.id || "";
it.projectileTimer = 0;
it.projectileHitMemo = {};
}
if (typeof isServingFoodType === "function" && isServingFoodType(type)) {
it.toolSize = source?.machineSlotSize || "medium";
it.foodServingScale = 1;
it.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(it.toolSize) : 5;
it.foodServingsRemaining = it.foodServingsMax;
it.amount = it.foodServingsMax;
}
if (type === "water") it.amount = 120;
if (type === "grass") { it.amount = Math.max(it.amount || 0, 120); it.growth = Math.max(it.growth || 0, 0.82); }
if (type === "zunchi") { it.amount = 240; it.stage = "fresh"; it.stageTimer = 0; it.freshness = 1; }
return it;
}
class Item {
constructor(type, x, y) {
this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
@ -206,6 +243,14 @@ class Item {
this.text = "";
this.textEditedAt = -999;
}
if (type === "duplicator" || type === "cannon") {
this.machineSlotType = "";
this.machineSlotSize = "medium";
this.machineSpawnTimer = rand(0.4, 1.1);
this.machineLastSlotType = "";
this.cannonAngle = type === "cannon" ? -Math.PI * 0.25 : 0;
this.cannonFireTimer = rand(0.8, 1.6);
}
if (type === "ball") {
this.vx = rand(-5, 5);
this.vy = rand(-5, 5);
@ -278,6 +323,8 @@ class Item {
this.passiveFoodDecayTimer = rand(0, passiveFoodDecayInterval());
this.amount = this.foodServingsRemaining;
}
if (window.TarinaiNeedSystem?.itemRolesFor) window.TarinaiNeedSystem.itemRolesFor(this);
if (window.TarinaiNeedSystem?.ensureItemNeedEffects) window.TarinaiNeedSystem.ensureItemNeedEffects(this);
}
update(dt, worldRef = world) {
this.age += dt;
@ -289,8 +336,8 @@ class Item {
if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
}
}
if (this.type === "water") this.amount -= dt * 0.9;
if (isServingFoodType(this.type)) {
if (this.type === "water" && !this.noDecay && !this.infiniteServing) this.amount -= dt * 0.9;
if (isServingFoodType(this.type) && !this.noDecay && !this.infiniteServing) {
const interval = passiveFoodDecayInterval(worldRef);
this.passiveFoodDecayTimer = (this.passiveFoodDecayTimer || 0) + dt;
let ticks = 0;
@ -344,6 +391,9 @@ class Item {
if (this.type === "ball") this.updateBall(dt, worldRef);
if (isPinType(this.type)) this.updatePushpin(dt, worldRef);
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
if (this.cannonProjectile) this.updateConsumableProjectile(dt, worldRef);
if (this.type === "duplicator") this.updateDuplicator(dt, worldRef);
if (this.type === "cannon") this.updateCannon(dt, worldRef);
if (this.type === "grass") {
this.lifecycleTimer += dt;
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
@ -364,6 +414,152 @@ class Item {
}
}
machineDishPosition() {
return machineDishPosition(this);
}
clearMachineProducedItems(worldRef, keepType = "") {
for (const it of worldRef?.items || []) {
if (!it || it.dead || it.machineProducerId !== this.id) continue;
if (keepType && it.type === keepType && this.type === "duplicator") continue;
it.amount = 0;
}
}
updateDuplicator(dt, worldRef) {
if (!worldRef || !this.machineSlotType) return;
if (this.machineLastSlotType && this.machineLastSlotType !== this.machineSlotType) this.clearMachineProducedItems(worldRef);
this.machineLastSlotType = this.machineSlotType;
this.machineSpawnTimer = Math.max(0, (this.machineSpawnTimer || 0) - dt);
const dish = this.machineDishPosition();
let serving = null;
for (const it of worldRef.items || []) {
if (!it || it.dead || it.machineProducerId !== this.id || it.type !== this.machineSlotType) continue;
serving = it;
break;
}
if (!serving && this.machineSpawnTimer <= 0) {
serving = makeMachineConsumableCopy(this.machineSlotType, dish.x, dish.y, this, { infiniteServing: true });
serving.r = Math.max(6, (serving.r || 12) * 0.92);
worldRef.items.push(serving);
worldRef.itemCounts[serving.type] = (worldRef.itemCounts[serving.type] || 0) + 1;
this.machineSpawnTimer = 1.2;
worldRef.rebuildSpatial?.(true);
}
if (serving) {
serving.x = dish.x;
serving.y = dish.y;
serving.vx = 0;
serving.vy = 0;
serving.prevX = serving.x;
serving.prevY = serving.y;
serving.noDecay = true;
serving.infiniteServing = true;
serving.machineProduced = true;
serving.machineProducerId = this.id;
if (typeof isServingFoodType === "function" && isServingFoodType(serving.type)) {
if (!Number.isFinite(serving.foodServingsMax) || serving.foodServingsMax <= 0) serving.foodServingsMax = typeof foodServingsForSize === "function" ? foodServingsForSize(serving.toolSize || "medium") : 5;
serving.foodServingsRemaining = serving.foodServingsMax;
serving.amount = serving.foodServingsMax;
} else if (serving.type === "water") serving.amount = Math.max(serving.amount || 0, 120);
else if (serving.type === "grass") serving.amount = Math.max(serving.amount || 0, 120);
else if (serving.type === "zunchi") serving.amount = Math.max(serving.amount || 0, 240);
}
}
updateCannon(dt, worldRef) {
if (!worldRef || !this.machineSlotType) return;
this.cannonFireTimer = Math.max(0, (this.cannonFireTimer || 0) - dt);
if (this.cannonFireTimer > 0) return;
this.cannonFireTimer = rand(2.2, 3.4);
const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0;
const muzzle = machineMuzzlePosition(this);
const shot = makeMachineConsumableCopy(this.machineSlotType, muzzle.x, muzzle.y, this, { projectile: true });
shot.r = Math.max(5, (shot.r || 12) * 0.78);
const speed = rand(470, 620);
shot.vx = Math.cos(a) * speed + rand(-18, 18);
shot.vy = Math.sin(a) * speed + rand(-18, 18);
shot.prevX = shot.x;
shot.prevY = shot.y;
shot.spinVelocity = rand(-6, 6) + speed / Math.max(8, shot.r || 10) * 0.12;
shot.amount = Math.max(shot.amount || 0, typeof isServingFoodType === "function" && isServingFoodType(shot.type) ? (shot.foodServingsMax || 5) : itemAmountFor(shot.type, 60));
worldRef.items.push(shot);
worldRef.itemCounts[shot.type] = (worldRef.itemCounts[shot.type] || 0) + 1;
worldRef.effects?.push(new Effect("ring", muzzle.x, muzzle.y, { size: Math.max(16, (this.r || 42) * 0.42), life: 0.22, color: "rgba(255,238,160,0.52)" }));
worldRef.log?.(`大砲が${toolLabel(shot.type)}を撃ち出した。`, "accident");
audio.place?.(shot.type);
worldRef.rebuildSpatial?.(true);
}
updateConsumableProjectile(dt, worldRef) {
if (!worldRef) return;
this.projectileTimer = (this.projectileTimer || 0) + dt;
this.prevX = this.x;
this.prevY = this.y;
const speedBefore = Math.hypot(this.vx || 0, this.vy || 0);
if (speedBefore > 0.05) {
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 28, bounce: 0.46, frictionBase: 0.62, frictionRate: 1.25, spin: false, stopSpeed: 0.12, zeroBelow: 14 });
} else {
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.46; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.46; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.46; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.46; }
const slow = Math.pow(0.62, dt * 1.25);
this.vx *= slow;
this.vy *= slow;
}
this.spin = (this.spin || 0) + speedBefore * dt / Math.max(6, this.r || 10);
this.resolveProjectileTarinaiCollision(dt, worldRef, speedBefore);
}
const speedAfter = Math.hypot(this.vx || 0, this.vy || 0);
if (speedAfter < 18 && (this.projectileTimer || 0) > 0.30) {
this.cannonProjectile = false;
this.noDecay = false;
this.projectileHitMemo = {};
this.vx = 0;
this.vy = 0;
}
if ((this.projectileTimer || 0) > 28) this.cannonProjectile = false;
}
resolveProjectileTarinaiCollision(dt, worldRef, speed = 0) {
if (!worldRef || speed < 210 || (this.amount || 0) <= 0) return;
const range = Math.max(42, (this.r || 12) + speed * Math.max(dt || 0.016, 0.016) + 22);
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, range, true) || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(this.x, this.y, t.x, t.y);
if (d > (t.radius || 20) * 0.72 + (this.r || 12)) continue;
const now = worldRef.time || 0;
this.projectileHitMemo = this.projectileHitMemo || {};
if (this.projectileHitMemo[t.id] && this.projectileHitMemo[t.id] + 0.58 > now) continue;
this.projectileHitMemo[t.id] = now;
const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1);
const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1);
const impulse = clamp(speed * 0.95, 130, 560);
t.vx = (t.vx || 0) + nx * impulse + rand(-18, 18);
t.vy = (t.vy || 0) + ny * impulse * 0.70 + rand(-14, 12);
t.fallTimer = Math.max(t.fallTimer || 0, 0.72);
t.fallMax = Math.max(t.fallMax || 0.72, t.fallTimer);
t.fallDir = nx < 0 ? -1 : 1;
const label = toolLabel(this.type) || "食べ物";
if (t.enterPanic) t.enterPanic({ threat: this, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, fear: 0.72, stress: 6, stressDuration: 3.0, surpriseTimer: 0.48, cause: "cannon_projectile_hit" });
else t.setActionState?.("panic", { target: { x: this.x, y: this.y }, reason: `大砲から飛んできた${label}にぶつかって吹き飛んでいる`, wake: true });
if (worldRef.applyImpactDamage) worldRef.applyImpactDamage(t, clamp((speed - 190) / 30, 1.4, 16), "大砲");
worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.50, 0.78);
worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(17, (this.r || 12) * 1.15), life: 0.22, color: "rgba(220,160,70,0.52)" }));
if (worldRef.relationNotice?.(t.id, this.id || "projectile", "cannon-projectile-hit", 2.8)) worldRef.log?.(`${t.name}は大砲から飛んできた${label}にぶつかって吹き飛ばされた。`, "accident", { participants: [t] });
this.vx *= -0.12;
this.vy *= -0.12;
this.cannonProjectile = false;
this.noDecay = false;
break;
}
}
updateZunchiMotion(dt, worldRef) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
@ -668,9 +864,11 @@ class Item {
this.pinState = "lodged";
this.deletable = false;
this.pinTargetId = t.id;
this.pinAttachAngle = isOshibyo ? 0.62 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1));
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.58 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.30 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
const visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1;
this.pinAttachAngle = isOshibyo ? (visualSide < 0 ? -0.48 : 0.48) : Math.atan2(dy || -1, dx || visualSide);
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.76 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.22 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
this.pinFlipX = visualSide;
this.pinDamageTick = 0;
this.pinFallCheckTimer = 0;
this.vx = 0;
@ -714,11 +912,13 @@ class Item {
const isOshibyo = Boolean(behavior?.blocksZunchi);
t.stuckPushpinId = this.id;
this.deletable = false;
this.x = isOshibyo ? t.x + (t.radius || 16) * 0.42 : t.x + (t.radius || 16) * 0.36;
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.34 : t.y - (t.radius || 16) * 0.04;
const visualSide = (t.facingDir ? t.facingDir() : (t.facing || 1)) > 0 ? -1 : 1;
this.pinFlipX = visualSide;
this.x = t.x + visualSide * (isOshibyo ? (t.radius || 16) * 0.72 : (t.radius || 16) * 0.36);
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.22 : t.y - (t.radius || 16) * 0.04;
this.prevX = this.x;
this.prevY = this.y;
this.spin = isOshibyo ? 0.52 : 0.28;
this.spin = (isOshibyo ? 0.42 : 0.28) * (this.pinFlipX < 0 ? -1 : 1);
this.vx = t.vx || 0;
this.vy = t.vy || 0;
if (isOshibyo) {
@ -1114,20 +1314,89 @@ class Item {
ctx.fill();
}
ctx.restore();
} else if (this.type === "duplicator" || this.type === "cannon") {
const r = this.r || 42;
const isCannon = this.type === "cannon";
ctx.save();
ctx.shadowColor = "rgba(40,35,30,0.24)";
ctx.shadowBlur = 6;
ctx.shadowOffsetY = 3;
if (!isCannon) {
// Upper machine body.
const g = ctx.createLinearGradient(0, -r * 1.0, 0, r * 0.32);
g.addColorStop(0, "#e7f1ff");
g.addColorStop(1, "#8aa2bd");
ctx.fillStyle = g;
ctx.strokeStyle = "rgba(48,68,92,0.70)";
ctx.lineWidth = Math.max(2, r * 0.06);
roundedRect(ctx, -r * 0.82, -r * 1.05, r * 1.64, r * 0.92, r * 0.18);
ctx.fill(); ctx.stroke();
ctx.fillStyle = this.machineSlotType ? "rgba(100,214,102,0.92)" : "rgba(255,180,72,0.92)";
ctx.beginPath(); ctx.arc(r * 0.48, -r * 0.76, r * 0.12, 0, Math.PI * 2); ctx.fill();
ctx.strokeStyle = "rgba(42,55,68,0.54)";
ctx.lineWidth = Math.max(1.6, r * 0.04);
ctx.beginPath(); ctx.moveTo(-r * 0.48, -r * 0.78); ctx.lineTo(r * 0.20, -r * 0.78); ctx.stroke();
// Plate for tarinai below.
ctx.fillStyle = "rgba(245,244,233,0.96)";
ctx.strokeStyle = "rgba(116,96,75,0.58)";
ctx.lineWidth = Math.max(2, r * 0.05);
ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.95, r * 0.34, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(196,180,154,0.22)";
ctx.beginPath(); ctx.ellipse(0, r * 1.18, r * 0.58, r * 0.16, 0, 0, Math.PI * 2); ctx.fill();
if (this.machineSlotType) {
ctx.fillStyle = "rgba(42,36,28,0.72)";
ctx.font = `${Math.max(10, Math.round(r * 0.25))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(toolLabel(this.machineSlotType), 0, r * 0.52);
}
} else {
const a = Number.isFinite(this.cannonAngle) ? this.cannonAngle : 0;
// Base plate.
ctx.fillStyle = "#6c7685";
ctx.strokeStyle = "rgba(42,48,58,0.72)";
ctx.lineWidth = Math.max(2, r * 0.06);
ctx.beginPath(); ctx.ellipse(0, r * 0.72, r * 0.78, r * 0.30, 0, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
// Barrel.
ctx.rotate(a);
const bg = ctx.createLinearGradient(-r * 0.34, -r * 0.26, r * 1.08, r * 0.26);
bg.addColorStop(0, "#596575");
bg.addColorStop(0.55, "#2f3946");
bg.addColorStop(1, "#1f2630");
ctx.fillStyle = bg;
ctx.strokeStyle = "rgba(16,20,26,0.82)";
roundedRect(ctx, -r * 0.28, -r * 0.26, r * 1.32, r * 0.52, r * 0.18);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(10,12,16,0.88)";
ctx.beginPath(); ctx.ellipse(r * 1.02, 0, r * 0.16, r * 0.23, 0, 0, Math.PI * 2); ctx.fill();
ctx.rotate(-a);
ctx.fillStyle = "#8793a5";
ctx.beginPath(); ctx.arc(0, 0, r * 0.36, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
if (this.machineSlotType) {
ctx.fillStyle = "rgba(42,36,28,0.72)";
ctx.font = `${Math.max(10, Math.round(r * 0.23))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(toolLabel(this.machineSlotType), 0, -r * 0.62);
}
}
ctx.restore();
} else if (isPinType(this.type)) {
const stuck = this.pinState === "lodged";
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin");
const isOshibyo = Boolean(behavior?.blocksZunchi);
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
ctx.save();
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
else if ((this.pinFlipX || 1) < 0) ctx.scale(-1, 1);
ctx.shadowColor = stuck ? "rgba(116,24,32,0.18)" : "rgba(54,42,31,0.16)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 2;
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
const ratio = metrics?.ratio || (stuck ? 1 : 2.0);
const w = isOshibyo ? (stuck ? this.r * 3.1 : this.r * 1.55) : (stuck ? this.r * 2.15 : this.r * 1.78);
const w = isOshibyo ? (stuck ? this.r * 2.45 : this.r * 1.25) : (stuck ? this.r * 2.15 : this.r * 1.78);
const h = w * ratio;
ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h);
} else if (stuck) {

View file

@ -134,7 +134,7 @@ loadImages(null, (done, total) => {
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.20.16";
const swUrl = window.TARINAI_APP?.withVersion?.("./service-worker.js") || "./service-worker.js?v=15.30.0";
navigator.serviceWorker.register(swUrl, { updateViaCache: "none" }).catch(() => undefined);
});
}

547
js/need_behavior_system.js Normal file
View file

@ -0,0 +1,547 @@
"use strict";
(function (global) {
const NEED_KEYS = Object.freeze(["food", "sleep", "health", "safety", "social", "fulfill"]);
const NEED_LABELS = Object.freeze({
food: "\u6442\u990c",
sleep: "\u7761\u7720",
health: "\u5065\u5eb7",
safety: "\u5b89\u5168",
social: "\u95a2\u4fc2",
fulfill: "\u5145\u8db3",
});
const NEED_PHRASES = Object.freeze({
food: "\u304a\u306a\u304b\u304c\u3059\u3044\u3066\u3044\u308b",
sleep: "\u306d\u3080\u3044",
health: "\u8abf\u5b50\u304c\u60aa\u3044",
safety: "\u3042\u3076\u306a\u3044",
social: "\u3060\u308c\u304b\u304c\u6c17\u306b\u306a\u308b",
fulfill: "\u81ea\u5206\u306e\u5834\u6240\u304c\u6c17\u306b\u306a\u308b",
});
const NEED_PRIORITY = Object.freeze(["safety", "health", "food", "sleep", "social", "fulfill"]);
function createId(prefix = "id") {
if (global.crypto?.randomUUID) return `${prefix}_${global.crypto.randomUUID()}`;
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function createDefaultNeeds() {
return { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
}
function quantizeNeed(value) {
return Math.max(0, Math.min(100, Math.round((Number(value) || 0) / 10) * 10));
}
function getNeedDisplayValue(value) {
return Math.round(quantizeNeed(value) / 10);
}
function personalityValue(t, key) {
if (typeof global.effectivePersonalityValue === "function") return Number(global.effectivePersonalityValue(t, key)) || 0;
return Number(t?.currentPersonality?.[key] ?? t?.birthPersonality?.[key] ?? 0) || 0;
}
function needPressure(value) {
const x = clamp((Number(value) || 0) / 100, 0, 1);
return x * x * 100;
}
function deriveStressFromNeeds(needs = {}) {
return clamp(
needPressure(needs.food) * 0.10 +
needPressure(needs.sleep) * 0.12 +
needPressure(needs.health) * 0.25 +
needPressure(needs.safety) * 0.30 +
needPressure(needs.social) * 0.08 +
needPressure(needs.fulfill) * 0.15,
0,
130
);
}
function itemRolesFor(item) {
if (!item) return {};
if (!item.roles) item.roles = {};
const type = String(item.type || "");
const roles = item.roles;
if (["sweet", "food", "grass", "ant_corpse", "zunchi", "love_mochi", "fight_mochi", "sleep_drug", "laxative", "protein", "niteropu", "ammo", "mystery_drug", "mercury", "giant_drug", "dwarf_drug", "zunda_juice"].includes(type)) roles.food = true;
if (type === "water" || type === "water_bowl") roles.drink = true;
if (["sweet", "protein", "water", "water_bowl", "zunda_juice"].includes(type)) roles.medicine = true;
if (type === "bed" || type === "nest_box") roles.sleepPlace = true;
if (["firecracker", "genkotsu", "pushpin", "oshibyo", "splat", "zunchi", "ant_nest", "cannon"].includes(type)) roles.danger = true;
if (type === "grass") roles.grassMaterial = true;
if (type === "ball") roles.playObject = true;
if (item.isStructure) roles.ownedStructure = true;
return roles;
}
function ensureItemNeedEffects(item) {
if (!item) return createDefaultNeeds();
if (!item.needEffects) item.needEffects = createDefaultNeeds();
return item.needEffects;
}
function findNearestItemWithRole(world, t, role, maxDist = 640, predicate = null) {
let best = null;
let bestD = maxDist;
const items = Number.isFinite(maxDist) && world?.nearbyItems ? world.nearbyItems(t.x, t.y, maxDist) : (world?.items || []);
for (const it of items) {
if (!it || it.dead || (it.amount != null && it.amount <= 0)) continue;
const roles = itemRolesFor(it);
if (!roles[role]) continue;
if (predicate && !predicate(it)) continue;
if (t?.shouldAvoidTarget?.(it)) continue;
const d = distXY(t.x, t.y, it.x, it.y);
if (d < bestD) { best = it; bestD = d; }
}
return best;
}
function findNearestSleepPlace(world, t, maxDist = 560) {
const own = findOwnedStructure(world, t, "grass_bed", maxDist);
return own || findNearestItemWithRole(world, t, "sleepPlace", maxDist);
}
function findNearbyDanger(world, t, maxDist = 180) {
const ant = (world?.nearbyAnts?.(t.x, t.y, maxDist, true) || []).find(a => a && !a.dead);
if (ant) return ant;
return findNearestItemWithRole(world, t, "danger", maxDist);
}
function findNearbyMaterial(world, t, materialRole, maxDist = 520) {
return findNearestItemWithRole(world, t, materialRole, maxDist);
}
function ownedStructures(world, t, type = "") {
return (world?.items || []).filter(it => it && !it.dead && it.isStructure && it.ownerId === t?.id && (!type || it.type === type));
}
function findOwnedStructure(world, t, type = "", maxDist = Infinity) {
let best = null;
let bestD = maxDist;
for (const it of ownedStructures(world, t, type)) {
const d = distXY(t.x, t.y, it.x, it.y);
if (d < bestD) { best = it; bestD = d; }
}
return best;
}
function hasOwnedStructure(world, t, type) {
return Boolean(findOwnedStructure(world, t, type));
}
function canActNormally(t) {
return !t.dead && t.state !== "sleep" && t.state !== "fight" && t.state !== "panic" && t.state !== "intimidate" && t.birthRitualTimer <= 0.04 && t.fightTimer <= 0.04 && t.defeatedTimer <= 0.04;
}
function updateAttachedStructures(world, t) {
for (const s of ownedStructures(world, t)) {
if (s.type !== "plushie" || s.carriedById !== t.id || !s.onHead) continue;
s.x = t.x + (t.facingDir ? t.facingDir() : 1) * t.radius * 0.12;
s.y = t.y - t.radius * 1.10;
s.amount = Math.max(1, s.hp || 1);
if (t.needs) t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 0.08) };
}
}
function updateNeeds(t, world) {
const prev = t.needsInitialized && t.needs ? { ...t.needs } : null;
const pAgg = personalityValue(t, "aggression");
const pOpen = personalityValue(t, "openness");
const pSoc = personalityValue(t, "sociability");
const pNeu = personalityValue(t, "neuroticism");
const raw = createDefaultNeeds();
raw.food = clamp((t.hunger || 0) * 0.88 + (findNearestItemWithRole(world, t, "drink", 120) ? 6 : 0), 0, 100);
const dayP = world?.dayProgress?.() ?? 0.5;
const night = dayP >= 0.62 || dayP < 0.18 ? 12 : 0;
raw.sleep = clamp((100 - (t.energy || 0)) * 0.98 + night, 0, 100);
raw.health = clamp(
(t.zunchiDisease ? 38 : 0) + (t.sleepDisease ? 22 : 0) + (t.explosionDisease ? 30 : 0) + (t.fightDisease ? 34 : 0) +
clamp((100 - (t.energy || 100)) * 0.22, 0, 26) + clamp((t.zunchiStain || 0) * 0.34, 0, 34) + Number(t.needShock?.health || 0),
0,
100
);
const danger = findNearbyDanger(world, t, 210);
const recentDamage = (world?.time || 0) - (t.lastDamageAt || -999) < 8 ? clamp((t.lastDamageAmount || 0) * 2.4, 0, 52) : 0;
const fear = clamp((t.fearTimer || 0) * 22, 0, 45);
raw.safety = clamp((danger ? 42 : 0) + recentDamage + fear + Number(t.needShock?.safety || 0), 0, 100);
raw.social = clamp((t.loneliness || 0) * (0.78 + Math.max(0, pSoc) * 0.16) + (t.reproductionTimer <= 4 ? 18 : 0), 0, 100);
const hasBed = hasOwnedStructure(world, t, "grass_bed");
const hasPlushie = hasOwnedStructure(world, t, "plushie");
const play = findNearestItemWithRole(world, t, "playObject", 280) ? 10 : 0;
raw.fulfill = clamp((hasBed ? 0 : 26) + (hasPlushie ? 0 : 20) + play + Math.max(0, pOpen) * 16 + Number(t.needShock?.fulfill || 0), 0, 100);
raw.safety *= clamp(1 - Math.max(0, pAgg) * 0.12 + Math.max(0, pNeu) * 0.16, 0.78, 1.22);
raw.health *= clamp(1 + Math.max(0, pNeu) * 0.12, 0.9, 1.16);
raw.social *= clamp(1 + Math.max(0, pSoc) * 0.12, 0.9, 1.16);
raw.fulfill *= clamp(1 + Math.max(0, pOpen) * 0.12, 0.9, 1.18);
const needs = createDefaultNeeds();
for (const key of NEED_KEYS) needs[key] = quantizeNeed(raw[key]);
t.previousNeeds = prev;
t.needs = needs;
t.needsInitialized = true;
t.stress = deriveStressFromNeeds(needs);
handleNeedShockReaction(t, prev, needs);
return needs;
}
function handleNeedShockReaction(t, prev, needs, breaker = null) {
if (!prev || !needs || t.birthRitualTimer > 0.04 || t.fightTimer > 0.04) return false;
const safetyDelta = (needs.safety || 0) - (prev.safety || 0);
const healthDelta = (needs.health || 0) - (prev.health || 0);
const fulfillDelta = (needs.fulfill || 0) - (prev.fulfill || 0);
if (safetyDelta < 40 && healthDelta < 45 && fulfillDelta < 50) return false;
const attacker = breaker || t.needShockBreaker || null;
if (attacker && personalityValue(t, "aggression") > 0.6) {
t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.6);
t.intimidateTargetId = attacker.id || null;
t.setActionState?.("intimidate", { target: attacker, reason: "\u5927\u4e8b\u306a\u3082\u306e\u3092\u58ca\u3055\u308c\u3066\u5a01\u5687\u3057\u3066\u3044\u308b", wake: true });
} else {
t.panicTimer = Math.max(t.panicTimer || 0, 3.0);
t.enterPanic?.({ target: t.panicDestination?.(attacker, true) || null, reason: "\u304a\u3069\u308d\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b", fear: 0.8, wake: true, cause: "need_shock" });
}
t.needShockBreaker = null;
return true;
}
function addNeedShock(t, values = {}, breaker = null) {
if (!t) return;
const shock = { ...(t.needShock || {}) };
for (const key of NEED_KEYS) shock[key] = clamp(Number(shock[key] || 0) + Number(values[key] || 0), 0, 100);
t.needShock = shock;
t.needShockBreaker = breaker || t.needShockBreaker || null;
const prev = t.needsInitialized && t.needs ? { ...t.needs } : null;
updateNeeds(t, t.world);
handleNeedShockReaction(t, prev, t.needs, breaker);
}
function decayNeedShock(t, dt) {
if (!t?.needShock) return;
for (const key of NEED_KEYS) t.needShock[key] = Math.max(0, Number(t.needShock[key] || 0) - dt * (key === "fulfill" ? 2.2 : 2.8));
}
function chooseTopNeedRandom(needs) {
const maxNeed = Math.max(...NEED_KEYS.map(key => Number(needs?.[key] || 0)));
const tiedNeeds = NEED_KEYS.filter(key => Number(needs?.[key] || 0) === maxNeed);
return tiedNeeds[Math.floor(Math.random() * tiedNeeds.length)] || "fulfill";
}
function weightedRandom(items) {
const total = items.reduce((sum, a) => sum + Math.max(0.01, Number(a.weight || 1)), 0);
let pickPoint = Math.random() * total;
for (const item of items) {
pickPoint -= Math.max(0.01, Number(item.weight || 1));
if (pickPoint <= 0) return item;
}
return items[items.length - 1] || null;
}
function buildReasonText(chosenNeed, tiedNeeds, action) {
const tied = Array.isArray(tiedNeeds) ? tiedNeeds : [chosenNeed];
const ignored = NEED_PRIORITY.find(key => key !== chosenNeed && tied.includes(key));
const phrase = action?.phrase || "\u5f85\u3063\u305f";
if (ignored) return `${NEED_PHRASES[ignored]}\u3051\u3069\u3001${phrase}\u3002`;
return `${NEED_PHRASES[chosenNeed]}\u306e\u3067\u3001${phrase}\u3002`;
}
function moveTowardOrUse(t, target, state, label) {
if (!target) return false;
if (distXY(t.x, t.y, target.x, target.y) > Math.max(34, (target.r || 12) + t.radius * 0.75)) {
t.setActionState?.(state, { target, reason: label });
return true;
}
return false;
}
function createStructure(world, owner, type, x, y) {
const def = StructureRegistry[type];
if (!def) return null;
const structure = def.create(owner, x, y, world);
structure.update = updateStructure;
structure.draw = drawStructure;
structure.damage = damageStructure;
world.items.push(structure);
world.itemCounts[structure.type] = (world.itemCounts[structure.type] || 0) + 1;
world.markTerrainDirty?.(`structure:${structure.type}`);
world.rebuildSpatial?.(true);
return structure;
}
function consumeGrassMaterial(grass) {
if (!grass || grass.dead) return false;
grass.amount = Math.max(0, Number(grass.amount || 0) - 34);
grass.growth = clamp((grass.growth || 0.5) - 0.24, 0.04, 1.2);
if (grass.amount <= 1) grass.dead = true;
return true;
}
function finishBuild(t, type, grass) {
const world = t.world;
const x = clamp(t.x + rand(-18, 18), CONFIG.worldPadding, world.w - CONFIG.worldPadding);
const y = clamp(t.y + rand(10, 30), CONFIG.worldPadding, world.h - CONFIG.worldPadding);
consumeGrassMaterial(grass);
const s = createStructure(world, t, type, x, y);
if (s) {
t.buildTimer = 0;
t.buildType = "";
t.buildMaterialId = "";
t.needShock = { ...(t.needShock || {}), fulfill: Math.max(0, Number(t.needShock?.fulfill || 0) - 26) };
StructureRegistry[type].onUse?.(t, s, world);
}
return Boolean(s);
}
function runBuildAction(t, type, label) {
const grass = findNearbyMaterial(t.world, t, "grassMaterial", 520);
if (!grass) return false;
if (moveTowardOrUse(t, grass, "seek_food", label)) {
t.buildType = type;
t.buildMaterialId = grass.id;
return true;
}
t.buildType = type;
t.buildMaterialId = grass.id;
t.buildTimer = Math.max(0, Number(t.buildTimer || 0)) + 1;
t.setActionState?.("idle", { target: grass, reason: label });
if (t.buildTimer >= (StructureRegistry[type]?.buildTime || 4)) return finishBuild(t, type, grass);
return true;
}
const TARINAI_ACTIONS = [
{ id: "eat_food", need: "food", label: "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u98df\u3079\u305f", weight: 50,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it))),
run(t, world) { const item = findNearestItemWithRole(world, t, "food", 620, it => !t.foodItemHasServingLeft || t.foodItemHasServingLeft(it)); return moveTowardOrUse(t, item, "seek_food", this.label) || t.beginEating?.(item, this.label); } },
{ id: "drink_water", need: "food", label: "\u6c34\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u6c34\u3092\u98f2\u3093\u3060", weight: 22,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "drink", 620)),
run(t, world) { const item = findNearestItemWithRole(world, t, "drink", 620); return moveTowardOrUse(t, item, "seek_water", this.label) || (t.setActionState?.("seek_water", { target: item, reason: this.label }), true); } },
{ id: "sleep_in_bed", need: "sleep", label: "\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b", phrase: "\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 42,
condition: (t, world) => Boolean(findNearestSleepPlace(world, t, 620)),
run(t, world) { const bed = findNearestSleepPlace(world, t, 620); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; return t.startSleeping?.(bed, this.label); } },
{ id: "sleep_anywhere", need: "sleep", label: "\u305d\u306e\u5834\u3067\u4f11\u3093\u3067\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true,
run(t) { return t.startSleeping?.(null, this.label); } },
{ id: "use_medicine", need: "health", label: "\u4f53\u306b\u3088\u3055\u305d\u3046\u306a\u3082\u306e\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u624b\u5f53\u3066\u3057\u305f", weight: 35,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "medicine", 620)),
run(t, world) { const item = findNearestItemWithRole(world, t, "medicine", 620); return moveTowardOrUse(t, item, itemRolesFor(item).drink ? "seek_water" : "seek_food", this.label) || t.beginEating?.(item, this.label); } },
{ id: "rest_to_recover", need: "health", label: "\u4f53\u3092\u4f11\u3081\u3066\u3044\u308b", phrase: "\u4f11\u3093\u3060", weight: 20, condition: () => true,
run(t) { t.setActionState?.("idle", { target: null, reason: this.label }); t.energy = clamp((t.energy || 0) + 1.0, 0, 100); return true; } },
{ id: "flee", need: "safety", label: "\u96e2\u308c\u308b\u5834\u6240\u3092\u63a2\u3057\u3066\u3044\u308b", phrase: "\u9003\u3052\u305f", weight: 32,
condition: (t, world) => Boolean(findNearbyDanger(world, t, 260)),
run(t, world) { const danger = findNearbyDanger(world, t, 260); t.setActionState?.("panic", { target: t.panicDestination?.(danger, true) || null, reason: this.label, wake: true }); t.panicTimer = Math.max(t.panicTimer || 0, 2.0); return true; } },
{ id: "hide_at_owned_structure", need: "safety", label: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u308d\u3046\u3068\u3057\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3078\u623b\u3063\u305f", weight: 28,
condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)),
run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return t.startSleeping?.(bed, this.label); } },
{ id: "approach_friend", need: "social", label: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u3066\u3044\u308b", phrase: "\u4ef2\u9593\u306b\u8fd1\u3065\u3044\u305f", weight: 38,
condition: (t, world) => Boolean(world.nearestOther?.(t, 560)),
run(t, world) { const other = world.nearestOther?.(t, 560); t.setActionState?.(t.parentToFollow?.() === other ? "follow_parent" : "seek_friend", { target: other, reason: this.label }); return true; } },
{ id: "intimidate_enemy", need: "social", label: "\u6c17\u306b\u306a\u308b\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b", phrase: "\u5a01\u5687\u3057\u305f", weight: 8,
condition: (t, world) => personalityValue(t, "aggression") > 0.45 && Boolean(world.nearestOther?.(t, 220)),
run(t, world) { const other = world.nearestOther?.(t, 220); t.intimidateTimer = Math.max(t.intimidateTimer || 0, 2.0); t.intimidateTargetId = other?.id || null; t.setActionState?.("intimidate", { target: other, reason: this.label }); return true; } },
{ id: "play", need: "fulfill", label: "\u904a\u3093\u3067\u3044\u308b", phrase: "\u904a\u3093\u3060", weight: 26,
condition: (t, world) => Boolean(findNearestItemWithRole(world, t, "playObject", 460)),
run(t, world) { const ball = findNearestItemWithRole(world, t, "playObject", 460); t.setActionState?.("play_ball", { target: ball, reason: this.label }); t.goodMode = "smile"; return true; } },
{ id: "build_grass_bed", need: "fulfill", label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5bdd\u5e8a\u3092\u4f5c\u3063\u305f", weight: 24,
condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "grass_bed") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)),
run(t) { return runBuildAction(t, "grass_bed", this.label); } },
{ id: "build_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3092\u4f5c\u3063\u305f", weight: 18,
condition: (t, world) => canActNormally(t) && !hasOwnedStructure(world, t, "plushie") && Boolean(findNearbyMaterial(world, t, "grassMaterial", 520)),
run(t) { return runBuildAction(t, "plushie", this.label); } },
{ id: "return_owned_structure", need: "fulfill", label: "\u81ea\u5206\u306e\u5834\u6240\u306b\u623b\u3063\u3066\u3044\u308b", phrase: "\u81ea\u5206\u306e\u5834\u6240\u3078\u623b\u3063\u305f", weight: 18,
condition: (t, world) => Boolean(findOwnedStructure(world, t, "grass_bed", 760)),
run(t, world) { const bed = findOwnedStructure(world, t, "grass_bed", 760); if (moveTowardOrUse(t, bed, "seek_bed", this.label)) return true; StructureRegistry.grass_bed.onUse(t, bed, world); return true; } },
{ id: "use_plushie", need: "fulfill", label: "\u306c\u3044\u3050\u308b\u307f\u3092\u5927\u4e8b\u306b\u3057\u3066\u3044\u308b", phrase: "\u306c\u3044\u3050\u308b\u307f\u3067\u843d\u3061\u7740\u3044\u305f", weight: 22,
condition: (t, world) => Boolean(findOwnedStructure(world, t, "plushie", Infinity)),
run(t, world) { const plushie = findOwnedStructure(world, t, "plushie", Infinity); StructureRegistry.plushie.onUse(t, plushie, world); t.setActionState?.("idle", { target: plushie, reason: this.label }); return true; } },
{ id: "wander_lightly", need: "fulfill", label: "\u8efd\u304f\u6563\u6b69\u3057\u3066\u3044\u308b", phrase: "\u6563\u6b69\u3057\u305f", weight: 12, condition: () => true,
run(t) { t.wanderAngle += rand(-1.7, 1.7); t.setActionState?.("idle", { target: null, reason: this.label }); return true; } },
];
function chooseActionForNeed(need, t, world) {
const candidates = TARINAI_ACTIONS.filter(a => a.need === need && (!a.condition || a.condition(t, world)));
return weightedRandom(candidates) || TARINAI_ACTIONS.find(a => a.id === "wander_lightly");
}
function runNeedDecision(t, world, dt = 0.1) {
updateAttachedStructures(world, t);
decayNeedShock(t, dt);
const needs = updateNeeds(t, world);
const maxNeed = Math.max(...NEED_KEYS.map(key => needs[key]));
const tiedNeeds = NEED_KEYS.filter(key => needs[key] === maxNeed);
const chosenNeed = chooseTopNeedRandom(needs);
const action = chooseActionForNeed(chosenNeed, t, world);
if (!action || !action.run(t, world, dt)) {
t.setActionState?.("idle", { target: null, reason: "\u5f85\u3063\u3066\u3044\u308b" });
}
const reasonText = buildReasonText(chosenNeed, tiedNeeds, action);
t.intent = {
need: chosenNeed,
tiedNeeds: tiedNeeds.slice(),
actionId: action?.id || "idle",
actionLabel: action?.label || "\u5f85\u3063\u3066\u3044\u308b",
reasonText,
};
t.need = chosenNeed;
t.thought = reasonText;
return t.intent;
}
function updateStructure(dt, world) {
if (this.dead) return;
this.age = (this.age || 0) + dt;
if (this.type === "plushie" && this.carriedById) {
const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === this.carriedById);
if (owner) {
this.x = owner.x + (owner.facingDir ? owner.facingDir() : 1) * owner.radius * 0.12;
this.y = owner.y - owner.radius * 1.10;
} else {
this.carriedById = "";
this.onHead = false;
}
}
this.amount = Math.max(0, this.hp || 0);
}
function damageStructure(amount = 1, breaker = null, world = null) {
this.hp = Math.max(0, Number(this.hp || 0) - Math.max(0, Number(amount) || 0));
if (this.hp > 0) return false;
const w = world || global.world;
const def = StructureRegistry[this.type];
def?.onDestroyed?.(this, w, breaker);
this.dead = true;
this.amount = 0;
w?.markTerrainDirty?.(`structure-destroyed:${this.type}`);
return true;
}
function drawStructure(ctx, time, lighting) {
const r = this.r || (this.type === "plushie" ? 14 : 28);
ctx.save();
ctx.translate(this.x, this.y);
if (this.type === "grass_bed") {
ctx.fillStyle = "rgba(175, 196, 102, 0.88)";
ctx.strokeStyle = "rgba(90, 118, 55, 0.72)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 3, r * 1.55, r * 0.74, -0.08, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.strokeStyle = "rgba(71, 112, 48, 0.55)";
ctx.lineWidth = 1.6;
for (let i = 0; i < 15; i++) {
const x = randSeed((this.seed || 1) + i, -r * 1.1, r * 1.1);
const y = randSeed((this.seed || 1) + i + 20, -r * 0.32, r * 0.46);
const len = randSeed((this.seed || 1) + i + 40, r * 0.30, r * 0.68);
const a = randSeed((this.seed || 1) + i + 60, -0.7, 0.7);
ctx.beginPath();
ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "plushie") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("smile", "smile") : null;
ctx.rotate(Math.sin(time * 2.2 + (this.seed || 0)) * 0.08);
if (img) ctx.drawImage(img, -r * 0.8, -r * 0.8, r * 1.6, r * 1.45);
else {
ctx.fillStyle = "#f4ead6";
ctx.strokeStyle = "rgba(92,71,49,0.48)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.ellipse(0, 0, r * 0.68, r * 0.58, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
ctx.restore();
}
const StructureRegistry = {
grass_bed: {
type: "grass_bed",
label: "\u8349\u306e\u7c21\u6613\u30d9\u30c3\u30c9",
buildNeed: "fulfill",
buildTime: 4,
maxHp: 100,
materials: { grassMaterial: 1 },
roles: { sleepPlace: true, ownedStructure: true },
needEffects: { food: 0, sleep: 60, health: 5, safety: 25, social: 0, fulfill: 40 },
create(owner, x, y) {
return { id: createId("structure"), type: "grass_bed", isStructure: true, ownerId: owner.id, hp: 100, attachment: 20, usedCount: 0, x, y, r: 28, amount: 100, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } };
},
onUse(user, structure) {
const owner = user?.id === structure?.ownerId;
structure.usedCount = (structure.usedCount || 0) + 1;
structure.attachment = clamp((structure.attachment || 0) + (owner ? 2 : 0.4), 0, 100);
user.needShock = { ...(user.needShock || {}) };
user.needShock.sleep = Math.max(0, Number(user.needShock.sleep || 0) - (owner ? 18 : 8));
user.needShock.safety = Math.max(0, Number(user.needShock.safety || 0) - (owner ? 12 : 4));
user.needShock.fulfill = Math.max(0, Number(user.needShock.fulfill || 0) - (owner ? 18 : 6));
},
onDestroyed(structure, world, breaker) {
const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId);
if (owner) addNeedShock(owner, { safety: 45, fulfill: 52 }, breaker);
},
},
plushie: {
type: "plushie",
label: "\u306c\u3044\u3050\u308b\u307f",
buildNeed: "fulfill",
buildTime: 4,
maxHp: 1,
materials: { grassMaterial: 1 },
roles: { carriedObject: true, ownedStructure: true, fulfillObject: true },
needEffects: { food: 0, sleep: 0, health: 0, safety: 5, social: 0, fulfill: 50 },
create(owner, x, y) {
return { id: createId("structure"), type: "plushie", isStructure: true, ownerId: owner.id, hp: 1, attachment: 30, usedCount: 0, x, y, r: 14, amount: 1, carriedById: owner.id, onHead: true, seed: Math.random() * 1000, roles: { ...this.roles }, needEffects: { ...this.needEffects } };
},
onUse(user, structure) {
if (!user || !structure) return;
structure.attachment = clamp((structure.attachment || 0) + 1.5, 0, 100);
user.needShock = { ...(user.needShock || {}), fulfill: Math.max(0, Number(user.needShock?.fulfill || 0) - 20), safety: Math.max(0, Number(user.needShock?.safety || 0) - 3) };
},
onDestroyed(structure, world, breaker) {
const owner = (world?.tarinai || []).find(t => t && !t.dead && t.id === structure.ownerId);
if (owner) addNeedShock(owner, { safety: 58, fulfill: 70 }, breaker);
},
},
};
global.TarinaiNeedSystem = {
NEED_KEYS,
NEED_LABELS,
NEED_PHRASES,
TARINAI_ACTIONS,
StructureRegistry,
createDefaultNeeds,
quantizeNeed,
updateNeeds,
chooseTopNeedRandom,
chooseActionForNeed,
buildReasonText,
getNeedDisplayValue,
findNearestItemWithRole,
findNearestSleepPlace,
findNearbyDanger,
findNearbyMaterial,
findOwnedStructure,
ownedStructures,
itemRolesFor,
ensureItemNeedEffects,
deriveStressFromNeeds,
addNeedShock,
runNeedDecision,
createStructure,
damageStructure,
};
global.createDefaultNeeds = createDefaultNeeds;
global.quantizeNeed = quantizeNeed;
global.updateNeeds = updateNeeds;
global.chooseTopNeedRandom = chooseTopNeedRandom;
global.chooseActionForNeed = chooseActionForNeed;
global.buildReasonText = buildReasonText;
global.getNeedDisplayValue = getNeedDisplayValue;
global.findNearestItemWithRole = findNearestItemWithRole;
global.findNearestSleepPlace = findNearestSleepPlace;
global.findNearbyDanger = findNearbyDanger;
global.findNearbyMaterial = findNearbyMaterial;
global.StructureRegistry = StructureRegistry;
})(typeof window !== "undefined" ? window : globalThis);

View file

@ -249,8 +249,8 @@ const terrainCache = {
function isCachedTerrainItem(it) {
if (!it || it.dead) return false;
if (it.machineProduced) return false;
if (it.type === "grass" || it.type === "trace" || it.type === "splat") return true;
if (it.type === "zunchi") return Math.hypot(it.vx || 0, it.vy || 0) < 0.08;
return false;
}
@ -740,15 +740,6 @@ function render() {
for (const ef of world.effects) {
if (ef.type === "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
}
for (const it of world.items) {
if (isCachedTerrainItem(it)) continue;
if (isPinType(it.type) && it.pinState === "lodged") continue;
if (it.type !== "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
for (const it of world.items) {
if (it.type === "zunchi" && isEntityVisibleInRect(it, visibleRect)) it.draw(ctx, world.time, lighting);
}
ctx.save();
ctx.lineWidth = 1.15;
ctx.setLineDash([4, 4]);
@ -765,13 +756,20 @@ function render() {
ctx.setLineDash([]);
ctx.restore();
const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai;
for (const t of sorted) {
if (isEntityVisibleInRect(t, visibleRect)) t.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);
const fieldEntries = [];
const pushFieldEntry = (entity, kind, order = 0) => {
if (!entity || entity.dead || !isEntityVisibleInRect(entity, visibleRect)) return;
fieldEntries.push({ entity, kind, order, y: Number.isFinite(entity.y) ? entity.y : 0 });
};
for (const it of world.items || []) {
if (isCachedTerrainItem(it)) continue;
const layerOffset = it.type === "plushie" && it.onHead ? 999 : (it.type === "bed" ? -1.0 : ((typeof isMachineType === "function" && isMachineType(it.type)) ? (it.r || 42) * 0.58 : (isPinType(it.type) && it.pinState === "lodged" ? 0.15 : 0)));
pushFieldEntry(it, "item", layerOffset);
}
for (const t of world.tarinai || []) pushFieldEntry(t, "actor", 0.1);
for (const a of world.ants || []) pushFieldEntry(a, "actor", 0.05);
fieldEntries.sort((a, b) => ((a.y + a.order) - (b.y + b.order)));
for (const entry of fieldEntries) entry.entity.draw(ctx, world.time, lighting);
for (const ef of world.effects) {
if (ef.type !== "bleed" && isEntityVisibleInRect(ef, visibleRect)) ef.draw(ctx);
}

View file

@ -3,11 +3,81 @@
(function (global) {
const STORAGE_PREFIX = "tarinai_save_slot_v1_";
const EXPORT_PREFIX = "TARINAI_SAVE_V1:";
const EXPORT_PREFIX_COMPRESSED = "TARINAI-SAVE-V11:";
// Alpha note: save/import compatibility is not guaranteed yet.
// Hash imports intentionally accept only the current V11 format during alpha.
// V11 stores compact semantic early-game saves when possible, falls back to the V10 binary packet for complex states, and encodes bytes with printable ASCII excluding \, *, ' and _.
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);
const htmlEscape = global.TarinaiUIHelpers?.htmlEscape || ((v) => String(v ?? "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])));
const BASE94_ALPHABET = (() => {
const excluded = new Set(["\\", "*", "'", "_"]);
let out = "";
for (let i = 33; i <= 126; i++) {
const ch = String.fromCharCode(i);
if (!excluded.has(ch)) out += ch;
}
return out;
})();
const BASE94_RADIX = BASE94_ALPHABET.length;
const BASE94_DECODE = (() => {
const map = Object.create(null);
for (let i = 0; i < BASE94_ALPHABET.length; i++) map[BASE94_ALPHABET[i]] = i;
return map;
})();
function bytesToBase94(bytes) {
if (!bytes || !bytes.length) return "!";
let zeros = 0;
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
const digits = [0];
for (let i = zeros; i < bytes.length; i++) {
let carry = bytes[i];
for (let j = 0; j < digits.length; j++) {
const x = digits[j] * 256 + carry;
digits[j] = x % BASE94_RADIX;
carry = Math.floor(x / BASE94_RADIX);
}
while (carry > 0) {
digits.push(carry % BASE94_RADIX);
carry = Math.floor(carry / BASE94_RADIX);
}
}
let out = BASE94_ALPHABET[0].repeat(zeros);
for (let i = digits.length - 1; i >= 0; i--) out += BASE94_ALPHABET[digits[i]];
return out || BASE94_ALPHABET[0];
}
function base94ToBytes(text) {
const str = String(text || "").replace(/\s/g, "");
if (!str) return new Uint8Array();
let zeros = 0;
while (zeros < str.length && str[zeros] === BASE94_ALPHABET[0]) zeros++;
const bytes = [0];
for (let i = zeros; i < str.length; i++) {
const digit = BASE94_DECODE[str[i]];
if (digit === undefined) throw new Error("invalid base94 character");
let carry = digit;
for (let j = 0; j < bytes.length; j++) {
const x = bytes[j] * BASE94_RADIX + carry;
bytes[j] = x & 255;
carry = x >> 8;
}
while (carry > 0) {
bytes.push(carry & 255);
carry >>= 8;
}
}
const out = new Uint8Array(zeros + bytes.length);
for (let i = 0; i < zeros; i++) out[i] = 0;
for (let i = 0; i < bytes.length; i++) out[out.length - 1 - i] = bytes[i];
return out;
}
function bytesToBase64(bytes) {
let binary = "";
@ -16,8 +86,18 @@
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function stripExportPrefix(text, prefixes) {
let value = String(text || "").trim();
const hashless = value.replace(/^#+/, "");
if (prefixes.some(prefix => hashless.startsWith(prefix))) value = hashless;
for (const prefix of prefixes) {
if (value.startsWith(prefix)) return value.slice(prefix.length);
}
return value;
}
function base64ToBytes(text) {
let b64 = String(text || "").trim().replace(/^#+/, "").replace(EXPORT_PREFIX, "").replace(/-/g, "+").replace(/_/g, "/");
let b64 = stripExportPrefix(text, [EXPORT_PREFIX]).replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
@ -25,25 +105,88 @@
return bytes;
}
function encodeSnapshot(snapshot) {
async function gzipBytes(bytes) {
if (typeof CompressionStream !== "function") return null;
const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function gunzipBytes(bytes) {
if (typeof DecompressionStream !== "function") throw new Error("compressed import is not supported in this browser");
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
function encodeSnapshotLegacy(snapshot) {
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
return EXPORT_PREFIX + bytesToBase64(bytes);
}
function decodeSnapshot(text) {
async function encodeSnapshot(snapshot) {
if (snapshot?.__tarinaiCompactBytes && snapshot.bytes) {
const raw = snapshot.bytes instanceof Uint8Array ? snapshot.bytes : new Uint8Array(snapshot.bytes);
const compressed = await gzipBytes(raw);
if (compressed && compressed.length < raw.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed);
return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(raw);
}
const json = JSON.stringify(snapshot);
const bytes = new TextEncoder().encode(json);
const compressed = await gzipBytes(bytes);
if (compressed && compressed.length < bytes.length) return EXPORT_PREFIX_COMPRESSED + "z" + bytesToBase94(compressed);
return EXPORT_PREFIX_COMPRESSED + "r" + bytesToBase94(bytes);
}
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);
const json = new TextDecoder().decode(bytes);
return JSON.parse(json);
const normalized = raw.replace(/^#+/, "");
if (!normalized) throw new Error("empty import text");
if (!normalized.startsWith(EXPORT_PREFIX_COMPRESSED)) throw new Error("unsupported save hash version");
const body = stripExportPrefix(normalized, [EXPORT_PREFIX_COMPRESSED]);
const mode = body[0] || "r";
const payload = body.slice(1);
const bytes = base94ToBytes(payload);
const packet = mode === "z" ? await gunzipBytes(bytes) : bytes;
if (!Snapshot.decodeV11SnapshotBytes) throw new Error("V11 decoder is not available");
return Snapshot.decodeV11SnapshotBytes(packet);
}
function slotKey(slot) { return `${STORAGE_PREFIX}${slot}`; }
function saveSlot(slot) {
function makeThumbnail() {
const source = global.canvas || document.getElementById("gameCanvas");
if (!source || !source.width || !source.height) return "";
try {
const tw = 168;
const th = 96;
const c = document.createElement("canvas");
c.width = tw;
c.height = th;
const ctx = c.getContext("2d", { alpha: false });
if (!ctx) return "";
ctx.fillStyle = "#f6efe3";
ctx.fillRect(0, 0, tw, th);
const scale = Math.min(tw / source.width, th / source.height);
const w = Math.max(1, Math.round(source.width * scale));
const h = Math.max(1, Math.round(source.height * scale));
const x = Math.floor((tw - w) / 2);
const y = Math.floor((th - h) / 2);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(source, x, y, w, h);
return c.toDataURL("image/jpeg", 0.58);
} catch (_) {
return "";
}
}
function makeSaveSnapshot() {
const snapshot = createSnapshot(global.world);
snapshot.thumbnail = makeThumbnail();
return snapshot;
}
function saveSlot(slot) {
const snapshot = makeSaveSnapshot();
localStorage.setItem(slotKey(slot), JSON.stringify(snapshot));
return snapshot;
}
@ -62,14 +205,11 @@
try { return JSON.parse(raw); } catch (_) { return null; }
}
const htmlEscape = global.TarinaiUIHelpers.htmlEscape;
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}`;
return `${date}\n${d.fieldType || "庭"} / ${d.day || 1}日目 ${d.time || ""}\n${d.population || 0} / 道具${d.items || 0}`;
}
function ensureDialog() {
@ -83,14 +223,21 @@
dialog.innerHTML = `
<div class="field-dialog-panel save-dialog-panel">
<h2 id="saveDialogTitle">セーブ / ロード</h2>
<p class="hint">9つのスロットに現在のコロニーを保存できますハッシュテキストは別端末や別ブラウザへの移動に使えます</p>
<div id="saveSlotList" class="save-slot-list"></div>
<p class="hint">9つのスロットに現在のコロニーを保存できますハッシュテキストは圧縮して短く書き出します</p>
<div id="saveSlotList" class="save-slot-list save-slot-grid"></div>
<div class="save-export-panel">
<div class="save-export-actions">
<button id="saveExportBtn" class="btn" type="button">現在の状態を書き出し</button>
<button id="saveImportBtn" class="btn primary" type="button">ハッシュテキストを読み込み</button>
</div>
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="ここにハッシュテキストが表示されます。読み込む場合はここに貼り付けてください。"></textarea>
<textarea id="saveHashText" class="save-hash-text" spellcheck="false" placeholder="ここに短縮ハッシュテキストが表示されます。読み込む場合はここに貼り付けてください。"></textarea>
</div>
<div id="saveConfirmBox" class="save-confirm-box hidden" role="alertdialog" aria-modal="false">
<div class="save-confirm-message"></div>
<div class="save-confirm-actions">
<button class="btn" data-save-confirm="cancel" type="button">やめる</button>
<button class="btn danger" data-save-confirm="ok" type="button">実行</button>
</div>
</div>
<div class="field-dialog-actions">
<button id="saveCloseBtn" class="btn" type="button">閉じる</button>
@ -100,6 +247,35 @@
return dialog;
}
function confirmInGame(message, okLabel = "実行") {
const dialog = ensureDialog();
const box = dialog.querySelector("#saveConfirmBox");
if (!box) return Promise.resolve(false);
box.querySelector(".save-confirm-message").textContent = message;
const ok = box.querySelector('[data-save-confirm="ok"]');
if (ok) ok.textContent = okLabel;
box.classList.remove("hidden");
return new Promise(resolve => {
const finish = (value) => {
box.classList.add("hidden");
box.removeEventListener("click", onClick);
resolve(value);
};
const onClick = (e) => {
const btn = e.target.closest("[data-save-confirm]");
if (!btn) return;
finish(btn.dataset.saveConfirm === "ok");
};
box.addEventListener("click", onClick);
});
}
function slotThumbnailHtml(snapshot) {
const src = snapshot?.thumbnail || "";
if (src) return `<img src="${htmlEscape(src)}" alt="" loading="lazy">`;
return `<div class="save-slot-thumb-empty">No Image</div>`;
}
function renderSlotList() {
const dialog = ensureDialog();
const list = dialog.querySelector("#saveSlotList");
@ -108,10 +284,11 @@
for (let i = 1; i <= SLOT_COUNT; i++) {
const snapshot = readSlot(i);
const row = document.createElement("div");
row.className = "save-slot-row";
row.className = `save-slot-card ${snapshot ? "filled" : "empty"}`;
row.innerHTML = `
<div class="save-slot-thumb">${slotThumbnailHtml(snapshot)}</div>
<div class="save-slot-title">スロット ${i}</div>
<div class="save-slot-meta">${htmlEscape(formatSlotSummary(snapshot))}</div>
<div class="save-slot-meta">${htmlEscape(formatSlotSummary(snapshot)).replace(/\n/g, "<br>")}</div>
<div class="save-slot-actions">
<button class="btn mini" data-save-slot="${i}" type="button">保存</button>
<button class="btn mini" data-load-slot="${i}" type="button" ${snapshot ? "" : "disabled"}>読込</button>
@ -140,8 +317,9 @@
openDialog();
});
const dialog = ensureDialog();
dialog.addEventListener("click", (e) => {
dialog.addEventListener("click", async (e) => {
if (e.target === dialog) { closeDialog(); return; }
if (e.target.closest("#saveConfirmBox")) return;
const saveBtn = e.target.closest("[data-save-slot]");
const loadBtn = e.target.closest("[data-load-slot]");
const deleteBtn = e.target.closest("[data-delete-slot]");
@ -160,7 +338,7 @@
}
if (loadBtn) {
const slot = Number(loadBtn.dataset.loadSlot);
if (!global.confirm?.(`スロット${slot}を読み込みます。現在の状態は上書きされます。`)) return;
if (!await confirmInGame(`スロット${slot}を読み込みます。現在の状態は上書きされます。`, "読み込む")) return;
try {
loadSlot(slot);
closeDialog();
@ -174,7 +352,7 @@
}
if (deleteBtn) {
const slot = Number(deleteBtn.dataset.deleteSlot);
if (!global.confirm?.(`スロット${slot}を削除しますか?`)) return;
if (!await confirmInGame(`スロット${slot}を削除しますか?`, "削除")) return;
deleteSlot(slot);
renderSlotList();
global.audio?.delete?.();
@ -184,21 +362,37 @@
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.focus();
area.select();
const exportBtn = dialog.querySelector("#saveExportBtn");
try {
if (exportBtn) exportBtn.disabled = true;
if (area) {
area.value = "書き出し中…";
const fullSnapshot = createSnapshot(global.world);
const exportSnapshot = Snapshot.createCompactSnapshot ? Snapshot.createCompactSnapshot(global.world) : fullSnapshot;
const text = await encodeSnapshot(exportSnapshot);
area.value = text;
area.focus();
area.select();
const legacy = encodeSnapshotLegacy(fullSnapshot);
const rate = legacy.length ? Math.round((1 - text.length / legacy.length) * 100) : 0;
global.showToast?.(rate > 0 ? `軽量ハッシュを書き出しました(約${rate}%短縮)。` : "現在の状態を書き出しました。");
}
} catch (error) {
console.warn(error);
global.showToast?.("書き出しに失敗しました。");
} finally {
if (exportBtn) exportBtn.disabled = false;
}
global.showToast?.("現在の状態をハッシュテキストに書き出しました。");
}
if (e.target.closest("#saveImportBtn")) {
const area = dialog.querySelector("#saveHashText");
const text = area?.value || "";
if (!text.trim()) { global.showToast?.("読み込むハッシュテキストを貼り付けてください。"); return; }
if (!global.confirm?.("ハッシュテキストを読み込みます。現在の状態は上書きされます。")) return;
if (!await confirmInGame("ハッシュテキストを読み込みます。現在の状態は上書きされます。", "読み込む")) return;
try {
restoreSnapshot(decodeSnapshot(text), global.world);
restoreSnapshot(await decodeSnapshot(text), global.world);
closeDialog();
global.audio?.notify?.();
global.showToast?.("ハッシュテキストから読み込みました。");
} catch (error) {
console.warn(error);

File diff suppressed because it is too large Load diff

View file

@ -32,12 +32,12 @@
sfx_log_push: 0.10,
},
samplePaths: {
voice_hau: "assets/sounds/voice_001_hau.wav",
voice_flee: "assets/sounds/voice_002_flee.wav",
voice_poop: "assets/sounds/voice_003_po.wav",
sfx_eat: "assets/sounds/voice_004_eat.wav",
voice_stress: "assets/sounds/voice_005_stress.wav",
sfx_sleep: "assets/sounds/voice_006_sleep.wav",
voice_hau: "assets/sounds/voice_001_hau.mp3",
voice_flee: "assets/sounds/voice_002_flee.mp3",
voice_poop: "assets/sounds/voice_003_po.mp3",
sfx_eat: "assets/sounds/voice_004_eat.mp3",
voice_stress: "assets/sounds/voice_005_stress.mp3",
sfx_sleep: "assets/sounds/voice_006_sleep.mp3",
sfx_firecracker_explode: "assets/sounds/firecracker_explosion.mp3"
},
categories: {

View file

@ -57,7 +57,23 @@ class Tarinai { constructor(world, opts = {}) {
this.hpBarTimer = opts.hpBarTimer ?? 0;
this.stressBarTimer = opts.stressBarTimer ?? 0;
this.affection = opts.affection ?? rand(0, 30);
this.need = opts.need || pick(NEEDS);
const defaultNeeds = typeof createDefaultNeeds === "function" ? createDefaultNeeds() : { food: 0, sleep: 0, health: 0, safety: 0, social: 0, fulfill: 0 };
this.needs = opts.needs && typeof opts.needs === "object" ? { ...defaultNeeds, ...opts.needs } : { ...defaultNeeds };
this.previousNeeds = opts.previousNeeds && typeof opts.previousNeeds === "object" ? { ...this.needs, ...opts.previousNeeds } : { ...this.needs };
this.needsInitialized = Boolean(opts.needsInitialized);
this.needShock = opts.needShock && typeof opts.needShock === "object" ? { ...defaultNeeds, ...opts.needShock } : { ...defaultNeeds };
this.need = opts.need || opts.intent?.need || "fulfill";
this.intent = opts.intent && typeof opts.intent === "object" ? { ...opts.intent } : {
need: this.need,
tiedNeeds: [this.need],
actionId: "idle",
actionLabel: "\u5f85\u3063\u3066\u3044\u308b",
reasonText: "\u5f85\u3063\u3066\u3044\u308b",
};
this.panicTimer = opts.panicTimer ?? 0;
this.buildTimer = opts.buildTimer ?? 0;
this.buildType = opts.buildType || "";
this.buildMaterialId = opts.buildMaterialId || "";
this.state = opts.state || "idle";
this.dead = !!opts.dead;
this.deathReason = opts.deathReason || "";

View file

@ -73,6 +73,7 @@
if (Number.isFinite(opts.surpriseTimer)) this.surpriseTimer = Math.max(this.surpriseTimer || 0, opts.surpriseTimer);
if (Number.isFinite(opts.hurtTimer)) this.hurtTimer = Math.max(this.hurtTimer || 0, opts.hurtTimer);
if (Number.isFinite(opts.awakeLockTimer)) this.awakeLockTimer = Math.max(this.awakeLockTimer || 0, opts.awakeLockTimer);
this.panicTimer = Math.max(this.panicTimer || 0, Number.isFinite(opts.duration) ? opts.duration : 3.0);
if (Number.isFinite(opts.fallTimer)) {
this.fallTimer = Math.max(this.fallTimer || 0, opts.fallTimer);
this.fallMax = Math.max(this.fallMax || 0, this.fallTimer);

View file

@ -5,7 +5,19 @@
if (!Tarinai) throw new Error("Tarinai is not available for mixin: tarinai_needs_items.js");
Object.defineProperties(Tarinai.prototype, Object.getOwnPropertyDescriptors({
canEatItemType(type) {
return this.isZunchiSlave ? type === "zunchi" : true;
const t = String(type || "");
if (!this.isZunchiSlave) return true;
if (!t || t === "sweet" || t === "grass" || t === "water" || t === "water_bowl") return false;
if (t === "zunchi" || t === "food" || t === "ant_corpse") return true;
const foodDef = global.TarinaiFoodRegistry?.get?.(t);
return Boolean(foodDef && foodDef.interest !== "water");
},
zunchiSlaveFoodTypes() {
const defs = global.TARINAI_FOOD_DEFINITIONS || {};
const types = Object.keys(defs).filter(type => this.canEatItemType(type));
if (!types.includes("zunchi")) types.push("zunchi");
return types;
},
hasLoveMochiEffect() {
@ -146,8 +158,8 @@
startSunbath() {
if (!this.canStartSunbath()) return false;
this.setActionState?.("sunbath", { target: null, reason: "日なたぼっこ", sleeping: false });
this.sunbathTimer = rand(6, 14);
this.sunbathCooldown = rand(16, 34);
this.sunbathTimer = rand(12, 24);
this.sunbathCooldown = rand(8, 18);
this.sunbathFrameTimer = 0;
this.sunbathSpriteVariant = 1 + Math.floor(Math.random() * 3);
this.sunbathRecoveryChecked = false;
@ -166,7 +178,7 @@
}
this.stress = clamp((this.stress || 0) - dt * 1.15, 0, 130);
this.energy = clamp((this.energy || 0) + dt * 0.08, 0, 100);
if (!this.isSunbathTime() || this.hunger > 74 || this.energy < 10 || this.state === "panic") this.sunbathTimer = 0;
if (!this.isSunbathTime() || this.hunger > 86 || this.energy < 8 || this.state === "panic") this.sunbathTimer = 0;
if ((this.sunbathTimer || 0) <= 0 && this.state === "sunbath") {
this.goIdle("");
}
@ -211,6 +223,7 @@
this.fearTimer = Math.max(0, this.fearTimer - dt);
this.intimidateTimer = Math.max(0, this.intimidateTimer - dt);
this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt);
this.panicTimer = Math.max(0, (this.panicTimer || 0) - dt);
this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt);
this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt);
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
@ -453,8 +466,13 @@
this.ensureFoodServings(it);
if (!this.isServingFoodItem(it) || (it.foodServingsRemaining || 0) <= 0) return 0;
const value = Number.isFinite(nutrition) ? nutrition : (window.TarinaiFoodRegistry?.nutrition?.(it.type, 0) ?? 0);
it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1);
it.amount = it.foodServingsRemaining;
if (!it.infiniteServing) {
it.foodServingsRemaining = Math.max(0, (it.foodServingsRemaining || 0) - 1);
it.amount = it.foodServingsRemaining;
} else {
it.foodServingsRemaining = Math.max(it.foodServingsRemaining || 0, it.foodServingsMax || 1);
it.amount = it.foodServingsRemaining;
}
this.world?.emit?.("item:eaten", { tarinai: this, item: it, type: it.type, nutrition: value });
return value;
},
@ -578,6 +596,20 @@
}
this.stuckPushpinId = null;
}
if ((this.panicTimer || 0) > 0 && this.state === "panic") {
this.setActionState?.("panic", {
target: previousTarget || this.panicDestination?.(null, true) || null,
reason: this.intent?.reasonText || "\u304a\u3069\u308d\u3044\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b",
wake: true,
});
return;
}
if (window.TarinaiNeedSystem?.runNeedDecision) {
window.TarinaiNeedSystem.runNeedDecision(this, this.world, dt);
return;
}
const friendNearby = this.bestFriendLive ? this.bestFriendLive(FRIEND_AFFINITY_THRESHOLD, 170) : null;
const panicSource = (this.world?.nearbyTarinai?.(this.x, this.y, 128, true) || [])
@ -623,6 +655,16 @@
return;
}
if (this.canStartSunbath?.()) {
const moodBonus = (this.world?.colonyMood?.id === "sun_party") ? 0.72 : 0;
const sickBonus = (this.zunchiDisease || this.explosionDisease || this.fightDisease) ? 0.48 : 0;
const stressBonus = this.stress > 28 ? 0.22 : 0;
const idleBonus = this.state === "idle" ? 0.18 : 0;
if (Math.random() < dt * (0.34 + moodBonus + sickBonus + stressBonus + idleBonus)) {
if (this.startSunbath()) return;
}
}
const dayP = this.world.dayProgress();
const personalSleep = this.sleepStart < this.sleepEnd
? dayP >= this.sleepStart && dayP < this.sleepEnd
@ -793,14 +835,14 @@
}
}
if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "grass", "ant_corpse"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
if (friendNearby && this.hunger > 58 && friendNearby.target && (this.isZunchiSlave ? this.canEatItemType(friendNearby.target.type) : ["sweet", "grass", "ant_corpse"].includes(friendNearby.target.type)) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
this.setActionState?.("seek_food", { target: friendNearby.target, reason: "友達と一緒に食べに行く" });
this.foodReactTimer = Math.max(this.foodReactTimer, 0.24);
return;
}
if (this.hunger > 62) {
const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])];
const itemTypes = this.isZunchiSlave ? this.zunchiSlaveFoodTypes() : ["sweet", ...((window.TarinaiFoodRegistry?.typesByInterest?.("grass_like", "corpse")) || ["grass", "ant_corpse"])];
const item = this.world.nearest(this, itemTypes, 420);
if (item) {
this.setActionState?.("seek_food", { target: item, reason: "食べ物を探している" });
@ -856,12 +898,13 @@
interactWithItems(dt) {
const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 18;
const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 7;
const slaveCanFoodBite = this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 16;
const canZunchiBite = this.eatCooldown <= 0 && this.hunger > (this.isZunchiSlave ? 16 : 104);
for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) {
const d = dist(this, it);
if (d > this.radius + it.r + 12) continue;
if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && this.foodItemHasServingLeft(it)) {
if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && (canBite || (slaveCanFoodBite && this.canEatItemType(it.type))))) && this.foodItemHasServingLeft(it)) {
const eating = this.consumeFoodServing(it);
if (eating > 0) {
this.beginEating(it, `${toolLabel(it.type)}を食べている`);
@ -896,7 +939,7 @@
}
}
if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) {
if ((canSweetBite || (slaveCanFoodBite && this.canEatItemType(it.type))) && (it.type === "love_mochi" || it.type === "fight_mochi" || it.type === "sleep_drug" || (typeof isParamEffectItemType === "function" && isParamEffectItemType(it.type))) && this.foodItemHasServingLeft(it)) {
const eating = this.consumeFoodServing(it);
if (eating <= 0) continue;
this.beginEating(it, `${toolLabel(it.type)}を食べている`);
@ -946,9 +989,14 @@
this.grassEatTimer = Math.max(this.grassEatTimer, 0.22);
this.vx *= Math.pow(0.20, dt * 60);
this.vy *= Math.pow(0.20, dt * 60);
it.amount -= bite;
it.eatenAmount = (it.eatenAmount || 0) + bite;
it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2);
if (!it.infiniteServing) {
it.amount -= bite;
it.eatenAmount = (it.eatenAmount || 0) + bite;
it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2);
} else {
it.amount = Math.max(it.amount || 0, 120);
it.growth = Math.max(it.growth || 0, 0.82);
}
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.46) : (this.hunger -= bite * 0.46);
this.recoverHealth(bite * 0.18);
this.stress -= dt * 0.5;
@ -965,13 +1013,14 @@
break;
}
if (canBite && it.type === "ant_corpse") {
if ((canBite || (slaveCanFoodBite && this.canEatItemType(it.type))) && it.type === "ant_corpse") {
const bite = Math.min(it.amount, rand(3.2, 5.4));
if (bite > 0) {
this.beginEating(it, "アリの死骸を食べている");
this.vx *= Math.pow(0.18, dt * 60);
this.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite;
if (!it.infiniteServing) it.amount -= bite;
else it.amount = Math.max(it.amount || 0, 24);
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * 0.62) : (this.hunger -= bite * 0.62);
this.recoverHealth(bite * 0.12);
this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130);
@ -1000,7 +1049,8 @@
this.beginEating(it, this.isZunchiSlave ? "ずんちどれいなのでずんちを食べている" : "ずんちを食べている");
this.vx *= Math.pow(0.18, dt * 60);
this.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite;
if (!it.infiniteServing) it.amount -= bite;
else it.amount = Math.max(it.amount || 0, 240);
this.applyFoodHungerRelief ? this.applyFoodHungerRelief(bite * (this.isZunchiSlave ? 0.66 : 0.18)) : (this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18));
this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02));
this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52) * this.personalityProfile().fear, 0, 130);
@ -1025,7 +1075,8 @@
audio.play?.("sfx_drink", { category: "ops", minGap: 0.20 });
this.nextDrinkSound = (this.world?.time || 0) + 0.72;
}
if (it.type === "water") it.amount -= dt * 2.4;
if (it.type === "water" && !it.infiniteServing) it.amount -= dt * 2.4;
if (it.type === "water" && it.infiniteServing) it.amount = Math.max(it.amount || 0, 120);
if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18;
}

View file

@ -166,6 +166,25 @@ function selectedInfoRowHtml(label, valueHtml, metric = "") {
return `<div class="info-row"${metricAttr}><span>${escapeHtml(label)}</span><strong>${valueHtml}</strong></div>`;
}
function selectedNeedRowsHtml(t) {
const system = window.TarinaiNeedSystem;
const keys = system?.NEED_KEYS || ["food", "sleep", "health", "safety", "social", "fulfill"];
const labels = system?.NEED_LABELS || {
food: "\u6442\u990c",
sleep: "\u7761\u7720",
health: "\u5065\u5eb7",
safety: "\u5b89\u5168",
social: "\u95a2\u4fc2",
fulfill: "\u5145\u8db3",
};
const active = t?.intent?.need || t?.need || "";
return keys.map(key => {
const value = system?.getNeedDisplayValue ? system.getNeedDisplayValue(t?.needs?.[key] || 0) : Math.round((t?.needs?.[key] || 0) / 10);
const mark = key === active ? ` <span class="selected-need-mark">\u25b2</span>` : "";
return `<div class="info-row selected-need-row${key === active ? " active" : ""}" data-need="${escapeHtml(key)}"><span>${escapeHtml(labels[key] || key)}</span><strong>${escapeHtml(value)}${mark}</strong></div>`;
}).join("");
}
function renderSelected() {
const t = world.selected;
if (!t || t.dead || !world.tarinai.includes(t)) {
@ -201,6 +220,7 @@ function renderSelected() {
t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0),
t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0),
t.sleepDisease ? 1 : 0, t.explosionDisease ? 1 : 0, fmt(t.explosionDiseaseTimer || 0), t.fightDisease ? 1 : 0, t.favorite ? 1 : 0,
JSON.stringify(t.needs || {}), t.intent?.need || "", t.intent?.actionId || "", t.intent?.actionLabel || "", t.intent?.reasonText || "",
personalitySnapshot, personalityDailySnapshot(t),
["life", "attack", "speed", "size"].map(key => geneticPercentText(t, key)).join(","), fmt((t.growth || 0) * 100),
itemEffectSummary.map(e => `${e.id}:${e.detail}`).join(","),
@ -239,7 +259,8 @@ function renderSelected() {
const basicHtml = `
<div class="info-row"><span>\u4e16\u4ee3</span><strong>${escapeHtml(generationDisplay || "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div>
<div class="info-row"><span>\u7406\u7531</span><strong>${escapeHtml(reasonLabel(t))}</strong></div>
<div class="info-row"><span>\u3044\u307e</span><strong>${escapeHtml(t.intent?.actionLabel || stateLabel(t.state))}</strong></div>
<div class="info-row"><span>\u7406\u7531</span><strong>${escapeHtml(t.intent?.reasonText || reasonLabel(t))}</strong></div>
<div class="info-row"><span>\u5bfe\u8c61</span><strong>${escapeHtml(targetLabel(t.target))}</strong></div>
<div class="info-row"><span>\u5e74\u9f62</span><strong>${fmt(t.age)} / ${fmt(t.lifeSpan)}</strong></div>
<div class="info-row"><span>\u6700\u5927\u5bff\u547d</span><strong>${geneticPercentText(t, "life")}</strong></div>
@ -247,14 +268,7 @@ function renderSelected() {
<div class="info-row"><span>\u79fb\u52d5\u901f\u5ea6</span><strong>${geneticPercentText(t, "speed")}</strong></div>
<div class="info-row"><span>\u30b5\u30a4\u30ba</span><strong>${geneticSizeText(t)}</strong></div>
`;
const healthHtml = [
selectedInfoRowHtml("\u7dcf\u5408\u7684\u6c17\u5206", fmt(t.mood), "mood"),
selectedInfoRowHtml("\u7a7a\u8179", fmt(t.hunger), "hunger"),
selectedInfoRowHtml("\u5bc2\u3057\u3055", fmt(t.loneliness), "loneliness"),
selectedInfoRowHtml("\u4f53\u529b", fmt(t.energy), "energy"),
selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress"),
selectedInfoRowHtml("\u305a\u3093\u3061\u6c5a\u308c", fmt(t.zunchiStain || 0), "zunchi"),
].join("");
const healthHtml = selectedNeedRowsHtml(t) + selectedInfoRowHtml("\u30b9\u30c8\u30ec\u30b9", fmt(t.stress), "stress");
const itemEffectHtml = itemEffectSummary.length
? 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>`;

View file

@ -1,8 +1,8 @@
"use strict";
(function () {
const APP_VERSION = "15.24.0";
const APP_BUILD = "safe-refactor-ui-fix";
const APP_VERSION = "15.35.0";
const APP_BUILD = "hash-v10-shorter-current-only";
const APP_CACHE_NAME = `tarinai-colony-${APP_VERSION}`;
const STATIC_VERSION_PARAM = `v=${APP_VERSION}`;

View file

@ -38,6 +38,31 @@
return hit;
},
damageStructuresInRadius(x, y, radius, damageFn, breaker = null) {
let hit = 0;
for (const structure of this.items || []) {
if (!structure || structure.dead || !structure.isStructure) continue;
const d = Math.max(1, distXY(structure.x, structure.y, x, y));
if (d > radius + (structure.r || 18)) continue;
const p = clamp(1 - d / Math.max(1, radius), 0, 1);
if (p <= 0 && d > radius) continue;
const damage = typeof damageFn === "function" ? damageFn(p, structure, d) : Number(damageFn || 0);
if (damage <= 0) continue;
if (typeof structure.damage === "function") structure.damage(damage, breaker, this);
else if (window.TarinaiNeedSystem?.damageStructure) window.TarinaiNeedSystem.damageStructure.call(structure, damage, breaker, this);
else {
structure.hp = Math.max(0, Number(structure.hp || 0) - damage);
if (structure.hp <= 0) structure.dead = true;
}
hit += 1;
}
if (hit > 0) {
this.drawListDirty = true;
this.markTerrainDirty?.("structure-damage");
}
return hit;
},
explodeFirecracker(it) {
if (!it || it.dead || it.type !== "firecracker") return;
it.amount = 0;
@ -109,6 +134,7 @@
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
});
this.damageStructuresInRadius(x, y, blastRadius, p => 35 + p * 90, it);
let blastedBalls = 0;
for (const ball of this.items) {
if (!ball || ball.dead || ball.type !== "ball") continue;
@ -199,6 +225,7 @@
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u767a\u75c5", {
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * blastScale,
});
this.damageStructuresInRadius(x, y, blastRadius, q => 28 + q * 76, t);
for (const ball of this.items) {
if (!ball || ball.dead || ball.type !== "ball") continue;
let dx = ball.x - x;
@ -589,6 +616,7 @@
const antHit = this.damageAntsInRadius(it.x, it.y, radius + 54, p => damage * (0.35 + p * 0.65), reason, {
push: p => push * (0.62 + p * 1.25),
});
const structureHit = this.damageStructuresInRadius(it.x, it.y, radius + 54, p => damage * (0.45 + p * 0.85), it);
if (isGenkotsu) {
this.blastZunchiFrom?.(it.x, it.y, radius + 92, 2.75);
for (let r = 0; r < 4; r++) this.effects.push(new Effect("ring", it.x, it.y, { size: 32 + r * 34, life: 0.34 + r * 0.07, color: r % 2 ? "rgba(255,203,54,0.72)" : "rgba(74,57,42,0.42)" }));
@ -596,7 +624,7 @@
} else {
this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" }));
}
if (hit > 0 || antHit > 0 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45));
if (hit > 0 || antHit > 0 || structureHit > 0 || isGenkotsu) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isGenkotsu ? 1.32 : (isStone ? 0.9 : 0.45));
if (isGenkotsu) audio.genkotsuImpact?.();
else if (isStone) audio.stoneImpact?.();
else if (hit > 0 || antHit > 0) audio.poke?.();

View file

@ -211,6 +211,73 @@
return item;
},
signboardAt(x, y) {
let sign = null;
let bestD = Infinity;
for (let i = (this.items || []).length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type !== "signboard") continue;
const d = distXY(x, y, it.x, it.y);
if (d <= Math.max(72, (it.r || 42) * 2.2) && d < bestD) { sign = it; bestD = d; }
}
return sign;
},
machineAt(x, y) {
let best = null;
let bestD = Infinity;
for (let i = (this.items || []).length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || !(typeof isMachineType === "function" ? isMachineType(it.type) : (it.type === "duplicator" || it.type === "cannon"))) continue;
const hit = Math.max(46, (it.r || 42) * (it.type === "cannon" ? 1.35 : 1.55));
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < bestD) { best = it; bestD = d; }
}
return best;
},
setMachineSlot(machine, itemType) {
if (!machine || machine.dead || !itemType || !(typeof isMachineConsumableType === "function" ? isMachineConsumableType(itemType) : false)) return false;
if (machine.machineSlotType && machine.machineSlotType !== itemType) {
for (const it of this.items || []) {
if (it && !it.dead && it.machineProducerId === machine.id) it.amount = 0;
}
}
machine.machineSlotType = itemType;
machine.machineSlotSize = this.toolSizeFor ? this.toolSizeFor(itemType) : "medium";
machine.machineSpawnTimer = 0;
machine.cannonFireTimer = machine.type === "cannon" ? 0.35 : machine.cannonFireTimer;
const label = toolLabel(itemType);
this.log?.(`${toolLabel(machine.type)}${label}をセットした。`, "observe");
showToast(`${toolLabel(machine.type)}: ${label}をセット`);
this.rebuildSpatial?.(true);
return true;
},
rotateCannon(cannon, x, y) {
if (!cannon || cannon.dead || cannon.type !== "cannon") return false;
const dx = x - cannon.x;
const dy = y - cannon.y;
if (Math.hypot(dx, dy) > 3) cannon.cannonAngle = Math.atan2(dy, dx);
else cannon.cannonAngle = ((Number.isFinite(cannon.cannonAngle) ? cannon.cannonAngle : 0) + Math.PI / 4) % (Math.PI * 2);
const deg = Math.round((((cannon.cannonAngle * 180 / Math.PI) % 360) + 360) % 360);
showToast(`大砲の向き: ${deg}°`);
this.log?.(`大砲の向きを${deg}度に変えた。`, "observe");
return true;
},
editSignboard(sign) {
if (!sign || sign.dead || sign.type !== "signboard") return false;
const current = String(sign.text || "");
const next = typeof prompt === "function" ? prompt("看板のテキスト", current) : current;
if (next === null) return true;
sign.text = String(next || "").replace(/[\r\n]+/g, " ").slice(0, 36).trim();
sign.textEditedAt = this.time || 0;
this.markTerrainDirty?.("signboard:text");
this.log(sign.text ? `看板を書き換えた。` : `看板の文字を消した。`, "observe");
return true;
},
handleClick(x, y) {
const tool = this.tool;
if (tool === "observe") {
@ -219,22 +286,16 @@
if (!this.isTarinaiHiddenInNestBox(this.tarinai[i]) && this.tarinai[i].contains(x, y)) { found = this.tarinai[i]; break; }
}
if (!found) {
let sign = null;
let bestD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type !== "signboard") continue;
const d = distXY(x, y, it.x, it.y);
if (d <= Math.max(54, (it.r || 24) * 2.4) && d < bestD) { sign = it; bestD = d; }
const machine = this.machineAt?.(x, y) || null;
if (machine) {
if (machine.type === "cannon") this.rotateCannon?.(machine, x, y);
else showToast(machine.machineSlotType ? `複製機: ${toolLabel(machine.machineSlotType)}を複製中` : "複製機: 消費アイテムを選んでクリックするとセット");
this.selected = null;
return;
}
const sign = this.signboardAt?.(x, y) || null;
if (sign) {
const current = String(sign.text || "");
const next = typeof prompt === "function" ? prompt("看板のテキスト", current) : current;
if (next !== null) {
sign.text = String(next || "").replace(/[\r\n]+/g, " ").slice(0, 24).trim();
sign.textEditedAt = this.time || 0;
this.log(sign.text ? `看板を書き換えた。` : `看板の文字を消した。`, "observe");
}
this.editSignboard?.(sign);
this.selected = null;
return;
}
@ -284,7 +345,8 @@
}
if (!best) { showToast("\u524a\u9664\u3067\u304d\u308b\u30aa\u30d6\u30b8\u30a7\u30af\u30c8\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
if (isPinType(best.type) && best.pinState === "lodged") { showToast("\u523a\u3055\u3063\u305f\u92f2\u306f\u3064\u307e\u3093\u3067\u53d6\u308a\u9664\u3044\u3066\u304f\u3060\u3055\u3044\u3002"); return; }
best.amount = 0;
if (best.isStructure && typeof best.damage === "function") best.damage(9999, null, this);
else best.amount = 0;
audio.delete?.();
this.compactItems();
this.updateItemCounts();
@ -295,7 +357,20 @@
}
const itemType = toolItemType(tool);
if (itemType === "signboard") {
const sign = this.signboardAt?.(x, y) || null;
if (sign) { this.editSignboard?.(sign); return; }
}
if (itemType) {
const machine = this.machineAt?.(x, y) || null;
if (machine && (typeof isMachineConsumableType === "function" ? isMachineConsumableType(itemType) : false)) {
this.setMachineSlot?.(machine, itemType);
return;
}
if (machine && machine.type === "cannon" && itemType !== "cannon" && itemType !== "duplicator") {
this.rotateCannon?.(machine, x, y);
return;
}
let px = x, py = y;
const rawItem = this.applyToolSize(new Item(itemType, px, py));
// Placement uses the same footprint that the preview validates.
@ -303,6 +378,7 @@
if (!placed) return;
const label = toolLabel(itemType);
this.log(itemType === "genkotsu" || isPinType(itemType) ? `${label}\u3092\u843d\u3068\u3057\u305f\u3002` : `${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
if (itemType === "signboard") this.editSignboard?.(placed);
}
},

View file

@ -1,11 +1,11 @@
"use strict";
const APP_VERSION = "15.24.0";
const APP_VERSION = "15.35.0";
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", "need_behavior_system", "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"
];
const CORE_ASSETS = [
"./",