@linxin666/dsh-pet 0.2.2 → 0.2.4
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 +129 -3
- package/README.zh.md +129 -3
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +581 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +1684 -59
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +14 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +24 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +120 -12
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +14 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +192 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +112 -5
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +395 -38
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +312 -13
- package/lib/types/service.d.ts +38 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +46 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +13 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +57 -0
- package/src/client/PetSprite.test.tsx +265 -12
- package/src/client/PetSprite.tsx +165 -33
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +14 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +263 -0
- package/src/client/renderers/live2d.ts +231 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-form.ts +8 -6
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/index.ts +2 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +438 -2
- package/src/registry.ts +470 -38
- package/src/routes.ts +328 -14
- package/src/service.ts +65 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
|
@@ -11,6 +11,7 @@ import { t } from './locales.ts'
|
|
|
11
11
|
import type { PetStateView } from '../service.ts'
|
|
12
12
|
import type { PetDefinition, PetTrackDef } from '../registry.ts'
|
|
13
13
|
import type { PetAnimation } from '../state.ts'
|
|
14
|
+
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
14
15
|
|
|
15
16
|
/** A minimal pet definition (geometry + tracks) as served by the host. */
|
|
16
17
|
function petDefinition(): PetDefinition {
|
|
@@ -24,6 +25,7 @@ function petDefinition(): PetDefinition {
|
|
|
24
25
|
id: 'whale-girl',
|
|
25
26
|
displayName: '鲸鱼娘',
|
|
26
27
|
description: '测试用鲸鱼娘',
|
|
28
|
+
renderer: 'sprite2d',
|
|
27
29
|
cell: { width: 192, height: 208 },
|
|
28
30
|
columns: 8,
|
|
29
31
|
rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
|
|
@@ -90,14 +92,9 @@ afterEach(() => {
|
|
|
90
92
|
vi.restoreAllMocks()
|
|
91
93
|
})
|
|
92
94
|
|
|
93
|
-
/**
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
onOpenSession: ReturnType<typeof vi.fn>
|
|
97
|
-
} {
|
|
98
|
-
const onRename = vi.fn()
|
|
99
|
-
const onOpenSession = vi.fn()
|
|
100
|
-
const props: PetSpriteProps = {
|
|
95
|
+
/** Build the mocked props for one render. */
|
|
96
|
+
function petProps(overrides: Partial<PetSpriteProps> = {}): PetSpriteProps {
|
|
97
|
+
return {
|
|
101
98
|
snapshot,
|
|
102
99
|
definition: petDefinition(),
|
|
103
100
|
display: snapshot.display,
|
|
@@ -106,14 +103,24 @@ function renderPet(overrides: Partial<PetSpriteProps> = {}): {
|
|
|
106
103
|
onFeed: vi.fn(),
|
|
107
104
|
onHide: vi.fn(),
|
|
108
105
|
onDragEnd: vi.fn(),
|
|
109
|
-
onRename,
|
|
110
|
-
onOpenSession,
|
|
106
|
+
onRename: vi.fn(),
|
|
107
|
+
onOpenSession: vi.fn(),
|
|
111
108
|
onFeedbackDone: vi.fn(),
|
|
112
109
|
t,
|
|
113
110
|
...overrides,
|
|
114
111
|
}
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Render the pet with mocked callbacks; returns the rename/open spys + the RTL result. */
|
|
115
|
+
function renderPet(overrides: Partial<PetSpriteProps> = {}): {
|
|
116
|
+
onRename: ReturnType<typeof vi.fn>
|
|
117
|
+
onOpenSession: ReturnType<typeof vi.fn>
|
|
118
|
+
result: ReturnType<typeof render>
|
|
119
|
+
} {
|
|
120
|
+
const onRename = vi.fn()
|
|
121
|
+
const onOpenSession = vi.fn()
|
|
122
|
+
const result = render(<PetSprite {...petProps({ onRename, onOpenSession, ...overrides })} />)
|
|
123
|
+
return { onRename, onOpenSession, result }
|
|
117
124
|
}
|
|
118
125
|
|
|
119
126
|
/** Hover the sprite to open the panel, then click the rename button. */
|
|
@@ -142,6 +149,16 @@ function fireComposingKeydown(target: Element, key: string): void {
|
|
|
142
149
|
fireEvent.compositionEnd(target)
|
|
143
150
|
}
|
|
144
151
|
|
|
152
|
+
describe('PetSprite custom visual (pet-center M3)', () => {
|
|
153
|
+
it('renders the visual inside the sprite box and skips the atlas background', () => {
|
|
154
|
+
renderPet({ visual: <canvas data-testid="custom-visual" /> })
|
|
155
|
+
const spriteEl = screen.getByRole('button', { name: '鲸鱼娘' })
|
|
156
|
+
expect(spriteEl.querySelector('[data-testid="custom-visual"]')).toBeTruthy()
|
|
157
|
+
// The atlas background never applies while a renderer visual owns the box.
|
|
158
|
+
expect(spriteEl.style.backgroundImage).toBe('')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
161
|
+
|
|
145
162
|
describe('PetSprite rename input', () => {
|
|
146
163
|
it('submits the draft on Enter outside composition', () => {
|
|
147
164
|
const { onRename } = renderPet()
|
|
@@ -544,3 +561,239 @@ describe('PetSprite definition-driven render', () => {
|
|
|
544
561
|
expect(sprite.style.backgroundPosition).toBe('0px -960px')
|
|
545
562
|
})
|
|
546
563
|
})
|
|
564
|
+
|
|
565
|
+
describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
|
|
566
|
+
const voicedDefinition = (): PetDefinition => ({
|
|
567
|
+
...petDefinition(),
|
|
568
|
+
panel: {
|
|
569
|
+
labels: { feed: '投喂', rename: '起名字', hide: '藏起来', confirm: '好的' },
|
|
570
|
+
stats: { rank: '好感 {rank}', treats: '鱼干 {n}', points: '{points} 分' },
|
|
571
|
+
},
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
it('renders pack labels and stat formats, falling back per slot', () => {
|
|
575
|
+
renderPet({ definition: voicedDefinition() })
|
|
576
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
577
|
+
expect(screen.getByText('投喂')).toBeDefined()
|
|
578
|
+
expect(screen.getByText('起名字')).toBeDefined()
|
|
579
|
+
expect(screen.getByText('藏起来')).toBeDefined()
|
|
580
|
+
expect(screen.getByText('好感 幼鲸')).toBeDefined()
|
|
581
|
+
expect(screen.getByText('鱼干 3')).toBeDefined()
|
|
582
|
+
expect(screen.getByText('0 分')).toBeDefined()
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
it('uses the pack confirm label inside the rename row', () => {
|
|
586
|
+
renderPet({ definition: voicedDefinition() })
|
|
587
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
588
|
+
fireEvent.click(screen.getByText('起名字'))
|
|
589
|
+
expect(screen.getByText('好的')).toBeDefined()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
it('hides actions the pack omits', () => {
|
|
593
|
+
renderPet({
|
|
594
|
+
definition: {
|
|
595
|
+
...petDefinition(),
|
|
596
|
+
panel: { labels: { feed: '投喂' }, actions: ['feed'] },
|
|
597
|
+
},
|
|
598
|
+
})
|
|
599
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
600
|
+
expect(screen.getByText('投喂')).toBeDefined()
|
|
601
|
+
expect(screen.queryByText('改名')).toBeNull()
|
|
602
|
+
expect(screen.queryByText('隐藏')).toBeNull()
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
it('renders no action buttons when the pack hides them all', () => {
|
|
606
|
+
renderPet({ definition: { ...petDefinition(), panel: { actions: [] } } })
|
|
607
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
608
|
+
expect(screen.queryByText('喂食')).toBeNull()
|
|
609
|
+
expect(screen.queryByText('改名')).toBeNull()
|
|
610
|
+
expect(screen.queryByText('隐藏')).toBeNull()
|
|
611
|
+
// The stat rows keep rendering.
|
|
612
|
+
expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
|
|
613
|
+
expect(screen.getByText('小鱼干 ×3')).toBeDefined()
|
|
614
|
+
})
|
|
615
|
+
|
|
616
|
+
it('keeps the i18n copy when the pet carries no panel', () => {
|
|
617
|
+
renderPet()
|
|
618
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
619
|
+
expect(screen.getByText('喂食')).toBeDefined()
|
|
620
|
+
expect(screen.getByText('改名')).toBeDefined()
|
|
621
|
+
expect(screen.getByText('隐藏')).toBeDefined()
|
|
622
|
+
expect(screen.getByText('亲密度 幼鲸')).toBeDefined()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('substitutes cross-slot placeholders in pack stat formats', () => {
|
|
626
|
+
renderPet({ definition: { ...petDefinition(), panel: { stats: { treats: '鱼干 {n}({points} 分,{rank})' } } } })
|
|
627
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
628
|
+
// The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
|
|
629
|
+
// format may reference any of them; all three live values substitute.
|
|
630
|
+
expect(screen.getByText('鱼干 3(0 分,幼鲸)')).toBeDefined()
|
|
631
|
+
})
|
|
632
|
+
})
|
|
633
|
+
describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
634
|
+
const decoration: DecorationView = {
|
|
635
|
+
apiVersion: 'x-org.linxin666.pet-center/status-decoration-v1',
|
|
636
|
+
id: 'whale',
|
|
637
|
+
assetBase: '/api/pet/decoration/whale',
|
|
638
|
+
entryUrl: '/api/pet/decoration/whale/whale-frames.png',
|
|
639
|
+
cell: { width: 64, height: 48 },
|
|
640
|
+
columns: 4,
|
|
641
|
+
durations: [160, 160, 160, 160],
|
|
642
|
+
loop: true,
|
|
643
|
+
phases: {
|
|
644
|
+
idle: 'hide',
|
|
645
|
+
waiting: { from: 0, to: 1 },
|
|
646
|
+
thinking: { from: 0, to: 3 },
|
|
647
|
+
done: { from: 2, to: 3 },
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const ornament = (): HTMLElement | null => document.body.querySelector('[data-dsh-pet-decoration="whale"]')
|
|
652
|
+
|
|
653
|
+
it('renders an aria-hidden ornament inside the status bubble for a bound phase', () => {
|
|
654
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
655
|
+
const el = ornament()
|
|
656
|
+
expect(el).not.toBeNull()
|
|
657
|
+
expect(el!.getAttribute('aria-hidden')).toBe('true')
|
|
658
|
+
expect(el!.style.backgroundImage).toContain('whale-frames.png')
|
|
659
|
+
// The bubble keeps its semantics beside the ornament.
|
|
660
|
+
const bubble = document.body.querySelector('[role="status"][aria-live="polite"]')
|
|
661
|
+
expect(bubble).not.toBeNull()
|
|
662
|
+
expect(bubble!.textContent).toContain('正在思考')
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('hides the ornament for phases bound to hide and for the idle default', () => {
|
|
666
|
+
renderPet({ snapshot: { ...snapshot, bubble: '等待', phase: 'idle', decoration } })
|
|
667
|
+
expect(ornament()).toBeNull()
|
|
668
|
+
})
|
|
669
|
+
|
|
670
|
+
it('holds the segment first frame under prefers-reduced-motion', () => {
|
|
671
|
+
renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration } })
|
|
672
|
+
const el = ornament()
|
|
673
|
+
expect(el).not.toBeNull()
|
|
674
|
+
// The harness matchMedia mock reports reduced motion, so the ornament
|
|
675
|
+
// rests on the segment's first frame (column 2 of a 24px-wide frame).
|
|
676
|
+
expect(el!.style.backgroundPosition).toBe('-48px 0px')
|
|
677
|
+
})
|
|
678
|
+
|
|
679
|
+
it('yields the bubble to the whisper (voice moment hides the ornament)', () => {
|
|
680
|
+
renderPet({ snapshot: { ...snapshot, phase: 'thinking', whisper: '冲了冲了', decoration } })
|
|
681
|
+
expect(ornament()).toBeNull()
|
|
682
|
+
expect(document.body.textContent).toContain('冲了冲了')
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
it('renders no ornament when the host serves no decoration', () => {
|
|
686
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking' } })
|
|
687
|
+
expect(ornament()).toBeNull()
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
it('advances the ornament frames on the rAF loop and wraps when looping', () => {
|
|
691
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
692
|
+
matches: false,
|
|
693
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
694
|
+
onchange: null,
|
|
695
|
+
addEventListener: () => {},
|
|
696
|
+
removeEventListener: () => {},
|
|
697
|
+
addListener: () => {},
|
|
698
|
+
removeListener: () => {},
|
|
699
|
+
dispatchEvent: () => false,
|
|
700
|
+
})
|
|
701
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
702
|
+
const frames: FrameRequestCallback[] = []
|
|
703
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
704
|
+
frames.push(callback)
|
|
705
|
+
return frames.length
|
|
706
|
+
})
|
|
707
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
708
|
+
renderPet({ snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
709
|
+
const el = ornament()!
|
|
710
|
+
// Both the sprite loop and the ornament loop schedule frames; step every
|
|
711
|
+
// pending callback together (the sprite's idle track never moves).
|
|
712
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
713
|
+
// frameWidth = round(64 * 18 / 48) = 24 px; thinking binds frames 0..3.
|
|
714
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
715
|
+
act(() => { step(161) })
|
|
716
|
+
expect(el.style.backgroundPosition).toBe('-24px 0px')
|
|
717
|
+
act(() => { step(322) })
|
|
718
|
+
expect(el.style.backgroundPosition).toBe('-48px 0px')
|
|
719
|
+
act(() => { step(483) })
|
|
720
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
721
|
+
act(() => { step(644) })
|
|
722
|
+
// The looping segment wraps back to its first frame.
|
|
723
|
+
expect(el.style.backgroundPosition).toBe('0px 0px')
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('holds the segment last frame when the loop is off and stops scheduling', () => {
|
|
727
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
728
|
+
matches: false,
|
|
729
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
730
|
+
onchange: null,
|
|
731
|
+
addEventListener: () => {},
|
|
732
|
+
removeEventListener: () => {},
|
|
733
|
+
addListener: () => {},
|
|
734
|
+
removeListener: () => {},
|
|
735
|
+
dispatchEvent: () => false,
|
|
736
|
+
})
|
|
737
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
738
|
+
const frames: FrameRequestCallback[] = []
|
|
739
|
+
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
740
|
+
frames.push(callback)
|
|
741
|
+
return frames.length
|
|
742
|
+
})
|
|
743
|
+
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
744
|
+
renderPet({ snapshot: { ...snapshot, bubble: '完成', phase: 'done', decoration: { ...decoration, loop: false } } })
|
|
745
|
+
const el = ornament()!
|
|
746
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
747
|
+
// done binds frames 2..3; the segment starts on frame 2.
|
|
748
|
+
expect(el.style.backgroundPosition).toBe('-48px 0px')
|
|
749
|
+
act(() => { step(161) })
|
|
750
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
751
|
+
// The ornament stopped scheduling (only the sprite loop remains pending).
|
|
752
|
+
expect(frames).toHaveLength(1)
|
|
753
|
+
act(() => { step(161) })
|
|
754
|
+
// The last frame holds.
|
|
755
|
+
expect(el.style.backgroundPosition).toBe('-72px 0px')
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('does not restart the frame loop when an equal-content decoration re-renders', () => {
|
|
759
|
+
vi.spyOn(window, 'matchMedia').mockReturnValue({
|
|
760
|
+
matches: false,
|
|
761
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
762
|
+
onchange: null,
|
|
763
|
+
addEventListener: () => {},
|
|
764
|
+
removeEventListener: () => {},
|
|
765
|
+
addListener: () => {},
|
|
766
|
+
removeListener: () => {},
|
|
767
|
+
dispatchEvent: () => false,
|
|
768
|
+
})
|
|
769
|
+
vi.spyOn(performance, 'now').mockReturnValue(0)
|
|
770
|
+
const frames: FrameRequestCallback[] = []
|
|
771
|
+
const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
|
772
|
+
frames.push(callback)
|
|
773
|
+
return frames.length
|
|
774
|
+
})
|
|
775
|
+
const cancelSpy = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
|
776
|
+
// The definition comes from '/api/pet/pets', fetched once — a state
|
|
777
|
+
// poll never replaces it, so both renders share one definition object.
|
|
778
|
+
const definition = petDefinition()
|
|
779
|
+
const { result } = renderPet({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration } })
|
|
780
|
+
const step = (ts: number): void => { for (const callback of frames.splice(0)) callback(ts) }
|
|
781
|
+
act(() => { step(161) })
|
|
782
|
+
expect(ornament()!.style.backgroundPosition).toBe('-24px 0px')
|
|
783
|
+
const schedulesBefore = rafSpy.mock.calls.length
|
|
784
|
+
// The 2 s poll delivers a fresh JSON round-trip: identical content, new
|
|
785
|
+
// object identities everywhere. The loop must not cancel/restart.
|
|
786
|
+
const repolled: DecorationView = {
|
|
787
|
+
...decoration,
|
|
788
|
+
cell: { ...decoration.cell },
|
|
789
|
+
durations: [...decoration.durations],
|
|
790
|
+
phases: { ...decoration.phases },
|
|
791
|
+
}
|
|
792
|
+
result.rerender(<PetSprite {...petProps({ definition, snapshot: { ...snapshot, bubble: '正在思考', phase: 'thinking', decoration: repolled } })} />)
|
|
793
|
+
act(() => { step(322) })
|
|
794
|
+
expect(ornament()!.style.backgroundPosition).toBe('-48px 0px')
|
|
795
|
+
expect(cancelSpy).not.toHaveBeenCalled()
|
|
796
|
+
// One reschedule per loop tick; no effect restart added new schedules.
|
|
797
|
+
expect(rafSpy.mock.calls.length).toBe(schedulesBefore + 2)
|
|
798
|
+
})
|
|
799
|
+
})
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
13
|
-
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactPortal } from 'react'
|
|
13
|
+
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, ReactPortal } from 'react'
|
|
14
14
|
import { createPortal } from 'react-dom'
|
|
15
15
|
import clsx from 'clsx'
|
|
16
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
17
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
18
18
|
import type { PetStateView } from '../service.ts'
|
|
19
19
|
import type { PetDefinition } from '../registry.ts'
|
|
20
|
+
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
20
21
|
import type { PetFeedback } from './pet-store.ts'
|
|
21
22
|
import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
|
|
22
23
|
import { sequenceFrameAt } from './sequences.ts'
|
|
23
|
-
import { animationForPhase, type PetAnimation } from '../state.ts'
|
|
24
|
+
import { animationForPhase, type ActivityPhase, type PetAnimation } from '../state.ts'
|
|
24
25
|
import { NS } from './locales.ts'
|
|
25
26
|
import styles from './pet.module.css'
|
|
26
27
|
|
|
@@ -48,6 +49,12 @@ export interface PetSpriteProps {
|
|
|
48
49
|
onOpenSession: (sessionId: string) => void
|
|
49
50
|
/** Clear the reaction bubble (after its CSS animation). */
|
|
50
51
|
onFeedbackDone: () => void
|
|
52
|
+
/**
|
|
53
|
+
* Custom visual replacing the sprite2d atlas animation (pet-center M3).
|
|
54
|
+
* The chrome (drag, bubbles, panel, tap economy) is untouched: the visual
|
|
55
|
+
* renders inside the sprite box, and the atlas load + frame loop skip.
|
|
56
|
+
*/
|
|
57
|
+
visual?: ReactNode
|
|
51
58
|
/** Locale translate seat (namespace-bound). */
|
|
52
59
|
t: TranslateNS<typeof NS>
|
|
53
60
|
}
|
|
@@ -57,6 +64,81 @@ function clampOffset(value: number, max: number): number {
|
|
|
57
64
|
return Math.max(0, Math.min(max, value))
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The status decoration ornament (pet-center M5, #567). Renders the active
|
|
69
|
+
* phase's frame segment as a CSS-background strip at a compact bubble
|
|
70
|
+
* height; prefers-reduced-motion holds the segment's first frame, and a
|
|
71
|
+
* missing or undecodable asset simply paints nothing (CSS background
|
|
72
|
+
* failure) — the bubble text is never disturbed. The span is aria-hidden;
|
|
73
|
+
* the bubble keeps its own semantics untouched.
|
|
74
|
+
*/
|
|
75
|
+
function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhase }): ReactElement | null {
|
|
76
|
+
const { decoration, phase } = props
|
|
77
|
+
const segment = decoration.phases[phase]
|
|
78
|
+
const shown = segment !== undefined && segment !== 'hide'
|
|
79
|
+
const segmentKey = segment !== undefined && segment !== 'hide' ? segment.from + ':' + segment.to : 'none'
|
|
80
|
+
const spanRef = useRef<HTMLSpanElement | null>(null)
|
|
81
|
+
const scale = 18 / decoration.cell.height
|
|
82
|
+
const frameWidth = Math.round(decoration.cell.width * scale)
|
|
83
|
+
const stripWidth = decoration.columns * frameWidth
|
|
84
|
+
// Value-stable dependency key: the host serves a fresh DecorationView
|
|
85
|
+
// object on every state poll (2 s), so the effect must not depend on the
|
|
86
|
+
// object identity — otherwise each poll would cancel and restart the
|
|
87
|
+
// frame loop and the animation would jump back to its first frame.
|
|
88
|
+
const durationsKey = decoration.durations.join(',')
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (segment === undefined || segment === 'hide') return
|
|
91
|
+
const el = spanRef.current
|
|
92
|
+
if (el === null) return
|
|
93
|
+
const position = (index: number): string => (-index * frameWidth) + 'px 0px'
|
|
94
|
+
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
95
|
+
el.style.backgroundPosition = position(segment.from)
|
|
96
|
+
if (reduceMotion) return
|
|
97
|
+
let raf = 0
|
|
98
|
+
let index = segment.from
|
|
99
|
+
let elapsed = 0
|
|
100
|
+
let last = performance.now()
|
|
101
|
+
const tick = (ts: number): void => {
|
|
102
|
+
const delta = ts - last
|
|
103
|
+
last = ts
|
|
104
|
+
elapsed += delta
|
|
105
|
+
const duration = decoration.durations[index] ?? 160
|
|
106
|
+
if (elapsed >= duration) {
|
|
107
|
+
elapsed = 0
|
|
108
|
+
if (index < segment.to) index += 1
|
|
109
|
+
else if (decoration.loop) index = segment.from
|
|
110
|
+
}
|
|
111
|
+
el.style.backgroundPosition = position(index)
|
|
112
|
+
// A non-looping segment settles on its last frame; stop scheduling
|
|
113
|
+
// instead of repainting the same position every frame.
|
|
114
|
+
if (!decoration.loop && index === segment.to) return
|
|
115
|
+
raf = requestAnimationFrame(tick)
|
|
116
|
+
}
|
|
117
|
+
raf = requestAnimationFrame(tick)
|
|
118
|
+
return () => cancelAnimationFrame(raf)
|
|
119
|
+
}, [shown, segmentKey, frameWidth, decoration.loop, durationsKey])
|
|
120
|
+
if (!shown) return null
|
|
121
|
+
return (
|
|
122
|
+
<span
|
|
123
|
+
ref={spanRef}
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
data-dsh-pet-decoration={decoration.id}
|
|
126
|
+
style={{
|
|
127
|
+
display: 'inline-block',
|
|
128
|
+
width: frameWidth,
|
|
129
|
+
height: 18,
|
|
130
|
+
marginRight: 6,
|
|
131
|
+
verticalAlign: 'middle',
|
|
132
|
+
flexShrink: 0,
|
|
133
|
+
backgroundImage: 'url(' + decoration.entryUrl + ')',
|
|
134
|
+
backgroundSize: stripWidth + 'px 18px',
|
|
135
|
+
backgroundRepeat: 'no-repeat',
|
|
136
|
+
backgroundPosition: '0px 0px',
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
60
142
|
/**
|
|
61
143
|
* The floating pet. The spritesheet frame advances on requestAnimationFrame
|
|
62
144
|
* with per-frame durations from the definition's tracks; the atlas image is
|
|
@@ -104,10 +186,39 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
104
186
|
const rows = definition.rows
|
|
105
187
|
const tracks = definition.tracks
|
|
106
188
|
const sequences = definition.sequences
|
|
189
|
+
// Hover-panel chrome from the pet's voice pack (pet-center M4, issue
|
|
190
|
+
// #677): every slot falls back to the i18n dictionary when unset. Stat
|
|
191
|
+
// formats carry {rank}/{n}/{points} placeholders the host validated.
|
|
192
|
+
const panel = definition.panel
|
|
193
|
+
const panelLabel = (slot: 'feed' | 'rename' | 'hide' | 'confirm', i18n: string): string =>
|
|
194
|
+
panel?.labels?.[slot] ?? i18n
|
|
195
|
+
const panelStat = (
|
|
196
|
+
slot: 'rank' | 'treats' | 'points',
|
|
197
|
+
i18nKey: 'pet.rank' | 'pet.treats' | 'pet.points',
|
|
198
|
+
values: Record<string, string | number>,
|
|
199
|
+
): string => {
|
|
200
|
+
const format = panel?.stats?.[slot] ?? props.t(i18nKey, values)
|
|
201
|
+
if (panel?.stats?.[slot] === undefined) return format
|
|
202
|
+
// The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
|
|
203
|
+
// format may reference any of them; substitute all three live values
|
|
204
|
+
// (the slot's own value plus the siblings) instead of only the slot's.
|
|
205
|
+
const all: Record<string, string | number> = {
|
|
206
|
+
rank: snapshot?.affinity.rank ?? '?',
|
|
207
|
+
n: snapshot?.treats.stocked ?? 0,
|
|
208
|
+
points: snapshot?.affinity.points ?? 0,
|
|
209
|
+
}
|
|
210
|
+
let text = format
|
|
211
|
+
for (const [name, value] of Object.entries(all)) text = text.replaceAll('{' + name + '}', String(value))
|
|
212
|
+
return text
|
|
213
|
+
}
|
|
214
|
+
const panelShows = (action: 'feed' | 'rename' | 'hide'): boolean =>
|
|
215
|
+
panel?.actions === undefined || panel.actions.includes(action)
|
|
107
216
|
|
|
108
217
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
109
|
-
// frame counts and per-track durations, so nothing else is fetched.
|
|
218
|
+
// frame counts and per-track durations, so nothing else is fetched. A
|
|
219
|
+
// custom visual (pet-center M3) replaces the atlas entirely.
|
|
110
220
|
useEffect(() => {
|
|
221
|
+
if (props.visual !== undefined) return
|
|
111
222
|
let cancelled = false
|
|
112
223
|
const img = new Image()
|
|
113
224
|
img.onload = () => {
|
|
@@ -118,7 +229,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
118
229
|
cancelled = true
|
|
119
230
|
img.onload = null
|
|
120
231
|
}
|
|
121
|
-
}, [definition.atlasUrl])
|
|
232
|
+
}, [definition.atlasUrl, props.visual])
|
|
122
233
|
|
|
123
234
|
// Frame loop: advance the current track and write background-position.
|
|
124
235
|
// Offsets must be in SCALED coordinates (background-position applies to the
|
|
@@ -132,6 +243,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
132
243
|
const scaleRef = useRef(spriteScale)
|
|
133
244
|
scaleRef.current = spriteScale
|
|
134
245
|
useEffect(() => {
|
|
246
|
+
if (props.visual !== undefined) return
|
|
135
247
|
const reduceMotion = typeof window !== 'undefined'
|
|
136
248
|
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
137
249
|
const sequence = animation === animationForPhase(phase) ? sequences?.[phase] : undefined
|
|
@@ -200,7 +312,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
200
312
|
}
|
|
201
313
|
raf = requestAnimationFrame(tick)
|
|
202
314
|
return () => cancelAnimationFrame(raf)
|
|
203
|
-
}, [animation, phase, cell, columns, rows, tracks, sequences])
|
|
315
|
+
}, [animation, phase, cell, columns, rows, tracks, sequences, props.visual])
|
|
204
316
|
|
|
205
317
|
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
206
318
|
// rides a ref so re-renders never reset the timer: the 2s poll rebuilds
|
|
@@ -278,6 +390,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
278
390
|
const whisper = feedback === null ? snapshot?.whisper : undefined
|
|
279
391
|
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
|
|
280
392
|
const displayName = snapshot?.name ?? definition.displayName
|
|
393
|
+
// The host-served status decoration (M5, #567); absent = text-only bubbles.
|
|
394
|
+
const decoration = snapshot?.decoration
|
|
281
395
|
|
|
282
396
|
// A settled session list can no longer stay pinned open.
|
|
283
397
|
useEffect(() => {
|
|
@@ -343,10 +457,14 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
343
457
|
style={{
|
|
344
458
|
width: spriteWidth,
|
|
345
459
|
height: spriteHeight,
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
460
|
+
...(props.visual === undefined
|
|
461
|
+
? {
|
|
462
|
+
backgroundImage: imageReady ? 'url(' + definition.atlasUrl + ')' : undefined,
|
|
463
|
+
backgroundSize: (cell.width * columns * spriteScale) + 'px ' + (cell.height * (definition.atlasRows ?? rows.length) * spriteScale) + 'px',
|
|
464
|
+
backgroundRepeat: 'no-repeat',
|
|
465
|
+
backgroundPosition: '0 0',
|
|
466
|
+
}
|
|
467
|
+
: {}),
|
|
350
468
|
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
351
469
|
}}
|
|
352
470
|
onPointerDown={onPointerDown}
|
|
@@ -360,7 +478,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
360
478
|
}}
|
|
361
479
|
role="button"
|
|
362
480
|
aria-label={definition.displayName}
|
|
363
|
-
|
|
481
|
+
>
|
|
482
|
+
{props.visual}
|
|
483
|
+
</div>
|
|
364
484
|
{feedback !== null && (
|
|
365
485
|
<div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
366
486
|
{feedback.text}
|
|
@@ -393,6 +513,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
393
513
|
title={props.t('pet.openSessionHint')}
|
|
394
514
|
onClick={() => { props.onOpenSession(session.sessionId) }}
|
|
395
515
|
>
|
|
516
|
+
{index === 0 && !speaksWhisper && decoration !== undefined && (
|
|
517
|
+
<StatusOrnament decoration={decoration} phase={phase} />
|
|
518
|
+
)}
|
|
396
519
|
{speaksWhisper ? whisper : session.bubble}
|
|
397
520
|
</button>
|
|
398
521
|
)
|
|
@@ -431,6 +554,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
431
554
|
role="status"
|
|
432
555
|
aria-live="polite"
|
|
433
556
|
>
|
|
557
|
+
{whisper === undefined && decoration !== undefined && (
|
|
558
|
+
<StatusOrnament decoration={decoration} phase={phase} />
|
|
559
|
+
)}
|
|
434
560
|
{whisper ?? statusBubble}
|
|
435
561
|
</div>
|
|
436
562
|
)}
|
|
@@ -492,39 +618,45 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
492
618
|
}
|
|
493
619
|
}}
|
|
494
620
|
>
|
|
495
|
-
{props.t('pet.confirm')}
|
|
621
|
+
{panelLabel('confirm', props.t('pet.confirm'))}
|
|
496
622
|
</button>
|
|
497
623
|
</div>
|
|
498
624
|
) : (
|
|
499
625
|
<>
|
|
500
626
|
<div className={styles.rankRow}>
|
|
501
627
|
<span className={styles.nameCell}>{displayName}</span>
|
|
502
|
-
<span className={styles.statRank}>{
|
|
628
|
+
<span className={styles.statRank}>{panelStat('rank', 'pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
503
629
|
</div>
|
|
504
630
|
<div className={styles.rankRow}>
|
|
505
|
-
<span className={styles.statTreats}>{
|
|
506
|
-
<span className={styles.statPoints}>{
|
|
631
|
+
<span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
632
|
+
<span className={styles.statPoints}>{panelStat('points', 'pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
|
|
507
633
|
</div>
|
|
508
634
|
<div className={styles.actions}>
|
|
509
|
-
|
|
510
|
-
{props.
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
635
|
+
{panelShows('feed') && (
|
|
636
|
+
<button type="button" className={styles.action} onClick={props.onFeed}>
|
|
637
|
+
{panelLabel('feed', props.t('pet.feed'))}
|
|
638
|
+
</button>
|
|
639
|
+
)}
|
|
640
|
+
{panelShows('rename') && (
|
|
641
|
+
<button
|
|
642
|
+
type="button"
|
|
643
|
+
className={styles.action}
|
|
644
|
+
onClick={() => {
|
|
645
|
+
// Cancel any pending hide so the rename box cannot
|
|
646
|
+
// unmount right as the user starts typing (#303).
|
|
647
|
+
clearHideTimer()
|
|
648
|
+
setNameDraft(displayName)
|
|
649
|
+
setRenaming(true)
|
|
650
|
+
}}
|
|
651
|
+
>
|
|
652
|
+
{panelLabel('rename', props.t('pet.rename'))}
|
|
653
|
+
</button>
|
|
654
|
+
)}
|
|
655
|
+
{panelShows('hide') && (
|
|
656
|
+
<button type="button" className={styles.action} onClick={props.onHide}>
|
|
657
|
+
{panelLabel('hide', props.t('pet.hide'))}
|
|
658
|
+
</button>
|
|
659
|
+
)}
|
|
528
660
|
</div>
|
|
529
661
|
</>
|
|
530
662
|
)}
|
package/src/client/index.ts
CHANGED
|
@@ -26,6 +26,8 @@ import { createElement } from 'react'
|
|
|
26
26
|
import { createRoot } from 'react-dom/client'
|
|
27
27
|
import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
28
28
|
import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
|
|
29
|
+
import { defaultPetRendererRegistry } from './renderers/registry.ts'
|
|
30
|
+
import { live2dRenderer } from './renderers/live2d.ts'
|
|
29
31
|
import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
|
|
30
32
|
import { NS, en, zh, t } from './locales.ts'
|
|
31
33
|
|
|
@@ -103,6 +105,10 @@ declare module '@deepseek-ai/cordis' {
|
|
|
103
105
|
export function apply(ctx: ClientContext): void {
|
|
104
106
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pet: dictionaries')
|
|
105
107
|
|
|
108
|
+
// Built-in renderers dispatch through the plugin-wide registry (pet-center
|
|
109
|
+
// M3). Registration is idempotent (id wins), so re-applies stay clean.
|
|
110
|
+
defaultPetRendererRegistry.register(live2dRenderer)
|
|
111
|
+
|
|
106
112
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
107
113
|
const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
|
|
108
114
|
const enabled = (): boolean => {
|