@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.
- package/README.i18n.yaml +2 -2
- package/README.md +73 -38
- package/README.zh.md +91 -55
- package/assets/whale/pet.json +12 -1
- package/cordis.patch.yml +4 -4
- package/lib/client.js +270 -334
- package/lib/client.js.map +1 -1
- package/lib/index.js +641 -87
- package/lib/types/client/PetDockEntry.d.ts +14 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +13 -8
- package/lib/types/client/PetSettingsCard.d.ts +22 -9
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +42 -7
- package/lib/types/client/{WhalePet.d.ts → PetSprite.d.ts} +21 -20
- package/lib/types/client/PetSprite.d.ts.map +1 -0
- package/lib/types/client/{WhalePet.js → PetSprite.js} +44 -67
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -1
- package/lib/types/client/index.d.ts +11 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +30 -12
- package/lib/types/client/locales.d.ts +10 -10
- package/lib/types/client/locales.js +10 -10
- package/lib/types/client/pet-store.d.ts +5 -0
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/settings-form.d.ts +62 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +61 -12
- package/lib/types/client/spritesheet.d.ts +14 -48
- package/lib/types/client/spritesheet.d.ts.map +1 -1
- package/lib/types/client/spritesheet.js +13 -81
- package/lib/types/index.d.ts +24 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +53 -33
- package/lib/types/ledger.d.ts +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +11 -4
- package/lib/types/persist.d.ts +10 -3
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +32 -5
- package/lib/types/registry.d.ts +158 -0
- package/lib/types/registry.d.ts.map +1 -0
- package/lib/types/registry.js +276 -0
- package/lib/types/routes.d.ts +10 -10
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +169 -51
- package/lib/types/service.d.ts +46 -13
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +73 -16
- package/package.json +2 -2
- package/src/client/PetDockEntry.tsx +34 -26
- package/src/client/PetSettingsCard.tsx +67 -22
- package/src/client/{WhalePet.test.tsx → PetSprite.test.tsx} +56 -8
- package/src/client/{WhalePet.tsx → PetSprite.tsx} +55 -73
- package/src/client/PluginSettingsCard.tsx +7 -1
- package/src/client/index.ts +36 -14
- package/src/client/locales.ts +10 -10
- package/src/client/pet-store.ts +9 -0
- package/src/client/settings-card.module.css +3 -0
- package/src/client/settings-form.ts +114 -12
- package/src/client/spritesheet.ts +16 -92
- package/src/index.test.ts +23 -0
- package/src/index.ts +89 -45
- package/src/ledger.test.ts +5 -3
- package/src/ledger.ts +11 -4
- package/src/persist.test.ts +52 -3
- package/src/persist.ts +42 -8
- package/src/registry.test.ts +132 -0
- package/src/registry.ts +400 -0
- package/src/routes.ts +166 -53
- package/src/service.ts +102 -20
- 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
|
|
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
|
|
7
|
-
* (see index.ts): while visible it renders the floating
|
|
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 {
|
|
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
|
|
28
|
+
/** Pet the sprite (click). */
|
|
27
29
|
pet: () => void
|
|
28
|
-
/** Feed the
|
|
30
|
+
/** Feed the sprite. */
|
|
29
31
|
feed: () => void
|
|
30
|
-
/** Hide the
|
|
32
|
+
/** Hide the sprite. */
|
|
31
33
|
hide: () => void
|
|
32
|
-
/** Summon the hidden
|
|
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
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
3
|
-
* namespace the host plugin registers. Registered into the
|
|
4
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
25
|
-
|
|
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
|
-
/**
|
|
41
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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<
|
|
92
|
+
function renderPet(overrides: Partial<PetSpriteProps> = {}): { onRename: ReturnType<typeof vi.fn> } {
|
|
59
93
|
const onRename = vi.fn()
|
|
60
|
-
const props:
|
|
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(<
|
|
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: '
|
|
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('
|
|
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('
|
|
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
|
-
*
|
|
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
|
|
5
|
-
*
|
|
6
|
-
* reposition (persisted via setConfig).
|
|
7
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
23
|
-
export
|
|
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
|
|
36
|
+
/** Pet the sprite (click). */
|
|
37
37
|
onPet: () => void
|
|
38
|
-
/** Feed the
|
|
38
|
+
/** Feed the sprite (panel button). */
|
|
39
39
|
onFeed: () => void
|
|
40
|
-
/** Hide the
|
|
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
|
|
60
|
-
* the background position is written straight to the sprite
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
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)
|
|
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 =
|
|
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 /
|
|
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 =
|
|
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 =
|
|
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
|
|
153
|
+
const pos = framePosition(cell, columns, row, col, scaleRef.current)
|
|
173
154
|
if (spriteRef.current !== null) {
|
|
174
|
-
spriteRef.current.style.backgroundPosition =
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
230
|
-
const spriteHeight = Math.round(
|
|
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 (
|
|
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 ?
|
|
263
|
-
backgroundSize:
|
|
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=
|
|
260
|
+
aria-label={definition.displayName}
|
|
279
261
|
/>
|
|
280
262
|
{feedback !== null && (
|
|
281
|
-
<div key={feedback.at} className={
|
|
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={
|
|
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}>{
|
|
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(
|
|
340
|
+
setNameDraft(displayName)
|
|
359
341
|
setRenaming(true)
|
|
360
342
|
}}
|
|
361
343
|
>
|