@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
|
@@ -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
|
+
}
|
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,9 +28,11 @@ 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
|
+
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
34
36
|
|
|
35
37
|
/** The host pet API as the browser sees it (same-origin JSON endpoints). */
|
|
36
38
|
interface PetHttpApi {
|
|
@@ -41,6 +43,10 @@ interface PetHttpApi {
|
|
|
41
43
|
setConfig(patch: Partial<PetDisplayConfig>): Promise<{ ok: true; display: PetDisplayConfig }>
|
|
42
44
|
setName(name: string): Promise<{ ok: true; name: string } | { ok: false; error: string }>
|
|
43
45
|
setPet(petId: string): Promise<{ ok: true; petId: string } | { ok: false; error: string }>
|
|
46
|
+
gameplayTouch(zone?: string): Promise<PetGameplayVerbResult>
|
|
47
|
+
gameplaySetMode(mode: 'work' | 'sleep' | null): Promise<PetGameplayVerbResult>
|
|
48
|
+
gameplayWorkTick(): Promise<PetGameplayVerbResult>
|
|
49
|
+
gameplayBuy(item: string): Promise<PetGameplayVerbResult>
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
/** Same-origin JSON fetch helper (GET without body, POST with JSON body). */
|
|
@@ -67,6 +73,10 @@ const petApi: PetHttpApi = {
|
|
|
67
73
|
setConfig: (patch) => petFetch('/api/pet/set-config', patch),
|
|
68
74
|
setName: (name) => petFetch('/api/pet/set-name', { name }),
|
|
69
75
|
setPet: (petId) => petFetch('/api/pet/set-pet', { petId }),
|
|
76
|
+
gameplayTouch: (zone) => petFetch('/api/pet/gameplay/touch', zone === undefined ? {} : { zone }),
|
|
77
|
+
gameplaySetMode: (mode) => petFetch('/api/pet/gameplay/mode', { mode }),
|
|
78
|
+
gameplayWorkTick: () => petFetch('/api/pet/gameplay/work-tick', {}),
|
|
79
|
+
gameplayBuy: (item) => petFetch('/api/pet/gameplay/buy', { item }),
|
|
70
80
|
}
|
|
71
81
|
|
|
72
82
|
/** Poll interval for the host snapshot. */
|
|
@@ -89,7 +99,7 @@ export type { PetDefinition } from '../registry.ts'
|
|
|
89
99
|
declare module '@deepseek-ai/cordis' {
|
|
90
100
|
interface Context {
|
|
91
101
|
/**
|
|
92
|
-
* Optional rc.6 compatibility binder provided by dsh-web-
|
|
102
|
+
* Optional rc.6 compatibility binder provided by dsh-web-settings;
|
|
93
103
|
* absent when that group plugin is not installed, so callers fall back to
|
|
94
104
|
* the official settings scope.
|
|
95
105
|
*/
|
|
@@ -104,6 +114,10 @@ declare module '@deepseek-ai/cordis' {
|
|
|
104
114
|
* @param ctx - client root context.
|
|
105
115
|
*/
|
|
106
116
|
export function apply(ctx: ClientContext): void {
|
|
117
|
+
// Anonymous install heartbeat (docs/telemetry.md): one beat per browser per
|
|
118
|
+
// UTC day, package name only, silent failure.
|
|
119
|
+
reportDailyHeartbeat([{ name: '@linxin666/dsh-pet' }])
|
|
120
|
+
|
|
107
121
|
ctx.effect(() => {
|
|
108
122
|
try {
|
|
109
123
|
return ctx.locale.register(NS, { zh, en })
|
|
@@ -115,6 +129,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
115
129
|
// Built-in renderers dispatch through the plugin-wide registry (pet-center
|
|
116
130
|
// M3). Registration is idempotent (id wins), so re-applies stay clean.
|
|
117
131
|
defaultPetRendererRegistry.register(live2dRenderer)
|
|
132
|
+
defaultPetRendererRegistry.register(frames2dRenderer)
|
|
118
133
|
|
|
119
134
|
const binder = ctx.get('webUiSettings') ?? ctx.settingsScope
|
|
120
135
|
const settingsScope = binder.bind<PetSettings>({ namespace: PET_SETTINGS_NS })
|
|
@@ -316,6 +331,12 @@ export function apply(ctx: ClientContext): void {
|
|
|
316
331
|
feedbackDone: () => {
|
|
317
332
|
setFeedback(null)
|
|
318
333
|
},
|
|
334
|
+
gameplay: {
|
|
335
|
+
touch: (zone) => petApi.gameplayTouch(zone),
|
|
336
|
+
setMode: (mode) => petApi.gameplaySetMode(mode),
|
|
337
|
+
workTick: () => petApi.gameplayWorkTick(),
|
|
338
|
+
buy: (item) => petApi.gameplayBuy(item),
|
|
339
|
+
},
|
|
319
340
|
})
|
|
320
341
|
|
|
321
342
|
// 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
|
}
|