@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.
Files changed (127) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +25 -7
  3. package/README.zh.md +25 -7
  4. package/assets/ouo-neko/pet.json +31 -0
  5. package/assets/ouo-neko/previews/failed.gif +0 -0
  6. package/assets/ouo-neko/previews/idle.gif +0 -0
  7. package/assets/ouo-neko/previews/jumping.gif +0 -0
  8. package/assets/ouo-neko/previews/review.gif +0 -0
  9. package/assets/ouo-neko/previews/running-left.gif +0 -0
  10. package/assets/ouo-neko/previews/running-right.gif +0 -0
  11. package/assets/ouo-neko/previews/running.gif +0 -0
  12. package/assets/ouo-neko/previews/waiting.gif +0 -0
  13. package/assets/ouo-neko/previews/waving.gif +0 -0
  14. package/assets/ouo-neko/spritesheet.webp +0 -0
  15. package/contracts/pet-manifest-v2.schema.json +102 -1
  16. package/lib/client.js +830 -26
  17. package/lib/client.js.map +1 -1
  18. package/lib/index.js +1226 -24
  19. package/lib/types/client/PetDockEntry.d.ts +3 -0
  20. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  21. package/lib/types/client/PetDockEntry.js +18 -2
  22. package/lib/types/client/PetSprite.d.ts +23 -0
  23. package/lib/types/client/PetSprite.d.ts.map +1 -1
  24. package/lib/types/client/PetSprite.js +34 -29
  25. package/lib/types/client/PluginSettingsCard.d.ts +2 -0
  26. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  27. package/lib/types/client/PluginSettingsCard.js +2 -2
  28. package/lib/types/client/drag-stream.d.ts +19 -0
  29. package/lib/types/client/drag-stream.d.ts.map +1 -0
  30. package/lib/types/client/drag-stream.js +26 -0
  31. package/lib/types/client/gameplay-hud.d.ts +50 -0
  32. package/lib/types/client/gameplay-hud.d.ts.map +1 -0
  33. package/lib/types/client/gameplay-hud.js +274 -0
  34. package/lib/types/client/index.d.ts +1 -1
  35. package/lib/types/client/index.d.ts.map +1 -1
  36. package/lib/types/client/index.js +16 -0
  37. package/lib/types/client/locales.d.ts +34 -0
  38. package/lib/types/client/locales.d.ts.map +1 -1
  39. package/lib/types/client/locales.js +36 -0
  40. package/lib/types/client/pet-store.d.ts +7 -1
  41. package/lib/types/client/pet-store.d.ts.map +1 -1
  42. package/lib/types/client/pet-store.js +4 -0
  43. package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
  44. package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
  45. package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
  46. package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
  47. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
  48. package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
  49. package/lib/types/client/renderers/frames2d.d.ts +36 -0
  50. package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
  51. package/lib/types/client/renderers/frames2d.js +196 -0
  52. package/lib/types/client/settings-form.d.ts +1 -1
  53. package/lib/types/client/settings-form.js +1 -1
  54. package/lib/types/client/telemetry.d.ts +27 -0
  55. package/lib/types/client/telemetry.d.ts.map +1 -0
  56. package/lib/types/client/telemetry.js +97 -0
  57. package/lib/types/defaults.d.ts +6 -0
  58. package/lib/types/defaults.d.ts.map +1 -0
  59. package/lib/types/defaults.js +5 -0
  60. package/lib/types/dsh-home.d.ts.map +1 -1
  61. package/lib/types/dsh-home.js +9 -3
  62. package/lib/types/gameplay.d.ts +182 -0
  63. package/lib/types/gameplay.d.ts.map +1 -0
  64. package/lib/types/gameplay.js +618 -0
  65. package/lib/types/http.d.ts +1 -1
  66. package/lib/types/http.js +1 -1
  67. package/lib/types/index.d.ts +1 -1
  68. package/lib/types/index.js +1 -1
  69. package/lib/types/ledger.d.ts +14 -0
  70. package/lib/types/ledger.d.ts.map +1 -1
  71. package/lib/types/ledger.js +30 -0
  72. package/lib/types/loopback.d.ts +1 -1
  73. package/lib/types/loopback.js +1 -1
  74. package/lib/types/manifest-v2.d.ts +38 -1
  75. package/lib/types/manifest-v2.d.ts.map +1 -1
  76. package/lib/types/manifest-v2.js +182 -2
  77. package/lib/types/mount-once.d.ts +1 -1
  78. package/lib/types/mount-once.js +2 -2
  79. package/lib/types/persist.d.ts +4 -4
  80. package/lib/types/persist.d.ts.map +1 -1
  81. package/lib/types/persist.js +41 -4
  82. package/lib/types/registry.d.ts +29 -0
  83. package/lib/types/registry.d.ts.map +1 -1
  84. package/lib/types/registry.js +170 -1
  85. package/lib/types/routes.d.ts.map +1 -1
  86. package/lib/types/routes.js +22 -0
  87. package/lib/types/service.d.ts +56 -0
  88. package/lib/types/service.d.ts.map +1 -1
  89. package/lib/types/service.js +181 -0
  90. package/package.json +6 -3
  91. package/src/client/PetDockEntry.test.tsx +6 -0
  92. package/src/client/PetDockEntry.tsx +32 -1
  93. package/src/client/PetSprite.test.tsx +36 -6
  94. package/src/client/PetSprite.tsx +42 -21
  95. package/src/client/PluginSettingsCard.tsx +4 -2
  96. package/src/client/drag-stream.ts +35 -0
  97. package/src/client/gameplay-hud.test.tsx +338 -0
  98. package/src/client/gameplay-hud.tsx +403 -0
  99. package/src/client/index.ts +23 -2
  100. package/src/client/locales.ts +36 -0
  101. package/src/client/pet-store.ts +10 -1
  102. package/src/client/pet.module.css +192 -29
  103. package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
  104. package/src/client/renderers/PetRendererSwitch.tsx +28 -1
  105. package/src/client/renderers/frames2d.test.ts +137 -0
  106. package/src/client/renderers/frames2d.ts +216 -0
  107. package/src/client/settings-card.module.css +5 -0
  108. package/src/client/settings-form.ts +1 -1
  109. package/src/client/telemetry.ts +105 -0
  110. package/src/defaults.ts +7 -0
  111. package/src/dsh-home.ts +9 -3
  112. package/src/gameplay.test.ts +239 -0
  113. package/src/gameplay.ts +692 -0
  114. package/src/http.ts +1 -1
  115. package/src/index.ts +1 -1
  116. package/src/ledger.test.ts +16 -0
  117. package/src/ledger.ts +31 -0
  118. package/src/loopback.ts +1 -1
  119. package/src/manifest-v2.test.ts +130 -1
  120. package/src/manifest-v2.ts +181 -2
  121. package/src/mount-once.ts +2 -2
  122. package/src/persist.test.ts +3 -0
  123. package/src/persist.ts +43 -6
  124. package/src/registry.test.ts +168 -1
  125. package/src/registry.ts +199 -1
  126. package/src/routes.ts +19 -0
  127. package/src/service.ts +208 -0
@@ -152,18 +152,185 @@ describe('resolvePetManifest', () => {
152
152
  })
153
153
  })
154
154
 
155
+ describe('loadPetRegistry frames2d', () => {
156
+ /** Write a frames2d pet fixture: pet.json + thumb/<track>/<frame> files. */
157
+ function writeFrames2dPet(root: string, id: string, manifest: Record<string, unknown>, files: Record<string, string[]>): string {
158
+ const dir = join(root, 'assets', id)
159
+ mkdirSync(dir, { recursive: true })
160
+ writeFileSync(join(dir, 'pet.json'), JSON.stringify({
161
+ petManifestVersion: 2,
162
+ id,
163
+ displayName: id,
164
+ license: 'MIT',
165
+ renderer: 'frames2d',
166
+ ...manifest,
167
+ }), 'utf8')
168
+ for (const [trackDir, frames] of Object.entries(files)) {
169
+ mkdirSync(join(dir, trackDir), { recursive: true })
170
+ for (const frame of frames) writeFileSync(join(dir, trackDir, frame), 'webp', 'utf8')
171
+ }
172
+ return dir
173
+ }
174
+
175
+ it('resolves directory-listed tracks with filename-encoded durations', () => {
176
+ const root = tempDir()
177
+ try {
178
+ writeFrames2dPet(root, 'miku', {
179
+ frames2d: {
180
+ dir: 'thumb',
181
+ tracks: { idle: { loop: true }, happy: { loop: false } },
182
+ phases: { idle: 'idle', done: 'happy' },
183
+ },
184
+ }, {
185
+ 'thumb/idle': ['miku_1_200.webp', 'miku_2_260.webp'],
186
+ 'thumb/happy': ['miku-happy_1_300.webp'],
187
+ })
188
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
189
+ const entry = registry.byId('miku')
190
+ expect(entry?.renderer).toBe('frames2d')
191
+ expect(entry?.frames2d?.phases).toEqual({ idle: 'idle', done: 'happy' })
192
+ expect(entry?.frames2d?.tracks.idle).toMatchObject({ loop: true, durations: [200, 260] })
193
+ expect(entry?.frames2d?.tracks.idle?.frames).toEqual([
194
+ '/pet/miku/thumb/idle/miku_1_200.webp',
195
+ '/pet/miku/thumb/idle/miku_2_260.webp',
196
+ ])
197
+ expect(entry?.frames2d?.tracks.happy).toMatchObject({ loop: false, fallback: 'idle', durations: [300] })
198
+ expect([...(entry?.servable ?? [])].sort()).toEqual([
199
+ 'thumb/happy/miku-happy_1_300.webp',
200
+ 'thumb/idle/miku_1_200.webp',
201
+ 'thumb/idle/miku_2_260.webp',
202
+ ])
203
+ expect(entry?.atlasUrl).toBe('/pet/miku/thumb/idle/miku_1_200.webp')
204
+ // The browser view carries the frames2d block.
205
+ const view = petEntryView(entry!)
206
+ expect(view.frames2d?.tracks.idle?.frames).toHaveLength(2)
207
+ } finally {
208
+ rmSync(root, { recursive: true, force: true })
209
+ }
210
+ })
211
+
212
+ it('orders directory-scanned frames by their trailing index (10+ frames)', () => {
213
+ const root = tempDir()
214
+ try {
215
+ const names = Array.from({ length: 12 }, (_, index) => 'miku-pet-eat' + String(index + 1) + '.webp')
216
+ writeFrames2dPet(root, 'miku', {
217
+ frames2d: { dir: 'thumb', tracks: { eat: { loop: false } }, phases: { idle: 'eat' } },
218
+ }, { 'thumb/eat': names })
219
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
220
+ const frames = registry.byId('miku')?.frames2d?.tracks.eat?.frames ?? []
221
+ expect(frames).toHaveLength(12)
222
+ expect(frames[1]).toBe('/pet/miku/thumb/eat/miku-pet-eat2.webp')
223
+ expect(frames[9]).toBe('/pet/miku/thumb/eat/miku-pet-eat10.webp')
224
+ expect(frames[11]).toBe('/pet/miku/thumb/eat/miku-pet-eat12.webp')
225
+ } finally {
226
+ rmSync(root, { recursive: true, force: true })
227
+ }
228
+ })
229
+
230
+ it('honours explicit frames order and frameMs over filename-encoded durations', () => {
231
+ const root = tempDir()
232
+ try {
233
+ writeFrames2dPet(root, 'fox', {
234
+ frames2d: {
235
+ dir: 'thumb',
236
+ tracks: { idle: { frames: ['b_100.webp', 'a_100.webp'], frameMs: [111, 222], loop: true } },
237
+ phases: { idle: 'idle' },
238
+ },
239
+ }, { 'thumb/idle': ['a_100.webp', 'b_100.webp'] })
240
+ const entry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' }).byId('fox')
241
+ expect(entry?.frames2d?.tracks.idle?.frames).toEqual([
242
+ '/pet/fox/thumb/idle/b_100.webp',
243
+ '/pet/fox/thumb/idle/a_100.webp',
244
+ ])
245
+ expect(entry?.frames2d?.tracks.idle?.durations).toEqual([111, 222])
246
+ } finally {
247
+ rmSync(root, { recursive: true, force: true })
248
+ }
249
+ })
250
+
251
+ it('drops an empty track and remaps its phases to idle with a warning', () => {
252
+ const root = tempDir()
253
+ try {
254
+ writeFrames2dPet(root, 'miku', {
255
+ frames2d: {
256
+ dir: 'thumb',
257
+ tracks: { idle: {}, work: {} },
258
+ phases: { idle: 'idle', thinking: 'work' },
259
+ },
260
+ }, { 'thumb/idle': ['miku_1_200.webp'] })
261
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
262
+ const entry = registry.byId('miku')
263
+ expect(entry?.frames2d?.tracks.work).toBeUndefined()
264
+ expect(entry?.frames2d?.phases.thinking).toBe('idle')
265
+ expect(registry.warnings.some(warning => warning.includes('work'))).toBe(true)
266
+ } finally {
267
+ rmSync(root, { recursive: true, force: true })
268
+ }
269
+ })
270
+
271
+ it('rejects the entry fail-closed when the idle track has no frames on disk', () => {
272
+ const root = tempDir()
273
+ try {
274
+ writeFrames2dPet(root, 'miku', {
275
+ frames2d: {
276
+ dir: 'thumb',
277
+ tracks: { idle: {}, happy: {} },
278
+ phases: { idle: 'idle', done: 'happy' },
279
+ },
280
+ }, { 'thumb/happy': ['miku_1_200.webp'] })
281
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
282
+ expect(registry.byId('miku')).toBeUndefined()
283
+ expect(registry.diagnostics.some(d => d.level === 'error' && d.message.includes('idle'))).toBe(true)
284
+ } finally {
285
+ rmSync(root, { recursive: true, force: true })
286
+ }
287
+ })
288
+
289
+ it('warns and skips a missing explicit frame', () => {
290
+ const root = tempDir()
291
+ try {
292
+ writeFrames2dPet(root, 'miku', {
293
+ frames2d: {
294
+ dir: 'thumb',
295
+ tracks: { idle: { frames: ['a_100.webp', 'gone_100.webp'] } },
296
+ phases: { idle: 'idle' },
297
+ },
298
+ }, { 'thumb/idle': ['a_100.webp'] })
299
+ const registry = loadPetRegistry({ packageRoot: root, petsDir: '', dshPetsDir: '' })
300
+ const entry = registry.byId('miku')
301
+ expect(entry?.frames2d?.tracks.idle?.frames).toEqual(['/pet/miku/thumb/idle/a_100.webp'])
302
+ expect(registry.warnings.some(warning => warning.includes('gone_100.webp'))).toBe(true)
303
+ } finally {
304
+ rmSync(root, { recursive: true, force: true })
305
+ }
306
+ })
307
+ })
308
+
155
309
  describe('loadPetRegistry', () => {
156
- it('ships the original and refined whale variants while keeping the original default', () => {
310
+ it('ships every built-in pet while keeping the original whale as default', () => {
157
311
  const registry = loadPetRegistry({
158
312
  packageRoot: petPackageRoot(import.meta.url),
159
313
  petsDir: '',
160
314
  dshPetsDir: '',
161
315
  })
162
316
 
317
+ // The repo checkout also resolves miku (frames2d gameplay pet) from
318
+ // assets/; the npm files whitelist excludes it (Workshop delivery), so
319
+ // npm installs see the three atlas pets until a Workshop install lands
320
+ // miku under $DSH_HOME/pets.
163
321
  expect(registry.entries.map(entry => entry.id)).toEqual([
322
+ 'miku',
323
+ 'ouo-neko',
164
324
  'whale-girl',
165
325
  'whale-girl-refined',
166
326
  ])
327
+ expect(registry.byId('ouo-neko')).toMatchObject({
328
+ displayName: 'OUO Neko',
329
+ atlasRows: 11,
330
+ columns: 8,
331
+ rows: [6, 8, 8, 4, 5, 8, 6, 6, 6],
332
+ })
333
+ expect(existsSync(petAtlasFile(registry.byId('ouo-neko')!))).toBe(true)
167
334
  expect(registry.byId('whale-girl')?.displayName).toBe('鲸鱼娘(原版)')
168
335
  expect(registry.byId('whale-girl-refined')?.displayName).toBe('鲸鱼娘(精致版)')
169
336
  expect(existsSync(petAtlasFile(registry.byId('whale-girl-refined')!))).toBe(true)
package/src/registry.ts CHANGED
@@ -39,7 +39,9 @@ import { imageDimensions } from './image-dimensions.ts'
39
39
  import { PET_DECORATION_API_VERSION, type DecorationView } from './contracts/status-decoration.ts'
40
40
  import { dshHome } from './dsh-home.ts'
41
41
  import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
42
+ import type { PetGameplayManifest } from './gameplay.ts'
42
43
  import { collectModel3References } from './model3.ts'
44
+ import { DEFAULT_PET_ID } from './defaults.ts'
43
45
 
44
46
  /** Fixed row order of the 9-state animation contract. */
45
47
  export const PET_ROW_ORDER: readonly PetAnimation[] = [
@@ -195,6 +197,26 @@ export interface PetLive2dDefinition {
195
197
  hitAreas?: string[]
196
198
  }
197
199
 
200
+ /** One frames2d track as served to the browser half. */
201
+ export interface PetFrames2dTrackView {
202
+ /** Browser URLs of the frames in play order. */
203
+ frames: string[]
204
+ /** Per-frame durations in ms; same length as frames. */
205
+ durations: number[]
206
+ /** Whether the track loops; a non-looping track ends into fallback. */
207
+ loop: boolean
208
+ /** Track entered when a non-looping track finishes (defaults to the idle track). */
209
+ fallback?: string
210
+ }
211
+
212
+ /** The frames2d renderer block as served to the browser half. */
213
+ export interface PetFrames2dDefinition {
214
+ /** Named tracks keyed by track id. */
215
+ tracks: Record<string, PetFrames2dTrackView>
216
+ /** ActivityPhase -> track id; unmapped phases fall back to idle. */
217
+ phases: Partial<Record<ActivityPhase, string>> & { idle: string }
218
+ }
219
+
198
220
  /** A normalized pet as served to the browser half. */
199
221
  export interface PetDefinition {
200
222
  id: string
@@ -204,6 +226,14 @@ export interface PetDefinition {
204
226
  renderer: PetRendererKind
205
227
  /** Live2d render block; present exactly when renderer is 'live2d' (M3). */
206
228
  live2d?: PetLive2dDefinition
229
+ /** Frames2d render block; present exactly when renderer is 'frames2d'. */
230
+ frames2d?: PetFrames2dDefinition
231
+ /**
232
+ * The pet's gameplay layer (miku-pet generalization), present when the
233
+ * manifest declares 'gameplay'. Shop item images are served as browser
234
+ * URLs; every other field is the validated manifest block verbatim.
235
+ */
236
+ gameplay?: PetGameplayManifest
207
237
  /** Atlas cell size in px. */
208
238
  cell: PetCell
209
239
  /** Columns per row. */
@@ -580,6 +610,168 @@ function resolveLive2dEntry(
580
610
  }
581
611
  }
582
612
 
613
+ /** Filename-encoded frame duration tail ('<base>_<index>_<ms>.webp'). */
614
+ const FRAMES2D_FILENAME_MS = /_(\d+)\.[^.]+$/
615
+ /** Trailing frame index, allowing an optional '_<ms>' duration tail after it. */
616
+ const FRAMES2D_FRAME_INDEX = /(\d+)(?:_\d+)?\.[^.]+$/
617
+ /** Default per-frame duration when neither frameMs nor the filename encodes one. */
618
+ const FRAMES2D_DEFAULT_FRAME_MS = 200
619
+ /** Image extensions a frames2d track directory may list. */
620
+ const FRAMES2D_IMAGE_EXTENSIONS = new Set(['.webp', '.png', '.gif', '.jpg', '.jpeg'])
621
+
622
+ /**
623
+ * Resolve a validated frames2d manifest into a renderable entry. Track frame
624
+ * lists come from the manifest's explicit list or from listing
625
+ * '<dir>/<track>/' in filename order; durations resolve frameMs[i] >
626
+ * filename-encoded '_<ms>' tail > defaultFrameMs. Missing frames warn and
627
+ * skip (the live2d closure discipline); a track left with zero frames is
628
+ * dropped, and if the idle-mapped track ends up empty the entry is rejected
629
+ * fail-closed. The sprite fields carry contract defaults: the chrome sizes
630
+ * frames2d pets off 'display.size', not the atlas.
631
+ */
632
+ function resolveFrames2dEntry(
633
+ manifest: PetManifestV2,
634
+ dir: string,
635
+ options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
636
+ ): PetEntry | undefined {
637
+ const assetPrefix = options.assetPrefix ?? '/pet'
638
+ const record = (level: 'error' | 'warning', message: string): void => {
639
+ options.diagnostics?.push({ level, source: dir, message })
640
+ options.warnings?.push(message)
641
+ }
642
+ const block = manifest.frames2d
643
+ if (block === undefined) {
644
+ record('error', 'pet ' + manifest.id + ': renderer frames2d requires a frames2d block')
645
+ return undefined
646
+ }
647
+ const root = block.dir ?? '.'
648
+ const defaultMs = block.defaultFrameMs ?? FRAMES2D_DEFAULT_FRAME_MS
649
+ const idleTrack = block.phases.idle
650
+ const tracks: Record<string, PetFrames2dTrackView> = {}
651
+ const servable: string[] = []
652
+ const firstFrameRel: Record<string, string> = {}
653
+ for (const [name, track] of Object.entries(block.tracks)) {
654
+ const trackDir = root === '.' ? name : root + '/' + name
655
+ let relFrames: string[] = []
656
+ if (track.frames !== undefined) {
657
+ for (const frame of track.frames) {
658
+ const rel = trackDir + '/' + frame
659
+ if (!existsSync(join(dir, rel))) {
660
+ record('warning', 'pet ' + manifest.id + ': frames2d frame missing: ' + rel)
661
+ continue
662
+ }
663
+ relFrames.push(rel)
664
+ }
665
+ } else {
666
+ let files: string[] = []
667
+ try {
668
+ files = readdirSync(join(dir, trackDir)).filter(file => {
669
+ if (file.startsWith('.')) return false
670
+ const dot = file.lastIndexOf('.')
671
+ return dot > 0 && FRAMES2D_IMAGE_EXTENSIONS.has(file.slice(dot).toLowerCase())
672
+ })
673
+ } catch {
674
+ files = []
675
+ }
676
+ // Natural frame order: the trailing index (before any _<ms> duration
677
+ // suffix) sorts numerically, so 'eat10' comes after 'eat9' — plain
678
+ // lexicographic order would break any track with 10+ frames.
679
+ files.sort((a, b) => {
680
+ const ia = FRAMES2D_FRAME_INDEX.exec(a)?.[1]
681
+ const ib = FRAMES2D_FRAME_INDEX.exec(b)?.[1]
682
+ if (ia !== undefined && ib !== undefined && ia !== ib) return Number(ia) - Number(ib)
683
+ return a < b ? -1 : a > b ? 1 : 0
684
+ })
685
+ relFrames = files.map(file => trackDir + '/' + file)
686
+ }
687
+ if (relFrames.length === 0) {
688
+ record('warning', 'pet ' + manifest.id + ': frames2d track ' + JSON.stringify(name) + ' has no frames on disk; dropped')
689
+ continue
690
+ }
691
+ const durations = relFrames.map((rel, index) => {
692
+ if (track.frameMs !== undefined) return track.frameMs[index] ?? defaultMs
693
+ const match = FRAMES2D_FILENAME_MS.exec(rel)
694
+ if (match !== null) {
695
+ const ms = Number(match[1])
696
+ if (Number.isInteger(ms) && ms >= 16 && ms <= 5000) return ms
697
+ }
698
+ return defaultMs
699
+ })
700
+ const loop = track.loop ?? true
701
+ const view: PetFrames2dTrackView = {
702
+ frames: relFrames.map(rel => assetUrl(assetPrefix, manifest.id, rel)),
703
+ durations,
704
+ loop,
705
+ ...(loop ? {} : { fallback: track.fallback ?? idleTrack }),
706
+ }
707
+ tracks[name] = view
708
+ firstFrameRel[name] = relFrames[0]!
709
+ servable.push(...relFrames)
710
+ }
711
+ if (tracks[idleTrack] === undefined) {
712
+ record('error', 'pet ' + manifest.id + ': frames2d idle track ' + JSON.stringify(idleTrack) + ' has no frames on disk')
713
+ return undefined
714
+ }
715
+ const phases = { ...block.phases }
716
+ for (const [phase, target] of Object.entries(phases)) {
717
+ if (target !== undefined && tracks[target] === undefined) {
718
+ record('warning', 'pet ' + manifest.id + ': frames2d phase ' + phase + ' maps to dropped track ' + JSON.stringify(target) + '; using idle')
719
+ phases[phase as ActivityPhase] = idleTrack
720
+ }
721
+ }
722
+ const remarks = normalizePetRemarks(manifest.remarks, message => record('warning', 'pet ' + manifest.id + ': ' + message))
723
+ // Gameplay layer: shop item icons are manifest-relative frame paths —
724
+ // verify them, promote them to browser URLs, and add them to the servable
725
+ // allow-list so the asset route can serve them.
726
+ let gameplay: PetGameplayManifest | undefined
727
+ if (manifest.gameplay !== undefined) {
728
+ gameplay = manifest.gameplay
729
+ if (gameplay.shop !== undefined) {
730
+ const items = []
731
+ for (const item of gameplay.shop.items) {
732
+ if (item.image === undefined) {
733
+ items.push(item)
734
+ continue
735
+ }
736
+ if (!existsSync(join(dir, item.image))) {
737
+ record('warning', 'pet ' + manifest.id + ': gameplay shop item ' + item.id + ' image missing: ' + item.image)
738
+ const { image, ...rest } = item
739
+ items.push(rest)
740
+ continue
741
+ }
742
+ servable.push(item.image)
743
+ items.push({ ...item, image: assetUrl(assetPrefix, manifest.id, item.image) })
744
+ }
745
+ gameplay = { ...gameplay, shop: { ...gameplay.shop, items } }
746
+ }
747
+ }
748
+ const flatTracks = buildTracks(DEFAULT_FRAME_COUNTS, DEFAULT_PET_COLUMNS, {}, message => record('warning', 'pet ' + manifest.id + ': ' + message))
749
+ if (flatTracks === undefined) return undefined
750
+ // The render box comes from the first idle frame when decodable.
751
+ const firstAbs = join(dir, firstFrameRel[idleTrack]!)
752
+ const dims = existsSync(firstAbs) ? readImageDimensions(firstAbs) : undefined
753
+ const cell = dims !== undefined && dims.width >= 1 && dims.height >= 1 ? dims : { ...DEFAULT_PET_CELL }
754
+ return {
755
+ id: manifest.id,
756
+ displayName: manifest.displayName,
757
+ description: manifest.description ?? '',
758
+ renderer: 'frames2d' as const,
759
+ frames2d: { tracks, phases },
760
+ ...(gameplay === undefined ? {} : { gameplay }),
761
+ cell,
762
+ columns: DEFAULT_PET_COLUMNS,
763
+ rows: [...DEFAULT_FRAME_COUNTS],
764
+ atlasRows: DEFAULT_PET_ROW_COUNT,
765
+ tracks: flatTracks,
766
+ atlasUrl: tracks[idleTrack]!.frames[0]!,
767
+ manifestUrl: assetUrl(assetPrefix, manifest.id, 'pet.json'),
768
+ dir,
769
+ spritesheetPath: firstFrameRel[idleTrack]!,
770
+ servable,
771
+ ...(remarks === undefined ? {} : { remarks }),
772
+ }
773
+ }
774
+
583
775
  /** Scan one directory of pet folders; entries come back in name order. */
584
776
  function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): PetEntry[] {
585
777
  if (!existsSync(dir)) return []
@@ -606,6 +798,8 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
606
798
  let entry: PetEntry | undefined
607
799
  if (verdict.manifest.renderer === 'live2d') {
608
800
  entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
801
+ } else if (verdict.manifest.renderer === 'frames2d') {
802
+ entry = resolveFrames2dEntry(verdict.manifest, entryDir, options)
609
803
  } else {
610
804
  const legacy = flattenV2Sprite2d(verdict.manifest)
611
805
  if (legacy === undefined) {
@@ -907,7 +1101,9 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
907
1101
  warnings,
908
1102
  diagnostics,
909
1103
  byId: (id: string) => byId.get(id),
910
- defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
1104
+ defaultEntry: () => entries.find(entry => (
1105
+ entry.id === DEFAULT_PET_ID && builtinIds.has(entry.id)
1106
+ )) ?? entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
911
1107
  ...(globalVoice === undefined ? {} : { globalVoice }),
912
1108
  decorations,
913
1109
  decorationById: (id: string) => decorationById.get(id),
@@ -948,6 +1144,8 @@ export function petEntryView(entry: PetEntry, globalVoice?: VoicePack): PetDefin
948
1144
  description: entry.description,
949
1145
  renderer: entry.renderer,
950
1146
  ...(entry.live2d === undefined ? {} : { live2d: entry.live2d }),
1147
+ ...(entry.frames2d === undefined ? {} : { frames2d: entry.frames2d }),
1148
+ ...(entry.gameplay === undefined ? {} : { gameplay: entry.gameplay }),
951
1149
  cell: entry.cell,
952
1150
  columns: entry.columns,
953
1151
  rows: entry.rows,
package/src/routes.ts CHANGED
@@ -554,6 +554,25 @@ export function makePetRoutes(deps: { service: PetService; ctx: Context; assetCa
554
554
  if (typeof petId !== 'string') return Promise.reject(new Error('invalid-pet'))
555
555
  return service.setPetId(petId)
556
556
  }),
557
+ // Gameplay verbs (miku-pet generalization): touch rolls a named zone's
558
+ // branch; the omitted-zone form is the plain-click boost during a touch
559
+ // animation. Mode/tick/buy drive the work, sleep and shop loops.
560
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/touch', (body) => {
561
+ const zone = body.zone
562
+ if (zone !== undefined && typeof zone !== 'string') return Promise.reject(new Error('invalid-zone'))
563
+ return service.gameplayTouch(zone)
564
+ }),
565
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/mode', (body) => {
566
+ const mode = body.mode
567
+ if (mode !== null && mode !== 'work' && mode !== 'sleep') return Promise.reject(new Error('invalid-mode'))
568
+ return service.gameplaySetMode(mode)
569
+ }),
570
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/work-tick', () => service.gameplayWorkTick()),
571
+ postRoute(ctx, PET_API_PREFIX + '/gameplay/buy', (body) => {
572
+ const item = body.item
573
+ if (typeof item !== 'string') return Promise.reject(new Error('invalid-item'))
574
+ return service.gameplayBuy(item)
575
+ }),
557
576
  ]
558
577
 
559
578
  const assetRoute: WebRoute = {