@linxin666/dsh-pet 0.3.2 → 0.3.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 +25 -7
- package/README.zh.md +25 -7
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/lib/client.js +830 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1226 -24
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +34 -29
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +16 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -0
- package/lib/types/service.d.ts +56 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +181 -0
- package/package.json +6 -3
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +36 -6
- package/src/client/PetSprite.tsx +42 -21
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.ts +23 -2
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/http.ts +1 -1
- package/src/index.ts +1 -1
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/loopback.ts +1 -1
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/mount-once.ts +2 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +19 -0
- package/src/service.ts +208 -0
|
@@ -159,16 +159,17 @@ describe('PetSprite custom visual (pet-center M3)', () => {
|
|
|
159
159
|
})
|
|
160
160
|
})
|
|
161
161
|
|
|
162
|
-
describe('PetSprite
|
|
163
|
-
it('
|
|
162
|
+
describe('PetSprite hide control (#1126)', () => {
|
|
163
|
+
it('does not render an intrusive corner close button and allows hiding via hover panel', () => {
|
|
164
164
|
const onHide = vi.fn()
|
|
165
165
|
const onPet = vi.fn()
|
|
166
166
|
renderPet({ onHide, onPet })
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
expect(screen.queryByTestId('pet-close')).toBeNull()
|
|
169
|
+
|
|
170
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
171
|
+
const hideBtn = screen.getByText('隐藏')
|
|
172
|
+
fireEvent.click(hideBtn)
|
|
172
173
|
|
|
173
174
|
expect(onHide).toHaveBeenCalledTimes(1)
|
|
174
175
|
expect(onPet).not.toHaveBeenCalled()
|
|
@@ -677,6 +678,35 @@ describe('PetSprite panel chrome from the voice pack (pet-center M4)', () => {
|
|
|
677
678
|
expect(screen.getByText('鱼干 3(0 分,幼鲸)')).toBeDefined()
|
|
678
679
|
})
|
|
679
680
|
})
|
|
681
|
+
describe('PetSprite gameplay entry in the hover panel (miku generalization)', () => {
|
|
682
|
+
it('renders a 玩法 action and routes clicks to the HUD channel', () => {
|
|
683
|
+
const onGameplayMenu = vi.fn()
|
|
684
|
+
renderPet({ onGameplayMenu })
|
|
685
|
+
// The entry lives in the hover panel; a closed panel shows no chrome.
|
|
686
|
+
expect(screen.queryByText('玩法')).toBeNull()
|
|
687
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
688
|
+
expect(screen.getByText('玩法')).toBeDefined()
|
|
689
|
+
fireEvent.click(screen.getByText('玩法'))
|
|
690
|
+
expect(onGameplayMenu).toHaveBeenCalledTimes(1)
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
it('omits the 玩法 action when the pet carries no gameplay HUD', () => {
|
|
694
|
+
renderPet()
|
|
695
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
696
|
+
expect(screen.queryByText('玩法')).toBeNull()
|
|
697
|
+
})
|
|
698
|
+
|
|
699
|
+
it('keeps the 玩法 entry when a voice pack hides every panel action', () => {
|
|
700
|
+
renderPet({
|
|
701
|
+
definition: { ...petDefinition(), panel: { actions: [] } },
|
|
702
|
+
onGameplayMenu: vi.fn(),
|
|
703
|
+
})
|
|
704
|
+
fireEvent.pointerOver(screen.getByRole('button', { name: '鲸鱼娘' }))
|
|
705
|
+
expect(screen.getByText('玩法')).toBeDefined()
|
|
706
|
+
expect(screen.queryByText('喂食')).toBeNull()
|
|
707
|
+
})
|
|
708
|
+
})
|
|
709
|
+
|
|
680
710
|
describe('PetSprite status decoration (pet-center M5, #567)', () => {
|
|
681
711
|
const decoration: DecorationView = {
|
|
682
712
|
apiVersion: 'x-org.linxin666.pet-center/status-decoration-v1',
|
package/src/client/PetSprite.tsx
CHANGED
|
@@ -43,6 +43,8 @@ export interface PetSpriteProps {
|
|
|
43
43
|
onHide: () => void
|
|
44
44
|
/** Persist a drag position. */
|
|
45
45
|
onDragEnd: (right: number, bottom: number) => void
|
|
46
|
+
/** Drag gesture notifications for renderers with a drag track (frames2d). */
|
|
47
|
+
onDraggingChange?: (dragging: boolean) => void
|
|
46
48
|
/** Rename the selected pet (persisted by the host). */
|
|
47
49
|
onRename: (name: string) => void
|
|
48
50
|
/** Navigate to the session one status bubble reports on. */
|
|
@@ -55,6 +57,27 @@ export interface PetSpriteProps {
|
|
|
55
57
|
* renders inside the sprite box, and the atlas load + frame loop skip.
|
|
56
58
|
*/
|
|
57
59
|
visual?: ReactNode
|
|
60
|
+
/**
|
|
61
|
+
* Gameplay overlay (miku-pet generalization): rendered inside the float
|
|
62
|
+
* container so hover containment and stacking work unchanged. Absent for
|
|
63
|
+
* pets without a gameplay block.
|
|
64
|
+
*/
|
|
65
|
+
hud?: ReactNode
|
|
66
|
+
/**
|
|
67
|
+
* Gameplay tap sink: receives the tap point as sprite-box fractions
|
|
68
|
+
* (0..1). When present the chrome reports the tap IN ADDITION to the
|
|
69
|
+
* affinity pet; the HUD decides zones/no-ops.
|
|
70
|
+
*/
|
|
71
|
+
onGameplayTap?: (fractionX: number, fractionY: number) => void
|
|
72
|
+
/**
|
|
73
|
+
* Gameplay entry (miku-pet generalization): when present the hover panel
|
|
74
|
+
* renders a 玩法 action that opens/closes the gameplay card. The chrome
|
|
75
|
+
* wires it to the HUD through the per-pet bus (openCard), mirroring the
|
|
76
|
+
* onGameplayTap sink. Absent for pets without a gameplay block.
|
|
77
|
+
*/
|
|
78
|
+
onGameplayMenu?: () => void
|
|
79
|
+
/** Disable the drag gesture (gameplay work mode blocks dragging). */
|
|
80
|
+
dragDisabled?: boolean
|
|
58
81
|
/** Locale translate seat (namespace-bound). */
|
|
59
82
|
t: TranslateNS<typeof NS>
|
|
60
83
|
}
|
|
@@ -365,6 +388,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
365
388
|
useEffect(() => () => clearHideTimer(), [])
|
|
366
389
|
|
|
367
390
|
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
|
|
391
|
+
if (props.dragDisabled === true) return
|
|
368
392
|
e.preventDefault()
|
|
369
393
|
;(e.target as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
370
394
|
const current = dragPos ?? { right: display.right, bottom: display.bottom }
|
|
@@ -377,7 +401,10 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
377
401
|
if (drag === null) return
|
|
378
402
|
const dx = e.clientX - drag.startX
|
|
379
403
|
const dy = e.clientY - drag.startY
|
|
380
|
-
if (Math.abs(dx) > 4 || Math.abs(dy) > 4)
|
|
404
|
+
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) {
|
|
405
|
+
if (!draggedRef.current) props.onDraggingChange?.(true)
|
|
406
|
+
draggedRef.current = true
|
|
407
|
+
}
|
|
381
408
|
const right = clampOffset(drag.right - dx, window.innerWidth - 40)
|
|
382
409
|
const bottom = clampOffset(drag.bottom - dy, window.innerHeight - 40)
|
|
383
410
|
setDragPos({ right, bottom })
|
|
@@ -385,6 +412,7 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
385
412
|
const onPointerUp = (): void => {
|
|
386
413
|
if (dragRef.current === null) return
|
|
387
414
|
dragRef.current = null
|
|
415
|
+
if (draggedRef.current) props.onDraggingChange?.(false)
|
|
388
416
|
if (dragPos !== null) props.onDragEnd(dragPos.right, dragPos.bottom)
|
|
389
417
|
}
|
|
390
418
|
|
|
@@ -497,10 +525,16 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
497
525
|
onPointerDown={onPointerDown}
|
|
498
526
|
onPointerMove={onPointerMove}
|
|
499
527
|
onPointerUp={onPointerUp}
|
|
500
|
-
onClick={() => {
|
|
528
|
+
onClick={(e) => {
|
|
501
529
|
// A pointer sequence that moved (dragged) still fires a trailing
|
|
502
530
|
// click; skip the pet when that happened.
|
|
503
531
|
if (draggedRef.current) return
|
|
532
|
+
if (props.onGameplayTap !== undefined && spriteRef.current !== null) {
|
|
533
|
+
const rect = spriteRef.current.getBoundingClientRect()
|
|
534
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
535
|
+
props.onGameplayTap((e.clientX - rect.left) / rect.width, (e.clientY - rect.top) / rect.height)
|
|
536
|
+
}
|
|
537
|
+
}
|
|
504
538
|
props.onPet()
|
|
505
539
|
}}
|
|
506
540
|
role="button"
|
|
@@ -508,26 +542,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
508
542
|
>
|
|
509
543
|
{props.visual}
|
|
510
544
|
</div>
|
|
511
|
-
<button
|
|
512
|
-
type="button"
|
|
513
|
-
className={styles.closeButton}
|
|
514
|
-
aria-label={panelLabel('hide', props.t('pet.hide'))}
|
|
515
|
-
title={panelLabel('hide', props.t('pet.hide'))}
|
|
516
|
-
data-testid="pet-close"
|
|
517
|
-
onPointerDown={(e) => {
|
|
518
|
-
// Keep the close control from starting a drag on the sprite.
|
|
519
|
-
e.stopPropagation()
|
|
520
|
-
}}
|
|
521
|
-
onClick={(e) => {
|
|
522
|
-
// The close control sits beside the pet button; do not pet as a
|
|
523
|
-
// side effect of closing the overlay.
|
|
524
|
-
e.stopPropagation()
|
|
525
|
-
props.onHide()
|
|
526
|
-
}}
|
|
527
|
-
>
|
|
528
|
-
×
|
|
529
|
-
</button>
|
|
530
545
|
</div>
|
|
546
|
+
{props.hud}
|
|
531
547
|
{feedback !== null && (
|
|
532
548
|
<div key={feedback.at} ref={bubbleRef} className={clsx(styles.bubble, feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet)}>
|
|
533
549
|
{feedback.text}
|
|
@@ -704,6 +720,11 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
704
720
|
{panelLabel('hide', props.t('pet.hide'))}
|
|
705
721
|
</button>
|
|
706
722
|
)}
|
|
723
|
+
{props.onGameplayMenu !== undefined && (
|
|
724
|
+
<button type="button" className={styles.action} onClick={props.onGameplayMenu}>
|
|
725
|
+
{props.t('pet.gameplay.menu')}
|
|
726
|
+
</button>
|
|
727
|
+
)}
|
|
707
728
|
</div>
|
|
708
729
|
</>
|
|
709
730
|
)}
|
|
@@ -39,6 +39,8 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
|
|
|
39
39
|
titleKey: TKey
|
|
40
40
|
/** Locale key of the line describing what this plugin's settings govern. */
|
|
41
41
|
descriptionKey: TKey
|
|
42
|
+
/** Optional rich header description rendered instead of the plain t(descriptionKey) text (the plain text stays as the tooltip). */
|
|
43
|
+
descriptionNode?: ReactNode
|
|
42
44
|
/** The card's form state: availability, writability, and what a save would do. */
|
|
43
45
|
state: CardShell
|
|
44
46
|
/** Write every staged edit. */
|
|
@@ -87,7 +89,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
87
89
|
<div className={css.headerStatic}>
|
|
88
90
|
<span className={css.headText}>
|
|
89
91
|
<span className={css.name} title={title}>{title}</span>
|
|
90
|
-
<span className={css.description} title={description}>{description}</span>
|
|
92
|
+
<span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
|
|
91
93
|
</span>
|
|
92
94
|
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
93
95
|
</div>
|
|
@@ -102,7 +104,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
|
|
|
102
104
|
>
|
|
103
105
|
<span className={css.headText}>
|
|
104
106
|
<span className={css.name} title={title}>{title}</span>
|
|
105
|
-
<span className={css.description} title={description}>{description}</span>
|
|
107
|
+
<span className={css.description} title={description}>{props.descriptionNode ?? description}</span>
|
|
106
108
|
</span>
|
|
107
109
|
{state.dirty ? <span className={css.pending} title={props.t('settings.unsaved')}>{props.t('settings.unsaved')}</span> : null}
|
|
108
110
|
<svg
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drag stream — bridges the chrome's pointer drag state onto the renderer
|
|
3
|
+
* contract's { get, subscribe } shape, so renderers with a dedicated drag
|
|
4
|
+
* track (frames2d) can follow the chrome-owned drag gesture. Dispatch is
|
|
5
|
+
* change-only, mirroring the phase stream.
|
|
6
|
+
* @module @linxin666/dsh-pet/client/drag-stream
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The renderer-facing drag stream. */
|
|
10
|
+
export interface DragStream {
|
|
11
|
+
/** Whether the chrome reports an active drag gesture. */
|
|
12
|
+
get(): boolean
|
|
13
|
+
/** Subscribe to drag state changes; returns the unsubscribe. */
|
|
14
|
+
subscribe(listener: (dragging: boolean) => void): () => void
|
|
15
|
+
/** Feed a fresh drag state; no-op when unchanged. */
|
|
16
|
+
push(dragging: boolean): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Create the stream (one per pet activation, owned by the renderer switch). */
|
|
20
|
+
export function createDragStream(): DragStream {
|
|
21
|
+
let current = false
|
|
22
|
+
const listeners = new Set<(dragging: boolean) => void>()
|
|
23
|
+
return {
|
|
24
|
+
get: () => current,
|
|
25
|
+
subscribe(listener) {
|
|
26
|
+
listeners.add(listener)
|
|
27
|
+
return () => { listeners.delete(listener) }
|
|
28
|
+
},
|
|
29
|
+
push(dragging) {
|
|
30
|
+
if (dragging === current) return
|
|
31
|
+
current = dragging
|
|
32
|
+
for (const listener of [...listeners]) listener(dragging)
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* GameplayHud behavior tests: menu pages, touch taps through the bus, the
|
|
4
|
+
* idle director, the work/sleep loops and shop purchases — all with a mock
|
|
5
|
+
* verb API and fake timers so every roll is deterministic.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
8
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
9
|
+
// The npm SDK's client half is a closure-factory bundle for the GUI's
|
|
10
|
+
// __ModuleLoader__ (not importable under vitest); provide the defineStore
|
|
11
|
+
// the pet store needs (same fake-store pattern as PetDockEntry.test.tsx).
|
|
12
|
+
vi.mock('@deepseek-ai/dsh-client-runtime/client', () => ({
|
|
13
|
+
defineStore: (spec: {
|
|
14
|
+
init: () => unknown
|
|
15
|
+
actions: Record<string, (draft: never, ...args: never[]) => void>
|
|
16
|
+
}) => ({
|
|
17
|
+
create: () => {
|
|
18
|
+
let value = spec.init()
|
|
19
|
+
const listeners = new Set<() => void>()
|
|
20
|
+
const actions: Record<string, (...args: unknown[]) => void> = {}
|
|
21
|
+
for (const [name, fn] of Object.entries(spec.actions)) {
|
|
22
|
+
actions[name] = (...args: unknown[]) => {
|
|
23
|
+
fn(value as never, ...(args as never[]))
|
|
24
|
+
// Re-identity the root so useSyncExternalStore actually re-renders
|
|
25
|
+
// (the real engine store produces a fresh state per action).
|
|
26
|
+
value = { ...(value as Record<string, unknown>) }
|
|
27
|
+
for (const listener of listeners) listener()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
getSnapshot: () => value,
|
|
32
|
+
subscribe: (listener: () => void) => {
|
|
33
|
+
listeners.add(listener)
|
|
34
|
+
return () => { listeners.delete(listener) }
|
|
35
|
+
},
|
|
36
|
+
actions,
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
}),
|
|
40
|
+
}))
|
|
41
|
+
import { GameplayHud, type GameplayApi, type GameplayBus } from './gameplay-hud.tsx'
|
|
42
|
+
import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
43
|
+
import { createDragStream } from './drag-stream.ts'
|
|
44
|
+
import { t } from './locales.ts'
|
|
45
|
+
import type { PetDefinition } from '../registry.ts'
|
|
46
|
+
import type { PetGameplayStateView, PetGameplayVerbResult, PetStateView } from '../service.ts'
|
|
47
|
+
|
|
48
|
+
const VIEW: PetGameplayStateView = {
|
|
49
|
+
stats: { hunger: 100, mood: 100, energy: 100, affection: 100 },
|
|
50
|
+
mode: null,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function gameplayView(patch?: Partial<PetGameplayStateView>): PetGameplayStateView {
|
|
54
|
+
return { stats: { ...VIEW.stats }, mode: null, ...patch }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function petDefinition(): PetDefinition {
|
|
58
|
+
return {
|
|
59
|
+
id: 'miku',
|
|
60
|
+
displayName: 'Miku',
|
|
61
|
+
description: 'frames2d gameplay pet',
|
|
62
|
+
renderer: 'frames2d',
|
|
63
|
+
cell: { width: 100, height: 100 },
|
|
64
|
+
columns: 8,
|
|
65
|
+
rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
|
|
66
|
+
atlasRows: 9,
|
|
67
|
+
tracks: {} as PetDefinition['tracks'],
|
|
68
|
+
atlasUrl: '/pet/miku/thumb/idle/idle_1_200.webp',
|
|
69
|
+
manifestUrl: '/pet/miku/pet.json',
|
|
70
|
+
frames2d: {
|
|
71
|
+
tracks: {
|
|
72
|
+
idle: { frames: ['/pet/miku/thumb/idle/idle_1_200.webp'], durations: [200], loop: true },
|
|
73
|
+
happy: { frames: ['/pet/miku/thumb/happy/happy_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
|
|
74
|
+
work: { frames: ['/pet/miku/thumb/work/work_1_200.webp'], durations: [200], loop: true },
|
|
75
|
+
success: { frames: ['/pet/miku/thumb/success/success_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
|
|
76
|
+
fail: { frames: ['/pet/miku/thumb/fail/fail_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
|
|
77
|
+
sleep: { frames: ['/pet/miku/thumb/sleep/sleep_1_200.webp'], durations: [200], loop: true },
|
|
78
|
+
eat: { frames: ['/pet/miku/thumb/eat/eat_1_200.webp'], durations: [200], loop: false, fallback: 'idle' },
|
|
79
|
+
},
|
|
80
|
+
phases: { idle: 'idle', done: 'success', failed: 'fail' },
|
|
81
|
+
},
|
|
82
|
+
gameplay: {
|
|
83
|
+
idleDirector: { intervalMs: 5000, maxMiss: 2, idleWeight: 0, acts: [{ track: 'eat', weight: 1 }] },
|
|
84
|
+
stats: {
|
|
85
|
+
hunger: { max: 100 },
|
|
86
|
+
mood: { max: 100 },
|
|
87
|
+
energy: { max: 100 },
|
|
88
|
+
affection: { max: 500, initial: 100 },
|
|
89
|
+
},
|
|
90
|
+
hitBox: { x0: 0, y0: 0, x1: 1, y1: 1 },
|
|
91
|
+
touch: {
|
|
92
|
+
zones: [
|
|
93
|
+
{ name: 'head', y0: 0, y1: 0.55, branches: [{ probability: 1, effects: [{ stat: 'affection', amount: 5 }], state: 'happy', stateMs: 3000, phrases: ['happy!'] }] },
|
|
94
|
+
],
|
|
95
|
+
clickBoost: { stat: 'mood', min: 0, max: 3 },
|
|
96
|
+
},
|
|
97
|
+
work: {
|
|
98
|
+
state: 'work', successState: 'success', failState: 'fail', tickMs: 10_000,
|
|
99
|
+
resultMs: { success: 1300, fail: 1900 }, successProbability: 0.5,
|
|
100
|
+
success: { effects: [{ currency: 'treats', amount: 1 }] },
|
|
101
|
+
},
|
|
102
|
+
sleep: { state: 'sleep', restore: { stat: 'energy', amount: 4, intervalMs: 30_000 } },
|
|
103
|
+
shop: {
|
|
104
|
+
items: [
|
|
105
|
+
{ id: 'food1', label: 'Bread', image: '/pet/miku/thumb/shop/food.webp', price: 2, currency: 'treats', effects: [{ stat: 'hunger', amount: 40 }] },
|
|
106
|
+
{ id: 'lottery', label: 'Ticket', price: 3, currency: 'treats', lottery: { currency: 'treats', tiers: [{ probability: 1, prize: 5 }] } },
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function snapshot(view: PetGameplayStateView): PetStateView {
|
|
114
|
+
return {
|
|
115
|
+
animation: 'idle',
|
|
116
|
+
phase: 'idle',
|
|
117
|
+
sessionActive: false,
|
|
118
|
+
sessions: [],
|
|
119
|
+
affinity: { points: 0, rank: '幼鲸', rankEmoji: '*', pets: 0, feeds: 0, turns: 0, petCooldown: false, feedCooldown: false },
|
|
120
|
+
display: { visible: true, size: 160, right: 24, bottom: 20 },
|
|
121
|
+
pet: { id: 'miku', displayName: 'Miku', description: '' },
|
|
122
|
+
name: 'Miku',
|
|
123
|
+
treats: { stocked: 0, max: 5 },
|
|
124
|
+
gameplay: view,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface Harness {
|
|
129
|
+
store: PetStoreInstance
|
|
130
|
+
bus: GameplayBus
|
|
131
|
+
api: GameplayApi & { touch: ReturnType<typeof vi.fn>; setMode: ReturnType<typeof vi.fn>; workTick: ReturnType<typeof vi.fn>; buy: ReturnType<typeof vi.fn> }
|
|
132
|
+
setTrack: ReturnType<typeof vi.fn>
|
|
133
|
+
drag: ReturnType<typeof createDragStream>
|
|
134
|
+
setView: (view: PetGameplayStateView) => void
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function harness(view: PetGameplayStateView = gameplayView()): Harness {
|
|
138
|
+
const store = createPetStore().create()
|
|
139
|
+
store.actions.setSnapshot(snapshot(view))
|
|
140
|
+
const bus: GameplayBus = {}
|
|
141
|
+
const setTrack = vi.fn()
|
|
142
|
+
bus.setTrack = setTrack
|
|
143
|
+
const drag = createDragStream()
|
|
144
|
+
const ok = (patch?: Partial<PetGameplayVerbResult>): PetGameplayVerbResult => ({ ok: true, ...patch })
|
|
145
|
+
const api = {
|
|
146
|
+
touch: vi.fn(async () => ok({ hit: true, state: 'happy', stateMs: 3000, phrase: 'happy!', view: gameplayView({ stats: { ...VIEW.stats, affection: 105 } }) })),
|
|
147
|
+
setMode: vi.fn(async (mode: 'work' | 'sleep' | null) => ok({ view: gameplayView({ mode }) })),
|
|
148
|
+
workTick: vi.fn(async () => ok({ outcome: 'success' as const, view: gameplayView({ mode: 'work' }) })),
|
|
149
|
+
buy: vi.fn(async () => ok({ view: gameplayView() })),
|
|
150
|
+
}
|
|
151
|
+
const setView = (next: PetGameplayStateView): void => store.actions.setSnapshot(snapshot(next))
|
|
152
|
+
render(<GameplayHud definition={petDefinition()} store={store} api={api} bus={bus} drag={drag} t={t} />)
|
|
153
|
+
return { store, bus, api, setTrack, drag, setView }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
describe('GameplayHud', () => {
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
vi.useFakeTimers()
|
|
159
|
+
})
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
cleanup()
|
|
162
|
+
vi.useRealTimers()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('opens the menu card through the bus openCard channel and shows the shop page', () => {
|
|
166
|
+
const h = harness()
|
|
167
|
+
expect(h.bus.openCard).toBeDefined()
|
|
168
|
+
act(() => { h.bus.openCard?.() })
|
|
169
|
+
expect(screen.getByText('饱食')).toBeDefined()
|
|
170
|
+
expect(screen.getByText('好感')).toBeDefined()
|
|
171
|
+
fireEvent.click(screen.getByText('商店'))
|
|
172
|
+
expect(screen.getByText('Bread')).toBeDefined()
|
|
173
|
+
fireEvent.click(screen.getByText('返回'))
|
|
174
|
+
expect(screen.getByText('打工')).toBeDefined()
|
|
175
|
+
// The wallet page is gone: no wallet action anywhere in the card.
|
|
176
|
+
expect(screen.queryByText('钱包')).toBeNull()
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
it('opens the card beside the pet, on the right when the space allows', () => {
|
|
181
|
+
// The parent float box (160x160) sits away from the viewport edges, so
|
|
182
|
+
// the right side fits the measured card width and the card anchors to
|
|
183
|
+
// the sprite's right edge, clamped to the sprite height.
|
|
184
|
+
const rect = (w: number, h: number, x: number, y: number): DOMRect => ({
|
|
185
|
+
top: y, right: x + w, bottom: y + h, left: x, width: w, height: h, x, y, toJSON: () => ({}) } as DOMRect)
|
|
186
|
+
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
|
187
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
188
|
+
if (this.className.includes('gameplayHud')) return rect(0, 0, 300, 600)
|
|
189
|
+
if (this.className.includes('gameplayCard')) return rect(240, 150, 0, 600)
|
|
190
|
+
return rect(160, 160, 300, 600)
|
|
191
|
+
})
|
|
192
|
+
const h = harness()
|
|
193
|
+
act(() => { h.bus.openCard?.() })
|
|
194
|
+
const card = document.querySelector('[data-page]') as HTMLElement
|
|
195
|
+
// Right side: sprite width + 8px gap; vertically centered (up by half
|
|
196
|
+
// the sprite height); clamped to the sprite height (scrolls inside).
|
|
197
|
+
expect(card.style.transform).toContain('translate(168px, -80px)')
|
|
198
|
+
expect(card.style.maxHeight).toBe('160px')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('flips the card to the left when the pet is parked near the right edge', () => {
|
|
202
|
+
const rect = (w: number, h: number, x: number, y: number): DOMRect => ({
|
|
203
|
+
top: y, right: x + w, bottom: y + h, left: x, width: w, height: h, x, y, toJSON: () => ({}) } as DOMRect)
|
|
204
|
+
vi.spyOn(window, 'innerWidth', 'get').mockReturnValue(1280)
|
|
205
|
+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
|
206
|
+
if (this.className.includes('gameplayHud')) return rect(0, 0, 1080, 600)
|
|
207
|
+
if (this.className.includes('gameplayCard')) return rect(240, 150, 0, 600)
|
|
208
|
+
return rect(160, 160, 1080, 600)
|
|
209
|
+
})
|
|
210
|
+
const h = harness()
|
|
211
|
+
act(() => { h.bus.openCard?.() })
|
|
212
|
+
const card = document.querySelector('[data-page]') as HTMLElement
|
|
213
|
+
// Only 40px remain to the right of the pet: the card opens to the left
|
|
214
|
+
// of the sprite (measured card width + 8px gap).
|
|
215
|
+
expect(card.style.transform).toContain('translate(-248px, -80px)')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('toggles the card through openCard and closes with the close button', () => {
|
|
219
|
+
const h = harness()
|
|
220
|
+
act(() => { h.bus.openCard?.() })
|
|
221
|
+
expect(screen.getByText('饱食')).toBeDefined()
|
|
222
|
+
// A no-argument call toggles (the panel 玩法 button is a toggle).
|
|
223
|
+
act(() => { h.bus.openCard?.() })
|
|
224
|
+
expect(screen.queryByText('饱食')).toBeNull()
|
|
225
|
+
// An explicit boolean pins the state; the card close button still works.
|
|
226
|
+
act(() => { h.bus.openCard?.(true) })
|
|
227
|
+
expect(screen.getByText('饱食')).toBeDefined()
|
|
228
|
+
fireEvent.click(screen.getByLabelText('返回'))
|
|
229
|
+
expect(screen.queryByText('饱食')).toBeNull()
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('runs a touch tap through the bus: zone verb, track hold, phrase bubble', async () => {
|
|
233
|
+
const h = harness()
|
|
234
|
+
expect(h.bus.tap).toBeDefined()
|
|
235
|
+
await act(async () => {
|
|
236
|
+
h.bus.tap!(0.5, 0.3)
|
|
237
|
+
})
|
|
238
|
+
expect(h.api.touch).toHaveBeenCalledWith('head')
|
|
239
|
+
expect(h.setTrack).toHaveBeenCalledWith('happy')
|
|
240
|
+
expect(h.store.getSnapshot().feedback?.text).toBe('happy!')
|
|
241
|
+
expect(h.store.getSnapshot().snapshot?.gameplay?.stats.affection).toBe(105)
|
|
242
|
+
// The hold releases after stateMs.
|
|
243
|
+
await act(async () => {
|
|
244
|
+
vi.advanceTimersByTime(3100)
|
|
245
|
+
})
|
|
246
|
+
expect(h.setTrack).toHaveBeenCalledWith(undefined)
|
|
247
|
+
// A tap during the hold is the plain-click boost (no zone argument).
|
|
248
|
+
await act(async () => {
|
|
249
|
+
h.bus.tap!(0.5, 0.3)
|
|
250
|
+
h.bus.tap!(0.5, 0.3)
|
|
251
|
+
})
|
|
252
|
+
await act(async () => {
|
|
253
|
+
vi.advanceTimersByTime(100)
|
|
254
|
+
})
|
|
255
|
+
// Second tap landed inside the lock window? First tap re-locked; third is the boost.
|
|
256
|
+
const calls = h.api.touch.mock.calls.map(args => args[0])
|
|
257
|
+
expect(calls[0]).toBe('head')
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('ignores taps outside the hit box', async () => {
|
|
261
|
+
const def = petDefinition()
|
|
262
|
+
def.gameplay!.hitBox = { x0: 0.2, y0: 0.2, x1: 0.4, y1: 0.4 }
|
|
263
|
+
const store = createPetStore().create()
|
|
264
|
+
store.actions.setSnapshot(snapshot(gameplayView()))
|
|
265
|
+
const api = { touch: vi.fn(), setMode: vi.fn(), workTick: vi.fn(), buy: vi.fn() } as unknown as Harness['api']
|
|
266
|
+
const bus: GameplayBus = { setTrack: vi.fn() }
|
|
267
|
+
render(<GameplayHud definition={def} store={store} api={api} bus={bus} drag={createDragStream()} t={t} />)
|
|
268
|
+
await act(async () => {
|
|
269
|
+
bus.tap!(0.9, 0.9)
|
|
270
|
+
})
|
|
271
|
+
expect(api.touch).not.toHaveBeenCalled()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('runs the idle director: weighted act rolls on the interval', async () => {
|
|
275
|
+
const h = harness()
|
|
276
|
+
await act(async () => {
|
|
277
|
+
vi.advanceTimersByTime(5000)
|
|
278
|
+
})
|
|
279
|
+
expect(h.setTrack).toHaveBeenCalledWith('eat')
|
|
280
|
+
// A non-idle phase suppresses the roll.
|
|
281
|
+
h.store.actions.setSnapshot(snapshot(gameplayView()))
|
|
282
|
+
h.setTrack.mockClear()
|
|
283
|
+
const busy = snapshot(gameplayView())
|
|
284
|
+
busy.phase = 'thinking'
|
|
285
|
+
await act(async () => {
|
|
286
|
+
h.store.actions.setSnapshot(busy)
|
|
287
|
+
})
|
|
288
|
+
await act(async () => {
|
|
289
|
+
vi.advanceTimersByTime(5000)
|
|
290
|
+
})
|
|
291
|
+
expect(h.setTrack).not.toHaveBeenCalled()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('drives the work loop: work track, adjudicated ticks, result hold', async () => {
|
|
295
|
+
const h = harness(gameplayView({ mode: 'work' }))
|
|
296
|
+
expect(h.setTrack).toHaveBeenCalledWith('work')
|
|
297
|
+
await act(async () => {
|
|
298
|
+
vi.advanceTimersByTime(10_000)
|
|
299
|
+
})
|
|
300
|
+
expect(h.api.workTick).toHaveBeenCalled()
|
|
301
|
+
expect(h.setTrack).toHaveBeenCalledWith('success')
|
|
302
|
+
// Result hold (1300ms) then back to the work loop track.
|
|
303
|
+
await act(async () => {
|
|
304
|
+
vi.advanceTimersByTime(1400)
|
|
305
|
+
})
|
|
306
|
+
expect(h.setTrack).toHaveBeenLastCalledWith('work')
|
|
307
|
+
// The tick view lands in the store (treats ride the panel ledger, not the view).
|
|
308
|
+
expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBe('work')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('holds the sleep track and wakes on drag', async () => {
|
|
312
|
+
const h = harness(gameplayView({ mode: 'sleep' }))
|
|
313
|
+
expect(h.setTrack).toHaveBeenCalledWith('sleep')
|
|
314
|
+
await act(async () => {
|
|
315
|
+
h.drag.push(true)
|
|
316
|
+
})
|
|
317
|
+
expect(h.api.setMode).toHaveBeenCalledWith(null)
|
|
318
|
+
expect(h.store.getSnapshot().snapshot?.gameplay?.mode).toBeNull()
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('buys shop items and floats the lottery prize', async () => {
|
|
322
|
+
const h = harness()
|
|
323
|
+
h.api.buy.mockResolvedValueOnce({ ok: false, error: 'insufficient-funds', view: gameplayView() })
|
|
324
|
+
h.api.buy.mockResolvedValueOnce({ ok: true, prize: { amount: 5, currency: 'treats' }, view: gameplayView() })
|
|
325
|
+
act(() => { h.bus.openCard?.() })
|
|
326
|
+
fireEvent.click(screen.getByText('商店'))
|
|
327
|
+
expect(screen.getByText('Bread')).toBeDefined()
|
|
328
|
+
await act(async () => {
|
|
329
|
+
fireEvent.click(screen.getByText('Bread'))
|
|
330
|
+
})
|
|
331
|
+
expect(h.api.buy).toHaveBeenCalledWith('food1')
|
|
332
|
+
expect(screen.getByText('小鱼干不足')).toBeDefined()
|
|
333
|
+
await act(async () => {
|
|
334
|
+
fireEvent.click(screen.getByText('Ticket'))
|
|
335
|
+
})
|
|
336
|
+
expect(screen.getByText('中奖 +5 小鱼干')).toBeDefined()
|
|
337
|
+
})
|
|
338
|
+
})
|