@linxin666/dsh-pet 0.3.17 → 0.3.18
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 +16 -1
- package/README.zh.md +16 -1
- package/assets/blue-throated-bee-eater/pet.json +204 -0
- package/assets/blue-throated-bee-eater/previews/failed.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/idle.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/jumping.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/review.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/running-left.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/running-right.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/running.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/waiting.gif +0 -0
- package/assets/blue-throated-bee-eater/previews/waving.gif +0 -0
- package/assets/blue-throated-bee-eater/spritesheet.webp +0 -0
- package/assets/blue-throated-bee-eater/voice.json +13 -0
- package/contracts/pet-manifest-v2.schema.json +84 -0
- package/lib/client.js +176 -27
- package/lib/client.js.map +1 -1
- package/lib/index.js +170 -6
- package/lib/types/client/gameplay-hud.d.ts +2 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -1
- package/lib/types/client/gameplay-hud.js +65 -12
- package/lib/types/client/index.d.ts +0 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +25 -2
- package/lib/types/client/locales.d.ts +7 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +7 -3
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/Frames2dVisualMount.js +5 -1
- package/lib/types/client/renderers/frames2d.d.ts +25 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
- package/lib/types/client/renderers/frames2d.js +84 -7
- package/lib/types/manifest-v2.d.ts +45 -0
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +139 -1
- package/lib/types/persist.d.ts +1 -1
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +1 -1
- package/lib/types/registry.d.ts +22 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +45 -1
- package/package.json +2 -1
- package/src/client/gameplay-hud.test.tsx +64 -0
- package/src/client/gameplay-hud.tsx +109 -21
- package/src/client/index.ts +26 -2
- package/src/client/locales.ts +7 -3
- package/src/client/pet.module.css +31 -1
- package/src/client/renderers/Frames2dVisualMount.tsx +5 -1
- package/src/client/renderers/frames2d.test.ts +43 -0
- package/src/client/renderers/frames2d.ts +100 -7
- package/src/manifest-v2.ts +167 -1
- package/src/persist.ts +1 -1
- package/src/registry.test.ts +19 -5
- package/src/registry.ts +66 -1
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactElement } from 'react'
|
|
13
13
|
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
|
-
import type { PetDefinition } from '../registry.ts'
|
|
14
|
+
import type { PetDefinition, PetSkinDefinition } from '../registry.ts'
|
|
15
15
|
import type { PetGameplayVerbResult } from '../service.ts'
|
|
16
16
|
import { touchZoneAt } from '../gameplay.ts'
|
|
17
17
|
import type { PetStoreInstance } from './pet-store.ts'
|
|
@@ -35,6 +35,8 @@ export interface GameplayApi {
|
|
|
35
35
|
*/
|
|
36
36
|
export interface GameplayBus {
|
|
37
37
|
setTrack?: (track?: string) => void
|
|
38
|
+
/** Swap the pet's base idle track (skin switch); undefined restores default. */
|
|
39
|
+
setIdleTrack?: (track?: string) => void
|
|
38
40
|
tap?: (fx: number, fy: number) => void
|
|
39
41
|
/**
|
|
40
42
|
* Card open/close request from the chrome (the hover panel's 玩法 action):
|
|
@@ -44,7 +46,7 @@ export interface GameplayBus {
|
|
|
44
46
|
openCard?: (open?: boolean) => void
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
type HudPage = 'root' | 'shop'
|
|
49
|
+
type HudPage = 'root' | 'shop' | 'skins'
|
|
48
50
|
|
|
49
51
|
/** One floating toast (prize / insufficient funds). */
|
|
50
52
|
interface HudFloat {
|
|
@@ -71,6 +73,10 @@ export function GameplayHud(props: {
|
|
|
71
73
|
|
|
72
74
|
const [open, setOpen] = useState(false)
|
|
73
75
|
const [page, setPage] = useState<HudPage>('root')
|
|
76
|
+
// Currently selected skin id (base idle swap); undefined = default look.
|
|
77
|
+
const [skinId, setSkinId] = useState<string | undefined>(undefined)
|
|
78
|
+
const skinIdRef = useRef<string | undefined>(undefined)
|
|
79
|
+
skinIdRef.current = skinId
|
|
74
80
|
const hudRef = useRef<HTMLDivElement | null>(null)
|
|
75
81
|
const cardRef = useRef<HTMLDivElement | null>(null)
|
|
76
82
|
const [floats, setFloats] = useState<HudFloat[]>([])
|
|
@@ -104,8 +110,32 @@ export function GameplayHud(props: {
|
|
|
104
110
|
// Tap handling (registered on the bus; PetSprite reports sprite-box
|
|
105
111
|
// fractions). Sleep wakes on tap; work blocks taps; a held touch
|
|
106
112
|
// animation turns taps into the plain-click boost.
|
|
113
|
+
// Skin contract: while a skin is selected, taps only ever play that
|
|
114
|
+
// skin's own click actions — a miss resolves to the plain click boost,
|
|
115
|
+
// never the default touch zones (so a skinned pet cannot trigger the
|
|
116
|
+
// default pet's shy/work reactions).
|
|
107
117
|
useEffect(() => {
|
|
108
118
|
if (def === undefined) return undefined
|
|
119
|
+
// Play a one-shot override track and lock tap input for its duration so
|
|
120
|
+
// consecutive taps cannot retrigger mid-play (skin click actions and
|
|
121
|
+
// default touch-zone reactions share this path).
|
|
122
|
+
const holdTrack = (track: string, holdMs: number): void => {
|
|
123
|
+
bus.setTrack?.(track)
|
|
124
|
+
touchLockUntilRef.current = Date.now() + holdMs
|
|
125
|
+
window.setTimeout(() => {
|
|
126
|
+
if (Date.now() >= touchLockUntilRef.current) bus.setTrack?.(undefined)
|
|
127
|
+
}, holdMs)
|
|
128
|
+
}
|
|
129
|
+
const speak = (phrases?: string[]): void => {
|
|
130
|
+
if (phrases !== undefined && phrases.length > 0) {
|
|
131
|
+
const phrase = phrases[Math.floor(Math.random() * phrases.length)]!
|
|
132
|
+
store.actions.setFeedback({ text: phrase, kind: 'none', at: Date.now() })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Total play time of a track in ms (its fallback lands back in the skin
|
|
136
|
+
// base idle, so holding the lock for the full loop is unnecessary).
|
|
137
|
+
const trackDuration = (track: string): number =>
|
|
138
|
+
definition.frames2d?.tracks[track]?.durations.reduce((sum, ms) => sum + ms, 0) ?? 0
|
|
109
139
|
bus.tap = (fx, fy) => {
|
|
110
140
|
if (modeRef.current === 'sleep') {
|
|
111
141
|
void api.setMode(null).then(applyResult, () => undefined)
|
|
@@ -120,19 +150,34 @@ export function GameplayHud(props: {
|
|
|
120
150
|
void api.touch().then(applyResult, () => undefined)
|
|
121
151
|
return
|
|
122
152
|
}
|
|
153
|
+
const activeSkin = definition.frames2d?.skins?.find(skin => skin.id === skinIdRef.current)
|
|
154
|
+
if (activeSkin !== undefined) {
|
|
155
|
+
// Skin click actions roll first (declared order, cumulative
|
|
156
|
+
// probabilities); on a hit play the action's track once. A miss
|
|
157
|
+
// stays on the plain click boost — never the default touch zones.
|
|
158
|
+
const actions = activeSkin.clickActions ?? []
|
|
159
|
+
if (actions.length > 0) {
|
|
160
|
+
let roll = Math.random()
|
|
161
|
+
const fired = actions.find(action => {
|
|
162
|
+
if (roll < action.probability) return true
|
|
163
|
+
roll -= action.probability
|
|
164
|
+
return false
|
|
165
|
+
})
|
|
166
|
+
if (fired !== undefined) {
|
|
167
|
+
holdTrack(fired.track, trackDuration(fired.track) || 3000)
|
|
168
|
+
speak(fired.phrases)
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
void api.touch().then(applyResult, () => undefined)
|
|
173
|
+
return
|
|
174
|
+
}
|
|
123
175
|
const zone = def.touch === undefined ? undefined : touchZoneAt(def.touch, hy)
|
|
124
176
|
if (zone === undefined) return
|
|
125
177
|
void api.touch(zone.name).then((result) => {
|
|
126
178
|
applyResult(result)
|
|
127
179
|
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
|
-
}
|
|
180
|
+
if (result.state !== undefined) holdTrack(result.state, result.stateMs ?? 3000)
|
|
136
181
|
if (result.phrase !== undefined) {
|
|
137
182
|
store.actions.setFeedback({ text: result.phrase, kind: 'none', at: Date.now() })
|
|
138
183
|
}
|
|
@@ -269,13 +314,17 @@ export function GameplayHud(props: {
|
|
|
269
314
|
}, [definition.id, def, view?.mode])
|
|
270
315
|
|
|
271
316
|
// Sleep loop: hold the sleep track; restore is host-side (lazy settle).
|
|
317
|
+
// While a skin with a gameplayTracks.sleep override is selected, the skin's
|
|
318
|
+
// own track replaces the default sleep track (e.g. a skin-specific doze).
|
|
272
319
|
useEffect(() => {
|
|
273
320
|
const sleep = def?.sleep
|
|
274
321
|
if (def === undefined || sleep === undefined || view?.mode !== 'sleep') return undefined
|
|
275
|
-
|
|
322
|
+
const skinGameplay = definition.frames2d?.skins?.find(skin => skin.id === skinIdRef.current)?.gameplayTracks
|
|
323
|
+
const hold = skinGameplay?.['sleep'] ?? sleep.state
|
|
324
|
+
bus.setTrack?.(hold)
|
|
276
325
|
return () => bus.setTrack?.(undefined)
|
|
277
326
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- the loop keys on the mode value
|
|
278
|
-
}, [definition.id, def, view?.mode])
|
|
327
|
+
}, [definition.id, def, view?.mode, skinId])
|
|
279
328
|
|
|
280
329
|
if (def === undefined || view === undefined) return null
|
|
281
330
|
|
|
@@ -303,6 +352,12 @@ export function GameplayHud(props: {
|
|
|
303
352
|
void api.setMode(next).then(applyResult, () => undefined)
|
|
304
353
|
}
|
|
305
354
|
|
|
355
|
+
const skins = definition.frames2d?.skins
|
|
356
|
+
const selectSkin = (skin: PetSkinDefinition | undefined): void => {
|
|
357
|
+
setSkinId(skin?.id)
|
|
358
|
+
bus.setIdleTrack?.(skin?.idleTrack)
|
|
359
|
+
}
|
|
360
|
+
|
|
306
361
|
return (
|
|
307
362
|
<div ref={hudRef} className={styles.gameplayHud} data-dsh-pet-gameplay={definition.id}>
|
|
308
363
|
{floats.map(entry => (
|
|
@@ -334,15 +389,6 @@ export function GameplayHud(props: {
|
|
|
334
389
|
})}
|
|
335
390
|
</div>
|
|
336
391
|
<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
392
|
{def.sleep !== undefined && (
|
|
347
393
|
<button
|
|
348
394
|
type="button"
|
|
@@ -357,6 +403,20 @@ export function GameplayHud(props: {
|
|
|
357
403
|
{tr('pet.gameplay.shop')}
|
|
358
404
|
</button>
|
|
359
405
|
)}
|
|
406
|
+
{skins !== undefined && skins.length > 0 && (
|
|
407
|
+
<button type="button" className={styles.action} onClick={() => setPage('skins')}>
|
|
408
|
+
{tr('pet.gameplay.skin')}
|
|
409
|
+
</button>
|
|
410
|
+
)}
|
|
411
|
+
{def.work !== undefined && (
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
className={styles.action}
|
|
415
|
+
onClick={() => setMode(mode === 'work' ? null : 'work')}
|
|
416
|
+
>
|
|
417
|
+
{tr(mode === 'work' ? 'pet.gameplay.stopWork' : 'pet.gameplay.work')}
|
|
418
|
+
</button>
|
|
419
|
+
)}
|
|
360
420
|
</div>
|
|
361
421
|
</>
|
|
362
422
|
)}
|
|
@@ -388,6 +448,34 @@ export function GameplayHud(props: {
|
|
|
388
448
|
</div>
|
|
389
449
|
</>
|
|
390
450
|
)}
|
|
451
|
+
{page === 'skins' && skins !== undefined && (
|
|
452
|
+
<>
|
|
453
|
+
<div className={styles.gameplaySkinItems}>
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
className={skinId === undefined ? styles.gameplaySkinItem + ' ' + styles.gameplaySkinItemActive : styles.gameplaySkinItem}
|
|
457
|
+
onClick={() => selectSkin(undefined)}
|
|
458
|
+
>
|
|
459
|
+
{tr('pet.gameplay.skinDefault')}
|
|
460
|
+
</button>
|
|
461
|
+
{skins.map(skin => (
|
|
462
|
+
<button
|
|
463
|
+
key={skin.id}
|
|
464
|
+
type="button"
|
|
465
|
+
className={skinId === skin.id ? styles.gameplaySkinItem + ' ' + styles.gameplaySkinItemActive : styles.gameplaySkinItem}
|
|
466
|
+
onClick={() => selectSkin(skin)}
|
|
467
|
+
>
|
|
468
|
+
{skin.label}
|
|
469
|
+
</button>
|
|
470
|
+
))}
|
|
471
|
+
</div>
|
|
472
|
+
<div className={styles.gameplayActions}>
|
|
473
|
+
<button type="button" className={styles.action} onClick={() => setPage('root')}>
|
|
474
|
+
{tr('pet.gameplay.back')}
|
|
475
|
+
</button>
|
|
476
|
+
</div>
|
|
477
|
+
</>
|
|
478
|
+
)}
|
|
391
479
|
<button
|
|
392
480
|
type="button"
|
|
393
481
|
className={styles.gameplayClose}
|
package/src/client/index.ts
CHANGED
|
@@ -122,6 +122,18 @@ declare module '@deepseek-ai/cordis' {
|
|
|
122
122
|
* first-level settings section.
|
|
123
123
|
* @param ctx - client root context.
|
|
124
124
|
*/
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Module-wide work-tick throttle (ms). Hot reloads can leave several
|
|
128
|
+
* GameplayHud instances alive, each running its own 10s work interval;
|
|
129
|
+
* without a shared gate every interval would call workTick and each stale
|
|
130
|
+
* call re-rolls, re-grants treats and re-plays the success/fail track, so
|
|
131
|
+
* the outcome appears to play several times per window. This shared marker
|
|
132
|
+
* accepts the first adjudication of a window and silently suppresses the
|
|
133
|
+
* duplicates that follow. Reset when (re-)entering work mode.
|
|
134
|
+
*/
|
|
135
|
+
let lastWorkTickAt = 0
|
|
136
|
+
|
|
125
137
|
export function apply(ctx: ClientContext): void {
|
|
126
138
|
// Anonymous install heartbeat (docs/telemetry.md): one beat per browser per
|
|
127
139
|
// UTC day, package name only, silent failure.
|
|
@@ -359,8 +371,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
359
371
|
},
|
|
360
372
|
gameplay: {
|
|
361
373
|
touch: (zone) => petApi.gameplayTouch(zone),
|
|
362
|
-
setMode: (mode) =>
|
|
363
|
-
|
|
374
|
+
setMode: async (mode) => {
|
|
375
|
+
if (mode === 'work') lastWorkTickAt = 0
|
|
376
|
+
return petApi.gameplaySetMode(mode)
|
|
377
|
+
},
|
|
378
|
+
workTick: async () => {
|
|
379
|
+
// One adjudication per tick window, page-wide: suppress stale
|
|
380
|
+
// duplicate intervals (HMR) re-playing the result track.
|
|
381
|
+
const now = Date.now()
|
|
382
|
+
if (now - lastWorkTickAt < 8500) {
|
|
383
|
+
return { ok: true } as PetGameplayVerbResult
|
|
384
|
+
}
|
|
385
|
+
lastWorkTickAt = now
|
|
386
|
+
return petApi.gameplayWorkTick()
|
|
387
|
+
},
|
|
364
388
|
buy: (item) => petApi.gameplayBuy(item),
|
|
365
389
|
},
|
|
366
390
|
})
|
package/src/client/locales.ts
CHANGED
|
@@ -37,9 +37,11 @@ export const zh = {
|
|
|
37
37
|
'pet.gameplay.menu': '玩法',
|
|
38
38
|
'pet.gameplay.work': '打工',
|
|
39
39
|
'pet.gameplay.stopWork': '收工',
|
|
40
|
-
'pet.gameplay.sleep': '
|
|
40
|
+
'pet.gameplay.sleep': '休息',
|
|
41
41
|
'pet.gameplay.wake': '起床',
|
|
42
42
|
'pet.gameplay.shop': '商店',
|
|
43
|
+
'pet.gameplay.skin': '皮肤',
|
|
44
|
+
'pet.gameplay.skinDefault': '默认',
|
|
43
45
|
'pet.gameplay.back': '返回',
|
|
44
46
|
'pet.gameplay.buy': '购买',
|
|
45
47
|
'pet.gameplay.insufficient': '{currency}不足',
|
|
@@ -66,7 +68,7 @@ export const zh = {
|
|
|
66
68
|
'settings.visible': '显示宠物',
|
|
67
69
|
'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
|
|
68
70
|
'settings.size': '大小(px)',
|
|
69
|
-
'settings.sizeHint': '精灵单元高度,范围 32–
|
|
71
|
+
'settings.sizeHint': '精灵单元高度,范围 32–1024。',
|
|
70
72
|
'settings.right': '距右侧(px)',
|
|
71
73
|
'settings.rightHint': '距视口右边缘的水平内缩距离。',
|
|
72
74
|
'settings.bottom': '距底部(px)',
|
|
@@ -122,6 +124,8 @@ export const en = {
|
|
|
122
124
|
'pet.gameplay.sleep': 'Sleep',
|
|
123
125
|
'pet.gameplay.wake': 'Wake up',
|
|
124
126
|
'pet.gameplay.shop': 'Shop',
|
|
127
|
+
'pet.gameplay.skin': 'Skin',
|
|
128
|
+
'pet.gameplay.skinDefault': 'Default',
|
|
125
129
|
'pet.gameplay.back': 'Back',
|
|
126
130
|
'pet.gameplay.buy': 'Buy',
|
|
127
131
|
'pet.gameplay.insufficient': 'Not enough {currency}',
|
|
@@ -148,7 +152,7 @@ export const en = {
|
|
|
148
152
|
'settings.visible': 'Show the pet',
|
|
149
153
|
'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
|
|
150
154
|
'settings.size': 'Size (px)',
|
|
151
|
-
'settings.sizeHint': 'Sprite cell height, 32\
|
|
155
|
+
'settings.sizeHint': 'Sprite cell height, 32\u20131024.',
|
|
152
156
|
'settings.right': 'Right inset (px)',
|
|
153
157
|
'settings.rightHint': 'Horizontal inset from the viewport right edge.',
|
|
154
158
|
'settings.bottom': 'Bottom inset (px)',
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
background: rgba(3, 105, 161, 0.95);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/*
|
|
49
|
+
/* 状态气泡:皮肤底色玻璃 + 皮肤描边微光 + 进场浮现。
|
|
50
50
|
碎碎念不再单独渲染第二只气泡,也不换色调,而是以 .bubbleWhisper
|
|
51
51
|
接管这同一只气泡的文案,气泡栈内所有气泡共用同一片玻璃。 */
|
|
52
52
|
.bubbleStatus {
|
|
@@ -546,6 +546,36 @@
|
|
|
546
546
|
color: #ffd27e;
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
.gameplaySkinItems {
|
|
550
|
+
display: flex;
|
|
551
|
+
flex-direction: column;
|
|
552
|
+
gap: 6px;
|
|
553
|
+
margin-bottom: 8px;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.gameplaySkinItem {
|
|
557
|
+
display: block;
|
|
558
|
+
width: 100%;
|
|
559
|
+
padding: 8px 10px;
|
|
560
|
+
border: 1px solid rgba(126, 152, 255, 0.25);
|
|
561
|
+
border-radius: 8px;
|
|
562
|
+
background: rgba(126, 152, 255, 0.08);
|
|
563
|
+
color: inherit;
|
|
564
|
+
cursor: pointer;
|
|
565
|
+
text-align: left;
|
|
566
|
+
font-size: 12px;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
.gameplaySkinItem:hover {
|
|
570
|
+
border-color: rgba(126, 152, 255, 0.7);
|
|
571
|
+
background: rgba(126, 152, 255, 0.16);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.gameplaySkinItemActive {
|
|
575
|
+
border-color: rgba(126, 152, 255, 0.9);
|
|
576
|
+
background: rgba(126, 152, 255, 0.28);
|
|
577
|
+
}
|
|
578
|
+
|
|
549
579
|
.gameplayClose {
|
|
550
580
|
position: absolute;
|
|
551
581
|
top: 4px;
|
|
@@ -67,7 +67,11 @@ export function Frames2dVisualMount(props: {
|
|
|
67
67
|
if (props.bus !== undefined) {
|
|
68
68
|
const gameplayBus = props.bus
|
|
69
69
|
gameplayBus.setTrack = (track) => { handleRef.current?.setState(track) }
|
|
70
|
-
|
|
70
|
+
gameplayBus.setIdleTrack = (track) => { handleRef.current?.setIdleTrack(track) }
|
|
71
|
+
cleanups.push(() => {
|
|
72
|
+
gameplayBus.setTrack = undefined
|
|
73
|
+
gameplayBus.setIdleTrack = undefined
|
|
74
|
+
})
|
|
71
75
|
}
|
|
72
76
|
// The drag gesture drives the conventional 'drag' track when declared.
|
|
73
77
|
// On release, a declared gameplay.dragEndState (miku: standup) plays
|
|
@@ -113,6 +113,49 @@ describe('frames2dRenderer', () => {
|
|
|
113
113
|
handle.dispose()
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
it('swaps the base idle target via setIdleTrack (skin semantics)', () => {
|
|
117
|
+
const skinConfig: PetFrames2dConfig = {
|
|
118
|
+
tracks: {
|
|
119
|
+
idle: { frames: ['/pet/miku/idle/1.webp'], durations: [100], loop: true },
|
|
120
|
+
skin: { frames: ['/pet/miku/skin/1.webp'], durations: [100], loop: true },
|
|
121
|
+
happy: { frames: ['/pet/miku/happy/1.webp'], durations: [100], loop: false, fallback: 'idle' },
|
|
122
|
+
},
|
|
123
|
+
phases: { idle: 'idle' },
|
|
124
|
+
skins: [{ id: 's1', label: 'S1', idleTrack: 'skin' }],
|
|
125
|
+
}
|
|
126
|
+
const { ctx, img } = setup()
|
|
127
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(skinConfig)) as Frames2dRendererHandle
|
|
128
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
129
|
+
// Selecting the skin swaps the base idle immediately (no override held).
|
|
130
|
+
handle.setIdleTrack('skin')
|
|
131
|
+
expect(handle.currentTrack()).toBe('skin')
|
|
132
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/skin/1.webp')
|
|
133
|
+
// A gameplay override still plays, then settles into the skin idle.
|
|
134
|
+
handle.setState('happy')
|
|
135
|
+
expect(handle.currentTrack()).toBe('happy')
|
|
136
|
+
vi.advanceTimersByTime(150)
|
|
137
|
+
expect(handle.currentTrack()).toBe('skin')
|
|
138
|
+
// Restoring undefined returns to the manifest idle target.
|
|
139
|
+
handle.setIdleTrack(undefined)
|
|
140
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
141
|
+
// Unknown skin tracks are ignored.
|
|
142
|
+
handle.setIdleTrack('ghost')
|
|
143
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
144
|
+
handle.dispose()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('drops skins whose idleTrack is missing or non-looping in validateConfig', () => {
|
|
148
|
+
const parsed = frames2dRenderer.validateConfig({
|
|
149
|
+
...CONFIG,
|
|
150
|
+
skins: [
|
|
151
|
+
{ id: 'good', label: 'Good', idleTrack: 'idle' },
|
|
152
|
+
{ id: 'loop', label: 'Loop', idleTrack: 'happy' },
|
|
153
|
+
{ id: 'ghost', label: 'Ghost', idleTrack: 'nope' },
|
|
154
|
+
],
|
|
155
|
+
})
|
|
156
|
+
expect(parsed.skins).toEqual([{ id: 'good', label: 'Good', idleTrack: 'idle' }])
|
|
157
|
+
})
|
|
158
|
+
|
|
116
159
|
it('re-kicks a stalled looping track via the watchdog', () => {
|
|
117
160
|
const { ctx, img } = setup()
|
|
118
161
|
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
@@ -38,11 +38,38 @@ export interface Frames2dTrackConfig {
|
|
|
38
38
|
export interface PetFrames2dConfig {
|
|
39
39
|
tracks: Record<string, Frames2dTrackConfig>
|
|
40
40
|
phases: Partial<Record<ActivityPhase, string>> & { idle: string }
|
|
41
|
+
/** Selectable skins; each swaps the base idle target while selected. */
|
|
42
|
+
skins?: Frames2dSkinConfig[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One selectable skins entry as served inside the pet definition. */
|
|
46
|
+
export interface Frames2dSkinConfig {
|
|
47
|
+
id: string
|
|
48
|
+
label: string
|
|
49
|
+
/** A declared looping track that becomes the base idle while selected. */
|
|
50
|
+
idleTrack: string
|
|
51
|
+
/** Click actions exclusive to this skin (roll by probability; miss → touch zones). */
|
|
52
|
+
clickActions?: Frames2dSkinClickActionConfig[]
|
|
53
|
+
/** Gameplay-state overrides (state -> track) swapped in while selected. */
|
|
54
|
+
gameplayTracks?: Record<string, string>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One probability-rolled tap action a skin may declare. */
|
|
58
|
+
export interface Frames2dSkinClickActionConfig {
|
|
59
|
+
track: string
|
|
60
|
+
probability: number
|
|
61
|
+
phrases?: string[]
|
|
41
62
|
}
|
|
42
63
|
|
|
43
64
|
export interface Frames2dRendererHandle extends PetRendererHandle {
|
|
44
65
|
/** Force a track id (gameplay override); undefined returns to phase mapping. */
|
|
45
66
|
setState(track: string | undefined): void
|
|
67
|
+
/**
|
|
68
|
+
* Swap the base idle target (idle phase, unmapped phases and every
|
|
69
|
+
* fallback back to idle) to a declared looping track — a skin. undefined
|
|
70
|
+
* restores the manifest idle track.
|
|
71
|
+
*/
|
|
72
|
+
setIdleTrack(track: string | undefined): void
|
|
46
73
|
/** The track currently playing (diagnostics and tests). */
|
|
47
74
|
currentTrack(): string
|
|
48
75
|
}
|
|
@@ -78,7 +105,52 @@ function validateFrames2dConfig(config: unknown): PetFrames2dConfig {
|
|
|
78
105
|
if (typeof phases.idle !== 'string' || tracks[phases.idle] === undefined) {
|
|
79
106
|
throw new Error('frames2d phases.idle must name an existing track')
|
|
80
107
|
}
|
|
81
|
-
|
|
108
|
+
// Skins: keep entries whose idleTrack survives validation and loops —
|
|
109
|
+
// a non-looping base idle would settle into itself forever. Click actions
|
|
110
|
+
// with an unresolvable track are dropped from that skin.
|
|
111
|
+
let skins: Frames2dSkinConfig[] | undefined
|
|
112
|
+
if (Array.isArray(config.skins)) {
|
|
113
|
+
const resolved: Frames2dSkinConfig[] = []
|
|
114
|
+
for (const skin of config.skins as unknown[]) {
|
|
115
|
+
if (!isRecord(skin) || typeof skin.id !== 'string' || typeof skin.label !== 'string'
|
|
116
|
+
|| typeof skin.idleTrack !== 'string' || skin.idleTrack === '') continue
|
|
117
|
+
const target = tracks[skin.idleTrack]
|
|
118
|
+
if (target === undefined || !target.loop) continue
|
|
119
|
+
let clickActions: Frames2dSkinClickActionConfig[] | undefined
|
|
120
|
+
if (Array.isArray(skin.clickActions)) {
|
|
121
|
+
const kept: Frames2dSkinClickActionConfig[] = []
|
|
122
|
+
for (const action of skin.clickActions as unknown[]) {
|
|
123
|
+
if (!isRecord(action) || typeof action.track !== 'string' || action.track === ''
|
|
124
|
+
|| typeof action.probability !== 'number' || !(action.probability > 0) || action.probability > 1) continue
|
|
125
|
+
if (tracks[action.track] === undefined) continue
|
|
126
|
+
kept.push({
|
|
127
|
+
track: action.track,
|
|
128
|
+
probability: action.probability,
|
|
129
|
+
...(Array.isArray(action.phrases) ? { phrases: action.phrases as string[] } : {}),
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
if (kept.length > 0) clickActions = kept
|
|
133
|
+
}
|
|
134
|
+
let gameplayTracks: Record<string, string> | undefined
|
|
135
|
+
if (isRecord(skin.gameplayTracks)) {
|
|
136
|
+
const kept: Record<string, string> = {}
|
|
137
|
+
for (const [state, trackName] of Object.entries(skin.gameplayTracks)) {
|
|
138
|
+
if (typeof trackName !== 'string' || trackName === '' || tracks[trackName] === undefined) continue
|
|
139
|
+
kept[state] = trackName
|
|
140
|
+
}
|
|
141
|
+
if (Object.keys(kept).length > 0) gameplayTracks = kept
|
|
142
|
+
}
|
|
143
|
+
resolved.push({
|
|
144
|
+
id: skin.id,
|
|
145
|
+
label: skin.label,
|
|
146
|
+
idleTrack: skin.idleTrack,
|
|
147
|
+
...(clickActions === undefined ? {} : { clickActions }),
|
|
148
|
+
...(gameplayTracks === undefined ? {} : { gameplayTracks }),
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
if (resolved.length > 0) skins = resolved
|
|
152
|
+
}
|
|
153
|
+
return { tracks, phases: phases as PetFrames2dConfig['phases'], ...(skins === undefined ? {} : { skins }) }
|
|
82
154
|
}
|
|
83
155
|
|
|
84
156
|
interface DecodedFrame {
|
|
@@ -178,12 +250,18 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
178
250
|
let frameIndex = 0
|
|
179
251
|
let lastAdvance = Date.now()
|
|
180
252
|
let override: string | undefined
|
|
253
|
+
// Skin base idle: every "back to idle" target resolves through this
|
|
254
|
+
// (idle phase, unmapped phases and fallbacks), so a selected skin swaps
|
|
255
|
+
// the pet's resting look without touching gameplay tracks.
|
|
256
|
+
let baseIdle: string = config.phases.idle
|
|
181
257
|
let drawToken = 0
|
|
182
258
|
let lastDrawnUrl: string | undefined
|
|
183
259
|
|
|
184
260
|
const trackForPhase = (phase: ActivityPhase): string => {
|
|
185
261
|
const mapped = config.phases[phase]
|
|
186
|
-
|
|
262
|
+
if (mapped === undefined) return baseIdle
|
|
263
|
+
const target = mapped === config.phases.idle ? baseIdle : mapped
|
|
264
|
+
return config.tracks[target] !== undefined ? target : baseIdle
|
|
187
265
|
}
|
|
188
266
|
|
|
189
267
|
/** Canvas path: paints the newest requested frame; stale draws drop out. */
|
|
@@ -239,18 +317,21 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
239
317
|
schedule(def.durations[frameIndex] ?? 200)
|
|
240
318
|
return
|
|
241
319
|
}
|
|
242
|
-
// Non-loop completion: settle into the fallback
|
|
243
|
-
//
|
|
320
|
+
// Non-loop completion: settle into the fallback — an explicit fallback to
|
|
321
|
+
// a real track wins, except when it points back at the manifest idle
|
|
322
|
+
// (that resolves through the skin base idle); anything else lands on
|
|
323
|
+
// the skin base idle. When the settle target is what the phase map
|
|
324
|
+
// plays anyway, release the gameplay override.
|
|
244
325
|
const target = def.fallback !== undefined && config.tracks[def.fallback] !== undefined
|
|
245
|
-
? def.fallback
|
|
246
|
-
:
|
|
326
|
+
? (def.fallback === config.phases.idle ? baseIdle : def.fallback)
|
|
327
|
+
: baseIdle
|
|
247
328
|
if (target === trackForPhase(ctx.phase.get())) override = undefined
|
|
248
329
|
play(target)
|
|
249
330
|
}
|
|
250
331
|
|
|
251
332
|
function play(trackId: string): void {
|
|
252
333
|
if (disposed) return
|
|
253
|
-
if (config.tracks[trackId] === undefined) trackId =
|
|
334
|
+
if (config.tracks[trackId] === undefined) trackId = baseIdle
|
|
254
335
|
if (timer !== undefined) clearTimeout(timer)
|
|
255
336
|
track = trackId
|
|
256
337
|
frameIndex = 0
|
|
@@ -328,6 +409,18 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
328
409
|
override = next
|
|
329
410
|
if (next !== track) play(next)
|
|
330
411
|
},
|
|
412
|
+
setIdleTrack(next: string | undefined): void {
|
|
413
|
+
if (disposed) return
|
|
414
|
+
if (next === undefined || (config.tracks[next] !== undefined && config.tracks[next]!.loop)) {
|
|
415
|
+
baseIdle = next ?? config.phases.idle
|
|
416
|
+
}
|
|
417
|
+
// A skin only owns the resting look: re-resolve only when no
|
|
418
|
+
// gameplay override is active (active overrides end into baseIdle).
|
|
419
|
+
if (override === undefined) {
|
|
420
|
+
const target = trackForPhase(ctx.phase.get())
|
|
421
|
+
if (target !== track) play(target)
|
|
422
|
+
}
|
|
423
|
+
},
|
|
331
424
|
currentTrack(): string {
|
|
332
425
|
return track
|
|
333
426
|
},
|