@linxin666/dsh-pet 0.3.16 → 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.
Files changed (55) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +16 -1
  3. package/README.zh.md +16 -1
  4. package/assets/blue-throated-bee-eater/pet.json +204 -0
  5. package/assets/blue-throated-bee-eater/previews/failed.gif +0 -0
  6. package/assets/blue-throated-bee-eater/previews/idle.gif +0 -0
  7. package/assets/blue-throated-bee-eater/previews/jumping.gif +0 -0
  8. package/assets/blue-throated-bee-eater/previews/review.gif +0 -0
  9. package/assets/blue-throated-bee-eater/previews/running-left.gif +0 -0
  10. package/assets/blue-throated-bee-eater/previews/running-right.gif +0 -0
  11. package/assets/blue-throated-bee-eater/previews/running.gif +0 -0
  12. package/assets/blue-throated-bee-eater/previews/waiting.gif +0 -0
  13. package/assets/blue-throated-bee-eater/previews/waving.gif +0 -0
  14. package/assets/blue-throated-bee-eater/spritesheet.webp +0 -0
  15. package/assets/blue-throated-bee-eater/voice.json +13 -0
  16. package/contracts/pet-manifest-v2.schema.json +84 -0
  17. package/lib/client.js +176 -27
  18. package/lib/client.js.map +1 -1
  19. package/lib/index.js +170 -6
  20. package/lib/types/client/gameplay-hud.d.ts +2 -0
  21. package/lib/types/client/gameplay-hud.d.ts.map +1 -1
  22. package/lib/types/client/gameplay-hud.js +65 -12
  23. package/lib/types/client/index.d.ts +0 -6
  24. package/lib/types/client/index.d.ts.map +1 -1
  25. package/lib/types/client/index.js +25 -2
  26. package/lib/types/client/locales.d.ts +7 -3
  27. package/lib/types/client/locales.d.ts.map +1 -1
  28. package/lib/types/client/locales.js +7 -3
  29. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -1
  30. package/lib/types/client/renderers/Frames2dVisualMount.js +5 -1
  31. package/lib/types/client/renderers/frames2d.d.ts +25 -0
  32. package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
  33. package/lib/types/client/renderers/frames2d.js +84 -7
  34. package/lib/types/manifest-v2.d.ts +45 -0
  35. package/lib/types/manifest-v2.d.ts.map +1 -1
  36. package/lib/types/manifest-v2.js +139 -1
  37. package/lib/types/persist.d.ts +1 -1
  38. package/lib/types/persist.d.ts.map +1 -1
  39. package/lib/types/persist.js +1 -1
  40. package/lib/types/registry.d.ts +22 -0
  41. package/lib/types/registry.d.ts.map +1 -1
  42. package/lib/types/registry.js +45 -1
  43. package/package.json +2 -1
  44. package/src/client/gameplay-hud.test.tsx +64 -0
  45. package/src/client/gameplay-hud.tsx +109 -21
  46. package/src/client/index.ts +26 -2
  47. package/src/client/locales.ts +7 -3
  48. package/src/client/pet.module.css +31 -1
  49. package/src/client/renderers/Frames2dVisualMount.tsx +5 -1
  50. package/src/client/renderers/frames2d.test.ts +43 -0
  51. package/src/client/renderers/frames2d.ts +100 -7
  52. package/src/manifest-v2.ts +167 -1
  53. package/src/persist.ts +1 -1
  54. package/src/registry.test.ts +19 -5
  55. 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
- bus.setTrack?.(sleep.state)
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}
@@ -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) => petApi.gameplaySetMode(mode),
363
- workTick: () => petApi.gameplayWorkTick(),
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
  })
@@ -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–512。',
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\u2013512.',
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
- /* 状态气泡:虎鲸蓝黑渐变玻璃 + DeepSeek 蓝描边微光 + 进场浮现。
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
- cleanups.push(() => { gameplayBus.setTrack = undefined })
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
- return { tracks, phases: phases as PetFrames2dConfig['phases'] }
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
- return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle
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; when the fallback is
243
- // what the phase map would play anyway, release the gameplay override.
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
- : config.phases.idle
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 = config.phases.idle
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
  },