@linxin666/dsh-pet 0.1.20 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +17 -10
  3. package/README.zh.md +17 -10
  4. package/assets/whale/pet.json +9 -0
  5. package/lib/client.js +158 -26
  6. package/lib/client.js.map +1 -1
  7. package/lib/index.js +853 -41
  8. package/lib/invariant.js +1 -1
  9. package/lib/{state-Diiq8vjk.js → state-Ch3f4luZ.js} +51 -23
  10. package/lib/types/affinity.d.ts +4 -0
  11. package/lib/types/affinity.d.ts.map +1 -1
  12. package/lib/types/affinity.js +32 -6
  13. package/lib/types/chatter.d.ts +111 -0
  14. package/lib/types/chatter.d.ts.map +1 -0
  15. package/lib/types/chatter.js +666 -0
  16. package/lib/types/client/PetSprite.d.ts.map +1 -1
  17. package/lib/types/client/PetSprite.js +88 -14
  18. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  19. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PluginSettingsCard.js +5 -3
  21. package/lib/types/client/sequences.d.ts +10 -0
  22. package/lib/types/client/sequences.d.ts.map +1 -0
  23. package/lib/types/client/sequences.js +20 -0
  24. package/lib/types/event-projection.d.ts +14 -1
  25. package/lib/types/event-projection.d.ts.map +1 -1
  26. package/lib/types/event-projection.js +38 -17
  27. package/lib/types/ledger.d.ts.map +1 -1
  28. package/lib/types/ledger.js +15 -7
  29. package/lib/types/persist.d.ts.map +1 -1
  30. package/lib/types/persist.js +3 -1
  31. package/lib/types/registry.d.ts +7 -1
  32. package/lib/types/registry.d.ts.map +1 -1
  33. package/lib/types/registry.js +36 -0
  34. package/lib/types/remarks.d.ts +2 -0
  35. package/lib/types/remarks.d.ts.map +1 -1
  36. package/lib/types/remarks.js +5 -0
  37. package/lib/types/service.d.ts +6 -0
  38. package/lib/types/service.d.ts.map +1 -1
  39. package/lib/types/service.js +15 -2
  40. package/lib/types/state.d.ts +7 -4
  41. package/lib/types/state.d.ts.map +1 -1
  42. package/lib/types/state.js +20 -20
  43. package/package.json +9 -9
  44. package/src/affinity.test.ts +4 -2
  45. package/src/affinity.ts +37 -6
  46. package/src/chatter.test.ts +154 -0
  47. package/src/chatter.ts +697 -0
  48. package/src/client/PetSprite.test.tsx +190 -1
  49. package/src/client/PetSprite.tsx +112 -25
  50. package/src/client/PluginSettingsCard.tsx +10 -0
  51. package/src/client/pet-css.test.ts +42 -0
  52. package/src/client/pet.module.css +154 -26
  53. package/src/client/sequences.test.ts +33 -0
  54. package/src/client/sequences.ts +33 -0
  55. package/src/event-projection.ts +49 -16
  56. package/src/ledger.test.ts +42 -1
  57. package/src/ledger.ts +15 -7
  58. package/src/persist.test.ts +18 -1
  59. package/src/persist.ts +3 -1
  60. package/src/registry.test.ts +50 -3
  61. package/src/registry.ts +47 -3
  62. package/src/remarks.ts +6 -0
  63. package/src/service.ts +27 -2
  64. package/src/state.test.ts +13 -0
  65. package/src/state.ts +24 -18
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # node scripts/verify-docs.mjs --write <dir>
5
- README.md: b8705d67cb561b581ecfad2d644e6d70e17cf6b1
6
- README.zh.md: fb5bc5846a273a667c0f72adac1ecc1c9e0d5130
5
+ README.md: 2784c32e13b4f35dd94caef439f4b73d99045289
6
+ README.zh.md: f062272c21046b364510c016c97877605083689d
package/README.md CHANGED
@@ -15,15 +15,16 @@ Re-implemented from the pet feature of the Codex desktop app, as an official DSH
15
15
  | Multi-pet registry | The host scans built-in `assets/`, the hatch-pet custom pets directory, and composed config entries; each pet is a manifest plus an atlas |
16
16
  | Pet selection in settings | The plugin settings card lists every registered pet; switching persists and the sprite swaps immediately |
17
17
  | Per-pet naming | Rename from the hover panel; each pet keeps its own name (stored per pet id, migrated from the legacy flat name) |
18
- | State animation | Official session activity → 9-state animation: `thinking running`, `tool running-right`, `review review`, `waiting waiting`, `done jumping`, `failed failed` |
18
+ | State animation | Official session activity → manifest-defined sequences of 9-state tracks; each track finishes its full duration before the sequence advances and the complete sequence loops |
19
19
  | Head-pat interaction | Click the pet → bubble feedback + affinity +1 (10s cooldown) |
20
20
  | Feeding | Hover panel 喂食 (Feed) → consumes 1 dried fish + affinity +5 (30s cooldown) |
21
21
  | Treat economy | Dried-fish stock (cap 20): +1 every 30 rounds of work, +1 every 300 minutes (5 hours) — 10x rarer than the original cadence |
22
22
  | Affinity | +1 per round completed; 9 levels: 幼鲸 → 伙伴 → 挚友 → 深海羁绊 → 心有灵犀 → 传说羁绊 → 神话羁绊 → 永恒之契 → 鲸生共渡 (capped at 999,999,999) |
23
23
  | Dragging | Hold and drag the pet to reposition; position persisted |
24
- | Hide/Summon | Hover panel 隐藏 (Hide); after hiding, a 召唤{name} (Summon {name}) button appears |
25
- | Witty remarks | Built-in remark library (10 lines per event) plus per-pet custom lines from a manifest remarks block community PRs give their pet its own voice |
26
- | Status bubbles | Each concurrently active top-level session gets its own bubble, stacked above the pet (up to 12); subagent sessions report through their spawning conversation and never occupy a bubble of their own; click a bubble to jump to its session; transient interaction feedback temporarily takes priority |
24
+ | Hide/Summon | The hover panel sits below the pet (lifted above the status bubbles when there is no room below) and provides 隐藏 (Hide); after hiding, a 召唤{name} (Summon {name}) button appears |
25
+ | Witty remarks | Built-in remark library (10 lines per event) plus per-pet custom lines; success lines rotate by persisted success counts and cooldown lines by persisted rejection counts |
26
+ | Status bubbles | Each concurrently active top-level session gets its own bubble, stacked above the pet (up to 12); subagent sessions report through their spawning conversation and never occupy a bubble of their own; click a bubble to jump to its session; transient interaction feedback temporarily takes priority. Bubble copy comes from generous rotating pools per scene (waiting / thinking / writing / done / failed...), tool calls map onto per-family witty lines carrying the real argument hint (e.g. 跑跑 npm test), and a long-lived scene re-phrases itself every few seconds |
27
+ | Inner whispers | 碎碎念: while the model streams, the pet occasionally speaks its inner voice in a distinct violet whisper bubble — keyword moods woken by the model output (errors, test greens, plans, victories...) plus ambient whispers earned by output volume; paced by a cooldown, expired after a few seconds |
27
28
  | Multi-session activity | The pet is host-global: the most recent meaningful event drives the sprite animation while every active top-level session reports its own state in a separate bubble; completed turns from every session (subagents included) contribute affinity and treats |
28
29
 
29
30
  ## Pet contract
@@ -38,10 +39,14 @@ A pet is a directory holding one `pet.json` manifest and one atlas image. Nothin
38
39
  "spritesheetPath": "spritesheet.webp", // atlas, relative to the manifest
39
40
  "cell": { "width": 192, "height": 208 }, // optional; defaults to the Codex contract
40
41
  "columns": 8, // optional; default 8
42
+ "spriteVersionNumber": 1, // optional; 2 marks an 11-row v2 atlas (9 animation rows + 2 look rows)
41
43
  "frames": [6, 8, 8, 4, 5, 8, 6, 6, 6], // optional per-row frame counts
42
44
  "tracks": { // optional per-track rhythm overrides
43
45
  "idle": { "durations": [400, 400, 500, 400, 400, 500] }
44
46
  },
47
+ "sequences": { // optional per-scene track sequences (at least 5 items each)
48
+ "thinking": ["running", "running-right", "running", "running-left", "waiting"]
49
+ },
45
50
  "remarks": { // optional witty remarks (one line or a pool per slot)
46
51
  "pet": "摸摸水獭的头~",
47
52
  "feed": ["小鱼干真香", "再来一条~"]
@@ -49,9 +54,10 @@ A pet is a directory holding one `pet.json` manifest and one atlas image. Nothin
49
54
  }
50
55
  ```
51
56
 
52
- - The atlas is an 8-column × 9-row grid (192×208 cells by default); rows are fixed in this order: 0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping, 5 failed, 6 waiting, 7 running, 8 review. Unused cells stay fully transparent.
53
- - The optional remarks block overrides the reaction bubbles the pet speaks on pet / petCooldown / feed / feedCooldown / noTreats events. Each slot accepts one line or a pool of lines (cycled round-robin); a declared slot replaces the built-in pool for that slot only. This is how community contributions give their pet its own witty voice.
57
+ - The atlas is an 8-column × 9-row grid (192×208 cells by default); rows are fixed in this order: 0 idle, 1 running-right, 2 running-left, 3 waving, 4 jumping, 5 failed, 6 waiting, 7 running, 8 review. Unused cells stay fully transparent. v2 Codex atlases declare `"spriteVersionNumber": 2` and hold 11 rows — the same 9 animation rows plus 2 trailing look rows; the plugin renders the 9 animation rows and ignores the look rows.
58
+ - The optional remarks block overrides the reaction bubbles the pet speaks on pet / petCooldown / feed / feedCooldown / noTreats events. Each slot accepts one line or a pool of lines; a declared slot replaces the built-in pool for that slot only. Success and cooldown pools use the corresponding persisted success or rejection count, while noTreats cycles independently. This is how community contributions give their pet its own witty voice.
54
59
  - `frames` counts the used columns per row (defaults to the hatch-pet contract table `[6, 8, 8, 4, 5, 8, 6, 6, 6]`); `tracks` overrides per-frame durations (cycled to the row's frame count), `loop`, and `fallback` per animation (defaults: everything loops; `jumping` and `failed` hold their last frame, then fall back to `idle`).
60
+ - `sequences` optionally maps activity scenes (`idle` / `waiting` / `thinking` / `tool` / `review` / `done` / `failed`) to at least 5 animation tracks. Each item plays every frame for the durations in `tracks`, then the next item starts; the complete sequence loops. An omitted scene keeps its canonical single-track playback.
55
61
 
56
62
  Where pets come from (later sources override earlier ones on id collision):
57
63
 
@@ -82,16 +88,17 @@ dsh-pet/
82
88
  | |-- registry.ts # multi-pet contract: manifest scan + normalization (assets + custom pets)
83
89
  | |-- service.ts # PetService: pet selection + state machine + affinity + config
84
90
  | |-- state.ts # pet state machine: projected session activity → 9 state animations
85
- | |-- remarks.ts # witty-remark library: built-in pools + per-pet overrides + picker
91
+ | |-- remarks.ts # witty-remark library: built-in pools + per-pet overrides + counted picker
86
92
  | |-- affinity.ts # affinity ledger (pure functions + cooldowns)
87
93
  | |-- treats.ts # dried-fish stock ledger
88
- | |-- persist.ts # persistence ($DSH_HOME/pet.json: selection + per-pet names, atomic write)
94
+ | |-- persist.ts # persistence ($DSH_HOME/pet.json: selection + names + interaction counts)
89
95
  | |-- routes.ts # /api/pet/* JSON API + /pet/<id>/* asset routes
90
96
  | `-- client/ # browser half
91
97
  | |-- index.ts # global mount (createRoot → body) + registry fetch + polling + wiring
92
98
  | |-- PetDockEntry.tsx # global floating entry (document.body, always shown)
93
99
  | |-- PetSprite.tsx # definition-driven floating sprite (portal + rAF + dragging)
94
100
  | |-- PetSettingsCard.tsx # settings card: pet selector + display layout
101
+ | |-- sequences.ts # full-track scene sequence timing
95
102
  | |-- spritesheet.ts # atlas geometry helpers + track trimming
96
103
  | `-- pet.module.css
97
104
  |-- assets/whale/ # built-in whale-girl (pet.json + spritesheet.webp + previews)
@@ -115,7 +122,7 @@ global React root (createRoot → document.body) <-- polling 2s -- pet-client (b
115
122
  - **Selection & naming**: `petId` lives in the settings namespace; per-pet names live in `pet.json` under `names`, edited through the hover-panel rename of the active pet. Legacy installs migrate their flat `name` onto the whale girl.
116
123
  - **Multi-session semantics**: the API and browser mount are host-global and expose no foreground-session identity. Concurrent sessions each keep their own projected state: the most recent meaningful event drives the sprite animation, while every active TOP-LEVEL session reports its stage in its own bubble (the state view's sessions list, capped at 12 most-recent). Subagent children are tracked for animation, rewards, and the single display bubble but render no bubble of their own, so N conversations never multiply into an N-plus-subagents stack. Every session's completed turns are still rewarded independently; disposing a session removes its bubble, and disposing the display session falls back to the most recent remaining one.
117
124
  - **Mount point**: `document.body` (global React root, always shown: no session / new session / mid-session — the old mount point `conversation.composer.dock` only rendered in an active session, hiding the pet in new sessions); the component uses `createPortal` internally to render the global floating layer.
118
- - **Rendering**: CSS sprite (background-position) per-frame animation; frame durations come from the served definition's tracks.
125
+ - **Rendering**: CSS sprite (background-position) per-frame animation; frame durations and optional scene sequences come from the served definition. The hover panel is anchored below the pet with a pointer bridge across the gap; when the viewport leaves no room below, it flips above the pet and is lifted clear of the status bubble stack so the two never overlap.
119
126
  - **Communication**: browser ↔ host over the same-origin `/api/pet/*` JSON endpoints (state/pets/interact/set-visible/set-config/set-name/set-pet); each pet's atlas loads from `/pet/<id>/<spritesheetPath>` — the plugin self-sufficiently provides its own API and assets (the same pattern as dsh-remote-web-ui's `/api/pair`).
120
127
 
121
128
  ## Install
@@ -149,7 +156,7 @@ The browser bundle rides the `window.__ModuleLoader__.load` contract; React/cord
149
156
 
150
157
  ## Sprites and animation-track calibration
151
158
 
152
- The built-in whale-girl atlas is generated by the hatch-pet pipeline as 9 states × 8 columns: `assets/whale/spritesheet.webp` (1536×1872, 8 columns × 9 rows of 192×208 cells) + `assets/whale/pet.json`. The frame count and rhythm of each row live in that manifest's `frames` and `tracks` fields — the whale girl carries its own slower healing durations, while pets without overrides follow the hatch-pet contract rhythm. Redoing artwork therefore only edits `assets/whale/pet.json` (row-order contract: 0 idle / 1 running-right / 2 running-left / 3 waving / 4 jumping / 5 failed / 6 waiting / 7 running / 8 review).
159
+ The built-in whale-girl atlas is generated by the hatch-pet pipeline as 9 states × 8 columns: `assets/whale/spritesheet.webp` (1536×1872, 8 columns × 9 rows of 192×208 cells) + `assets/whale/pet.json`. The frame count, rhythm, and scene rotation live in that manifest's `frames`, `tracks`, and `sequences` fields; pets without overrides follow the hatch-pet contract rhythm and canonical single-track scene mapping. Redoing artwork therefore only edits `assets/whale/pet.json` (row-order contract: 0 idle / 1 running-right / 2 running-left / 3 waving / 4 jumping / 5 failed / 6 waiting / 7 running / 8 review).
153
160
 
154
161
  ## License
155
162
 
package/README.zh.md CHANGED
@@ -15,15 +15,16 @@
15
15
  | 多宠物注册表 | 宿主扫描内置 `assets/`、hatch-pet 自定义宠物目录和组合配置条目;每只宠物 = manifest + 图集 |
16
16
  | 设置中选择宠物 | 插件设置卡片列出所有已注册宠物;切换即持久化,精灵立即更换 |
17
17
  | 每只宠物独立命名 | 在悬浮面板改名;每只宠物保存自己的名字(按宠物 id 存储,旧版平铺名字自动迁移) |
18
- | 状态动画 | 官方会话活动 → 9 态动画:`thinking running`、`tool → running-right`、`review → review`、`waiting → waiting`、`done → jumping`、`failed → failed` |
18
+ | 状态动画 | 官方会话活动 → manifest 定义的 9 态轨道序列;每条轨道播完自身完整时长后切换,整条序列循环 |
19
19
  | 摸头互动 | 点击宠物 → 气泡反馈 + 亲密度 +1(10s 冷却) |
20
20
  | 喂食 | 悬浮面板 喂食 → 消耗 1 条小鱼干 + 亲密度 +5(30s 冷却) |
21
21
  | 小鱼干经济 | 库存(上限 20):每工作 30 轮 +1,每 300 分钟(5 小时)+1 —— 获取难度为原来的 10 倍 |
22
22
  | 亲密度 | 每完成一轮 +1;9 级:幼鲸 → 伙伴 → 挚友 → 深海羁绊 → 心有灵犀 → 传说羁绊 → 神话羁绊 → 永恒之契 → 鲸生共渡(上限 999,999,999) |
23
23
  | 拖动 | 按住拖动宠物换位置;位置持久化 |
24
- | 隐藏/召唤 | 悬浮面板 隐藏;隐藏后出现 召唤{name} 按钮 |
25
- | 妙语库 | 内置默认妙语库(每类事件 10 句)+ 宠物 manifest 的 remarks 块自定义台词——社区 PR 可以给自家宠物配上专属妙语 |
26
- | 状态气泡 | 每个并行活动的顶层会话各自一个气泡,在宠物头顶堆叠(最多 12 个);子代理会话借由其发起会话体现,不占用独立气泡;点击气泡跳转到对应会话;瞬时互动反馈临时优先 |
24
+ | 隐藏/召唤 | 悬浮面板位于宠物下方(下方空间不足时上移到状态气泡之上)并提供 隐藏;隐藏后出现 召唤{name} 按钮 |
25
+ | 妙语库 | 内置默认妙语库(每类事件 10 句)+ 宠物自定义台词;成功文案按持久化成功次数轮换,冷却文案按持久化拒绝次数轮换 |
26
+ | 状态气泡 | 每个并行活动的顶层会话各自一个气泡,在宠物头顶堆叠(最多 12 个);子代理会话借由其发起会话体现,不占用独立气泡;点击气泡跳转到对应会话;瞬时互动反馈临时优先。气泡文案按场景准备了大量轮换词库(等待 / 思考 / 整理 / 完成 / 失败……),工具调用按工具族映射俏皮文案并带上真实参数(如 跑跑 npm test),同一场景持续数秒会自动换一种说法 |
27
+ | 碎碎念 | 模型流式输出期间,宠物会偶尔用专属的紫粉色悄悄话气泡说出内心独白——由模型输出里的关键词触发对应心境(报错、测试全绿、做计划、打胜仗……),输出量累积也会换来日常碎碎念;有冷却节制,数秒后自动消散 |
27
28
  | 多会话活动 | 宠物是宿主全局的:最近一次有意义事件驱动精灵动画,同时每个活动的顶层会话用自己的气泡报告各自状态;每个会话(含子代理)完成的轮次都计入亲密度与小鱼干 |
28
29
 
29
30
  ## 宠物契约
@@ -38,10 +39,14 @@
38
39
  "spritesheetPath": "spritesheet.webp", // 图集,相对 manifest 所在目录
39
40
  "cell": { "width": 192, "height": 208 }, // 可选;默认 Codex 契约
40
41
  "columns": 8, // 可选;默认 8
42
+ "spriteVersionNumber": 1, // 可选;2 表示 11 行 v2 图集(9 行动画行 + 2 行视线跟随行)
41
43
  "frames": [6, 8, 8, 4, 5, 8, 6, 6, 6], // 可选的每行帧数
42
44
  "tracks": { // 可选的每轨节奏覆盖
43
45
  "idle": { "durations": [400, 400, 500, 400, 400, 500] }
44
46
  },
47
+ "sequences": { // 可选的每场景轨道序列(每条至少 5 项)
48
+ "thinking": ["running", "running-right", "running", "running-left", "waiting"]
49
+ },
45
50
  "remarks": { // 可选妙语(每个槽位一行或多行)
46
51
  "pet": "摸摸水獭的头~",
47
52
  "feed": ["小鱼干真香", "再来一条~"]
@@ -49,9 +54,10 @@
49
54
  }
50
55
  ```
51
56
 
52
- - 图集是 8 列 × 9 行网格(默认 192×208 单元格);行序固定:0 idle、1 running-right、2 running-left、3 waving、4 jumping、5 failed、6 waiting、7 running、8 review。未使用的格子保持全透明。
53
- - 可选 remarks 块覆盖宠物在 pet(摸头)/ petCooldown / feed(喂食)/ feedCooldown / noTreats(缺粮)事件上的气泡台词。每个槽位接受一句或一组台词(轮询循环);声明过的槽位只替换该槽位的内置默认池。社区贡献就是这样给自家宠物配上专属妙语的。
57
+ - 图集是 8 列 × 9 行网格(默认 192×208 单元格);行序固定:0 idle、1 running-right、2 running-left、3 waving、4 jumping、5 failed、6 waiting、7 running、8 review。未使用的格子保持全透明。v2(Codex)图集在 manifest 里声明 `"spriteVersionNumber": 2`,共 11 行——同样的 9 行动画行外加末尾 2 行视线跟随行;插件渲染这 9 行动画行、忽略视线行。
58
+ - 可选 remarks 块覆盖宠物在 pet(摸头)/ petCooldown / feed(喂食)/ feedCooldown / noTreats(缺粮)事件上的气泡台词。每个槽位接受一句或一组台词;声明过的槽位只替换该槽位的内置默认池。成功与冷却池使用对应的持久化成功或拒绝次数,noTreats 则独立轮询。社区贡献就是这样给自家宠物配上专属妙语的。
54
59
  - `frames` 记录每行用到的列数(缺省按 hatch-pet 契约表 `[6, 8, 8, 4, 5, 8, 6, 6, 6]`);`tracks` 按动画覆盖每帧时长(按该行帧数循环补足)、`loop` 与 `fallback`(默认:全部循环;`jumping` 与 `failed` 停在最后一帧后回到 `idle`)。
60
+ - `sequences` 可选地把活动场景(`idle` / `waiting` / `thinking` / `tool` / `review` / `done` / `failed`)映射到至少 5 条动画轨道。每项按 `tracks` 中的时长播完所有帧后进入下一项,整条序列循环;未声明的场景保持标准单轨播放。
55
61
 
56
62
  宠物的来源(后注册的来源在同 id 冲突时覆盖前者):
57
63
 
@@ -82,16 +88,17 @@ dsh-pet/
82
88
  | |-- registry.ts # 多宠物契约:manifest 扫描 + 归一化(内置 + 自定义宠物)
83
89
  | |-- service.ts # PetService:宠物选择 + 状态机 + 亲密度 + 配置
84
90
  | |-- state.ts # 宠物状态机:会话活动投影 → 9 态动画
85
- | |-- remarks.ts # 妙语库:内置默认池 + 每宠物覆盖 + 轮询选取
91
+ | |-- remarks.ts # 妙语库:内置默认池 + 每宠物覆盖 + 计数选取
86
92
  | |-- affinity.ts # 亲密度账本(纯函数 + 冷却)
87
93
  | |-- treats.ts # 小鱼干库存账本
88
- | |-- persist.ts # 持久化($DSH_HOME/pet.json:选择 + 每宠物名字,原子写入)
94
+ | |-- persist.ts # 持久化($DSH_HOME/pet.json:选择 + 名字 + 互动计数)
89
95
  | |-- routes.ts # /api/pet/* JSON API + /pet/<id>/* 静态资源路由
90
96
  | `-- client/ # 浏览器半区
91
97
  | |-- index.ts # 全局挂载(createRoot → body)+ 注册表拉取 + 轮询 + 接线
92
98
  | |-- PetDockEntry.tsx # 全局浮层入口(document.body,始终显示)
93
99
  | |-- PetSprite.tsx # 由定义驱动的浮层精灵(portal + rAF + 拖动)
94
100
  | |-- PetSettingsCard.tsx # 设置卡片:宠物选择器 + 显示布局
101
+ | |-- sequences.ts # 完整轨道场景序列计时
95
102
  | |-- spritesheet.ts # 图集几何辅助 + 轨道裁剪
96
103
  | `-- pet.module.css
97
104
  |-- assets/whale/ # 内置鲸鱼娘(pet.json + spritesheet.webp + 预览)
@@ -115,7 +122,7 @@ dsh-pet/
115
122
  - **选择与命名**:`petId` 存于设置命名空间;每只宠物的名字存于 `pet.json` 的 `names`,通过悬浮面板对当前宠物改名编辑。旧版安装的平铺 `name` 自动迁移到鲸鱼娘名下。
116
123
  - **多会话语义**:API 与浏览器挂载都是宿主全局的,不暴露前台会话身份。并行会话各自保留投影状态:最近一次有意义事件驱动精灵动画,同时每个活动的顶层会话在独立气泡里报告自己的阶段(state 视图的 sessions 列表,最多保留最近 12 个)。子代理会话仍参与动画、计奖与单一显示气泡,但不占独立气泡位——N 个对话不会变成"N + 子代理数"的气泡堆。每个会话完成的轮次仍独立计奖;销毁会话移除它的气泡,销毁当前显示会话则回退到最近仍在活动的会话。
117
124
  - **挂载点**:`document.body`(全局 React 根,始终显示:无会话 / 新会话 / 会话中都可见——旧挂载点 `conversation.composer.dock` 只在活动会话里渲染,新会话里宠物消失);组件内部用 `createPortal` 渲染全局浮层。
118
- - **渲染**:CSS 精灵(background-position)逐帧动画;帧时长来自下发定义里的轨道表。
125
+ - **渲染**:CSS 精灵(background-position)逐帧动画;帧时长和可选场景序列来自下发定义。悬浮面板锚定在宠物下方,间隙由指针桥接覆盖;当视口下方空间不足时,面板翻转到宠物上方并抬升到状态气泡栈之上,两者互不遮挡。
119
126
  - **通信**:浏览器 ↔ 宿主走同源 `/api/pet/*` JSON 端点(state/pets/interact/set-visible/set-config/set-name/set-pet);每只宠物的图集从 `/pet/<id>/<spritesheetPath>` 加载——插件自给自足地提供自己的 API 与资源(与 dsh-remote-web-ui 的 `/api/pair` 同一模式)。
120
127
 
121
128
  ## 安装
@@ -149,7 +156,7 @@ pnpm typecheck # 仅类型检查
149
156
 
150
157
  ## 精灵图与动画轨道校准
151
158
 
152
- 内置鲸鱼娘图集由 hatch-pet 流水线生成,9 态 × 8 列:`assets/whale/spritesheet.webp`(1536×1872,8 列 × 9 行,192×208 单元格)+ `assets/whale/pet.json`。每行的帧数与节奏就写在该 manifest 的 `frames` 与 `tracks` 字段里——鲸鱼娘带着自己更慢的治愈系时长,未覆盖的宠物沿用 hatch-pet 契约节奏。重做美术因此只需修改 `assets/whale/pet.json`(行序契约:0 idle / 1 running-right / 2 running-left / 3 waving / 4 jumping / 5 failed / 6 waiting / 7 running / 8 review)。
159
+ 内置鲸鱼娘图集由 hatch-pet 流水线生成,9 态 × 8 列:`assets/whale/spritesheet.webp`(1536×1872,8 列 × 9 行,192×208 单元格)+ `assets/whale/pet.json`。每行的帧数、节奏与场景轮换写在该 manifest 的 `frames`、`tracks` 与 `sequences` 字段里;未覆盖的宠物沿用 hatch-pet 契约节奏和标准单轨场景映射。重做美术因此只需修改 `assets/whale/pet.json`(行序契约:0 idle / 1 running-right / 2 running-left / 3 waving / 4 jumping / 5 failed / 6 waiting / 7 running / 8 review)。
153
160
 
154
161
  ## 许可证
155
162
 
@@ -14,5 +14,14 @@
14
14
  "waiting": { "durations": [450, 450, 500, 450, 450, 500] },
15
15
  "running": { "durations": [250, 250, 250, 250, 250, 250] },
16
16
  "review": { "durations": [550, 550, 550, 550, 550, 550] }
17
+ },
18
+ "sequences": {
19
+ "idle": ["idle", "waving", "idle", "waiting", "idle", "idle"],
20
+ "waiting": ["waiting", "idle", "waving", "waiting", "idle", "waiting"],
21
+ "thinking": ["running", "running-right", "running", "running-left", "running", "waiting", "running"],
22
+ "tool": ["running-right", "running", "running-left", "running", "running-right", "running"],
23
+ "review": ["review", "waiting", "review", "running", "review", "idle"],
24
+ "done": ["jumping", "waving", "jumping", "waving", "jumping", "idle"],
25
+ "failed": ["failed", "waiting", "failed", "idle", "waiting", "failed"]
17
26
  }
18
27
  }
package/lib/client.js CHANGED
@@ -63,6 +63,26 @@ window.__ModuleLoader__.load({
63
63
  }
64
64
  //#endregion
65
65
  //#region src/state.ts
66
+ /**
67
+ * Map one activity phase onto the animation contract.
68
+ * - thinking → `running` and tool → `running-right` (focused work).
69
+ * - review → `review` while answer text is streaming.
70
+ * - waiting → `waiting` (expectant pose, needs user input).
71
+ * - done → `jumping` (celebration), then back to `idle` after the window.
72
+ * - failed → `failed` briefly, then back to `idle`.
73
+ * - idle → `idle` (calm breathing loop).
74
+ */
75
+ function animationForPhase(phase) {
76
+ switch (phase) {
77
+ case "thinking": return "running";
78
+ case "tool": return "running-right";
79
+ case "review": return "review";
80
+ case "waiting": return "waiting";
81
+ case "done": return "jumping";
82
+ case "failed": return "failed";
83
+ case "idle": return "idle";
84
+ }
85
+ }
66
86
  /** The spritesheet row index for one animation track. */
67
87
  function rowOf(animation) {
68
88
  return {
@@ -120,8 +140,32 @@ window.__ModuleLoader__.load({
120
140
  };
121
141
  }
122
142
  //#endregion
143
+ //#region src/client/sequences.ts
144
+ /** Resolve the active track and frame after elapsed milliseconds of a looping sequence. */
145
+ function sequenceFrameAt(sequence, tracks, elapsedMs) {
146
+ const itemDurations = sequence.map((animation) => tracks[animation].durations.reduce((sum, value) => sum + value, 0));
147
+ const sequenceDuration = itemDurations.reduce((sum, value) => sum + value, 0);
148
+ let offset = Math.max(0, elapsedMs) % sequenceDuration;
149
+ let itemIndex = 0;
150
+ while (itemIndex < sequence.length - 1 && offset >= itemDurations[itemIndex]) {
151
+ offset -= itemDurations[itemIndex];
152
+ itemIndex += 1;
153
+ }
154
+ const animation = sequence[itemIndex];
155
+ const track = tracks[animation];
156
+ let frameIndex = 0;
157
+ while (frameIndex < track.frames.length - 1 && offset >= track.durations[frameIndex]) {
158
+ offset -= track.durations[frameIndex];
159
+ frameIndex += 1;
160
+ }
161
+ return {
162
+ animation,
163
+ frameIndex
164
+ };
165
+ }
166
+ //#endregion
123
167
  //#region \0dsh-css:packages/dsh-pet/src/client/pet.module.css.mjs
124
- const css$2 = ".kz2Bea_float{pointer-events:auto;-webkit-user-select:none;user-select:none;flex-direction:column;align-items:center;display:flex;position:fixed}.kz2Bea_sprite{image-rendering:auto;touch-action:none;position:relative}.kz2Bea_bubble{white-space:nowrap;color:#fff;pointer-events:none;border-radius:999px;margin-bottom:6px;padding:4px 10px;font-size:12px;line-height:1.4;animation:2.6s ease-out forwards kz2Bea_pet-bubble-pop;position:absolute;bottom:100%;box-shadow:0 2px 8px #00000040}.kz2Bea_bubblePet{background:#f472b6eb}.kz2Bea_bubbleFeed{background:#38bdf8eb}.kz2Bea_bubbleStatus{text-overflow:ellipsis;background:#0f172ae6;border:1px solid #7dd3fc80;max-width:min(280px,100vw - 24px);animation:none;overflow:hidden}.kz2Bea_bubbleStack{pointer-events:auto;flex-direction:column;align-items:center;gap:4px;display:flex;position:absolute;bottom:100%}.kz2Bea_bubbleStack .kz2Bea_bubble{pointer-events:auto;margin-bottom:0;position:relative;bottom:auto}.kz2Bea_bubbleClickable{cursor:pointer;font:inherit;text-align:center;appearance:none;transition:filter .12s,box-shadow .12s}.kz2Bea_bubbleClickable:hover{filter:brightness(1.12)}.kz2Bea_bubbleClickable:focus-visible{outline:none;box-shadow:0 0 0 2px #38bdf8d9}@keyframes kz2Bea_pet-bubble-pop{0%{opacity:0;transform:translateY(6px)scale(.85)}15%{opacity:1;transform:translateY(0)scale(1.05)}25%{transform:translateY(0)scale(1)}75%{opacity:1}to{opacity:0;transform:translateY(-8px)scale(.95)}}.kz2Bea_panel{color:#e2e8f0;backdrop-filter:blur(6px);background:#0f172aeb;border:1px solid #94a3b859;border-radius:10px;flex-direction:column;gap:6px;min-width:132px;margin-right:8px;padding:8px 10px;font-size:12px;display:flex;position:absolute;bottom:0;right:100%;box-shadow:0 4px 16px #00000059}.kz2Bea_panel:after{content:\"\";width:14px;position:absolute;top:0;bottom:0;left:100%}.kz2Bea_rankRow{white-space:nowrap;justify-content:space-between;gap:10px;display:flex}.kz2Bea_nameCell{font-weight:600}.kz2Bea_renameRow{align-items:center;gap:6px;display:flex}.kz2Bea_nameInput{color:#e2e8f0;background:#1e293be6;border:1px solid #7dd3fc80;border-radius:6px;outline:none;flex:1;min-width:0;padding:3px 6px;font-size:12px}.kz2Bea_nameInput:focus{border-color:#38bdf8;box-shadow:0 0 0 2px #38bdf873}.kz2Bea_actions{gap:6px;display:flex}.kz2Bea_action{cursor:pointer;color:#0f172a;background:linear-gradient(#7dd3fc,#38bdf8);border:none;border-radius:6px;flex:1;padding:4px 8px;font-size:12px;transition:filter .12s,box-shadow .12s}.kz2Bea_action:hover{filter:brightness(1.08)}.kz2Bea_action:active{filter:brightness(.94)}.kz2Bea_action:focus-visible{outline:none;box-shadow:0 0 0 2px #38bdf8d9}.kz2Bea_summon{color:#7dd3fc;cursor:pointer;background:#0f172abf;border:1px dashed #7dd3fc99;border-radius:999px;padding:2px 10px;font-size:11px;transition:border-color .12s,color .12s,background .12s,box-shadow .12s}.kz2Bea_summon:hover{color:#bae6fd;background:#0f172ae6;border-color:#7dd3fcf2}.kz2Bea_summon:active{color:#7dd3fc;border-color:#7dd3fccc}.kz2Bea_summon:focus-visible{outline:none;box-shadow:0 0 0 2px #38bdf8d9}@media (prefers-reduced-motion:reduce){.kz2Bea_bubble{opacity:1;animation:none}.kz2Bea_action,.kz2Bea_summon{transition:none}}";
168
+ const css$2 = ".kz2Bea_float{pointer-events:auto;-webkit-user-select:none;user-select:none;flex-direction:column;align-items:center;display:flex;position:fixed}.kz2Bea_sprite{image-rendering:auto;touch-action:none;position:relative}.kz2Bea_bubble{white-space:nowrap;color:#fff;pointer-events:none;border-radius:999px;margin-bottom:6px;padding:4px 10px;font-size:12px;line-height:1.4;animation:2.6s ease-out forwards kz2Bea_pet-bubble-pop;position:absolute;bottom:100%;box-shadow:0 2px 8px #00000040}.kz2Bea_bubblePet{background:#f472b6eb}.kz2Bea_bubbleFeed{background:#38bdf8eb}.kz2Bea_bubbleStatus{text-overflow:ellipsis;backdrop-filter:blur(8px);letter-spacing:.02em;background:linear-gradient(160deg,#1e293be0,#0f172aeb);border:1px solid #7dd3fc8c;max-width:min(280px,100vw - 24px);animation:.24s ease-out kz2Bea_pet-bubble-in;overflow:hidden;box-shadow:0 4px 14px #02061766,0 0 0 1px #7dd3fc1f,0 0 18px #38bdf82e}.kz2Bea_bubbleWhisper{letter-spacing:.03em;white-space:nowrap;text-overflow:ellipsis;color:#f5d0fe;backdrop-filter:blur(8px);pointer-events:none;background:linear-gradient(150deg,#581c8785,#9d174d70);border:1px dashed #f0abfc8c;border-radius:999px;max-width:min(260px,100vw - 24px);padding:4px 12px;font-size:11.5px;font-style:italic;line-height:1.45;animation:.32s cubic-bezier(.22,1,.36,1) kz2Bea_pet-whisper-in;position:relative;overflow:hidden;box-shadow:0 3px 12px #2e106566,0 0 14px #d946ef29}.kz2Bea_bubbleWhisper:before{content:\"「\";opacity:.75;margin-right:2px}.kz2Bea_bubbleWhisper:after{content:\"」\";opacity:.75;margin-left:2px}.kz2Bea_bubbleStack{pointer-events:auto;flex-direction:column;align-items:center;gap:4px;display:flex;position:absolute;bottom:100%}.kz2Bea_bubbleStack .kz2Bea_bubble{pointer-events:auto;margin-bottom:0;position:relative;bottom:auto}.kz2Bea_bubbleClickable{cursor:pointer;font:inherit;text-align:center;appearance:none;transition:filter .12s,box-shadow .12s}.kz2Bea_bubbleClickable:hover{filter:brightness(1.12)}.kz2Bea_bubbleClickable:focus-visible{outline:none;box-shadow:0 0 0 2px #38bdf8d9}@keyframes kz2Bea_pet-bubble-in{0%{opacity:0;transform:translateY(6px)scale(.96)}to{opacity:1;transform:translateY(0)scale(1)}}@keyframes kz2Bea_pet-whisper-in{0%{opacity:0;transform:translateY(8px)scale(.9)}60%{transform:translateY(-1px)scale(1.02)}to{opacity:1;transform:translateY(0)scale(1)}}@keyframes kz2Bea_pet-panel-in{0%{opacity:0;transform:translateY(4px)scale(.97)}to{opacity:1;transform:translateY(0)scale(1)}}@keyframes kz2Bea_pet-bubble-pop{0%{opacity:0;transform:translateY(6px)scale(.85)}15%{opacity:1;transform:translateY(0)scale(1.05)}25%{transform:translateY(0)scale(1)}75%{opacity:1}to{opacity:0;transform:translateY(-8px)scale(.95)}}.kz2Bea_panel{color:#e2e8f0;backdrop-filter:blur(10px);transform-origin:50% 0;background:linear-gradient(165deg,#1e293bf0,#0f172af5);border:1px solid #94a3b866;border-radius:12px;flex-direction:column;gap:7px;min-width:148px;margin-top:8px;padding:10px 12px;font-size:12px;animation:.2s cubic-bezier(.22,1,.36,1) kz2Bea_pet-panel-in;display:flex;position:absolute;top:100%;box-shadow:0 8px 24px #02061773,0 0 0 1px #7dd3fc14,inset 0 1px #e2e8f014}.kz2Bea_panel:after{content:\"\";height:14px;position:absolute;bottom:100%;left:0;right:0}.kz2Bea_panelAbove{transform-origin:50% 100%;margin-top:0;margin-bottom:8px;top:auto;bottom:100%}.kz2Bea_panelAbove:after{top:100%;bottom:auto}.kz2Bea_rankRow{white-space:nowrap;justify-content:space-between;gap:10px;display:flex}.kz2Bea_nameCell{letter-spacing:.02em;background:linear-gradient(90deg,#7dd3fc,#38bdf8 60%,#818cf8);color:#0000;-webkit-background-clip:text;background-clip:text;font-weight:600}.kz2Bea_statRank{color:#c4b5fd;font-weight:600}.kz2Bea_statTreats{color:#7dd3fc;font-weight:600}.kz2Bea_statPoints{color:#fcd34d;font-weight:600}.kz2Bea_renameRow{align-items:center;gap:6px;display:flex}.kz2Bea_nameInput{color:#e2e8f0;background:#1e293be6;border:1px solid #7dd3fc80;border-radius:6px;outline:none;flex:1;min-width:0;padding:3px 6px;font-size:12px}.kz2Bea_nameInput:focus{border-color:#38bdf8;box-shadow:0 0 0 2px #38bdf873}.kz2Bea_actions{gap:6px;display:flex}.kz2Bea_action{cursor:pointer;color:#0f172a;background:linear-gradient(#7dd3fc,#38bdf8);border:none;border-radius:6px;flex:1;padding:4px 8px;font-size:12px;transition:filter .12s,box-shadow .12s,transform .12s;box-shadow:0 2px 6px #38bdf840}.kz2Bea_action:hover{filter:brightness(1.08);transform:translateY(-1px);box-shadow:0 4px 10px #38bdf859}.kz2Bea_action:active{filter:brightness(.94);transform:translateY(0);box-shadow:0 1px 4px #38bdf840}.kz2Bea_action:focus-visible{outline:none;box-shadow:0 0 0 2px #38bdf8d9}.kz2Bea_summon{color:#7dd3fc;cursor:pointer;background:#0f172abf;border:1px dashed #7dd3fc99;border-radius:999px;padding:2px 10px;font-size:11px;transition:border-color .12s,color .12s,background .12s,box-shadow .12s}.kz2Bea_summon:hover{color:#bae6fd;background:#0f172ae6;border-color:#7dd3fcf2}.kz2Bea_summon:active{color:#7dd3fc;border-color:#7dd3fccc}.kz2Bea_summon:focus-visible{outline:none;box-shadow:0 0 0 2px #38bdf8d9}@media (prefers-reduced-motion:reduce){.kz2Bea_bubble,.kz2Bea_bubbleStatus,.kz2Bea_bubbleWhisper,.kz2Bea_panel{opacity:1;animation:none}.kz2Bea_action,.kz2Bea_summon{transition:none}}";
125
169
  const tagId$2 = "@linxin666/dsh-pet/pet.module.css";
126
170
  if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId$2) + "]") === null) {
127
171
  const tag = document.createElement("style");
@@ -139,14 +183,22 @@ window.__ModuleLoader__.load({
139
183
  "bubblePet": "kz2Bea_bubblePet",
140
184
  "bubbleStack": "kz2Bea_bubbleStack",
141
185
  "bubbleStatus": "kz2Bea_bubbleStatus",
186
+ "bubbleWhisper": "kz2Bea_bubbleWhisper",
142
187
  "float": "kz2Bea_float",
143
188
  "nameCell": "kz2Bea_nameCell",
144
189
  "nameInput": "kz2Bea_nameInput",
145
190
  "panel": "kz2Bea_panel",
191
+ "panelAbove": "kz2Bea_panelAbove",
192
+ "pet-bubble-in": "kz2Bea_pet-bubble-in",
146
193
  "pet-bubble-pop": "kz2Bea_pet-bubble-pop",
194
+ "pet-panel-in": "kz2Bea_pet-panel-in",
195
+ "pet-whisper-in": "kz2Bea_pet-whisper-in",
147
196
  "rankRow": "kz2Bea_rankRow",
148
197
  "renameRow": "kz2Bea_renameRow",
149
198
  "sprite": "kz2Bea_sprite",
199
+ "statPoints": "kz2Bea_statPoints",
200
+ "statRank": "kz2Bea_statRank",
201
+ "statTreats": "kz2Bea_statTreats",
150
202
  "summon": "kz2Bea_summon"
151
203
  };
152
204
  //#endregion
@@ -175,10 +227,15 @@ window.__ModuleLoader__.load({
175
227
  const { snapshot, definition, display, feedback } = props;
176
228
  const spriteRef = (0, react.useRef)(null);
177
229
  const floatRef = (0, react.useRef)(null);
230
+ const panelRef = (0, react.useRef)(null);
231
+ const bubbleRef = (0, react.useRef)(null);
178
232
  const [imageReady, setImageReady] = (0, react.useState)(false);
179
233
  const [hovered, setHovered] = (0, react.useState)(false);
180
234
  const [renaming, setRenaming] = (0, react.useState)(false);
235
+ const [panelAbove, setPanelAbove] = (0, react.useState)(false);
236
+ const [panelLift, setPanelLift] = (0, react.useState)(0);
181
237
  const [nameDraft, setNameDraft] = (0, react.useState)("");
238
+ const composingRef = (0, react.useRef)(false);
182
239
  const [dragPos, setDragPos] = (0, react.useState)(null);
183
240
  const dragRef = (0, react.useRef)(null);
184
241
  const hideTimerRef = (0, react.useRef)(null);
@@ -191,6 +248,7 @@ window.__ModuleLoader__.load({
191
248
  const columns = definition.columns;
192
249
  const rows = definition.rows;
193
250
  const tracks = definition.tracks;
251
+ const sequences = definition.sequences;
194
252
  (0, react.useEffect)(() => {
195
253
  let cancelled = false;
196
254
  const img = new Image();
@@ -204,22 +262,36 @@ window.__ModuleLoader__.load({
204
262
  };
205
263
  }, [definition.atlasUrl]);
206
264
  const spriteScale = display.size / cell.height;
265
+ const phase = snapshot?.phase ?? "idle";
207
266
  const animation = snapshot?.animation ?? "idle";
208
267
  const scaleRef = (0, react.useRef)(spriteScale);
209
268
  scaleRef.current = spriteScale;
210
269
  (0, react.useEffect)(() => {
211
270
  const reduceMotion = typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
212
- const row = rowOfTrack(animation);
213
- const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length);
271
+ const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : void 0;
272
+ const leadAnimation = sequence?.[0] ?? animation;
273
+ const row = rowOfTrack(leadAnimation);
274
+ const track = trimTrack(tracks[leadAnimation], rows[row] ?? tracks[leadAnimation].frames.length);
214
275
  const leadCol = track.frames[0];
215
276
  const lead = framePosition(cell, columns, row, leadCol, scaleRef.current);
216
277
  if (spriteRef.current !== null) spriteRef.current.style.backgroundPosition = lead.x + "px " + lead.y + "px";
217
278
  if (reduceMotion) return;
218
279
  let raf = 0;
219
280
  let last = performance.now();
281
+ let sequenceElapsed = 0;
220
282
  const tick = (ts) => {
221
283
  const delta = ts - last;
222
284
  last = ts;
285
+ if (sequence !== void 0) {
286
+ sequenceElapsed += delta;
287
+ const current = sequenceFrameAt(sequence, tracks, sequenceElapsed);
288
+ const currentRow = rowOfTrack(current.animation);
289
+ const col = trimTrack(tracks[current.animation], rows[currentRow] ?? tracks[current.animation].frames.length).frames[current.frameIndex];
290
+ const pos = framePosition(cell, columns, currentRow, col, scaleRef.current);
291
+ if (spriteRef.current !== null) spriteRef.current.style.backgroundPosition = pos.x + "px " + pos.y + "px";
292
+ raf = requestAnimationFrame(tick);
293
+ return;
294
+ }
223
295
  const st = frameRef.current;
224
296
  if (st.track !== animation) {
225
297
  st.track = animation;
@@ -245,10 +317,12 @@ window.__ModuleLoader__.load({
245
317
  return () => cancelAnimationFrame(raf);
246
318
  }, [
247
319
  animation,
320
+ phase,
248
321
  cell,
249
322
  columns,
250
323
  rows,
251
- tracks
324
+ tracks,
325
+ sequences
252
326
  ]);
253
327
  const feedbackDoneRef = (0, react.useRef)(props.onFeedbackDone);
254
328
  feedbackDoneRef.current = props.onFeedbackDone;
@@ -305,7 +379,37 @@ window.__ModuleLoader__.load({
305
379
  const spriteHeight = Math.round(cell.height * spriteScale);
306
380
  const sessionBubbles = snapshot?.sessions ?? [];
307
381
  const statusBubble = feedback === null && sessionBubbles.length === 0 ? snapshot?.bubble : void 0;
382
+ const whisper = feedback === null ? snapshot?.whisper : void 0;
383
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== void 0 || whisper !== void 0;
308
384
  const displayName = snapshot?.name ?? definition.displayName;
385
+ (0, react.useLayoutEffect)(() => {
386
+ if (!hovered) {
387
+ setPanelAbove(false);
388
+ setPanelLift(0);
389
+ return;
390
+ }
391
+ const updatePanelPlacement = () => {
392
+ const sprite = spriteRef.current;
393
+ const panel = panelRef.current;
394
+ if (sprite === null || panel === null) return;
395
+ const above = window.innerHeight - sprite.getBoundingClientRect().bottom < panel.getBoundingClientRect().height + 8;
396
+ setPanelAbove(above);
397
+ const bubbleHeight = above ? bubbleRef.current?.getBoundingClientRect().height ?? 0 : 0;
398
+ setPanelLift(bubbleHeight > 0 ? Math.ceil(bubbleHeight) + 14 : 0);
399
+ };
400
+ updatePanelPlacement();
401
+ window.addEventListener("resize", updatePanelPlacement);
402
+ return () => window.removeEventListener("resize", updatePanelPlacement);
403
+ }, [
404
+ hovered,
405
+ renaming,
406
+ pos.right,
407
+ pos.bottom,
408
+ display.size,
409
+ bubblePresent,
410
+ sessionBubbles.length,
411
+ feedback
412
+ ]);
309
413
  return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
310
414
  ref: floatRef,
311
415
  className: pet_module_css_default.float,
@@ -321,6 +425,7 @@ window.__ModuleLoader__.load({
321
425
  onPointerLeave: (e) => {
322
426
  const next = e.relatedTarget;
323
427
  if (next instanceof Node && floatRef.current?.contains(next)) return;
428
+ if (renaming) return;
324
429
  clearHideTimer();
325
430
  hideTimerRef.current = window.setTimeout(() => setHovered(false), 300);
326
431
  },
@@ -332,7 +437,7 @@ window.__ModuleLoader__.load({
332
437
  width: spriteWidth,
333
438
  height: spriteHeight,
334
439
  backgroundImage: imageReady ? "url(" + definition.atlasUrl + ")" : void 0,
335
- backgroundSize: cell.width * columns * spriteScale + "px " + cell.height * rows.length * spriteScale + "px",
440
+ backgroundSize: cell.width * columns * spriteScale + "px " + cell.height * (definition.atlasRows ?? rows.length) * spriteScale + "px",
336
441
  backgroundRepeat: "no-repeat",
337
442
  backgroundPosition: "0 0",
338
443
  cursor: dragRef.current === null ? "grab" : "grabbing"
@@ -348,29 +453,40 @@ window.__ModuleLoader__.load({
348
453
  "aria-label": definition.displayName
349
454
  }),
350
455
  feedback !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
456
+ ref: bubbleRef,
351
457
  className: clsx(pet_module_css_default.bubble, feedback.kind === "feed" ? pet_module_css_default.bubbleFeed : pet_module_css_default.bubblePet),
352
458
  children: feedback.text
353
459
  }, feedback.at),
354
- feedback === null && sessionBubbles.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
460
+ feedback === null && (sessionBubbles.length > 0 || statusBubble !== void 0 || whisper !== void 0) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
461
+ ref: bubbleRef,
355
462
  className: pet_module_css_default.bubbleStack,
356
- children: sessionBubbles.map((session) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
357
- type: "button",
358
- className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, pet_module_css_default.bubbleClickable),
359
- title: props.t("pet.openSessionHint"),
360
- onClick: () => {
361
- props.onOpenSession(session.sessionId);
362
- },
363
- children: session.bubble
364
- }, session.sessionId))
365
- }),
366
- statusBubble !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
367
- className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus),
368
- role: "status",
369
- "aria-live": "polite",
370
- children: statusBubble
463
+ children: [
464
+ sessionBubbles.map((session) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
465
+ type: "button",
466
+ className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus, pet_module_css_default.bubbleClickable),
467
+ title: props.t("pet.openSessionHint"),
468
+ onClick: () => {
469
+ props.onOpenSession(session.sessionId);
470
+ },
471
+ children: session.bubble
472
+ }, session.sessionId)),
473
+ sessionBubbles.length === 0 && statusBubble !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
474
+ className: clsx(pet_module_css_default.bubble, pet_module_css_default.bubbleStatus),
475
+ role: "status",
476
+ "aria-live": "polite",
477
+ children: statusBubble
478
+ }),
479
+ whisper !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
480
+ className: pet_module_css_default.bubbleWhisper,
481
+ children: whisper
482
+ }, whisper)
483
+ ]
371
484
  }),
372
485
  hovered && dragRef.current === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
373
- className: pet_module_css_default.panel,
486
+ ref: panelRef,
487
+ className: clsx(pet_module_css_default.panel, panelAbove && pet_module_css_default.panelAbove),
488
+ "data-placement": panelAbove ? "above" : "below",
489
+ style: panelAbove && panelLift > 0 ? { marginBottom: panelLift } : void 0,
374
490
  onPointerEnter: () => {
375
491
  clearHideTimer();
376
492
  },
@@ -383,8 +499,14 @@ window.__ModuleLoader__.load({
383
499
  placeholder: props.t("pet.namePlaceholder"),
384
500
  autoFocus: true,
385
501
  onChange: (e) => setNameDraft(e.target.value),
502
+ onCompositionStart: () => {
503
+ composingRef.current = true;
504
+ },
505
+ onCompositionEnd: () => {
506
+ composingRef.current = false;
507
+ },
386
508
  onKeyDown: (e) => {
387
- if (e.nativeEvent.isComposing) return;
509
+ if (composingRef.current || e.nativeEvent.isComposing || e.key === "Process") return;
388
510
  if (e.key === "Enter") {
389
511
  const trimmed = nameDraft.trim();
390
512
  if (trimmed !== "") {
@@ -411,11 +533,20 @@ window.__ModuleLoader__.load({
411
533
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
412
534
  className: pet_module_css_default.nameCell,
413
535
  children: displayName
414
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: props.t("pet.rank", { rank: snapshot?.affinity.rank ?? "?" }) })]
536
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
537
+ className: pet_module_css_default.statRank,
538
+ children: props.t("pet.rank", { rank: snapshot?.affinity.rank ?? "?" })
539
+ })]
415
540
  }),
416
541
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
417
542
  className: pet_module_css_default.rankRow,
418
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: props.t("pet.treats", { n: snapshot?.treats.stocked ?? 0 }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: props.t("pet.points", { points: snapshot?.affinity.points ?? 0 }) })]
543
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
544
+ className: pet_module_css_default.statTreats,
545
+ children: props.t("pet.treats", { n: snapshot?.treats.stocked ?? 0 })
546
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
547
+ className: pet_module_css_default.statPoints,
548
+ children: props.t("pet.points", { points: snapshot?.affinity.points ?? 0 })
549
+ })]
419
550
  }),
420
551
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
421
552
  className: pet_module_css_default.actions,
@@ -430,6 +561,7 @@ window.__ModuleLoader__.load({
430
561
  type: "button",
431
562
  className: pet_module_css_default.action,
432
563
  onClick: () => {
564
+ clearHideTimer();
433
565
  setNameDraft(displayName);
434
566
  setRenaming(true);
435
567
  },
@@ -663,7 +795,7 @@ window.__ModuleLoader__.load({
663
795
  children: props.t("settings.readOnly")
664
796
  }) : null,
665
797
  props.children,
666
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
798
+ props.hideFooter === true ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
667
799
  className: settings_card_module_css_default.footer,
668
800
  children: [
669
801
  state.failed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {