@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
@@ -100,6 +100,43 @@ export interface PetManifestFrames2d {
100
100
  tracks: Record<string, PetManifestFrames2dTrack>
101
101
  /** ActivityPhase -> track name; idle is required, unmapped phases fall back to it. */
102
102
  phases: Partial<Record<ActivityPhase, string>> & { idle: string }
103
+ /**
104
+ * Optional selectable skins: a skin swaps the base idle target (the idle
105
+ * phase, unmapped phases, and every fallback back to idle) to its own
106
+ * looping idle track. Gameplay tracks (shy/work/sleep...) are not replaced.
107
+ */
108
+ skins?: PetManifestSkin[]
109
+ }
110
+
111
+ /** One selectable frames2d skin. */
112
+ export interface PetManifestSkin {
113
+ id: string
114
+ label: string
115
+ /** A declared, looping track that becomes the base idle while selected. */
116
+ idleTrack: string
117
+ /**
118
+ * Optional click-triggered actions exclusive to this skin: while the skin
119
+ * is selected, a tap rolls each action by probability (in order) and plays
120
+ * its track once if hit. Misses fall through to the regular touch zones.
121
+ */
122
+ clickActions?: PetManifestSkinClickAction[]
123
+ /**
124
+ * Optional gameplay-track overrides: gameplay state name (e.g. 'sleep' or
125
+ * 'work') -> a declared track the skin swaps in while selected. Lets a skin
126
+ * restyle a gameplay loop (e.g. its own sleep animation) without touching
127
+ * the default pet's tracks.
128
+ */
129
+ gameplayTracks?: Record<string, string>
130
+ }
131
+
132
+ /** One probability-rolled tap action a skin may declare. */
133
+ export interface PetManifestSkinClickAction {
134
+ /** A declared track played once on hit (usually non-looping). */
135
+ track: string
136
+ /** Hit probability for this action (0..1). */
137
+ probability: number
138
+ /** Optional lines spoken when the action fires. */
139
+ phrases?: string[]
103
140
  }
104
141
 
105
142
  /** Normalized v2 manifest the registry consumes. */
@@ -153,9 +190,35 @@ export const KNOWN_SPRITE2D = new Set(['spritesheetPath', 'cell', 'columns', 'at
153
190
  /** live2d block field allow-list (drift-locked to the schema file). */
154
191
  export const KNOWN_LIVE2D = new Set(['model', 'scale', 'translate', 'motions', 'expressions', 'hitAreas', 'lipSync'])
155
192
  /** frames2d block field allow-list (drift-locked to the schema file). */
156
- export const KNOWN_FRAMES2D = new Set(['dir', 'defaultFrameMs', 'tracks', 'phases'])
193
+ export const KNOWN_FRAMES2D = new Set(['dir', 'defaultFrameMs', 'tracks', 'phases', 'skins'])
157
194
  /** frames2d track field allow-list (drift-locked to the schema file). */
158
195
  export const KNOWN_FRAMES2D_TRACK = new Set(['frames', 'frameMs', 'loop', 'fallback'])
196
+ /** frames2d skin entry field allow-list (drift-locked to the schema file). */
197
+ export const KNOWN_SKIN = new Set(['id', 'label', 'idleTrack', 'clickActions', 'gameplayTracks'])
198
+ /** frames2d skin click-action field allow-list (drift-locked to the schema file). */
199
+ export const KNOWN_SKIN_CLICK = new Set(['track', 'probability', 'phrases'])
200
+ /** Max selectable skins a frames2d manifest may declare. */
201
+ export const FRAMES2D_MAX_SKINS = 16
202
+ /** Max click actions one skin may declare. */
203
+ export const FRAMES2D_MAX_SKIN_CLICKS = 8
204
+ /** Max gameplay-state overrides one skin may declare. */
205
+ export const FRAMES2D_MAX_SKIN_GAMEPLAY = 8
206
+ /** Max spoken lines one skin click action may declare. */
207
+ const SKIN_CLICK_MAX_PHRASES = 5
208
+ /** Max length of one spoken line. */
209
+ const SKIN_CLICK_PHRASE_MAX_LENGTH = 120
210
+
211
+ /** Validate a skin click action's optional phrase pool (same spirit as gameplay phrases). */
212
+ function parseSkinPhrases(raw: unknown, field: string, diag: Diagnostics): string[] | undefined {
213
+ if (raw === undefined) return undefined
214
+ if (!Array.isArray(raw) || raw.length === 0 || raw.length > SKIN_CLICK_MAX_PHRASES
215
+ || raw.some(line => typeof line !== 'string' || line.trim() === '' || line.length > SKIN_CLICK_PHRASE_MAX_LENGTH)) {
216
+ diag.error(field + ' must be 1..' + SKIN_CLICK_MAX_PHRASES + ' non-empty lines of at most '
217
+ + SKIN_CLICK_PHRASE_MAX_LENGTH + ' chars')
218
+ return undefined
219
+ }
220
+ return raw as string[]
221
+ }
159
222
 
160
223
  class Diagnostics {
161
224
  readonly list: PetManifestDiagnostic[] = []
@@ -451,6 +514,109 @@ function parseFrames2dBlock(raw: unknown, diag: Diagnostics): PetManifestFrames2
451
514
  diag.error('frames2d.tracks.' + name + '.fallback references unknown track ' + JSON.stringify(track.fallback))
452
515
  }
453
516
  }
517
+ // Optional skins: each selects a declared looping idle track as the base
518
+ // idle while active (id/label are UI-facing, idleTrack is structure).
519
+ if (raw.skins !== undefined) {
520
+ if (!Array.isArray(raw.skins) || raw.skins.length === 0 || raw.skins.length > FRAMES2D_MAX_SKINS) {
521
+ diag.error('frames2d.skins must be an array of 1..' + FRAMES2D_MAX_SKINS + ' skins')
522
+ } else {
523
+ const skins: PetManifestSkin[] = []
524
+ const seen = new Set<string>()
525
+ for (const [index, entry] of raw.skins.entries()) {
526
+ const field = 'frames2d.skins[' + index + ']'
527
+ if (!isRecord(entry)) {
528
+ diag.error(field + ' must be an object')
529
+ continue
530
+ }
531
+ const extra = unknownKeys(entry, KNOWN_SKIN)
532
+ if (extra.length > 0) diag.error(field + ': unknown field(s) ' + extra.map(k => JSON.stringify(k)).join(', '))
533
+ const id = typeof entry.id === 'string' ? entry.id.trim() : ''
534
+ if (id === '' || id.length > 24 || !FRAMES2D_TRACK_NAME.test(id)) {
535
+ diag.error(field + '.id must be a lowercase kebab id of at most 24 chars')
536
+ continue
537
+ }
538
+ if (seen.has(id)) {
539
+ diag.error(field + ': duplicate skin id ' + JSON.stringify(id))
540
+ continue
541
+ }
542
+ seen.add(id)
543
+ const label = typeof entry.label === 'string' ? entry.label.trim() : ''
544
+ if (label === '' || label.length > 40) {
545
+ diag.error(field + '.label must be a non-empty string of at most 40 chars')
546
+ continue
547
+ }
548
+ const idleTrack = typeof entry.idleTrack === 'string' ? entry.idleTrack : ''
549
+ if (!validTrackName(idleTrack) || tracks[idleTrack] === undefined) {
550
+ diag.error(field + '.idleTrack must name a declared frames2d track')
551
+ continue
552
+ }
553
+ // Optional click actions: each names a declared track and a hit
554
+ // probability; phrases reuse the gameplay phrase constraints.
555
+ let clickActions: PetManifestSkinClickAction[] | undefined
556
+ if (entry.clickActions !== undefined) {
557
+ if (!Array.isArray(entry.clickActions) || entry.clickActions.length === 0 || entry.clickActions.length > FRAMES2D_MAX_SKIN_CLICKS) {
558
+ diag.error(field + '.clickActions must be an array of 1..' + FRAMES2D_MAX_SKIN_CLICKS + ' actions')
559
+ } else {
560
+ const resolved: PetManifestSkinClickAction[] = []
561
+ for (const [cIndex, action] of entry.clickActions.entries()) {
562
+ const cField = field + '.clickActions[' + cIndex + ']'
563
+ if (!isRecord(action)) {
564
+ diag.error(cField + ' must be an object')
565
+ continue
566
+ }
567
+ const cExtra = unknownKeys(action, KNOWN_SKIN_CLICK)
568
+ if (cExtra.length > 0) diag.error(cField + ': unknown field(s) ' + cExtra.map(k => JSON.stringify(k)).join(', '))
569
+ const trackName = typeof action.track === 'string' ? action.track : ''
570
+ if (!validTrackName(trackName) || tracks[trackName] === undefined) {
571
+ diag.error(cField + '.track must name a declared frames2d track')
572
+ continue
573
+ }
574
+ if (typeof action.probability !== 'number' || !Number.isFinite(action.probability)
575
+ || action.probability <= 0 || action.probability > 1) {
576
+ diag.error(cField + '.probability must be a number in (0, 1]')
577
+ continue
578
+ }
579
+ const phrases = parseSkinPhrases(action.phrases, cField + '.phrases', diag)
580
+ resolved.push({ track: trackName, probability: action.probability, ...(phrases === undefined ? {} : { phrases }) })
581
+ }
582
+ if (resolved.length > 0) clickActions = resolved
583
+ }
584
+ }
585
+ // Optional gameplay-track overrides: gameplay state name -> declared
586
+ // track swapped in while this skin is selected (e.g. a skin variant of
587
+ // the sleep loop). State names mirror gameplay blocks (sleep/work...);
588
+ // values must reference declared tracks.
589
+ let gameplayTracks: Record<string, string> | undefined
590
+ if (entry.gameplayTracks !== undefined) {
591
+ if (!isRecord(entry.gameplayTracks)) {
592
+ diag.error(field + '.gameplayTracks must be an object mapping gameplay state names to tracks')
593
+ } else {
594
+ const states = Object.keys(entry.gameplayTracks)
595
+ if (states.length === 0 || states.length > FRAMES2D_MAX_SKIN_GAMEPLAY) {
596
+ diag.error(field + '.gameplayTracks must map 1..' + FRAMES2D_MAX_SKIN_GAMEPLAY + ' gameplay states')
597
+ } else {
598
+ const resolvedTracks: Record<string, string> = {}
599
+ for (const state of states) {
600
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(state)) {
601
+ diag.error(field + '.gameplayTracks key ' + JSON.stringify(state) + ' must be a lowercase kebab state name')
602
+ continue
603
+ }
604
+ const gTrack = entry.gameplayTracks[state as keyof typeof entry.gameplayTracks]
605
+ if (typeof gTrack !== 'string' || !validTrackName(gTrack) || tracks[gTrack] === undefined) {
606
+ diag.error(field + '.gameplayTracks[' + JSON.stringify(state) + '] must name a declared frames2d track')
607
+ continue
608
+ }
609
+ resolvedTracks[state] = gTrack
610
+ }
611
+ if (Object.keys(resolvedTracks).length > 0) gameplayTracks = resolvedTracks
612
+ }
613
+ }
614
+ }
615
+ skins.push({ id, label, idleTrack, ...(clickActions === undefined ? {} : { clickActions }), ...(gameplayTracks === undefined ? {} : { gameplayTracks }) })
616
+ }
617
+ if (skins.length > 0) block.skins = skins
618
+ }
619
+ }
454
620
  return diag.hasErrors ? undefined : block
455
621
  }
456
622
 
package/src/persist.ts CHANGED
@@ -36,7 +36,7 @@ export const defaultDisplayConfig: PetDisplayConfig = {
36
36
 
37
37
  /** Display value bounds (shared by load-time validation and setConfig). */
38
38
  export const DISPLAY_SIZE_MIN = 32
39
- export const DISPLAY_SIZE_MAX = 512
39
+ export const DISPLAY_SIZE_MAX = 1024
40
40
  export const DISPLAY_INSET_MAX = 10_000
41
41
 
42
42
  /** Everything persisted for the pet. */
@@ -314,18 +314,32 @@ describe('loadPetRegistry', () => {
314
314
  dshPetsDir: '',
315
315
  })
316
316
 
317
- // The repo checkout also resolves miku (frames2d gameplay pet) and
318
- // starry-doll (community sprite2d pet) from assets/; the npm files
319
- // whitelist excludes them (Workshop delivery), so npm installs see the
320
- // three atlas pets until a Workshop install lands them under
321
- // $DSH_HOME/pets.
317
+ // The repo checkout also resolves miku (frames2d gameplay pet), jyn
318
+ // (frames2d gameplay pet) and starry-doll (community sprite2d pet) from
319
+ // assets/; the npm files whitelist excludes them (Workshop delivery), so
320
+ // npm installs see the atlas pets until a Workshop install lands them
321
+ // under $DSH_HOME/pets. blue-throated-bee-eater ships bundled alongside
322
+ // the other atlas pets.
322
323
  expect(registry.entries.map(entry => entry.id)).toEqual([
324
+ 'blue-throated-bee-eater',
325
+ 'jyn',
323
326
  'miku',
324
327
  'ouo-neko',
325
328
  'starry-doll',
326
329
  'whale-girl',
327
330
  'whale-girl-refined',
328
331
  ])
332
+ expect(registry.byId('blue-throated-bee-eater')).toMatchObject({
333
+ displayName: '蓝喉蜂虎',
334
+ atlasRows: 9,
335
+ columns: 8,
336
+ rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
337
+ })
338
+ expect(existsSync(petAtlasFile(registry.byId('blue-throated-bee-eater')!))).toBe(true)
339
+ expect(registry.byId('jyn')).toMatchObject({
340
+ displayName: '女仆鲸鱼娘',
341
+ renderer: 'frames2d',
342
+ })
329
343
  expect(registry.byId('ouo-neko')).toMatchObject({
330
344
  displayName: 'OUO Neko',
331
345
  atlasRows: 11,
package/src/registry.ts CHANGED
@@ -215,6 +215,30 @@ export interface PetFrames2dDefinition {
215
215
  tracks: Record<string, PetFrames2dTrackView>
216
216
  /** ActivityPhase -> track id; unmapped phases fall back to idle. */
217
217
  phases: Partial<Record<ActivityPhase, string>> & { idle: string }
218
+ /** Selectable skins; each swaps the base idle target to its idleTrack. */
219
+ skins?: PetSkinDefinition[]
220
+ }
221
+
222
+ /** One selectable frames2d skin as served to the browser half. */
223
+ export interface PetSkinDefinition {
224
+ id: string
225
+ label: string
226
+ /** A declared looping track that becomes the base idle while selected. */
227
+ idleTrack: string
228
+ /** Click actions exclusive to this skin (roll by probability; miss → touch zones). */
229
+ clickActions?: PetSkinClickActionDefinition[]
230
+ /** Gameplay-state overrides (state -> track) swapped in while selected. */
231
+ gameplayTracks?: Record<string, string>
232
+ }
233
+
234
+ /** One probability-rolled tap action a skin may declare. */
235
+ export interface PetSkinClickActionDefinition {
236
+ /** A declared track played once on hit. */
237
+ track: string
238
+ /** Hit probability (0..1). */
239
+ probability: number
240
+ /** Optional lines spoken when the action fires. */
241
+ phrases?: string[]
218
242
  }
219
243
 
220
244
  /** A normalized pet as served to the browser half. */
@@ -719,6 +743,47 @@ function resolveFrames2dEntry(
719
743
  phases[phase as ActivityPhase] = idleTrack
720
744
  }
721
745
  }
746
+ // Skins: keep only entries whose idleTrack survived track resolution, and
747
+ // drop click actions whose track did not (the skin itself stays usable).
748
+ let skins: PetSkinDefinition[] | undefined
749
+ if (block.skins !== undefined) {
750
+ const resolved: PetSkinDefinition[] = []
751
+ for (const skin of block.skins) {
752
+ if (tracks[skin.idleTrack] === undefined) {
753
+ record('warning', 'pet ' + manifest.id + ': frames2d skin ' + JSON.stringify(skin.id) + ' idleTrack dropped; skipping')
754
+ continue
755
+ }
756
+ let clickActions: PetSkinClickActionDefinition[] | undefined
757
+ if (skin.clickActions !== undefined) {
758
+ const kept: PetSkinClickActionDefinition[] = []
759
+ for (const action of skin.clickActions) {
760
+ if (tracks[action.track] === undefined) {
761
+ record('warning', 'pet ' + manifest.id + ': frames2d skin ' + JSON.stringify(skin.id)
762
+ + ' click action track ' + JSON.stringify(action.track) + ' dropped')
763
+ continue
764
+ }
765
+ kept.push(action)
766
+ }
767
+ if (kept.length > 0) clickActions = kept
768
+ }
769
+ // Gameplay track overrides: drop entries whose track did not survive.
770
+ let gameplayTracks: Record<string, string> | undefined
771
+ if (skin.gameplayTracks !== undefined) {
772
+ const kept: Record<string, string> = {}
773
+ for (const [state, gTrack] of Object.entries(skin.gameplayTracks)) {
774
+ if (tracks[gTrack] === undefined) {
775
+ record('warning', 'pet ' + manifest.id + ': frames2d skin ' + JSON.stringify(skin.id)
776
+ + ' gameplay track override ' + JSON.stringify(state) + ' -> ' + JSON.stringify(gTrack) + ' dropped')
777
+ continue
778
+ }
779
+ kept[state] = gTrack
780
+ }
781
+ if (Object.keys(kept).length > 0) gameplayTracks = kept
782
+ }
783
+ resolved.push({ ...skin, ...(clickActions === undefined ? {} : { clickActions }), ...(gameplayTracks === undefined ? {} : { gameplayTracks }) })
784
+ }
785
+ if (resolved.length > 0) skins = resolved
786
+ }
722
787
  const remarks = normalizePetRemarks(manifest.remarks, message => record('warning', 'pet ' + manifest.id + ': ' + message))
723
788
  // Gameplay layer: shop item icons are manifest-relative frame paths —
724
789
  // verify them, promote them to browser URLs, and add them to the servable
@@ -756,7 +821,7 @@ function resolveFrames2dEntry(
756
821
  displayName: manifest.displayName,
757
822
  description: manifest.description ?? '',
758
823
  renderer: 'frames2d' as const,
759
- frames2d: { tracks, phases },
824
+ frames2d: { tracks, phases, ...(skins === undefined ? {} : { skins }) },
760
825
  ...(gameplay === undefined ? {} : { gameplay }),
761
826
  cell,
762
827
  columns: DEFAULT_PET_COLUMNS,