@linxin666/dsh-pet 0.3.3 → 0.3.5

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 (123) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +30 -13
  3. package/README.zh.md +30 -13
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/contracts/voice-pack-v1.schema.json +23 -28
  17. package/lib/client.js +781 -40
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.js +1567 -482
  20. package/lib/types/chatter.d.ts +68 -46
  21. package/lib/types/chatter.d.ts.map +1 -1
  22. package/lib/types/chatter.js +243 -301
  23. package/lib/types/client/PetDockEntry.d.ts +3 -0
  24. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  25. package/lib/types/client/PetDockEntry.js +18 -2
  26. package/lib/types/client/PetSprite.d.ts +23 -0
  27. package/lib/types/client/PetSprite.d.ts.map +1 -1
  28. package/lib/types/client/PetSprite.js +49 -45
  29. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  30. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  31. package/lib/types/client/PluginSettingsCard.js +2 -2
  32. package/lib/types/client/drag-stream.d.ts +19 -0
  33. package/lib/types/client/drag-stream.d.ts.map +1 -0
  34. package/lib/types/client/drag-stream.js +26 -0
  35. package/lib/types/client/gameplay-hud.d.ts +50 -0
  36. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  37. package/lib/types/client/gameplay-hud.js +274 -0
  38. package/lib/types/client/index.d.ts.map +1 -1
  39. package/lib/types/client/index.js +38 -9
  40. package/lib/types/client/locales.d.ts +34 -0
  41. package/lib/types/client/locales.d.ts.map +1 -1
  42. package/lib/types/client/locales.js +36 -0
  43. package/lib/types/client/pet-store.d.ts +7 -1
  44. package/lib/types/client/pet-store.d.ts.map +1 -1
  45. package/lib/types/client/pet-store.js +4 -0
  46. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  47. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  48. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  49. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  50. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  51. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  52. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  53. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  54. package/lib/types/client/renderers/frames2d.js +196 -0
  55. package/lib/types/defaults.d.ts +6 -0
  56. package/lib/types/defaults.d.ts.map +1 -0
  57. package/lib/types/defaults.js +5 -0
  58. package/lib/types/dsh-home.d.ts.map +1 -1
  59. package/lib/types/dsh-home.js +9 -3
  60. package/lib/types/event-projection.d.ts +9 -4
  61. package/lib/types/event-projection.d.ts.map +1 -1
  62. package/lib/types/event-projection.js +46 -14
  63. package/lib/types/gameplay.d.ts +182 -0
  64. package/lib/types/gameplay.d.ts.map +1 -0
  65. package/lib/types/gameplay.js +618 -0
  66. package/lib/types/ledger.d.ts +14 -0
  67. package/lib/types/ledger.d.ts.map +1 -1
  68. package/lib/types/ledger.js +30 -0
  69. package/lib/types/manifest-v2.d.ts +38 -1
  70. package/lib/types/manifest-v2.d.ts.map +1 -1
  71. package/lib/types/manifest-v2.js +182 -2
  72. package/lib/types/persist.d.ts +4 -4
  73. package/lib/types/persist.d.ts.map +1 -1
  74. package/lib/types/persist.js +41 -4
  75. package/lib/types/registry.d.ts +29 -0
  76. package/lib/types/registry.d.ts.map +1 -1
  77. package/lib/types/registry.js +170 -1
  78. package/lib/types/routes.d.ts.map +1 -1
  79. package/lib/types/routes.js +30 -3
  80. package/lib/types/service.d.ts +63 -11
  81. package/lib/types/service.d.ts.map +1 -1
  82. package/lib/types/service.js +208 -19
  83. package/lib/types/voice-pack.d.ts +6 -7
  84. package/lib/types/voice-pack.d.ts.map +1 -1
  85. package/lib/types/voice-pack.js +44 -47
  86. package/package.json +5 -2
  87. package/src/chatter.test.ts +68 -38
  88. package/src/chatter.ts +269 -316
  89. package/src/client/PetDockEntry.test.tsx +6 -0
  90. package/src/client/PetDockEntry.tsx +32 -1
  91. package/src/client/PetSprite.test.tsx +68 -21
  92. package/src/client/PetSprite.tsx +63 -45
  93. package/src/client/PluginSettingsCard.tsx +4 -2
  94. package/src/client/drag-stream.ts +35 -0
  95. package/src/client/gameplay-hud.test.tsx +338 -0
  96. package/src/client/gameplay-hud.tsx +403 -0
  97. package/src/client/index.test.tsx +10 -1
  98. package/src/client/index.ts +46 -11
  99. package/src/client/locales.ts +36 -0
  100. package/src/client/pet-store.ts +10 -1
  101. package/src/client/pet.module.css +192 -29
  102. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  103. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  104. package/src/client/renderers/frames2d.test.ts +137 -0
  105. package/src/client/renderers/frames2d.ts +216 -0
  106. package/src/client/settings-card.module.css +5 -0
  107. package/src/defaults.ts +7 -0
  108. package/src/dsh-home.ts +9 -3
  109. package/src/event-projection.ts +57 -16
  110. package/src/gameplay.test.ts +239 -0
  111. package/src/gameplay.ts +692 -0
  112. package/src/ledger.test.ts +16 -0
  113. package/src/ledger.ts +31 -0
  114. package/src/manifest-v2.test.ts +130 -1
  115. package/src/manifest-v2.ts +181 -2
  116. package/src/persist.test.ts +3 -0
  117. package/src/persist.ts +43 -6
  118. package/src/registry.test.ts +168 -1
  119. package/src/registry.ts +199 -1
  120. package/src/routes.ts +28 -4
  121. package/src/service.ts +240 -29
  122. package/src/voice-pack.test.ts +48 -25
  123. package/src/voice-pack.ts +50 -47
@@ -79,6 +79,12 @@ function injected(): PetInjected {
79
79
  rename: vi.fn(),
80
80
  openSession: vi.fn(),
81
81
  feedbackDone: vi.fn(),
82
+ gameplay: {
83
+ touch: vi.fn(),
84
+ setMode: vi.fn(),
85
+ workTick: vi.fn(),
86
+ buy: vi.fn(),
87
+ },
82
88
  }
83
89
  }
84
90
 
@@ -11,12 +11,14 @@
11
11
  * @module @linxin666/dsh-pet/client/PetDockEntry
12
12
  */
13
13
 
14
- import { useEffect, useSyncExternalStore, type ReactElement } from 'react'
14
+ import { useEffect, useRef, useSyncExternalStore, type ReactElement } from 'react'
15
15
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
16
16
  import type { PetDisplayConfig } from '../persist.ts'
17
17
  import type { PetStoreInstance } from './pet-store.ts'
18
18
  import { PetSprite } from './PetSprite.tsx'
19
19
  import { PetRendererSwitch } from './renderers/PetRendererSwitch.tsx'
20
+ import { createDragStream, type DragStream } from './drag-stream.ts'
21
+ import { GameplayHud, type GameplayApi, type GameplayBus } from './gameplay-hud.tsx'
20
22
  import { NS } from './locales.ts'
21
23
  import styles from './pet.module.css'
22
24
 
@@ -42,6 +44,8 @@ export interface PetInjected {
42
44
  openSession: (sessionId: string) => void
43
45
  /** Clear the reaction bubble. */
44
46
  feedbackDone: () => void
47
+ /** Gameplay verb API (miku-pet generalization); wired but unused for pets without a gameplay block. */
48
+ gameplay: GameplayApi
45
49
  }
46
50
 
47
51
  /** Composed props of the global pet entry (locale + injected; no slot runtime share). */
@@ -70,6 +74,15 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
70
74
  ensure()
71
75
  }, [ensure])
72
76
 
77
+ // Per-pet gameplay wiring: the coordination bus (HUD taps <-> chrome,
78
+ // HUD track overrides <-> frames2d mount) and the shared drag stream.
79
+ const auxRef = useRef<{ id: string; bus: GameplayBus; drag: DragStream } | null>(null)
80
+ if (definition !== null && (auxRef.current === null || auxRef.current.id !== definition.id)) {
81
+ auxRef.current = { id: definition.id, bus: {}, drag: createDragStream() }
82
+ }
83
+ const aux = auxRef.current
84
+ const gameplay = definition?.gameplay
85
+
73
86
  if (visible) {
74
87
  return (
75
88
  <span data-pet-dock data-testid="pet-dock">
@@ -80,6 +93,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
80
93
  definition={definition}
81
94
  phase={snapshot?.phase ?? 'idle'}
82
95
  onPet={props.pet}
96
+ {...(aux === null ? {} : { drag: aux.drag, bus: aux.bus })}
83
97
  t={props.t}
84
98
  >
85
99
  <PetSprite
@@ -94,6 +108,23 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
94
108
  onRename={props.rename}
95
109
  onOpenSession={props.openSession}
96
110
  onFeedbackDone={props.feedbackDone}
111
+ dragDisabled={snapshot.gameplay?.mode === 'work'}
112
+ {...(gameplay === undefined || aux === null
113
+ ? {}
114
+ : {
115
+ onGameplayTap: (fx: number, fy: number) => aux.bus.tap?.(fx, fy),
116
+ onGameplayMenu: () => aux.bus.openCard?.(),
117
+ hud: (
118
+ <GameplayHud
119
+ definition={definition}
120
+ store={store}
121
+ api={props.gameplay}
122
+ bus={aux.bus}
123
+ drag={aux.drag}
124
+ t={props.t}
125
+ />
126
+ ),
127
+ })}
97
128
  t={props.t}
98
129
  />
99
130
  </PetRendererSwitch>
@@ -159,16 +159,17 @@ describe('PetSprite custom visual (pet-center M3)', () => {
159
159
  })
160
160
  })
161
161
 
162
- describe('PetSprite always-visible close control', () => {
163
- it('renders a corner close button and hides without petting', () => {
162
+ describe('PetSprite hide control (#1126)', () => {
163
+ it('does not render an intrusive corner close button and allows hiding via hover panel', () => {
164
164
  const onHide = vi.fn()
165
165
  const onPet = vi.fn()
166
166
  renderPet({ onHide, onPet })
167
167
 
168
- const close = screen.getByTestId('pet-close')
169
- expect(close.getAttribute('aria-label')).toBe('隐藏')
170
- expect(close.getAttribute('title')).toBe('隐藏')
171
- fireEvent.click(close)
168
+ expect(screen.queryByTestId('pet-close')).toBeNull()
169
+
170
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
171
+ const hideBtn = screen.getByText('隐藏')
172
+ fireEvent.click(hideBtn)
172
173
 
173
174
  expect(onHide).toHaveBeenCalledTimes(1)
174
175
  expect(onPet).not.toHaveBeenCalled()
@@ -423,27 +424,18 @@ describe('PetSprite status bubble', () => {
423
424
  expect(screen.queryByText('正在使用 grep')).not.toBeNull()
424
425
  })
425
426
 
426
- it('lets the inner whisper take over the status bubble', () => {
427
- renderPet({ snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' } })
428
- // The whisper speaks THROUGH the bubble: it replaces (not accompanies)
429
- // the status copy, so the pet never shows two bubbles at once.
430
- expect(screen.queryByText('哼哧哼哧,大脑转得飞快~')).not.toBeNull()
431
- expect(screen.queryByText('正在思考')).toBeNull()
432
- })
433
-
434
- it('lets the whisper take over the display session bubble in the stack', () => {
427
+ it('lets a session whisper take over its own bubble while others stay collapsed', () => {
435
428
  const { onOpenSession } = renderPet({
436
429
  snapshot: {
437
430
  ...workingSnapshot,
438
- whisper: '我在这儿陪着你呢,别急别急',
439
431
  sessions: [
440
- { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考' },
432
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '我在这儿陪着你呢,别急别急' },
441
433
  { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep' },
442
434
  ],
443
435
  },
444
436
  })
445
- // The primary bubble (the display session) speaks the whisper instead
446
- // of its status copy; the collapsed extra session hides behind the badge.
437
+ // The lead session's bubble speaks ITS whisper instead of its status
438
+ // copy; the collapsed extra session hides behind the badge.
447
439
  expect(screen.queryByText('我在这儿陪着你呢,别急别急')).not.toBeNull()
448
440
  expect(screen.queryByText('正在思考')).toBeNull()
449
441
  expect(screen.queryByText('正在使用 grep')).toBeNull()
@@ -455,9 +447,28 @@ describe('PetSprite status bubble', () => {
455
447
  expect(onOpenSession).toHaveBeenCalledWith('s-a')
456
448
  })
457
449
 
450
+ it('renders each session whisper on its own expanded bubble', () => {
451
+ renderPet({
452
+ snapshot: {
453
+ ...workingSnapshot,
454
+ sessions: [
455
+ { sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: 'A 的碎碎念' },
456
+ { sessionId: 's-b', animation: 'running-right', phase: 'tool', bubble: '正在使用 grep', whisper: 'B 的碎碎念' },
457
+ ],
458
+ },
459
+ })
460
+ fireEvent.pointerOver(screen.getByText('A 的碎碎念').closest('div')!)
461
+ expect(screen.queryByText('A 的碎碎念')).not.toBeNull()
462
+ expect(screen.queryByText('B 的碎碎念')).not.toBeNull()
463
+ expect(screen.queryByText('正在思考')).toBeNull()
464
+ })
465
+
458
466
  it('lets transient interaction feedback take over the whisper too', () => {
459
467
  renderPet({
460
- snapshot: { ...workingSnapshot, whisper: '哼哧哼哧,大脑转得飞快~' },
468
+ snapshot: {
469
+ ...workingSnapshot,
470
+ sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '哼哧哼哧,大脑转得飞快~' }],
471
+ },
461
472
  feedback: { text: '摸摸成功', kind: 'pet', at: 1 },
462
473
  })
463
474
  expect(screen.queryByText('摸摸成功')).not.toBeNull()
@@ -677,6 +688,35 @@ describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
677
688
  expect(screen.getByText('鱼干 3(0 分,幼鲸)')).toBeDefined()
678
689
  })
679
690
  })
691
+ describe('PetSprite gameplay entry in the hover panel (miku generalization)', () => {
692
+ it('renders a 玩法 action and routes clicks to the HUD channel', () => {
693
+ const onGameplayMenu = vi.fn()
694
+ renderPet({ onGameplayMenu })
695
+ // The entry lives in the hover panel; a closed panel shows no chrome.
696
+ expect(screen.queryByText('玩法')).toBeNull()
697
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
698
+ expect(screen.getByText('玩法')).toBeDefined()
699
+ fireEvent.click(screen.getByText('玩法'))
700
+ expect(onGameplayMenu).toHaveBeenCalledTimes(1)
701
+ })
702
+
703
+ it('omits the 玩法 action when the pet carries no gameplay HUD', () => {
704
+ renderPet()
705
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
706
+ expect(screen.queryByText('玩法')).toBeNull()
707
+ })
708
+
709
+ it('keeps the 玩法 entry when a voice pack hides every panel action', () => {
710
+ renderPet({
711
+ definition: { ...petDefinition(), panel: { actions: [] } },
712
+ onGameplayMenu: vi.fn(),
713
+ })
714
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
715
+ expect(screen.getByText('玩法')).toBeDefined()
716
+ expect(screen.queryByText('喂食')).toBeNull()
717
+ })
718
+ })
719
+
680
720
  describe('PetSprite status decoration (pet-center M5, #567)', () => {
681
721
  const decoration: DecorationView = {
682
722
  apiVersion: 'x-org.linxin666.pet-center/status-decoration-v1',
@@ -724,7 +764,14 @@ describe('PetSprite status decoration (pet-center M5, #567)', () => {
724
764
  })
725
765
 
726
766
  it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
727
- renderPet({ snapshot: { ...snapshot, phase: 'thinking', whisper: '冲了冲了', decoration } })
767
+ renderPet({
768
+ snapshot: {
769
+ ...snapshot,
770
+ phase: 'thinking',
771
+ decoration,
772
+ sessions: [{ sessionId: 's-a', animation: 'running', phase: 'thinking', bubble: '正在思考', whisper: '冲了冲了' }],
773
+ },
774
+ })
728
775
  expect(ornament()).toBeNull()
729
776
  expect(document.body.textContent).toContain('冲了冲了')
730
777
  })
@@ -43,6 +43,8 @@ export interface PetSpriteProps {
43
43
  onHide: () => void
44
44
  /** Persist a drag position. */
45
45
  onDragEnd: (right: number, bottom: number) => void
46
+ /** Drag gesture notifications for renderers with a drag track (frames2d). */
47
+ onDraggingChange?: (dragging: boolean) => void
46
48
  /** Rename the selected pet (persisted by the host). */
47
49
  onRename: (name: string) => void
48
50
  /** Navigate to the session one status bubble reports on. */
@@ -55,6 +57,27 @@ export interface PetSpriteProps {
55
57
  * renders inside the sprite box, and the atlas load + frame loop skip.
56
58
  */
57
59
  visual?: ReactNode
60
+ /**
61
+ * Gameplay overlay (miku-pet generalization): rendered inside the float
62
+ * container so hover containment and stacking work unchanged. Absent for
63
+ * pets without a gameplay block.
64
+ */
65
+ hud?: ReactNode
66
+ /**
67
+ * Gameplay tap sink: receives the tap point as sprite-box fractions
68
+ * (0..1). When present the chrome reports the tap IN ADDITION to the
69
+ * affinity pet; the HUD decides zones/no-ops.
70
+ */
71
+ onGameplayTap?: (fractionX: number, fractionY: number) => void
72
+ /**
73
+ * Gameplay entry (miku-pet generalization): when present the hover panel
74
+ * renders a 玩法 action that opens/closes the gameplay card. The chrome
75
+ * wires it to the HUD through the per-pet bus (openCard), mirroring the
76
+ * onGameplayTap sink. Absent for pets without a gameplay block.
77
+ */
78
+ onGameplayMenu?: () => void
79
+ /** Disable the drag gesture (gameplay work mode blocks dragging). */
80
+ dragDisabled?: boolean
58
81
  /** Locale translate seat (namespace-bound). */
59
82
  t: TranslateNS<typeof NS>
60
83
  }
@@ -365,6 +388,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
365
388
  useEffect(() => () => clearHideTimer(), [])
366
389
 
367
390
  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
391
+ if (props.dragDisabled === true) return
368
392
  e.preventDefault()
369
393
  ;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
370
394
  const current = dragPos ?? { right: display.right, bottom: display.bottom }
@@ -377,7 +401,10 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
377
401
  if (drag === null) return
378
402
  const dx = e.clientX - drag.startX
379
403
  const dy = e.clientY - drag.startY
380
- if (Math.abs(dx) > 4 || Math.abs(dy) > 4) draggedRef.current = true
404
+ if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
405
+ if (!draggedRef.current) props.onDraggingChange?.(true)
406
+ draggedRef.current = true
407
+ }
381
408
  const right = clampOffset(drag.right - dx, window.innerWidth - 40)
382
409
  const bottom = clampOffset(drag.bottom - dy, window.innerHeight - 40)
383
410
  setDragPos({ right, bottom })
@@ -385,6 +412,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
385
412
  const onPointerUp = (): void => {
386
413
  if (dragRef.current === null) return
387
414
  dragRef.current = null
415
+ if (draggedRef.current) props.onDraggingChange?.(false)
388
416
  if (dragPos !== null) props.onDragEnd(dragPos.right, dragPos.bottom)
389
417
  }
390
418
 
@@ -404,14 +432,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
404
432
  const statusBubble = feedback === null && sessionBubbles.length === 0
405
433
  ? snapshot?.bubble
406
434
  : undefined
407
- // The display session's inner whisper (碎碎念) — short inner-voice copy
408
- // woken by the model's output. Instead of a second bubble of its own, a
409
- // fresh whisper takes over the display session's bubble (the stack top, or
410
- // the single status bubble) and re-tints it, so the pet never wears two
411
- // voices at once. Interaction feedback takes over the whole bubble area
412
- // while it plays, so whispers yield to it like status copy.
413
- const whisper = feedback === null ? snapshot?.whisper : undefined
414
- const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
435
+ // Each session's inner whisper (碎碎念) rides its own bubble — short
436
+ // inner-voice copy woken by that session's activity, never the model's or
437
+ // another session's. Instead of a second bubble of its own, a fresh
438
+ // whisper takes over its session's bubble and re-tints it, so the pet
439
+ // never wears two voices at once. Interaction feedback takes over the
440
+ // whole bubble area while it plays, so whispers yield to it like status
441
+ // copy.
442
+ const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined
415
443
  const displayName = snapshot?.name ?? definition.displayName
416
444
  // The host-served status decoration (M5, #567); absent = text-only bubbles.
417
445
  const decoration = snapshot?.decoration
@@ -497,10 +525,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
497
525
  onPointerDown={onPointerDown}
498
526
  onPointerMove={onPointerMove}
499
527
  onPointerUp={onPointerUp}
500
- onClick={() => {
528
+ onClick={(e) => {
501
529
  // A pointer sequence that moved (dragged) still fires a trailing
502
530
  // click; skip the pet when that happened.
503
531
  if (draggedRef.current) return
532
+ if (props.onGameplayTap !== undefined && spriteRef.current !== null) {
533
+ const rect = spriteRef.current.getBoundingClientRect()
534
+ if (rect.width > 0 && rect.height > 0) {
535
+ props.onGameplayTap((e.clientX - rect.left) / rect.width, (e.clientY - rect.top) / rect.height)
536
+ }
537
+ }
504
538
  props.onPet()
505
539
  }}
506
540
  role="button"
@@ -508,32 +542,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
508
542
  >
509
543
  {props.visual}
510
544
  </div>
511
- <button
512
- type="button"
513
- className={styles.closeButton}
514
- aria-label={panelLabel('hide', props.t('pet.hide'))}
515
- title={panelLabel('hide', props.t('pet.hide'))}
516
- data-testid="pet-close"
517
- onPointerDown={(e) => {
518
- // Keep the close control from starting a drag on the sprite.
519
- e.stopPropagation()
520
- }}
521
- onClick={(e) => {
522
- // The close control sits beside the pet button; do not pet as a
523
- // side effect of closing the overlay.
524
- e.stopPropagation()
525
- props.onHide()
526
- }}
527
- >
528
- ×
529
- </button>
530
545
  </div>
546
+ {props.hud}
531
547
  {feedback !== null && (
532
548
  <div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
533
549
  {feedback.text}
534
550
  </div>
535
551
  )}
536
- {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined) && (
552
+ {feedback === null && (sessionBubbles.length > 0 || statusBubble !== undefined) && (
537
553
  <div
538
554
  ref={bubbleRef}
539
555
  className={styles.bubbleStack}
@@ -541,15 +557,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
541
557
  onPointerLeave={() => setStackPeek(false)}
542
558
  >
543
559
  {visibleSessions.map((session, index) => {
544
- // The whisper rides the display session's bubble the stack's
545
- // primary entry (DOM-first, rendered bottom-most by the reversed
546
- // column so it stays glued to the sprite when extras open above).
547
- // The key swap restarts the entrance animation so the mood change
548
- // reads as the bubble re-speaking.
549
- const speaksWhisper = index === 0 && whisper !== undefined
560
+ // A session's whisper rides ITS OWN bubble (the stack lead when
561
+ // the current session has one, any bubble when the stack is
562
+ // expanded). The key swap restarts the entrance animation so the
563
+ // mood change reads as the bubble re-speaking.
564
+ const speaksWhisper = session.whisper !== undefined
550
565
  const bubble = (
551
566
  <button
552
- key={speaksWhisper ? 'whisper:' + whisper : session.sessionId}
567
+ key={speaksWhisper ? 'whisper:' + session.whisper : session.sessionId}
553
568
  type="button"
554
569
  className={clsx(
555
570
  styles.bubble,
@@ -563,7 +578,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
563
578
  {index === 0 && !speaksWhisper && decoration !== undefined && (
564
579
  <StatusOrnament decoration={decoration} phase={phase} />
565
580
  )}
566
- {speaksWhisper ? whisper : session.bubble}
581
+ {session.whisper ?? session.bubble}
567
582
  </button>
568
583
  )
569
584
  // The primary bubble carries the '+N' badge while other sessions
@@ -592,19 +607,17 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
592
607
  </span>
593
608
  )
594
609
  })}
595
- {sessionBubbles.length === 0 && (statusBubble !== undefined || whisper !== undefined) && (
596
- // The key swap (status copy <-> whisper) restarts the entrance
597
- // animation on every mood change.
610
+ {sessionBubbles.length === 0 && statusBubble !== undefined && (
598
611
  <div
599
- key={whisper === undefined ? 'status' : 'whisper:' + whisper}
600
- className={clsx(styles.bubble, styles.bubbleStatus, whisper !== undefined && styles.bubbleWhisper)}
612
+ key="status"
613
+ className={clsx(styles.bubble, styles.bubbleStatus)}
601
614
  role="status"
602
615
  aria-live="polite"
603
616
  >
604
- {whisper === undefined && decoration !== undefined && (
617
+ {decoration !== undefined && (
605
618
  <StatusOrnament decoration={decoration} phase={phase} />
606
619
  )}
607
- {whisper ?? statusBubble}
620
+ {statusBubble}
608
621
  </div>
609
622
  )}
610
623
  </div>
@@ -704,6 +717,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
704
717
  {panelLabel('hide', props.t('pet.hide'))}
705
718
  </button>
706
719
  )}
720
+ {props.onGameplayMenu !== undefined && (
721
+ <button type="button" className={styles.action} onClick={props.onGameplayMenu}>
722
+ {props.t('pet.gameplay.menu')}
723
+ </button>
724
+ )}
707
725
  </div>
708
726
  </>
709
727
  )}
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
39
39
  titleKey: TKey
40
40
  /** Locale key of the line describing what this plugin's settings govern. */
41
41
  descriptionKey: TKey
42
+ /** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
43
+ descriptionNode?: ReactNode
42
44
  /** The card's form state: availability, writability, and what a save would do. */
43
45
  state: CardShell
44
46
  /** Write every staged edit. */
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
87
89
  <div className={css.headerStatic}>
88
90
  <span className={css.headText}>
89
91
  <span className={css.name} title={title}>{title}</span>
90
- <span className={css.description} title={description}>{description}</span>
92
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
91
93
  </span>
92
94
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
93
95
  </div>
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
102
104
  >
103
105
  <span className={css.headText}>
104
106
  <span className={css.name} title={title}>{title}</span>
105
- <span className={css.description} title={description}>{description}</span>
107
+ <span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
106
108
  </span>
107
109
  {state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
108
110
  <svg
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Drag stream — bridges the chrome's pointer drag state onto the renderer
3
+ * contract's { get, subscribe } shape, so renderers with a dedicated drag
4
+ * track (frames2d) can follow the chrome-owned drag gesture. Dispatch is
5
+ * change-only, mirroring the phase stream.
6
+ * @module @linxin666/dsh-pet/client/drag-stream
7
+ */
8
+
9
+ /** The renderer-facing drag stream. */
10
+ export interface DragStream {
11
+ /** Whether the chrome reports an active drag gesture. */
12
+ get(): boolean
13
+ /** Subscribe to drag state changes; returns the unsubscribe. */
14
+ subscribe(listener: (dragging: boolean) => void): () => void
15
+ /** Feed a fresh drag state; no-op when unchanged. */
16
+ push(dragging: boolean): void
17
+ }
18
+
19
+ /** Create the stream (one per pet activation, owned by the renderer switch). */
20
+ export function createDragStream(): DragStream {
21
+ let current = false
22
+ const listeners = new Set<(dragging: boolean) => void>()
23
+ return {
24
+ get: () => current,
25
+ subscribe(listener) {
26
+ listeners.add(listener)
27
+ return () => { listeners.delete(listener) }
28
+ },
29
+ push(dragging) {
30
+ if (dragging === current) return
31
+ current = dragging
32
+ for (const listener of [...listeners]) listener(dragging)
33
+ },
34
+ }
35
+ }