@linxin666/dsh-pet 0.1.15 → 0.1.16

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 (74) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +73 -38
  3. package/README.zh.md +91 -55
  4. package/assets/whale/pet.json +12 -1
  5. package/cordis.patch.yml +4 -4
  6. package/lib/client.js +270 -334
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +641 -87
  9. package/lib/types/client/PetDockEntry.d.ts +14 -12
  10. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  11. package/lib/types/client/PetDockEntry.js +13 -8
  12. package/lib/types/client/PetSettingsCard.d.ts +22 -9
  13. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PetSettingsCard.js +42 -7
  15. package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
  16. package/lib/types/client/PetSprite.d.ts.map +1 -0
  17. package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
  18. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/PluginSettingsCard.js +3 -1
  20. package/lib/types/client/index.d.ts +11 -8
  21. package/lib/types/client/index.d.ts.map +1 -1
  22. package/lib/types/client/index.js +30 -12
  23. package/lib/types/client/locales.d.ts +10 -10
  24. package/lib/types/client/locales.js +10 -10
  25. package/lib/types/client/pet-store.d.ts +5 -0
  26. package/lib/types/client/pet-store.d.ts.map +1 -1
  27. package/lib/types/client/pet-store.js +4 -0
  28. package/lib/types/client/settings-form.d.ts +62 -0
  29. package/lib/types/client/settings-form.d.ts.map +1 -1
  30. package/lib/types/client/settings-form.js +61 -12
  31. package/lib/types/client/spritesheet.d.ts +14 -48
  32. package/lib/types/client/spritesheet.d.ts.map +1 -1
  33. package/lib/types/client/spritesheet.js +13 -81
  34. package/lib/types/index.d.ts +24 -11
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/lib/types/index.js +53 -33
  37. package/lib/types/ledger.d.ts +4 -2
  38. package/lib/types/ledger.d.ts.map +1 -1
  39. package/lib/types/ledger.js +11 -4
  40. package/lib/types/persist.d.ts +10 -3
  41. package/lib/types/persist.d.ts.map +1 -1
  42. package/lib/types/persist.js +32 -5
  43. package/lib/types/registry.d.ts +158 -0
  44. package/lib/types/registry.d.ts.map +1 -0
  45. package/lib/types/registry.js +276 -0
  46. package/lib/types/routes.d.ts +10 -10
  47. package/lib/types/routes.d.ts.map +1 -1
  48. package/lib/types/routes.js +169 -51
  49. package/lib/types/service.d.ts +46 -13
  50. package/lib/types/service.d.ts.map +1 -1
  51. package/lib/types/service.js +73 -16
  52. package/package.json +2 -2
  53. package/src/client/PetDockEntry.tsx +34 -26
  54. package/src/client/PetSettingsCard.tsx +67 -22
  55. package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
  56. package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
  57. package/src/client/PluginSettingsCard.tsx +7 -1
  58. package/src/client/index.ts +36 -14
  59. package/src/client/locales.ts +10 -10
  60. package/src/client/pet-store.ts +9 -0
  61. package/src/client/settings-card.module.css +3 -0
  62. package/src/client/settings-form.ts +114 -12
  63. package/src/client/spritesheet.ts +16 -92
  64. package/src/index.test.ts +23 -0
  65. package/src/index.ts +89 -45
  66. package/src/ledger.test.ts +5 -3
  67. package/src/ledger.ts +11 -4
  68. package/src/persist.test.ts +52 -3
  69. package/src/persist.ts +42 -8
  70. package/src/registry.test.ts +132 -0
  71. package/src/registry.ts +400 -0
  72. package/src/routes.ts +166 -53
  73. package/src/service.ts +102 -20
  74. package/lib/types/client/WhalePet.d.ts.map +0 -1
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Global floating pet entry. The pet is host-global (its state, display and
3
- * interactions live on `/api/pet/*` endpoints with no session dimension), so
3
+ * interactions live on '/api/pet/*' endpoints with no session dimension), so
4
4
  * it must not ride a session-scoped slot — on the new-conversation screen no
5
5
  * session exists to scope a slot by, and the pet would vanish (issue #48).
6
- * The client half therefore mounts this entry straight onto `document.body`
7
- * (see index.ts): while visible it renders the floating WhalePet (a portal),
8
- * while hidden it renders a fixed-position summon button.
6
+ * The client half therefore mounts this entry straight onto 'document.body'
7
+ * (see index.ts): while visible it renders the floating PetSprite (a
8
+ * portal), while hidden it renders a fixed-position summon button. Which
9
+ * sprite renders is decided by the host snapshot's pet id resolved against
10
+ * the registry list — no per-pet component exists.
9
11
  * @module @linxin666/dsh-pet/client/PetDockEntry
10
12
  */
11
13
 
@@ -13,27 +15,27 @@ import { useEffect, useSyncExternalStore, type ReactElement } from 'react'
13
15
  import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
14
16
  import type { PetDisplayConfig } from '../persist.ts'
15
17
  import type { PetStoreInstance } from './pet-store.ts'
16
- import { WhalePet } from './WhalePet.tsx'
18
+ import { PetSprite } from './PetSprite.tsx'
17
19
  import { NS } from './locales.ts'
18
20
  import styles from './pet.module.css'
19
21
 
20
22
  /** Injected actions handed to the dock entry component. */
21
23
  export interface PetInjected {
22
- /** The app-wide pet store instance (snapshot + feedback). */
24
+ /** The app-wide pet store instance (snapshot + registry list + feedback). */
23
25
  store: PetStoreInstance
24
- /** Ensure the first snapshot is fetched (called on mount). */
26
+ /** Ensure the first snapshot (and registry list) is fetched (called on mount). */
25
27
  ensure: () => void
26
- /** Pet the whale girl (click). */
28
+ /** Pet the sprite (click). */
27
29
  pet: () => void
28
- /** Feed the whale girl. */
30
+ /** Feed the sprite. */
29
31
  feed: () => void
30
- /** Hide the whale girl. */
32
+ /** Hide the sprite. */
31
33
  hide: () => void
32
- /** Summon the hidden whale girl back. */
34
+ /** Summon the hidden sprite back. */
33
35
  summon: () => void
34
36
  /** Persist a drag position. */
35
37
  dragEnd: (right: number, bottom: number) => void
36
- /** Rename the pet (persisted by the host). */
38
+ /** Rename the selected pet (persisted by the host). */
37
39
  rename: (name: string) => void
38
40
  /** Clear the reaction bubble. */
39
41
  feedbackDone: () => void
@@ -47,7 +49,7 @@ export type PetDockEntryProps =
47
49
  const DEFAULT_DISPLAY: PetDisplayConfig = { visible: true, size: 160, right: 24, bottom: 20 }
48
50
 
49
51
  /**
50
- * Dock entry: while the pet is visible, mount the floating WhalePet (it
52
+ * Dock entry: while the pet is visible, mount the floating PetSprite (it
51
53
  * portals itself onto document.body); while hidden, render the summon
52
54
  * button so the pet can always come back. The store is the plugin-owned
53
55
  * single instance — the slot system provides none because the pet is
@@ -58,6 +60,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
58
60
  const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
59
61
  const snapshot = ui.snapshot
60
62
  const feedback = ui.feedback
63
+ const definition = ui.pets.find(entry => entry.id === snapshot?.pet.id) ?? null
61
64
  const visible = snapshot?.display.visible ?? true
62
65
 
63
66
  useEffect(() => {
@@ -67,18 +70,23 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
67
70
  if (visible) {
68
71
  return (
69
72
  <span data-pet-dock data-testid="pet-dock">
70
- <WhalePet
71
- snapshot={snapshot}
72
- display={snapshot?.display ?? DEFAULT_DISPLAY}
73
- feedback={feedback}
74
- onPet={props.pet}
75
- onFeed={props.feed}
76
- onHide={props.hide}
77
- onDragEnd={props.dragEnd}
78
- onRename={props.rename}
79
- onFeedbackDone={props.feedbackDone}
80
- t={props.t}
81
- />
73
+ {snapshot === null || definition === null
74
+ ? null
75
+ : (
76
+ <PetSprite
77
+ snapshot={snapshot}
78
+ definition={definition}
79
+ display={snapshot.display}
80
+ feedback={feedback}
81
+ onPet={props.pet}
82
+ onFeed={props.feed}
83
+ onHide={props.hide}
84
+ onDragEnd={props.dragEnd}
85
+ onRename={props.rename}
86
+ onFeedbackDone={props.feedbackDone}
87
+ t={props.t}
88
+ />
89
+ )}
82
90
  </span>
83
91
  )
84
92
  }
@@ -96,7 +104,7 @@ export function PetDockEntry(props: PetDockEntryProps): ReactElement {
96
104
  onClick={props.summon}
97
105
  data-testid="pet-summon"
98
106
  >
99
- {props.t('pet.summon', { name: snapshot?.name ?? '鲸鱼娘' })}
107
+ {props.t('pet.summon', { name: snapshot?.name ?? '' })}
100
108
  </button>
101
109
  )
102
110
  }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * The pet settings card: display layout and name, bound to the `pet` settings
3
- * namespace the host plugin registers. Registered into the
4
- * `settings.plugin.item` slot the plugin-configuration section renders.
2
+ * The pet settings card: pet selection plus display layout, bound to the
3
+ * 'pet' settings namespace the host plugin registers. Registered into the
4
+ * 'web-ui.plugin.item' slot the plugin-configuration section renders. The
5
+ * petId choices come from the registry endpoint ('/api/pet/pets') — the same
6
+ * list the sprite renders from — so the card carries no per-pet knowledge.
5
7
  */
6
8
 
7
9
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
10
  import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
9
- import { PluginSettingsCard, ValueField, BooleanField } from './PluginSettingsCard.tsx'
10
- import { CardForm, booleanField, numberField, textField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
11
+ import { PluginSettingsCard, ValueField, BooleanField, ChoiceField } from './PluginSettingsCard.tsx'
12
+ import { CardForm, booleanField, choiceField, numberField, type CardActions, type CardShell, type FieldState as CardFieldState } from './settings-form.ts'
11
13
 
12
14
  /** The pet's settings fields this card edits (the namespace's full schema). */
13
15
  export interface PetSettings {
@@ -21,8 +23,8 @@ export interface PetSettings {
21
23
  right?: number
22
24
  /** Vertical inset from the viewport bottom edge, px. */
23
25
  bottom?: number
24
- /** User-customizable pet display name. */
25
- name?: string
26
+ /** Selected pet id (a registry entry). */
27
+ petId?: string
26
28
  }
27
29
 
28
30
  /** What the pet settings card renders. */
@@ -37,8 +39,10 @@ export interface PetSettingsCardState extends CardShell {
37
39
  right: CardFieldState
38
40
  /** Bottom inset. */
39
41
  bottom: CardFieldState
40
- /** Pet name. */
41
- name: CardFieldState
42
+ /** Selected pet. */
43
+ petId: CardFieldState
44
+ /** Pet choices (registry ids + display names), loaded from the host. */
45
+ petChoices: readonly { value: string; label: string }[]
42
46
  }
43
47
 
44
48
  /** The registration-side face the card's slot entry injects. */
@@ -49,12 +53,32 @@ export interface PetSettingsCardFace extends CardActions {
49
53
  }
50
54
  }
51
55
 
52
- /** Bridges the `pet` scope onto the card's staged form. */
56
+ /** One registry choice as served by '/api/pet/pets'. */
57
+ interface PetChoice {
58
+ id: string
59
+ displayName: string
60
+ }
61
+
62
+ /** Fetch the registry list (the same data the sprite renders from). */
63
+ async function fetchPetChoices(): Promise<PetChoice[]> {
64
+ const response = await fetch('/api/pet/pets')
65
+ if (!response.ok) throw new Error('pet pets failed: ' + response.status)
66
+ return (await response.json()) as PetChoice[]
67
+ }
68
+
69
+ /** Bridges the 'pet' scope onto the card's staged form. */
53
70
  export class PetSettingsCardController {
54
71
  private readonly form: CardForm<PetSettings>
55
72
  private readonly store: SnapshotStore<PetSettingsCardState>
73
+ // The choice list rides a mutable array shared with the choiceField spec,
74
+ // so loading the registry re-validates and re-formats the petId field
75
+ // without rebuilding the form.
76
+ private readonly petChoices: string[] = []
77
+ private readonly petLabels = new Map<string, string>()
78
+ private loaded = false
79
+ private attempts = 0
56
80
 
57
- /** @param scope - the bound settings scope for the `pet` namespace. */
81
+ /** @param scope - the bound settings scope for the 'pet' namespace. */
58
82
  constructor(scope: SettingsScope<PetSettings>) {
59
83
  this.form = new CardForm(scope, [
60
84
  booleanField('enabled'),
@@ -62,9 +86,27 @@ export class PetSettingsCardController {
62
86
  numberField('size'),
63
87
  numberField('right'),
64
88
  numberField('bottom'),
65
- textField('name'),
89
+ choiceField('petId', this.petChoices),
66
90
  ])
67
91
  this.store = this.form.bind(() => this.projection())
92
+ void this.loadPets()
93
+ }
94
+
95
+ /** Resolve the registry choices once (retried a few times on failure). */
96
+ private async loadPets(): Promise<void> {
97
+ if (this.loaded) return
98
+ try {
99
+ const list = await fetchPetChoices()
100
+ this.petChoices.splice(0, this.petChoices.length, ...list.map(choice => choice.id))
101
+ for (const choice of list) this.petLabels.set(choice.id, choice.displayName)
102
+ this.loaded = true
103
+ this.store.set(this.projection())
104
+ } catch {
105
+ this.attempts += 1
106
+ if (this.attempts < 3) {
107
+ window.setTimeout(() => { void this.loadPets() }, 3000)
108
+ }
109
+ }
68
110
  }
69
111
 
70
112
  private projection(): PetSettingsCardState {
@@ -75,7 +117,8 @@ export class PetSettingsCardController {
75
117
  size: this.form.field('size'),
76
118
  right: this.form.field('right'),
77
119
  bottom: this.form.field('bottom'),
78
- name: this.form.field('name'),
120
+ petId: this.form.field('petId'),
121
+ petChoices: this.petChoices.map(id => ({ value: id, label: this.petLabels.get(id) ?? id })),
79
122
  }
80
123
  }
81
124
 
@@ -130,6 +173,17 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
130
173
  onEdit={(text) => { props.edit('enabled', text) }}
131
174
  onReset={() => { props.resetField('enabled') }}
132
175
  />
176
+ <ChoiceField
177
+ id="settings-pet-pet"
178
+ label={t('settings.pet')}
179
+ hint={t('settings.petHint')}
180
+ inheritLabel={t('settings.inherit')}
181
+ {...fieldProps}
182
+ {...state.petId}
183
+ choices={state.petChoices}
184
+ onEdit={(text) => { props.edit('petId', text) }}
185
+ onReset={() => { props.resetField('petId') }}
186
+ />
133
187
  <BooleanField
134
188
  id="settings-pet-visible"
135
189
  label={t('settings.visible')}
@@ -172,15 +226,6 @@ export function PetSettingsCard(props: PetSettingsCardProps) {
172
226
  onEdit={(text) => { props.edit('bottom', text) }}
173
227
  onReset={() => { props.resetField('bottom') }}
174
228
  />
175
- <ValueField
176
- id="settings-pet-name"
177
- label={t('settings.name')}
178
- hint={t('settings.nameHint')}
179
- {...fieldProps}
180
- {...state.name}
181
- onEdit={(text) => { props.edit('name', text) }}
182
- onReset={() => { props.resetField('name') }}
183
- />
184
229
  </PluginSettingsCard>
185
230
  )
186
231
  }
@@ -1,14 +1,47 @@
1
1
  // @vitest-environment jsdom
2
2
  /**
3
- * WhalePet rename-box keyboard handling. The rename input must treat
3
+ * PetSprite rename-box keyboard handling. The rename input must treat
4
4
  * Enter/Escape keydowns that arrive during IME composition (candidate
5
5
  * selection) as composition input, never as submit/cancel (issue #89).
6
6
  */
7
7
  import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
8
8
  import { cleanup, fireEvent, render, screen } from '@testing-library/react'
9
- import { WhalePet, type WhalePetProps } from './WhalePet.tsx'
9
+ import { PetSprite, type PetSpriteProps } from './PetSprite.tsx'
10
10
  import { t } from './locales.ts'
11
11
  import type { PetStateView } from '../service.ts'
12
+ import type { PetDefinition, PetTrackDef } from '../registry.ts'
13
+ import type { PetAnimation } from '../state.ts'
14
+
15
+ /** A minimal pet definition (geometry + tracks) as served by the host. */
16
+ function petDefinition(): PetDefinition {
17
+ const track = (frames: number[], durations: number[], loop = true, fallback?: PetAnimation): PetTrackDef => ({
18
+ frames,
19
+ durations,
20
+ loop,
21
+ ...(fallback === undefined ? {} : { fallback }),
22
+ })
23
+ return {
24
+ id: 'whale-girl',
25
+ displayName: '鲸鱼娘',
26
+ description: '测试用鲸鱼娘',
27
+ cell: { width: 192, height: 208 },
28
+ columns: 8,
29
+ rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
30
+ tracks: {
31
+ idle: track([0, 1, 2, 3, 4, 5], [400, 400, 400, 400, 400, 400]),
32
+ 'running-right': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
33
+ 'running-left': track([0, 1, 2, 3, 4, 5, 6, 7], [225, 225, 225, 225, 225, 225, 225, 225]),
34
+ waving: track([0, 1, 2, 3], [350, 350, 350, 350]),
35
+ jumping: track([0, 1, 2, 3, 4], [300, 300, 300, 300, 300], false, 'idle'),
36
+ failed: track([0, 1, 2, 3, 4, 5, 6, 7], [450, 450, 450, 450, 450, 450, 450, 450], false, 'idle'),
37
+ waiting: track([0, 1, 2, 3, 4, 5], [450, 450, 450, 450, 450, 450]),
38
+ running: track([0, 1, 2, 3, 4, 5], [250, 250, 250, 250, 250, 250]),
39
+ review: track([0, 1, 2, 3, 4, 5], [550, 550, 550, 550, 550, 550]),
40
+ },
41
+ atlasUrl: '/pet/whale-girl/spritesheet.webp',
42
+ manifestUrl: '/pet/whale-girl/pet.json',
43
+ }
44
+ }
12
45
 
13
46
  /** Snapshot fixture: idle whale girl named 泡泡. */
14
47
  const snapshot: PetStateView = {
@@ -26,6 +59,7 @@ const snapshot: PetStateView = {
26
59
  feedCooldown: false,
27
60
  },
28
61
  display: { visible: true, size: 160, right: 24, bottom: 20 },
62
+ pet: { id: 'whale-girl', displayName: '鲸鱼娘', description: '测试用鲸鱼娘' },
29
63
  name: '泡泡',
30
64
  treats: { stocked: 3, max: 5 },
31
65
  }
@@ -55,10 +89,11 @@ afterEach(() => {
55
89
  })
56
90
 
57
91
  /** Render the pet with mocked callbacks; returns the rename spy. */
58
- function renderPet(overrides: Partial<WhalePetProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
92
+ function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
59
93
  const onRename = vi.fn()
60
- const props: WhalePetProps = {
94
+ const props: PetSpriteProps = {
61
95
  snapshot,
96
+ definition: petDefinition(),
62
97
  display: snapshot.display,
63
98
  feedback: null,
64
99
  onPet: vi.fn(),
@@ -70,13 +105,13 @@ function renderPet(overrides: Partial<WhalePetProps> = {}): { onRename: ReturnTy
70
105
  t,
71
106
  ...overrides,
72
107
  }
73
- render(<WhalePet {...props} />)
108
+ render(<PetSprite {...props} />)
74
109
  return { onRename }
75
110
  }
76
111
 
77
112
  /** Hover the sprite to open the panel, then click the rename button. */
78
113
  function openRename(): HTMLInputElement {
79
- fireEvent.pointerOver(screen.getByRole('button', { name: 'whale girl' }))
114
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
80
115
  fireEvent.click(screen.getByText('改名'))
81
116
  return screen.getByPlaceholderText('输入新名字') as HTMLInputElement
82
117
  }
@@ -100,7 +135,7 @@ function fireComposingKeydown(target: Element, key: string): void {
100
135
  fireEvent.compositionEnd(target)
101
136
  }
102
137
 
103
- describe('WhalePet rename input', () => {
138
+ describe('PetSprite rename input', () => {
104
139
  it('submits the draft on Enter outside composition', () => {
105
140
  const { onRename } = renderPet()
106
141
  const input = openRename()
@@ -138,7 +173,7 @@ describe('WhalePet rename input', () => {
138
173
  })
139
174
  })
140
175
 
141
- describe('WhalePet status bubble', () => {
176
+ describe('PetSprite status bubble', () => {
142
177
  const workingSnapshot: PetStateView = {
143
178
  ...snapshot,
144
179
  animation: 'running',
@@ -160,3 +195,16 @@ describe('WhalePet status bubble', () => {
160
195
  expect(screen.queryByText('正在思考')).toBeNull()
161
196
  })
162
197
  })
198
+
199
+ describe('PetSprite definition-driven render', () => {
200
+ it('labels the sprite with the pet display name', () => {
201
+ renderPet()
202
+ expect(screen.queryByRole('button', { name: '鲸鱼娘' })).not.toBeNull()
203
+ })
204
+
205
+ it('shows the renamed snapshot name in the hover panel', () => {
206
+ renderPet()
207
+ fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
208
+ expect(screen.queryByText('泡泡')).not.toBeNull()
209
+ })
210
+ })
@@ -1,47 +1,47 @@
1
1
  /**
2
- * Whale-girl companion component — the browser half's centerpiece. Renders a
2
+ * Pet sprite companion component — the browser half's centerpiece. Renders a
3
3
  * fixed-position floating sprite (React portal onto document.body), plays
4
- * the spritesheet track matching the host animation snapshot, and exposes
5
- * the interaction surface: click to pet, hover panel with feed/hide, drag to
6
- * reposition (persisted via setConfig).
7
- * @module @linxin666/dsh-pet/client/WhalePet
4
+ * the track matching the host animation snapshot, and exposes the
5
+ * interaction surface: click to pet, hover panel with feed/rename/hide, drag
6
+ * to reposition (persisted via setConfig). Everything visual comes from the
7
+ * pet definition the host serves ('/api/pet/pets' + the state snapshot's
8
+ * pet id), so one component renders every registry entry.
9
+ * @module @linxin666/dsh-pet/client/PetSprite
8
10
  */
9
11
 
10
12
  import { useEffect, useRef, useState } from 'react'
11
13
  import type { PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
12
14
  import { createPortal } from 'react-dom'
15
+ import clsx from 'clsx'
13
16
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
14
17
  import type { PetDisplayConfig } from '../persist.ts'
15
18
  import type { PetStateView } from '../service.ts'
19
+ import type { PetDefinition } from '../registry.ts'
16
20
  import type { PetFeedback } from './pet-store.ts'
17
- import { framePosition, FRAME_WIDTH, FRAME_HEIGHT, FRAME_COLUMNS, TRACKS, rowOfTrack, trimTrack, detectFrameCounts } from './spritesheet.ts'
21
+ import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
18
22
  import type { PetAnimation } from '../state.ts'
19
23
  import { NS } from './locales.ts'
20
24
  import styles from './pet.module.css'
21
25
 
22
- /** Browser URL of the whale-girl atlas (served by the host half's own route). */
23
- export const PET_SPRITESHEET_URL = '/pet/whale/spritesheet.webp'
24
-
25
- /** Browser URL of the whale-girl manifest (authoritative per-row frame counts). */
26
- export const PET_MANIFEST_URL = '/pet/whale/pet.json'
27
-
28
- /** Props injected by the slot registration (store actions + locale). */
29
- export interface WhalePetProps {
26
+ /** Props injected by the plugin apply body (store actions + locale). */
27
+ export interface PetSpriteProps {
30
28
  /** Latest host snapshot; null while loading. */
31
29
  snapshot: PetStateView | null
30
+ /** The selected pet's registry definition (atlas URL + geometry + tracks). */
31
+ definition: PetDefinition
32
32
  /** Display configuration (persisted by the host). */
33
33
  display: PetDisplayConfig
34
34
  /** Active reaction bubble, if any. */
35
35
  feedback: PetFeedback | null
36
- /** Pet the whale girl (click). */
36
+ /** Pet the sprite (click). */
37
37
  onPet: () => void
38
- /** Feed the whale girl (panel button). */
38
+ /** Feed the sprite (panel button). */
39
39
  onFeed: () => void
40
- /** Hide the whale girl (panel button). */
40
+ /** Hide the sprite (panel button). */
41
41
  onHide: () => void
42
42
  /** Persist a drag position. */
43
43
  onDragEnd: (right: number, bottom: number) => void
44
- /** Rename the pet (persisted by the host). */
44
+ /** Rename the selected pet (persisted by the host). */
45
45
  onRename: (name: string) => void
46
46
  /** Clear the reaction bubble (after its CSS animation). */
47
47
  onFeedbackDone: () => void
@@ -56,16 +56,15 @@ function clampOffset(value: number, max: number): number {
56
56
 
57
57
  /**
58
58
  * The floating pet. The spritesheet frame advances on requestAnimationFrame
59
- * with per-frame durations from TRACKS; the atlas image is loaded once and
60
- * the background position is written straight to the sprite element (no
61
- * per-frame React state).
59
+ * with per-frame durations from the definition's tracks; the atlas image is
60
+ * loaded once and the background position is written straight to the sprite
61
+ * element (no per-frame React state).
62
62
  */
63
- export function WhalePet(props: WhalePetProps): ReactPortal {
64
- const { snapshot, display, feedback } = props
63
+ export function PetSprite(props: PetSpriteProps): ReactPortal {
64
+ const { snapshot, definition, display, feedback } = props
65
65
  const spriteRef = useRef<HTMLDivElement | null>(null)
66
66
  const floatRef = useRef<HTMLDivElement | null>(null)
67
67
  const [imageReady, setImageReady] = useState(false)
68
- const [frameCounts, setFrameCounts] = useState<number[] | null>(null)
69
68
  const [hovered, setHovered] = useState(false)
70
69
  const [renaming, setRenaming] = useState(false)
71
70
  const [nameDraft, setNameDraft] = useState('')
@@ -78,39 +77,25 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
78
77
  elapsed: 0,
79
78
  })
80
79
 
81
- // Load the atlas once; then resolve per-row frame counts so tracks never
82
- // play the transparent trailing cells of a short row. One decoded Image
83
- // feeds both the sprite render and the frame-count detection. The counts
84
- // prefer the authoritatively recorded `frames` field on the pet.json
85
- // manifest route and only fall back to the getImageData atlas scan when
86
- // that field is absent (older manifests).
80
+ const cell = definition.cell
81
+ const columns = definition.columns
82
+ const rows = definition.rows
83
+ const tracks = definition.tracks
84
+
85
+ // Load the atlas once; the definition carries the authoritative per-row
86
+ // frame counts and per-track durations, so nothing else is fetched.
87
87
  useEffect(() => {
88
88
  let cancelled = false
89
89
  const img = new Image()
90
90
  img.onload = () => {
91
- if (cancelled) return
92
- setImageReady(true)
93
- fetch(PET_MANIFEST_URL)
94
- .then((res) => (res.ok ? res.json() : Promise.resolve<{ frames?: unknown }>({})))
95
- .then((manifest: { frames?: unknown }) => {
96
- if (cancelled) return
97
- const frames = manifest.frames
98
- if (Array.isArray(frames) && frames.length === 9 && frames.every((n) => typeof n === 'number')) {
99
- setFrameCounts(frames as number[])
100
- } else {
101
- setFrameCounts(detectFrameCounts(img))
102
- }
103
- })
104
- .catch(() => {
105
- if (!cancelled) setFrameCounts(detectFrameCounts(img))
106
- })
91
+ if (!cancelled) setImageReady(true)
107
92
  }
108
- img.src = PET_SPRITESHEET_URL
93
+ img.src = definition.atlasUrl
109
94
  return () => {
110
95
  cancelled = true
111
96
  img.onload = null
112
97
  }
113
- }, [])
98
+ }, [definition.atlasUrl])
114
99
 
115
100
  // Frame loop: advance the current track and write background-position.
116
101
  // Offsets must be in SCALED coordinates (background-position applies to the
@@ -118,23 +103,21 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
118
103
  // the loop reads every tick. Under prefers-reduced-motion the sprite holds
119
104
  // its track's first frame instead of animating (presentation-only; the
120
105
  // animation state machine is untouched).
121
- const spriteScale = display.size / FRAME_HEIGHT
106
+ const spriteScale = display.size / cell.height
122
107
  const animation = snapshot?.animation ?? 'idle'
123
108
  const scaleRef = useRef(spriteScale)
124
109
  scaleRef.current = spriteScale
125
110
  useEffect(() => {
126
111
  const reduceMotion = typeof window !== 'undefined'
127
112
  && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
113
+ const row = rowOfTrack(animation)
114
+ const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
128
115
  // Paint one static sprite frame up front either way, so the pet is never
129
116
  // blank while the loop heat-up runs.
130
- const row = rowOfTrack(animation)
131
- const track = frameCounts === null
132
- ? TRACKS[animation]
133
- : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
134
117
  const leadCol = track.frames[0]!
135
- const lead = framePosition(row, leadCol, scaleRef.current)
118
+ const lead = framePosition(cell, columns, row, leadCol, scaleRef.current)
136
119
  if (spriteRef.current !== null) {
137
- spriteRef.current.style.backgroundPosition = `${lead.x}px ${lead.y}px`
120
+ spriteRef.current.style.backgroundPosition = lead.x + 'px ' + lead.y + 'px'
138
121
  }
139
122
  if (reduceMotion) return
140
123
  let raf = 0
@@ -145,9 +128,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
145
128
  // Trim the track to the row's real frame count (transparent cells
146
129
  // would render as a vanishing pet).
147
130
  const row = rowOfTrack(animation)
148
- const track = frameCounts === null
149
- ? TRACKS[animation]
150
- : trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length)
131
+ const track = trimTrack(tracks[animation], rows[row] ?? tracks[animation].frames.length)
151
132
  const st = frameRef.current
152
133
  if (st.track !== animation) {
153
134
  st.track = animation
@@ -169,18 +150,18 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
169
150
  }
170
151
  }
171
152
  const col = track.frames[st.index]!
172
- const { x, y } = framePosition(row, col, scaleRef.current)
153
+ const pos = framePosition(cell, columns, row, col, scaleRef.current)
173
154
  if (spriteRef.current !== null) {
174
- spriteRef.current.style.backgroundPosition = `${x}px ${y}px`
155
+ spriteRef.current.style.backgroundPosition = pos.x + 'px ' + pos.y + 'px'
175
156
  }
176
157
  raf = requestAnimationFrame(tick)
177
158
  }
178
159
  raf = requestAnimationFrame(tick)
179
160
  return () => cancelAnimationFrame(raf)
180
- }, [animation, frameCounts])
161
+ }, [animation, cell, columns, rows, tracks])
181
162
 
182
163
  // Auto-clear the feedback bubble after its CSS animation. The callback
183
- // rides a ref so re-renders never reset the timer: the 800ms poll rebuilds
164
+ // rides a ref so re-renders never reset the timer: the 2s poll rebuilds
184
165
  // `props` every tick, and depending on it would starve the timeout.
185
166
  const feedbackDoneRef = useRef(props.onFeedbackDone)
186
167
  feedbackDoneRef.current = props.onFeedbackDone
@@ -192,7 +173,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
192
173
 
193
174
  // Dragging: pointer events on the sprite; position is right/bottom based.
194
175
  // `draggedRef` records whether the pointer actually moved, so the browser's
195
- // trailing click (fired after pointerup) does not pet the whale.
176
+ // trailing click (fired after pointerup) does not pet the sprite.
196
177
  const draggedRef = useRef(false)
197
178
  const clearHideTimer = (): void => {
198
179
  if (hideTimerRef.current !== null) {
@@ -226,9 +207,10 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
226
207
  }
227
208
 
228
209
  const pos = dragPos ?? { right: display.right, bottom: display.bottom }
229
- const spriteWidth = Math.round(FRAME_WIDTH * spriteScale)
230
- const spriteHeight = Math.round(FRAME_HEIGHT * spriteScale)
210
+ const spriteWidth = Math.round(cell.width * spriteScale)
211
+ const spriteHeight = Math.round(cell.height * spriteScale)
231
212
  const statusBubble = feedback === null && !hovered ? snapshot?.bubble : undefined
213
+ const displayName = snapshot?.name ?? definition.displayName
232
214
 
233
215
  const float = (
234
216
  <div
@@ -245,7 +227,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
245
227
  // container. Treat a target still inside the container's DOM (the
246
228
  // overflowed panel) as "still hovering"; otherwise give the pointer a
247
229
  // short grace period to reach the panel across the gap above it. The
248
- // bridge (`.panel::after`) keeps the pointer inside the hit area, and
230
+ // bridge ('.panel::after') keeps the pointer inside the hit area, and
249
231
  // the grace period covers a slow mouse crossing the remaining sliver.
250
232
  const next = e.relatedTarget
251
233
  if (next instanceof Node && floatRef.current?.contains(next)) return
@@ -259,8 +241,8 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
259
241
  style={{
260
242
  width: spriteWidth,
261
243
  height: spriteHeight,
262
- backgroundImage: imageReady ? `url(${PET_SPRITESHEET_URL})` : undefined,
263
- backgroundSize: `${FRAME_WIDTH * FRAME_COLUMNS * spriteScale}px ${FRAME_HEIGHT * 9 * spriteScale}px`,
244
+ backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
245
+ backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * rows.length * spriteScale) + 'px',
264
246
  backgroundRepeat: 'no-repeat',
265
247
  backgroundPosition: '0 0',
266
248
  cursor: dragRef.current === null ? 'grab' : 'grabbing',
@@ -275,15 +257,15 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
275
257
  props.onPet()
276
258
  }}
277
259
  role="button"
278
- aria-label="whale girl"
260
+ aria-label={definition.displayName}
279
261
  />
280
262
  {feedback !== null && (
281
- <div key={feedback.at} className={`${styles.bubble} ${feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet}`}>
263
+ <div key={feedback.at} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
282
264
  {feedback.text}
283
265
  </div>
284
266
  )}
285
267
  {statusBubble !== undefined && (
286
- <div className={`${styles.bubble} ${styles.bubbleStatus}`} role="status" aria-live="polite">
268
+ <div className={clsx(styles.bubble, styles.bubbleStatus)} role="status" aria-live="polite">
287
269
  {statusBubble}
288
270
  </div>
289
271
  )}
@@ -340,7 +322,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
340
322
  ) : (
341
323
  <>
342
324
  <div className={styles.rankRow}>
343
- <span className={styles.nameCell}>{snapshot?.name ?? '鲸鱼娘'}</span>
325
+ <span className={styles.nameCell}>{displayName}</span>
344
326
  <span>{props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
345
327
  </div>
346
328
  <div className={styles.rankRow}>
@@ -355,7 +337,7 @@ export function WhalePet(props: WhalePetProps): ReactPortal {
355
337
  type="button"
356
338
  className={styles.action}
357
339
  onClick={() => {
358
- setNameDraft(snapshot?.name ?? '')
340
+ setNameDraft(displayName)
359
341
  setRenaming(true)
360
342
  }}
361
343
  >