@linxin666/dsh-pet 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +25 -7
- package/README.zh.md +25 -7
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/lib/client.js +830 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1226 -24
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +34 -29
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +16 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -0
- package/lib/types/service.d.ts +56 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +181 -0
- package/package.json +6 -3
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +36 -6
- package/src/client/PetSprite.tsx +42 -21
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.ts +23 -2
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/http.ts +1 -1
- package/src/index.ts +1 -1
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/loopback.ts +1 -1
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/mount-once.ts +2 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +19 -0
- package/src/service.ts +208 -0
package/src/registry.test.ts
CHANGED
|
@@ -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
|
|
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 =>
|
|
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 = {
|