@linxin666/dsh-pet 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +30 -13
- package/README.zh.md +30 -13
- 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/contracts/voice-pack-v1.schema.json +23 -28
- package/lib/client.js +781 -40
- package/lib/client.js.map +1 -1
- package/lib/index.js +1567 -482
- package/lib/types/chatter.d.ts +68 -46
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +243 -301
- 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 +49 -45
- 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.map +1 -1
- package/lib/types/client/index.js +38 -9
- 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/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/event-projection.d.ts +9 -4
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +46 -14
- 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/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- 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/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 +30 -3
- package/lib/types/service.d.ts +63 -11
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +208 -19
- package/lib/types/voice-pack.d.ts +6 -7
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +44 -47
- package/package.json +5 -2
- package/src/chatter.test.ts +68 -38
- package/src/chatter.ts +269 -316
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +68 -21
- package/src/client/PetSprite.tsx +63 -45
- 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.test.tsx +10 -1
- package/src/client/index.ts +46 -11
- 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/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/event-projection.ts +57 -16
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -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 +28 -4
- package/src/service.ts +240 -29
- package/src/voice-pack.test.ts +48 -25
- package/src/voice-pack.ts +50 -47
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gameplay HUD — the client half of the manifest 'gameplay' block (miku-pet
|
|
3
|
+
* generalization). One component owns everything the block needs: the stat
|
|
4
|
+
* bars and shop card (menu / shop pages), the touch-zone tap
|
|
5
|
+
* handling, the idle director rolls, the work and sleep loops, and the
|
|
6
|
+
* float-text toasts. It talks to the host through the injected verb API,
|
|
7
|
+
* writes results straight back into the store (the 2 s poll stays the
|
|
8
|
+
* backstop), and steers the frames2d renderer through the per-pet bus.
|
|
9
|
+
* @module @linxin666/dsh-pet/client/GameplayHud
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'
|
|
13
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
|
+
import type { PetDefinition } from '../registry.ts'
|
|
15
|
+
import type { PetGameplayVerbResult } from '../service.ts'
|
|
16
|
+
import { touchZoneAt } from '../gameplay.ts'
|
|
17
|
+
import type { PetStoreInstance } from './pet-store.ts'
|
|
18
|
+
import type { DragStream } from './drag-stream.ts'
|
|
19
|
+
import type { NS } from './locales.ts'
|
|
20
|
+
import styles from './pet.module.css'
|
|
21
|
+
|
|
22
|
+
/** The gameplay verb API the plugin apply body injects (host-authoritative). */
|
|
23
|
+
export interface GameplayApi {
|
|
24
|
+
touch: (zone?: string) => Promise<PetGameplayVerbResult>
|
|
25
|
+
setMode: (mode: 'work' | 'sleep' | null) => Promise<PetGameplayVerbResult>
|
|
26
|
+
workTick: () => Promise<PetGameplayVerbResult>
|
|
27
|
+
buy: (item: string) => Promise<PetGameplayVerbResult>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The per-pet coordination bus. The frames2d visual mount registers the
|
|
32
|
+
* track override (setTrack); the HUD registers the sprite tap handler; the
|
|
33
|
+
* chrome calls into whatever is registered. Optional chaining everywhere —
|
|
34
|
+
* either side may be mid-remount during a hot reload.
|
|
35
|
+
*/
|
|
36
|
+
export interface GameplayBus {
|
|
37
|
+
setTrack?: (track?: string) => void
|
|
38
|
+
tap?: (fx: number, fy: number) => void
|
|
39
|
+
/**
|
|
40
|
+
* Card open/close request from the chrome (the hover panel's 玩法 action):
|
|
41
|
+
* the HUD registers this, and calling it with no argument toggles the card
|
|
42
|
+
* while a boolean pins the state (same chrome -> HUD direction as tap).
|
|
43
|
+
*/
|
|
44
|
+
openCard?: (open?: boolean) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type HudPage = 'root' | 'shop'
|
|
48
|
+
|
|
49
|
+
/** One floating toast (prize / insufficient funds). */
|
|
50
|
+
interface HudFloat {
|
|
51
|
+
id: number
|
|
52
|
+
text: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let floatSeq = 0
|
|
56
|
+
|
|
57
|
+
/** The gameplay overlay for one frames2d pet that declares 'gameplay'. */
|
|
58
|
+
export function GameplayHud(props: {
|
|
59
|
+
definition: PetDefinition
|
|
60
|
+
store: PetStoreInstance
|
|
61
|
+
api: GameplayApi
|
|
62
|
+
bus: GameplayBus
|
|
63
|
+
drag: DragStream
|
|
64
|
+
t: PropsLocale<typeof NS>['t']
|
|
65
|
+
}): ReactElement | null {
|
|
66
|
+
const { definition, store, api, bus } = props
|
|
67
|
+
const ui = useSyncExternalStore(store.subscribe, store.getSnapshot)
|
|
68
|
+
const def = definition.gameplay
|
|
69
|
+
const view = ui.snapshot?.gameplay
|
|
70
|
+
const phase = ui.snapshot?.phase ?? 'idle'
|
|
71
|
+
|
|
72
|
+
const [open, setOpen] = useState(false)
|
|
73
|
+
const [page, setPage] = useState<HudPage>('root')
|
|
74
|
+
const hudRef = useRef<HTMLDivElement | null>(null)
|
|
75
|
+
const cardRef = useRef<HTMLDivElement | null>(null)
|
|
76
|
+
const [floats, setFloats] = useState<HudFloat[]>([])
|
|
77
|
+
// Mutable driver state (refs so intervals never re-arm on a poll tick).
|
|
78
|
+
const modeRef = useRef<'work' | 'sleep' | null>(view?.mode ?? null)
|
|
79
|
+
modeRef.current = view?.mode ?? null
|
|
80
|
+
const phaseRef = useRef(phase)
|
|
81
|
+
phaseRef.current = phase
|
|
82
|
+
const draggingRef = useRef(false)
|
|
83
|
+
const touchLockUntilRef = useRef(0)
|
|
84
|
+
const missRef = useRef(0)
|
|
85
|
+
const busyRef = useRef(false)
|
|
86
|
+
|
|
87
|
+
// Dynamic-key lookups (stat ids / currency ids are manifest data).
|
|
88
|
+
const tr = props.t as unknown as (key: string, values?: Record<string, string | number>) => string
|
|
89
|
+
const statLabel = (name: string): string => tr('pet.gameplay.stat.' + name)
|
|
90
|
+
const currencyLabel = (name: string): string => tr('pet.gameplay.currency.' + name)
|
|
91
|
+
|
|
92
|
+
const pushFloat = (text: string): void => {
|
|
93
|
+
const id = ++floatSeq
|
|
94
|
+
setFloats(list => [...list.slice(-3), { id, text }])
|
|
95
|
+
window.setTimeout(() => {
|
|
96
|
+
setFloats(list => list.filter(entry => entry.id !== id))
|
|
97
|
+
}, 1100)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const applyResult = (result: PetGameplayVerbResult): void => {
|
|
101
|
+
if (result.view !== undefined) store.actions.setGameplayView(result.view)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Tap handling (registered on the bus; PetSprite reports sprite-box
|
|
105
|
+
// fractions). Sleep wakes on tap; work blocks taps; a held touch
|
|
106
|
+
// animation turns taps into the plain-click boost.
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (def === undefined) return undefined
|
|
109
|
+
bus.tap = (fx, fy) => {
|
|
110
|
+
if (modeRef.current === 'sleep') {
|
|
111
|
+
void api.setMode(null).then(applyResult, () => undefined)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (modeRef.current === 'work') return
|
|
115
|
+
const box = def.hitBox ?? { x0: 0, y0: 0, x1: 1, y1: 1 }
|
|
116
|
+
const hx = (fx - box.x0) / (box.x1 - box.x0)
|
|
117
|
+
const hy = (fy - box.y0) / (box.y1 - box.y0)
|
|
118
|
+
if (hx < 0 || hx > 1 || hy < 0 || hy > 1) return
|
|
119
|
+
if (Date.now() < touchLockUntilRef.current) {
|
|
120
|
+
void api.touch().then(applyResult, () => undefined)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const zone = def.touch === undefined ? undefined : touchZoneAt(def.touch, hy)
|
|
124
|
+
if (zone === undefined) return
|
|
125
|
+
void api.touch(zone.name).then((result) => {
|
|
126
|
+
applyResult(result)
|
|
127
|
+
if (result.hit !== true) return
|
|
128
|
+
if (result.state !== undefined) {
|
|
129
|
+
bus.setTrack?.(result.state)
|
|
130
|
+
const holdMs = result.stateMs ?? 3000
|
|
131
|
+
touchLockUntilRef.current = Date.now() + holdMs
|
|
132
|
+
window.setTimeout(() => {
|
|
133
|
+
if (Date.now() >= touchLockUntilRef.current) bus.setTrack?.(undefined)
|
|
134
|
+
}, holdMs)
|
|
135
|
+
}
|
|
136
|
+
if (result.phrase !== undefined) {
|
|
137
|
+
store.actions.setFeedback({ text: result.phrase, kind: 'none', at: Date.now() })
|
|
138
|
+
}
|
|
139
|
+
}, () => undefined)
|
|
140
|
+
}
|
|
141
|
+
return () => { bus.tap = undefined }
|
|
142
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one registration per pet definition
|
|
143
|
+
}, [definition.id, def])
|
|
144
|
+
|
|
145
|
+
// Panel entry (chrome -> HUD): the hover panel's 玩法 button drives the
|
|
146
|
+
// card through this channel so the chrome never needs the card's state.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
bus.openCard = (next?: boolean) => {
|
|
149
|
+
setOpen(prev => next ?? !prev)
|
|
150
|
+
setPage('root')
|
|
151
|
+
}
|
|
152
|
+
return () => { bus.openCard = undefined }
|
|
153
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one registration per bus
|
|
154
|
+
}, [bus])
|
|
155
|
+
|
|
156
|
+
// Card placement: the card must not enter the bubble band above the sprite
|
|
157
|
+
// or the hover-panel band below it, so it opens beside the sprite instead
|
|
158
|
+
// of growing upward from the pet's feet. It is vertically centered on the
|
|
159
|
+
// sprite box, clamped to the sprite's height, and the side is chosen by
|
|
160
|
+
// the space available to each side (right first; a pet parked near the
|
|
161
|
+
// right viewport edge flips the card to the left).
|
|
162
|
+
useLayoutEffect(() => {
|
|
163
|
+
if (!open) return undefined
|
|
164
|
+
const hud = hudRef.current
|
|
165
|
+
const card = cardRef.current
|
|
166
|
+
if (hud === null || card === null) return undefined
|
|
167
|
+
const place = (): void => {
|
|
168
|
+
const box = hud.parentElement?.getBoundingClientRect()
|
|
169
|
+
if (box === undefined) return
|
|
170
|
+
const gap = 8
|
|
171
|
+
const width = card.getBoundingClientRect().width
|
|
172
|
+
const toRight = window.innerWidth - box.right
|
|
173
|
+
const x = toRight >= width + gap ? box.width + gap : -(width + gap)
|
|
174
|
+
card.style.transform = 'translate(' + Math.round(x) + 'px, ' + Math.round(-box.height / 2) + 'px) translateY(50%)'
|
|
175
|
+
card.style.maxHeight = Math.round(box.height) + 'px'
|
|
176
|
+
}
|
|
177
|
+
place()
|
|
178
|
+
window.addEventListener('resize', place)
|
|
179
|
+
return () => { window.removeEventListener('resize', place) }
|
|
180
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one placement per open/page change
|
|
181
|
+
}, [open, page])
|
|
182
|
+
|
|
183
|
+
// Drag gestures wake a sleeping pet (miku behavior); the drag stream also
|
|
184
|
+
// feeds the idle director's suppression check.
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
return props.drag.subscribe((dragging) => {
|
|
187
|
+
draggingRef.current = dragging
|
|
188
|
+
if (dragging && modeRef.current === 'sleep') {
|
|
189
|
+
void api.setMode(null).then(applyResult, () => undefined)
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one subscription per drag stream
|
|
193
|
+
}, [props.drag])
|
|
194
|
+
|
|
195
|
+
// Idle director: weighted rolls between staying idle and playing an act.
|
|
196
|
+
// Runs only while the phase mapping owns the visual (idle phase, no mode,
|
|
197
|
+
// no drag, no held touch animation); maxMiss forces an act after too many
|
|
198
|
+
// idle rolls in a row.
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
const director = def?.idleDirector
|
|
201
|
+
if (def === undefined || director === undefined) return undefined
|
|
202
|
+
const total = director.idleWeight + director.acts.reduce((sum, act) => sum + act.weight, 0)
|
|
203
|
+
if (total <= 0) return undefined
|
|
204
|
+
const timer = window.setInterval(() => {
|
|
205
|
+
if (phaseRef.current !== 'idle') return
|
|
206
|
+
if (modeRef.current !== null || draggingRef.current) return
|
|
207
|
+
if (Date.now() < touchLockUntilRef.current) return
|
|
208
|
+
let pickedAct: { track: string; weight: number; phrases?: string[] } | undefined
|
|
209
|
+
if (missRef.current >= director.maxMiss) {
|
|
210
|
+
// Forced act: pick among the acts only.
|
|
211
|
+
const actTotal = director.acts.reduce((sum, act) => sum + act.weight, 0)
|
|
212
|
+
let actRoll = Math.random() * actTotal
|
|
213
|
+
for (const act of director.acts) {
|
|
214
|
+
actRoll -= act.weight
|
|
215
|
+
if (actRoll < 0) { pickedAct = act; break }
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
let roll = Math.random() * total
|
|
219
|
+
for (const act of director.acts) {
|
|
220
|
+
roll -= act.weight
|
|
221
|
+
if (roll < 0) { pickedAct = act; break }
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (pickedAct === undefined) {
|
|
225
|
+
missRef.current += 1
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
missRef.current = 0
|
|
229
|
+
bus.setTrack?.(pickedAct.track)
|
|
230
|
+
// Acts with a phrase pool speak one line while they play (miku parity).
|
|
231
|
+
if (pickedAct.phrases !== undefined && pickedAct.phrases.length > 0) {
|
|
232
|
+
const phrase = pickedAct.phrases[Math.floor(Math.random() * pickedAct.phrases.length)]!
|
|
233
|
+
store.actions.setFeedback({ text: phrase, kind: 'none', at: Date.now() })
|
|
234
|
+
}
|
|
235
|
+
}, director.intervalMs)
|
|
236
|
+
return () => window.clearInterval(timer)
|
|
237
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one director per pet definition
|
|
238
|
+
}, [definition.id, def])
|
|
239
|
+
|
|
240
|
+
// Work loop: hold the work track, adjudicate one round per tick, play the
|
|
241
|
+
// result track for its hold window, then resume. Leaving the mode
|
|
242
|
+
// releases the override so the phase mapping takes over.
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
const work = def?.work
|
|
245
|
+
if (def === undefined || work === undefined || view?.mode !== 'work') return undefined
|
|
246
|
+
bus.setTrack?.(work.state)
|
|
247
|
+
let resultTimer = 0
|
|
248
|
+
const timer = window.setInterval(() => {
|
|
249
|
+
if (busyRef.current) return
|
|
250
|
+
busyRef.current = true
|
|
251
|
+
void api.workTick().then((result) => {
|
|
252
|
+
busyRef.current = false
|
|
253
|
+
applyResult(result)
|
|
254
|
+
if (result.ok !== true || result.outcome === undefined) return
|
|
255
|
+
const resultTrack = result.outcome === 'success' ? work.successState : work.failState
|
|
256
|
+
const hold = result.outcome === 'success' ? work.resultMs?.success ?? 1300 : work.resultMs?.fail ?? 1900
|
|
257
|
+
bus.setTrack?.(resultTrack)
|
|
258
|
+
resultTimer = window.setTimeout(() => {
|
|
259
|
+
if (modeRef.current === 'work') bus.setTrack?.(work.state)
|
|
260
|
+
}, hold)
|
|
261
|
+
}, () => { busyRef.current = false })
|
|
262
|
+
}, work.tickMs)
|
|
263
|
+
return () => {
|
|
264
|
+
window.clearInterval(timer)
|
|
265
|
+
window.clearTimeout(resultTimer)
|
|
266
|
+
bus.setTrack?.(undefined)
|
|
267
|
+
}
|
|
268
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode value
|
|
269
|
+
}, [definition.id, def, view?.mode])
|
|
270
|
+
|
|
271
|
+
// Sleep loop: hold the sleep track; restore is host-side (lazy settle).
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
const sleep = def?.sleep
|
|
274
|
+
if (def === undefined || sleep === undefined || view?.mode !== 'sleep') return undefined
|
|
275
|
+
bus.setTrack?.(sleep.state)
|
|
276
|
+
return () => bus.setTrack?.(undefined)
|
|
277
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode value
|
|
278
|
+
}, [definition.id, def, view?.mode])
|
|
279
|
+
|
|
280
|
+
if (def === undefined || view === undefined) return null
|
|
281
|
+
|
|
282
|
+
const mode = view.mode
|
|
283
|
+
const stats = def.stats ?? {}
|
|
284
|
+
const shop = def.shop
|
|
285
|
+
|
|
286
|
+
const buy = (itemId: string): void => {
|
|
287
|
+
void api.buy(itemId).then((result) => {
|
|
288
|
+
applyResult(result)
|
|
289
|
+
if (result.ok !== true) {
|
|
290
|
+
if (result.error === 'insufficient-funds') {
|
|
291
|
+
const item = shop?.items.find(entry => entry.id === itemId)
|
|
292
|
+
pushFloat(tr('pet.gameplay.insufficient', { currency: currencyLabel(item?.currency ?? 'treats') }))
|
|
293
|
+
}
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (result.prize !== undefined) {
|
|
297
|
+
pushFloat(tr('pet.gameplay.prize', { amount: result.prize.amount, currency: currencyLabel(result.prize.currency) }))
|
|
298
|
+
}
|
|
299
|
+
}, () => undefined)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const setMode = (next: 'work' | 'sleep' | null): void => {
|
|
303
|
+
void api.setMode(next).then(applyResult, () => undefined)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return (
|
|
307
|
+
<div ref={hudRef} className={styles.gameplayHud} data-dsh-pet-gameplay={definition.id}>
|
|
308
|
+
{floats.map(entry => (
|
|
309
|
+
<div key={entry.id} className={styles.gameplayFloat}>{entry.text}</div>
|
|
310
|
+
))}
|
|
311
|
+
{mode !== null && (
|
|
312
|
+
<div className={styles.gameplayModeChip}>
|
|
313
|
+
{tr(mode === 'work' ? 'pet.gameplay.working' : 'pet.gameplay.sleeping')}
|
|
314
|
+
</div>
|
|
315
|
+
)}
|
|
316
|
+
{open && (
|
|
317
|
+
<div ref={cardRef} className={styles.gameplayCard} data-page={page}>
|
|
318
|
+
{page === 'root' && (
|
|
319
|
+
<>
|
|
320
|
+
<div className={styles.gameplayBars}>
|
|
321
|
+
{Object.entries(stats).map(([name, stat]) => {
|
|
322
|
+
const value = view.stats[name] ?? 0
|
|
323
|
+
return (
|
|
324
|
+
<div key={name} className={styles.gameplayBarRow} title={statLabel(name) + ' ' + String(value) + '/' + String(stat.max)}>
|
|
325
|
+
<span className={styles.gameplayBarLabel}>{statLabel(name)}</span>
|
|
326
|
+
<span className={styles.gameplayBarTrack}>
|
|
327
|
+
<span
|
|
328
|
+
className={styles.gameplayBarFill}
|
|
329
|
+
style={{ width: Math.round((value / stat.max) * 100) + '%' }}
|
|
330
|
+
/>
|
|
331
|
+
</span>
|
|
332
|
+
</div>
|
|
333
|
+
)
|
|
334
|
+
})}
|
|
335
|
+
</div>
|
|
336
|
+
<div className={styles.gameplayActions}>
|
|
337
|
+
{def.work !== undefined && (
|
|
338
|
+
<button
|
|
339
|
+
type="button"
|
|
340
|
+
className={styles.action}
|
|
341
|
+
onClick={() => setMode(mode === 'work' ? null : 'work')}
|
|
342
|
+
>
|
|
343
|
+
{tr(mode === 'work' ? 'pet.gameplay.stopWork' : 'pet.gameplay.work')}
|
|
344
|
+
</button>
|
|
345
|
+
)}
|
|
346
|
+
{def.sleep !== undefined && (
|
|
347
|
+
<button
|
|
348
|
+
type="button"
|
|
349
|
+
className={styles.action}
|
|
350
|
+
onClick={() => setMode(mode === 'sleep' ? null : 'sleep')}
|
|
351
|
+
>
|
|
352
|
+
{tr(mode === 'sleep' ? 'pet.gameplay.wake' : 'pet.gameplay.sleep')}
|
|
353
|
+
</button>
|
|
354
|
+
)}
|
|
355
|
+
{shop !== undefined && (
|
|
356
|
+
<button type="button" className={styles.action} onClick={() => setPage('shop')}>
|
|
357
|
+
{tr('pet.gameplay.shop')}
|
|
358
|
+
</button>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
</>
|
|
362
|
+
)}
|
|
363
|
+
{page === 'shop' && shop !== undefined && (
|
|
364
|
+
<>
|
|
365
|
+
<div className={styles.gameplayShopItems}>
|
|
366
|
+
{shop.items.map(item => (
|
|
367
|
+
<button
|
|
368
|
+
key={item.id}
|
|
369
|
+
type="button"
|
|
370
|
+
className={styles.gameplayShopItem}
|
|
371
|
+
onClick={() => buy(item.id)}
|
|
372
|
+
title={item.label + ' — ' + String(item.price) + ' ' + currencyLabel(item.currency)}
|
|
373
|
+
>
|
|
374
|
+
{item.image !== undefined && (
|
|
375
|
+
<img className={styles.gameplayShopItemImage} src={item.image} alt="" draggable={false} />
|
|
376
|
+
)}
|
|
377
|
+
<span className={styles.gameplayShopItemLabel}>{item.label}</span>
|
|
378
|
+
<span className={styles.gameplayShopItemPrice}>
|
|
379
|
+
{item.price} {currencyLabel(item.currency)}
|
|
380
|
+
</span>
|
|
381
|
+
</button>
|
|
382
|
+
))}
|
|
383
|
+
</div>
|
|
384
|
+
<div className={styles.gameplayActions}>
|
|
385
|
+
<button type="button" className={styles.action} onClick={() => setPage('root')}>
|
|
386
|
+
{tr('pet.gameplay.back')}
|
|
387
|
+
</button>
|
|
388
|
+
</div>
|
|
389
|
+
</>
|
|
390
|
+
)}
|
|
391
|
+
<button
|
|
392
|
+
type="button"
|
|
393
|
+
className={styles.gameplayClose}
|
|
394
|
+
aria-label={tr('pet.gameplay.back')}
|
|
395
|
+
onClick={() => setOpen(false)}
|
|
396
|
+
>
|
|
397
|
+
×
|
|
398
|
+
</button>
|
|
399
|
+
</div>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
@@ -110,7 +110,16 @@ function fakeContext(): FakeClientLifecycle {
|
|
|
110
110
|
},
|
|
111
111
|
register: () => () => {},
|
|
112
112
|
},
|
|
113
|
-
sessions:
|
|
113
|
+
sessions: {
|
|
114
|
+
list: {
|
|
115
|
+
getSnapshot: () => ({ current: undefined, byId: {} }),
|
|
116
|
+
subscribe: (listener: () => void) => {
|
|
117
|
+
const set = new Set<() => void>([listener])
|
|
118
|
+
return () => { set.delete(listener) }
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
open: () => {},
|
|
122
|
+
},
|
|
114
123
|
} as unknown as ClientContext
|
|
115
124
|
let disposed = false
|
|
116
125
|
const lifecycle: FakeClientLifecycle = {
|
package/src/client/index.ts
CHANGED
|
@@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
|
19
19
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
20
20
|
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
|
21
21
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
22
|
-
import type { PetInteractResult, PetStateView } from '../service.ts'
|
|
22
|
+
import type { PetGameplayVerbResult, PetInteractResult, PetStateView } from '../service.ts'
|
|
23
23
|
import type { PetInteraction } from '../affinity.ts'
|
|
24
24
|
import type { PetDefinition } from '../registry.ts'
|
|
25
25
|
import { createElement } from 'react'
|
|
@@ -28,6 +28,7 @@ import { createPetStore, type PetStoreInstance } from './pet-store.ts'
|
|
|
28
28
|
import { PetDockEntry, type PetInjected } from './PetDockEntry.tsx'
|
|
29
29
|
import { defaultPetRendererRegistry } from './renderers/registry.ts'
|
|
30
30
|
import { live2dRenderer } from './renderers/live2d.ts'
|
|
31
|
+
import { frames2dRenderer } from './renderers/frames2d.ts'
|
|
31
32
|
import { registerPetUiTeardown, takeoverPetUiTeardown } from './ui-teardown.ts'
|
|
32
33
|
import { PetSettingsSection, PetSettingsCardController, type PetSettings } from './PetSettingsCard.tsx'
|
|
33
34
|
import { NS, en, zh, t } from './locales.ts'
|
|
@@ -35,13 +36,18 @@ import { reportDailyHeartbeat } from './telemetry.ts'
|
|
|
35
36
|
|
|
36
37
|
/** The host pet API as the browser sees it (same-origin JSON endpoints). */
|
|
37
38
|
interface PetHttpApi {
|
|
38
|
-
|
|
39
|
+
/** Poll the host snapshot; the GUI's current session id rides the query. */
|
|
40
|
+
state(currentSessionId?: string): Promise<PetStateView>
|
|
39
41
|
pets(): Promise<PetDefinition[]>
|
|
40
42
|
interact(kind: PetInteraction): Promise<PetInteractResult>
|
|
41
43
|
setVisible(visible: boolean): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
42
44
|
setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
43
45
|
setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }>
|
|
44
46
|
setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
|
|
47
|
+
gameplayTouch(zone?: string): Promise<PetGameplayVerbResult>
|
|
48
|
+
gameplaySetMode(mode: 'work' | 'sleep' | null): Promise<PetGameplayVerbResult>
|
|
49
|
+
gameplayWorkTick(): Promise<PetGameplayVerbResult>
|
|
50
|
+
gameplayBuy(item: string): Promise<PetGameplayVerbResult>
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
/** Same-origin JSON fetch helper (GET without body, POST with JSON body). */
|
|
@@ -61,13 +67,18 @@ async function petFetch<T>(path: string, body?: unknown): Promise<T> {
|
|
|
61
67
|
|
|
62
68
|
/** The live host API instance (always defined; failures surface per call). */
|
|
63
69
|
const petApi: PetHttpApi = {
|
|
64
|
-
state: () => petFetch('/api/pet/state'
|
|
70
|
+
state: (currentSessionId) => petFetch('/api/pet/state'
|
|
71
|
+
+ (currentSessionId === undefined ? '' : '?current=' + encodeURIComponent(currentSessionId))),
|
|
65
72
|
pets: () => petFetch('/api/pet/pets'),
|
|
66
73
|
interact: (kind) => petFetch('/api/pet/interact', { kind }),
|
|
67
74
|
setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
|
|
68
75
|
setConfig: (patch) => petFetch('/api/pet/set-config', patch),
|
|
69
76
|
setName: (name) => petFetch('/api/pet/set-name', { name }),
|
|
70
77
|
setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
|
|
78
|
+
gameplayTouch: (zone) => petFetch('/api/pet/gameplay/touch', zone === undefined ? {} : { zone }),
|
|
79
|
+
gameplaySetMode: (mode) => petFetch('/api/pet/gameplay/mode', { mode }),
|
|
80
|
+
gameplayWorkTick: () => petFetch('/api/pet/gameplay/work-tick', {}),
|
|
81
|
+
gameplayBuy: (item) => petFetch('/api/pet/gameplay/buy', { item }),
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/** Poll interval for the host snapshot. */
|
|
@@ -120,6 +131,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
120
131
|
// Built-in renderers dispatch through the plugin-wide registry (pet-center
|
|
121
132
|
// M3). Registration is idempotent (id wins), so re-applies stay clean.
|
|
122
133
|
defaultPetRendererRegistry.register(live2dRenderer)
|
|
134
|
+
defaultPetRendererRegistry.register(frames2dRenderer)
|
|
123
135
|
|
|
124
136
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
125
137
|
const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
|
|
@@ -187,6 +199,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
187
199
|
const setState = petStore.actions.setState
|
|
188
200
|
const setFeedback = petStore.actions.setFeedback
|
|
189
201
|
|
|
202
|
+
// Clicking a session bubble jumps the GUI to that session; the same
|
|
203
|
+
// sessions face reports which session the user is currently on, so the
|
|
204
|
+
// host can lead the bubble stack with it. A bubble can outlive its
|
|
205
|
+
// disposed session by one poll tick, and the sessions service fails
|
|
206
|
+
// loud on unknown ids, so consult the live list first. The pet's type
|
|
207
|
+
// program also loads the host-side dsh-session package through the
|
|
208
|
+
// service types, whose Context merge declares a different 'sessions'
|
|
209
|
+
// face; pin the browser runtime's outward face here.
|
|
210
|
+
const sessions = ctx.sessions as unknown as ISessions
|
|
211
|
+
const currentSessionId = (): string | undefined => {
|
|
212
|
+
const current = sessions.list.getSnapshot().current
|
|
213
|
+
return current === undefined ? undefined : String(current)
|
|
214
|
+
}
|
|
215
|
+
|
|
190
216
|
// The registry list is fetched lazily with retries baked into the poll
|
|
191
217
|
// cycle: until it lands, the dock entry renders nothing and every 2s
|
|
192
218
|
// tick tries again. After it lands, one list feeds both the sprite and
|
|
@@ -208,7 +234,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
208
234
|
}
|
|
209
235
|
const seq = stateSeq + 1
|
|
210
236
|
stateSeq = seq
|
|
211
|
-
petApi.state().then((snapshot) => {
|
|
237
|
+
petApi.state(currentSessionId()).then((snapshot) => {
|
|
212
238
|
if (seq !== stateSeq) return
|
|
213
239
|
setSnapshot(snapshot)
|
|
214
240
|
}, () => {
|
|
@@ -251,13 +277,16 @@ export function apply(ctx: ClientContext): void {
|
|
|
251
277
|
}
|
|
252
278
|
}, 'pet: poll')
|
|
253
279
|
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
280
|
+
// A current-session switch should re-order the bubble stack right away,
|
|
281
|
+
// not on the next 2s tick. Poll only while the tab is visible, as the
|
|
282
|
+
// poll loop itself does.
|
|
283
|
+
const disposeSessionWatch = ctx.effect(() => {
|
|
284
|
+
const unsubscribe = sessions.list.subscribe(() => {
|
|
285
|
+
if (document.visibilityState === 'visible') pollNow()
|
|
286
|
+
})
|
|
287
|
+
return unsubscribe
|
|
288
|
+
}, 'pet: current-session watch')
|
|
289
|
+
|
|
261
290
|
const openSession = (sessionId: string): void => {
|
|
262
291
|
const list = sessions.list.getSnapshot()
|
|
263
292
|
if (list.byId[sessionId as SessionId] === undefined) return
|
|
@@ -321,6 +350,12 @@ export function apply(ctx: ClientContext): void {
|
|
|
321
350
|
feedbackDone: () => {
|
|
322
351
|
setFeedback(null)
|
|
323
352
|
},
|
|
353
|
+
gameplay: {
|
|
354
|
+
touch: (zone) => petApi.gameplayTouch(zone),
|
|
355
|
+
setMode: (mode) => petApi.gameplaySetMode(mode),
|
|
356
|
+
workTick: () => petApi.gameplayWorkTick(),
|
|
357
|
+
buy: (item) => petApi.gameplayBuy(item),
|
|
358
|
+
},
|
|
324
359
|
})
|
|
325
360
|
|
|
326
361
|
// The pet is host-global (its state/display/interactions have no session
|
package/src/client/locales.ts
CHANGED
|
@@ -24,6 +24,24 @@ export const zh = {
|
|
|
24
24
|
'pet.live2d.vendor-missing': 'Live2D 组件缺失,请升级宠物插件。',
|
|
25
25
|
'pet.live2d.load-failed': 'Live2D 模型加载失败,请检查该宠物目录的完整性。',
|
|
26
26
|
'pet.openSessionHint': '点击跳转到对应会话',
|
|
27
|
+
// 玩法 HUD(miku-pet 泛化:属性条 / 打工 / 睡觉 / 商店;统一小鱼干经济)。
|
|
28
|
+
'pet.gameplay.menu': '玩法',
|
|
29
|
+
'pet.gameplay.work': '打工',
|
|
30
|
+
'pet.gameplay.stopWork': '收工',
|
|
31
|
+
'pet.gameplay.sleep': '睡觉',
|
|
32
|
+
'pet.gameplay.wake': '起床',
|
|
33
|
+
'pet.gameplay.shop': '商店',
|
|
34
|
+
'pet.gameplay.back': '返回',
|
|
35
|
+
'pet.gameplay.buy': '购买',
|
|
36
|
+
'pet.gameplay.insufficient': '{currency}不足',
|
|
37
|
+
'pet.gameplay.prize': '中奖 +{amount} {currency}',
|
|
38
|
+
'pet.gameplay.working': '打工中',
|
|
39
|
+
'pet.gameplay.sleeping': '睡觉中',
|
|
40
|
+
'pet.gameplay.stat.hunger': '饱食',
|
|
41
|
+
'pet.gameplay.stat.mood': '心情',
|
|
42
|
+
'pet.gameplay.stat.energy': '精力',
|
|
43
|
+
'pet.gameplay.stat.affection': '好感',
|
|
44
|
+
'pet.gameplay.currency.treats': '小鱼干',
|
|
27
45
|
'pet.moreSessions': '展开其余 {n} 个会话的气泡',
|
|
28
46
|
'pet.collapseSessions': '收起会话气泡',
|
|
29
47
|
// 一级设置页(settings.section 席位)。
|
|
@@ -79,6 +97,24 @@ export const en = {
|
|
|
79
97
|
'pet.live2d.vendor-missing': 'The Live2D component is missing; please update the pet plugin.',
|
|
80
98
|
'pet.live2d.load-failed': 'The Live2D model failed to load; check the pet directory is complete.',
|
|
81
99
|
'pet.openSessionHint': 'Click to jump to this session',
|
|
100
|
+
// Gameplay HUD (miku-pet generalization: stat bars / work / sleep / shop; unified treats economy).
|
|
101
|
+
'pet.gameplay.menu': 'Play',
|
|
102
|
+
'pet.gameplay.work': 'Work',
|
|
103
|
+
'pet.gameplay.stopWork': 'Stop work',
|
|
104
|
+
'pet.gameplay.sleep': 'Sleep',
|
|
105
|
+
'pet.gameplay.wake': 'Wake up',
|
|
106
|
+
'pet.gameplay.shop': 'Shop',
|
|
107
|
+
'pet.gameplay.back': 'Back',
|
|
108
|
+
'pet.gameplay.buy': 'Buy',
|
|
109
|
+
'pet.gameplay.insufficient': 'Not enough {currency}',
|
|
110
|
+
'pet.gameplay.prize': 'Prize +{amount} {currency}',
|
|
111
|
+
'pet.gameplay.working': 'Working',
|
|
112
|
+
'pet.gameplay.sleeping': 'Sleeping',
|
|
113
|
+
'pet.gameplay.stat.hunger': 'Hunger',
|
|
114
|
+
'pet.gameplay.stat.mood': 'Mood',
|
|
115
|
+
'pet.gameplay.stat.energy': 'Energy',
|
|
116
|
+
'pet.gameplay.stat.affection': 'Affection',
|
|
117
|
+
'pet.gameplay.currency.treats': 'Treats',
|
|
82
118
|
'pet.moreSessions': 'Expand {n} more session bubbles',
|
|
83
119
|
'pet.collapseSessions': 'Collapse session bubbles',
|
|
84
120
|
// First-level settings section (the `settings.section` seat).
|
package/src/client/pet-store.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
10
10
|
import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-runtime/client'
|
|
11
|
-
import type { PetStateView } from '../service.ts'
|
|
11
|
+
import type { PetGameplayStateView, PetStateView } from '../service.ts'
|
|
12
12
|
import type { PetInteraction } from '../affinity.ts'
|
|
13
13
|
import type { PetDefinition } from '../registry.ts'
|
|
14
14
|
|
|
@@ -46,6 +46,12 @@ export type PetUiActions = {
|
|
|
46
46
|
setState: (draft: PetUiState, state: PetUiState['state'], error: string | null) => void
|
|
47
47
|
/** Show a reaction bubble. */
|
|
48
48
|
setFeedback: (draft: PetUiState, feedback: PetFeedback | null) => void
|
|
49
|
+
/**
|
|
50
|
+
* Patch the gameplay slice of the snapshot (verb write-back), so the HUD
|
|
51
|
+
* reflects a touch/mode/buy result immediately instead of waiting one
|
|
52
|
+
* poll tick (2 s).
|
|
53
|
+
*/
|
|
54
|
+
setGameplayView: (draft: PetUiState, view: PetGameplayStateView) => void
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
/** Create the pet store handle (apply world only; never module-level). */
|
|
@@ -74,6 +80,9 @@ export function createPetStore(): EngineStoreHandle<PetUiState, PetUiActions> {
|
|
|
74
80
|
setFeedback: (draft, feedback) => {
|
|
75
81
|
draft.feedback = feedback
|
|
76
82
|
},
|
|
83
|
+
setGameplayView: (draft, view) => {
|
|
84
|
+
if (draft.snapshot !== null) draft.snapshot = { ...draft.snapshot, gameplay: view }
|
|
85
|
+
},
|
|
77
86
|
},
|
|
78
87
|
})
|
|
79
88
|
}
|