@linxin666/dsh-pet 0.2.1 → 0.2.3

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 (95) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +78 -5
  3. package/README.zh.md +78 -5
  4. package/assets/whale/pet.json +170 -21
  5. package/assets/whale-refined/pet.json +22 -0
  6. package/assets/whale-refined/spritesheet.webp +0 -0
  7. package/lib/client.js +442 -25
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +752 -36
  10. package/lib/live2d-vendor.js +995 -0
  11. package/lib/live2d-vendor.js.map +1 -0
  12. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  13. package/lib/types/client/PetDockEntry.js +2 -1
  14. package/lib/types/client/PetSettingsCard.d.ts +10 -0
  15. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  16. package/lib/types/client/PetSettingsCard.js +22 -1
  17. package/lib/types/client/PetSprite.d.ts +7 -1
  18. package/lib/types/client/PetSprite.d.ts.map +1 -1
  19. package/lib/types/client/PetSprite.js +20 -11
  20. package/lib/types/client/index.d.ts.map +1 -1
  21. package/lib/types/client/index.js +5 -0
  22. package/lib/types/client/locales.d.ts +10 -0
  23. package/lib/types/client/locales.d.ts.map +1 -1
  24. package/lib/types/client/locales.js +10 -0
  25. package/lib/types/client/phase-stream.d.ts +22 -0
  26. package/lib/types/client/phase-stream.d.ts.map +1 -0
  27. package/lib/types/client/phase-stream.js +28 -0
  28. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  29. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  30. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  31. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  32. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  33. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  34. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  35. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  36. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  37. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  38. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  39. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  40. package/lib/types/client/renderers/live2d.d.ts +52 -0
  41. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  42. package/lib/types/client/renderers/live2d.js +180 -0
  43. package/lib/types/client/renderers/registry.d.ts +32 -0
  44. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  45. package/lib/types/client/renderers/registry.js +49 -0
  46. package/lib/types/client/spritesheet.d.ts +1 -3
  47. package/lib/types/client/spritesheet.d.ts.map +1 -1
  48. package/lib/types/client/spritesheet.js +1 -5
  49. package/lib/types/contracts/renderer.d.ts +50 -0
  50. package/lib/types/contracts/renderer.d.ts.map +1 -0
  51. package/lib/types/contracts/renderer.js +15 -0
  52. package/lib/types/manifest-v2.d.ts +129 -0
  53. package/lib/types/manifest-v2.d.ts.map +1 -0
  54. package/lib/types/manifest-v2.js +387 -0
  55. package/lib/types/model3.d.ts +26 -0
  56. package/lib/types/model3.d.ts.map +1 -0
  57. package/lib/types/model3.js +96 -0
  58. package/lib/types/registry.d.ts +57 -5
  59. package/lib/types/registry.d.ts.map +1 -1
  60. package/lib/types/registry.js +208 -37
  61. package/lib/types/routes.d.ts +39 -2
  62. package/lib/types/routes.d.ts.map +1 -1
  63. package/lib/types/routes.js +183 -12
  64. package/lib/types/service.d.ts +5 -1
  65. package/lib/types/service.d.ts.map +1 -1
  66. package/lib/types/service.js +4 -0
  67. package/package.json +3 -2
  68. package/src/client/PetDockEntry.tsx +19 -11
  69. package/src/client/PetSettingsCard.tsx +39 -0
  70. package/src/client/PetSprite.test.tsx +11 -0
  71. package/src/client/PetSprite.tsx +27 -12
  72. package/src/client/index.ts +6 -0
  73. package/src/client/locales.ts +10 -0
  74. package/src/client/phase-stream.test.ts +76 -0
  75. package/src/client/phase-stream.ts +39 -0
  76. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  77. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  78. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  79. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  80. package/src/client/renderers/live2d/runtime.ts +119 -0
  81. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  82. package/src/client/renderers/live2d.test.ts +242 -0
  83. package/src/client/renderers/live2d.ts +218 -0
  84. package/src/client/renderers/registry.ts +58 -0
  85. package/src/client/settings-section.module.css +23 -0
  86. package/src/client/spritesheet.ts +1 -6
  87. package/src/contracts/renderer.ts +54 -0
  88. package/src/manifest-v2.test.ts +251 -0
  89. package/src/manifest-v2.ts +414 -0
  90. package/src/model3.test.ts +79 -0
  91. package/src/model3.ts +91 -0
  92. package/src/registry.test.ts +236 -3
  93. package/src/registry.ts +246 -38
  94. package/src/routes.ts +202 -13
  95. package/src/service.ts +6 -1
@@ -0,0 +1,242 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Live2D renderer unit tests (pet-center M3, issue #623) — the vendor stack
4
+ * is faked structurally (no pixi, no WebGL in jsdom); the runtime loader is
5
+ * module-mocked so script injection never happens.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+
9
+ const runtime = vi.hoisted(() => ({
10
+ core: true as boolean,
11
+ vendor: undefined as unknown,
12
+ }))
13
+ vi.mock('./live2d/runtime.ts', () => ({
14
+ ensureCubismCore: () => Promise.resolve(runtime.core),
15
+ ensureLive2dVendor: () => Promise.resolve(runtime.vendor),
16
+ }))
17
+
18
+ import { live2dRenderer, resetLive2dRenderer, type Live2dRendererHandle } from './live2d.ts'
19
+ import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
20
+ import type { PetRendererContext } from '../../contracts/renderer.ts'
21
+
22
+ interface FakeModel {
23
+ calls: unknown[][]
24
+ listeners: Record<string, () => void>
25
+ width: number
26
+ height: number
27
+ anchor: { set(x: number, y?: number): void }
28
+ position: { set(x: number, y: number): void }
29
+ scale: { set(x: number, y?: number): void }
30
+ internalModel: { settings: { motions: Record<string, unknown[]>; hitAreas: { Name: string }[] } }
31
+ motion(group: string, index?: number): Promise<boolean>
32
+ expression(name?: string): void
33
+ hitTest(x: number, y: number): string[]
34
+ on(event: string, fn: () => void): void
35
+ }
36
+
37
+ interface FakeApp {
38
+ canvas: HTMLCanvasElement
39
+ stage: { addChild(child: unknown): void }
40
+ renderer: { width: number; height: number }
41
+ init(options: Record<string, unknown>): Promise<void>
42
+ destroy(): void
43
+ destroyed: number
44
+ added: unknown
45
+ }
46
+
47
+ function fakeModel(motions: Record<string, unknown[]> = { Idle: [{}, {}], TapBody: [{}] }): FakeModel {
48
+ const model: FakeModel = {
49
+ calls: [],
50
+ listeners: {},
51
+ width: 1000,
52
+ height: 1200,
53
+ anchor: { set: (...args) => { model.calls.push(['anchor', ...args]) } },
54
+ position: { set: (...args) => { model.calls.push(['position', ...args]) } },
55
+ scale: { set: (...args) => { model.calls.push(['scale', ...args]) } },
56
+ internalModel: { settings: { motions, hitAreas: [{ Name: 'Body' }] } },
57
+ motion: (group, index) => { model.calls.push(['motion', group, index]); return Promise.resolve(true) },
58
+ expression: (name) => { model.calls.push(['expression', name]) },
59
+ hitTest: (x, y) => { model.calls.push(['hitTest', x, y]); return ['Body'] },
60
+ on: (event, fn) => { model.listeners[event] = fn },
61
+ }
62
+ return model
63
+ }
64
+
65
+ function fakeApp(): FakeApp {
66
+ const app: FakeApp = {
67
+ canvas: document.createElement('canvas'),
68
+ stage: { addChild: (child) => { app.added = child } },
69
+ renderer: { width: 160, height: 174 },
70
+ init: () => Promise.resolve(),
71
+ destroy: () => { app.destroyed += 1 },
72
+ destroyed: 0,
73
+ added: undefined,
74
+ }
75
+ return app
76
+ }
77
+
78
+ function fakeVendor(model: FakeModel, app: FakeApp): unknown {
79
+ return {
80
+ Application: class { constructor() { return app } },
81
+ extensions: { add: () => {} },
82
+ Live2DPlugin: {},
83
+ configureCubismSDK: () => {},
84
+ Live2DModel: { from: () => Promise.resolve(model) },
85
+ }
86
+ }
87
+
88
+ const CONFIG = { modelUrl: '/pet/haru/haru.model3.json', motions: { idle: 'Idle', thinking: 'TapBody' } }
89
+
90
+ function makeCtx(): { ctx: PetRendererContext; stream: PhaseStream; container: HTMLDivElement } {
91
+ const container = document.createElement('div')
92
+ const stream = createPhaseStream('idle')
93
+ return {
94
+ container,
95
+ stream,
96
+ ctx: {
97
+ petId: 'haru',
98
+ assetBase: '/pet/haru',
99
+ container,
100
+ phase: stream,
101
+ interact: () => {},
102
+ onCleanup: () => {},
103
+ },
104
+ }
105
+ }
106
+
107
+ async function flush(): Promise<void> {
108
+ for (let index = 0; index < 20; index += 1) await Promise.resolve()
109
+ }
110
+
111
+ describe('live2dRenderer', () => {
112
+ beforeEach(() => {
113
+ runtime.core = true
114
+ resetLive2dRenderer()
115
+ })
116
+ afterEach(() => {
117
+ vi.restoreAllMocks()
118
+ })
119
+
120
+ it('boots the model and maps phases onto motion groups with idle fallback', async () => {
121
+ const model = fakeModel()
122
+ const app = fakeApp()
123
+ runtime.vendor = fakeVendor(model, app)
124
+ vi.spyOn(Math, 'random').mockReturnValue(0.99)
125
+ const { ctx, stream, container } = makeCtx()
126
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
127
+ await flush()
128
+ expect(container.querySelector('canvas')).toBeTruthy()
129
+ expect(app.added).toBe(model)
130
+ // idle plays on boot: group 'Idle', random index floor(0.99 * 2) = 1.
131
+ expect(model.calls).toContainEqual(['motion', 'Idle', 1])
132
+ // auto-fit: min(160/1000, 174/1200) * 0.92 = 0.1334
133
+ const scaleCall = model.calls.find(call => call[0] === 'scale')
134
+ expect(scaleCall?.[1]).toBeCloseTo(0.145 * 0.92, 4)
135
+ stream.push('thinking')
136
+ expect(model.calls).toContainEqual(['motion', 'TapBody', 0])
137
+ stream.push('tool') // unmapped phase falls back to idle
138
+ expect(model.calls.filter(call => call[0] === 'motion').at(-1)).toEqual(['motion', 'Idle', 1])
139
+ handle.dispose()
140
+ expect(app.destroyed).toBe(1)
141
+ handle.dispose() // idempotent
142
+ expect(app.destroyed).toBe(1)
143
+ })
144
+
145
+ it('falls back to the idle group when a mapped group is absent from the model', async () => {
146
+ const model = fakeModel({ Idle: [{}] })
147
+ runtime.vendor = fakeVendor(model, fakeApp())
148
+ const { ctx, stream } = makeCtx()
149
+ live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, motions: { idle: 'Idle', done: 'Nope' } }))
150
+ await flush()
151
+ model.calls.length = 0
152
+ stream.push('done')
153
+ expect(model.calls).toContainEqual(['motion', 'Idle', 0])
154
+ })
155
+
156
+ it('layers the phase expression when the manifest declares one', async () => {
157
+ const model = fakeModel()
158
+ runtime.vendor = fakeVendor(model, fakeApp())
159
+ const { ctx, stream } = makeCtx()
160
+ live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, expressions: { thinking: 'F01' } }))
161
+ await flush()
162
+ stream.push('thinking')
163
+ expect(model.calls).toContainEqual(['expression', 'F01'])
164
+ })
165
+
166
+ it('plays the tap group on a declared hit area and resumes the phase group on finish', async () => {
167
+ const model = fakeModel()
168
+ runtime.vendor = fakeVendor(model, fakeApp())
169
+ const { ctx } = makeCtx()
170
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, hitAreas: ['Body'] })) as Live2dRendererHandle
171
+ await flush()
172
+ model.calls.length = 0
173
+ handle.tap(40, 60)
174
+ expect(model.calls).toContainEqual(['hitTest', 40, 60])
175
+ expect(model.calls).toContainEqual(['motion', 'TapBody', 0])
176
+ model.calls.length = 0
177
+ model.listeners.motionFinish?.()
178
+ expect(model.calls).toContainEqual(['motion', 'Idle', expect.any(Number)])
179
+ })
180
+
181
+ it('ignores taps outside the declared hit areas', async () => {
182
+ const model = fakeModel()
183
+ runtime.vendor = fakeVendor(model, fakeApp())
184
+ const { ctx } = makeCtx()
185
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, hitAreas: ['Head'] })) as Live2dRendererHandle
186
+ await flush()
187
+ model.calls.length = 0
188
+ handle.tap(10, 10)
189
+ expect(model.calls).toContainEqual(['hitTest', 10, 10])
190
+ expect(model.calls.some(call => call[0] === 'motion')).toBe(false)
191
+ })
192
+
193
+ it('reports core-missing when the user has not installed the Cubism Core', async () => {
194
+ runtime.core = false
195
+ runtime.vendor = fakeVendor(fakeModel(), fakeApp())
196
+ const { ctx } = makeCtx()
197
+ let code: string | undefined
198
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG)) as Live2dRendererHandle
199
+ handle.onError((next) => { code = next })
200
+ await flush()
201
+ expect(code).toBe('core-missing')
202
+ handle.dispose()
203
+ })
204
+
205
+ it('reports load-failed when the model fetch rejects', async () => {
206
+ const model = fakeModel()
207
+ const app = fakeApp()
208
+ runtime.vendor = {
209
+ Application: class { constructor() { return app } },
210
+ extensions: { add: () => {} },
211
+ Live2DPlugin: {},
212
+ configureCubismSDK: () => {},
213
+ Live2DModel: { from: () => Promise.reject(new Error('404')) },
214
+ }
215
+ const { ctx } = makeCtx()
216
+ let code: string | undefined
217
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG)) as Live2dRendererHandle
218
+ handle.onError((next) => { code = next })
219
+ await flush()
220
+ expect(code).toBe('load-failed')
221
+ handle.dispose()
222
+ })
223
+
224
+ it('never appends a canvas when disposed mid-boot', async () => {
225
+ const model = fakeModel()
226
+ const app = fakeApp()
227
+ runtime.vendor = fakeVendor(model, app)
228
+ const { ctx, container } = makeCtx()
229
+ const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
230
+ handle.dispose()
231
+ await flush()
232
+ expect(container.querySelector('canvas')).toBeNull()
233
+ // The app created mid-boot is destroyed exactly once (no leak), never twice.
234
+ expect(app.destroyed).toBe(1)
235
+ })
236
+
237
+ it('validateConfig is fail-closed', () => {
238
+ expect(() => live2dRenderer.validateConfig({})).toThrow('modelUrl')
239
+ expect(() => live2dRenderer.validateConfig({ modelUrl: '/x' })).toThrow('motions.idle')
240
+ expect(live2dRenderer.validateConfig(CONFIG)).toBe(CONFIG)
241
+ })
242
+ })
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Live2D renderer (pet-center M3, issue #623) — mounts a Cubism model into
3
+ * the center-owned container through the lazy vendor stack. The mount call
4
+ * itself is synchronous per the renderer contract: the boot (core script →
5
+ * vendor script → pixi → model) continues asynchronously and reports fatal
6
+ * failures through the handle's error sink, so the React bridge can render
7
+ * localized guidance. Disposing mid-boot is race-safe.
8
+ *
9
+ * Interaction model: the center chrome owns the affinity economy (its click
10
+ * handler fires the pet interaction exactly like sprite2d); this renderer's
11
+ * 'tap' affordance only drives the model's hit-area motion feedback. The
12
+ * contract's interact write-back stays available for future standalone
13
+ * mounts and is intentionally not invoked here.
14
+ *
15
+ * Motion mapping: manifests map ActivityPhases to motion GROUP names; every
16
+ * unmapped phase (and any mapped-but-absent group) falls back to the idle
17
+ * group — official sample models only ship Idle/TapBody, so the fallback is
18
+ * mandatory. Groups with multiple motions pick a random entry, and a tap
19
+ * that hits a declared hit area plays the conventional 'TapBody' group,
20
+ * returning to the phase's group when the tap motion finishes.
21
+ * @module @linxin666/dsh-pet/client/renderers/live2d
22
+ */
23
+
24
+ import type { ActivityPhase } from '../../state.ts'
25
+ import {
26
+ PET_RENDERER_API_VERSION,
27
+ type PetRenderer,
28
+ type PetRendererContext,
29
+ type PetRendererHandle,
30
+ } from '../../contracts/renderer.ts'
31
+ import {
32
+ ensureCubismCore,
33
+ ensureLive2dVendor,
34
+ type Live2dVendor,
35
+ type Live2dVendorApp,
36
+ type Live2dVendorModel,
37
+ } from './live2d/runtime.ts'
38
+
39
+ /** Renderer config: the client-visible live2d block (fail-closed validated). */
40
+ export interface PetLive2dConfig {
41
+ modelUrl: string
42
+ scale?: number
43
+ translate?: { x?: number; y?: number }
44
+ motions: Partial<Record<ActivityPhase, string>> & { idle: string }
45
+ expressions?: Partial<Record<ActivityPhase, string>>
46
+ hitAreas?: string[]
47
+ }
48
+
49
+ /** Fatal mount failure codes the bridge localizes. */
50
+ export type Live2dErrorCode = 'core-missing' | 'vendor-missing' | 'load-failed'
51
+
52
+ /** The live2d activation handle: contract dispose plus tap + error sink. */
53
+ export interface Live2dRendererHandle extends PetRendererHandle {
54
+ /** Forward a chrome tap in container coordinates; plays the hit motion. */
55
+ tap(x: number, y: number): void
56
+ /** Subscribe to fatal mount errors (at most one fires per activation). */
57
+ onError(listener: (code: Live2dErrorCode) => void): void
58
+ }
59
+
60
+ /** The de-facto tap-motion group of Cubism sample models. */
61
+ const TAP_GROUP = 'TapBody'
62
+
63
+ let vendorConfigured = false
64
+
65
+ /** Configure pixi extensions + the Cubism SDK once per page. */
66
+ function configureOnce(vendor: Live2dVendor): void {
67
+ if (vendorConfigured) return
68
+ vendorConfigured = true
69
+ vendor.extensions.add(vendor.Live2DPlugin)
70
+ vendor.configureCubismSDK({ memorySizeMB: 32 })
71
+ }
72
+
73
+ /** Reset module state (tests). */
74
+ export function resetLive2dRenderer(): void {
75
+ vendorConfigured = false
76
+ }
77
+
78
+ /** Fail-closed config validation (contract: unknown manifest block in). */
79
+ function validateLive2dConfig(config: unknown): PetLive2dConfig {
80
+ if (typeof config !== 'object' || config === null) throw new Error('live2d config is not an object')
81
+ const source = config as Record<string, unknown>
82
+ if (typeof source.modelUrl !== 'string' || source.modelUrl === '') throw new Error('live2d config modelUrl is required')
83
+ const motions = source.motions
84
+ if (typeof motions !== 'object' || motions === null || typeof (motions as Record<string, unknown>).idle !== 'string') {
85
+ throw new Error('live2d config motions.idle is required')
86
+ }
87
+ return config as PetLive2dConfig
88
+ }
89
+
90
+ /** The live2d renderer implementation. */
91
+ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
92
+ id: 'live2d',
93
+ apiVersion: PET_RENDERER_API_VERSION,
94
+ validateConfig: validateLive2dConfig,
95
+ mount(ctx: PetRendererContext, config: PetLive2dConfig): Live2dRendererHandle {
96
+ let disposed = false
97
+ let app: Live2dVendorApp | undefined
98
+ let model: Live2dVendorModel | undefined
99
+ let errorListener: ((code: Live2dErrorCode) => void) | undefined
100
+ let unsubscribe: (() => void) | undefined
101
+ /** The motion group the current phase maps to (resume target after taps). */
102
+ let phaseGroup: string = config.motions.idle
103
+ let tapPlaying = false
104
+
105
+ const playGroup = (group: string): void => {
106
+ if (model === undefined) return
107
+ const groups = model.internalModel.settings.motions ?? {}
108
+ const count = Array.isArray(groups[group]) ? groups[group]!.length : 0
109
+ if (count === 0) {
110
+ // A mapped-but-absent group falls back to idle (never blank motion).
111
+ if (group !== config.motions.idle) playGroup(config.motions.idle)
112
+ return
113
+ }
114
+ const index = count > 1 ? Math.floor(Math.random() * count) : 0
115
+ void model.motion(group, index)
116
+ }
117
+
118
+ const applyPhase = (phase: ActivityPhase): void => {
119
+ phaseGroup = config.motions[phase] ?? config.motions.idle
120
+ playGroup(phaseGroup)
121
+ const expression = config.expressions?.[phase]
122
+ if (expression !== undefined && model !== undefined) void model.expression(expression)
123
+ }
124
+
125
+ const boot = async (): Promise<void> => {
126
+ if (!await ensureCubismCore()) {
127
+ if (!disposed) errorListener?.('core-missing')
128
+ return
129
+ }
130
+ const vendor = await ensureLive2dVendor()
131
+ if (vendor === undefined) {
132
+ if (!disposed) errorListener?.('vendor-missing')
133
+ return
134
+ }
135
+ configureOnce(vendor)
136
+ const pixiApp = new vendor.Application()
137
+ await pixiApp.init({
138
+ width: Math.max(1, ctx.container.clientWidth || 160),
139
+ height: Math.max(1, ctx.container.clientHeight || 174),
140
+ backgroundAlpha: 0,
141
+ antialias: true,
142
+ autoDensity: true,
143
+ preference: 'webgl',
144
+ })
145
+ if (disposed) {
146
+ pixiApp.destroy(true, { children: true })
147
+ return
148
+ }
149
+ pixiApp.canvas.style.display = 'block'
150
+ pixiApp.canvas.style.width = '100%'
151
+ pixiApp.canvas.style.height = '100%'
152
+ ctx.container.appendChild(pixiApp.canvas)
153
+ const loaded = await vendor.Live2DModel.from(config.modelUrl, { autoHitTest: false, autoFocus: false })
154
+ if (disposed) {
155
+ pixiApp.destroy(true, { children: true })
156
+ return
157
+ }
158
+ app = pixiApp
159
+ model = loaded
160
+ // Auto-fit the model into the container; the manifest scale multiplies
161
+ // the fit and translate offsets from the center anchor.
162
+ const fit = Math.min(
163
+ pixiApp.renderer.width / Math.max(1, loaded.width),
164
+ pixiApp.renderer.height / Math.max(1, loaded.height),
165
+ ) * 0.92
166
+ loaded.scale.set(fit * (config.scale ?? 1))
167
+ loaded.anchor.set(0.5)
168
+ loaded.position.set(
169
+ pixiApp.renderer.width / 2 + (config.translate?.x ?? 0),
170
+ pixiApp.renderer.height / 2 + (config.translate?.y ?? 0),
171
+ )
172
+ pixiApp.stage.addChild(loaded)
173
+ // Resume the phase group once a tap motion finishes playing.
174
+ loaded.on('motionFinish', () => {
175
+ if (tapPlaying) {
176
+ tapPlaying = false
177
+ playGroup(phaseGroup)
178
+ }
179
+ })
180
+ applyPhase(ctx.phase.get())
181
+ unsubscribe = ctx.phase.subscribe(applyPhase)
182
+ }
183
+
184
+ void boot().catch(() => {
185
+ if (!disposed) errorListener?.('load-failed')
186
+ })
187
+
188
+ return {
189
+ dispose() {
190
+ if (disposed) return
191
+ disposed = true
192
+ unsubscribe?.()
193
+ model = undefined
194
+ if (app !== undefined) {
195
+ app.destroy(true, { children: true })
196
+ app = undefined
197
+ }
198
+ },
199
+ tap(x: number, y: number) {
200
+ const current = model
201
+ if (disposed || current === undefined) return
202
+ const hits = current.hitTest(x, y)
203
+ const allowed = config.hitAreas
204
+ const hit = allowed === undefined ? hits.length > 0 : hits.some(name => allowed.includes(name))
205
+ if (!hit) return
206
+ const groups = current.internalModel.settings.motions ?? {}
207
+ const group = groups[TAP_GROUP]
208
+ if (!Array.isArray(group) || group.length === 0) return
209
+ tapPlaying = true
210
+ const index = group.length > 1 ? Math.floor(Math.random() * group.length) : 0
211
+ void current.motion(TAP_GROUP, index)
212
+ },
213
+ onError(listener: (code: Live2dErrorCode) => void) {
214
+ errorListener = listener
215
+ },
216
+ }
217
+ },
218
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Renderer registry — dispatches a manifest's renderer kind to its
3
+ * implementation (pet-center M2 P4, issue #623). Unknown kinds never blank
4
+ * the pet: a fallback card names the problem and reports the kinds this
5
+ * build actually supports.
6
+ * @module @linxin666/dsh-pet/client/renderers/registry
7
+ */
8
+
9
+ import type { PetRenderer, PetRendererContext, PetRendererHandle } from '../../contracts/renderer.ts'
10
+
11
+ /** Renderer dispatch table. */
12
+ export class RendererRegistry {
13
+ private readonly renderers = new Map<string, PetRenderer>()
14
+
15
+ /** Register one renderer implementation (id wins on re-register). */
16
+ register(renderer: PetRenderer): void {
17
+ this.renderers.set(renderer.id, renderer)
18
+ }
19
+
20
+ /** Whether a renderer kind is available in this build. */
21
+ has(id: string): boolean {
22
+ return this.renderers.has(id)
23
+ }
24
+
25
+ /** The registered renderer kinds (for diagnostics). */
26
+ kinds(): string[] {
27
+ return [...this.renderers.keys()].sort()
28
+ }
29
+
30
+ /** Remove every registration (tests; the client index registers once). */
31
+ clear(): void {
32
+ this.renderers.clear()
33
+ }
34
+
35
+ /**
36
+ * Mount a renderer for one activation. An unknown kind renders a clear
37
+ * diagnostic card into the container instead of failing silently.
38
+ */
39
+ mount(kind: string, ctx: PetRendererContext, config: unknown): PetRendererHandle {
40
+ const renderer = this.renderers.get(kind)
41
+ if (renderer === undefined) {
42
+ const note = document.createElement('div')
43
+ note.dataset.dshPetRendererFallback = kind
44
+ note.textContent = 'Pet renderer "' + kind + '" is not available in this build (supported: ' + this.kinds().join(', ') + ').'
45
+ ctx.container.appendChild(note)
46
+ ctx.onCleanup(() => note.remove())
47
+ return { dispose: () => note.remove() }
48
+ }
49
+ return renderer.mount(ctx, renderer.validateConfig(config))
50
+ }
51
+ }
52
+
53
+ /**
54
+ * The plugin-wide renderer registry. The client entry registers the
55
+ * built-in renderers at apply time; the renderer switch and the live2d
56
+ * bridge dispatch through this instance.
57
+ */
58
+ export const defaultPetRendererRegistry = new RendererRegistry()
@@ -3,3 +3,26 @@
3
3
  margin: 0;
4
4
  padding: 0;
5
5
  }
6
+
7
+ /* Registry diagnostics block (pet-center M2 P7, issue #623). */
8
+ .diagnostics {
9
+ margin: 8px 0;
10
+ padding: 8px 12px;
11
+ border: 1px dashed var(--dsw-alias-border-l2);
12
+ border-radius: 8px;
13
+ font-size: 12px;
14
+ color: var(--dsw-alias-label-dimmed);
15
+ }
16
+ .diagnosticsTitle {
17
+ display: block;
18
+ font-weight: 600;
19
+ margin-bottom: 4px;
20
+ color: var(--dsw-alias-label);
21
+ }
22
+ .diagnostics ul {
23
+ margin: 0;
24
+ padding-left: 16px;
25
+ }
26
+ .diagnostics li[data-level="error"] {
27
+ color: var(--dsw-alias-error, #c04848);
28
+ }
@@ -26,15 +26,10 @@ export function rowOfTrack(animation: PetAnimation): number {
26
26
  * atlas coordinates here would drift each frame by the scale factor and
27
27
  * render torn/overlapping frames.
28
28
  */
29
- export function framePosition(cell: PetCell, columns: number, row: number, col: number, scale = 1): { x: number; y: number } {
29
+ export function framePosition(cell: PetCell, row: number, col: number, scale = 1): { x: number; y: number } {
30
30
  return { x: -col * cell.width * scale, y: -row * cell.height * scale }
31
31
  }
32
32
 
33
- /** Total duration of one track, ms. */
34
- export function trackDuration(track: TrackDef): number {
35
- return track.durations.reduce((sum, d) => sum + d, 0)
36
- }
37
-
38
33
  /**
39
34
  * Trim a track to the actual frame count of its row (the manifest's per-row
40
35
  * counts are authoritative; this is a last-line guard against a definition
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Renderer contract — the seam between the pet center and its renderers
3
+ * (issue #623, milestone M2 P4). Renderers never see the DSH session, the
4
+ * registry, or the network: they receive exactly three capabilities — an
5
+ * asset base URL, the ActivityPhase stream, and the interaction write-back —
6
+ * inside a center-owned container. Every mount is a fresh activation whose
7
+ * cleanups must be idempotent.
8
+ *
9
+ * This contract only serves real consumers: sprite2d (existing) and live2d
10
+ * (M3). Speculative capabilities join only when a renderer actually needs
11
+ * them.
12
+ * @module @linxin666/dsh-pet/contracts/renderer
13
+ */
14
+
15
+ import type { ActivityPhase } from '../state.ts'
16
+
17
+ /** Contract version renderers declare against (independent of the manifest). */
18
+ export const PET_RENDERER_API_VERSION = 'x-org.linxin666.pet-center/v1alpha1'
19
+
20
+ /** What the pet center hands a renderer on mount. */
21
+ export interface PetRendererContext {
22
+ /** The selected pet's id. */
23
+ readonly petId: string
24
+ /** Same-origin URL prefix of this pet's assets ('/pet/<id>'). */
25
+ readonly assetBase: string
26
+ /** Center-owned mount root; renderers must not attach to document.body. */
27
+ readonly container: HTMLElement
28
+ /** The ActivityPhase stream (pet-center owned; renderers subscribe). */
29
+ readonly phase: {
30
+ get(): ActivityPhase
31
+ subscribe(listener: (phase: ActivityPhase) => void): () => void
32
+ }
33
+ /** The single interaction write-back into the affinity economy. */
34
+ readonly interact: (kind: 'tap') => void
35
+ /** Register an activation-scoped cleanup; run on dispose, idempotently. */
36
+ onCleanup(fn: () => void): void
37
+ }
38
+
39
+ /** A mounted renderer activation. */
40
+ export interface PetRendererHandle {
41
+ /** Tear the activation down; may be called 0/1/N times. */
42
+ dispose(): void
43
+ }
44
+
45
+ /**
46
+ * One renderer implementation. validateConfig is fail-closed over the
47
+ * renderer-specific manifest block (schema v2 'sprite2d'/'live2d' blocks).
48
+ */
49
+ export interface PetRenderer<Config = unknown> {
50
+ readonly id: string
51
+ readonly apiVersion: string
52
+ validateConfig(config: unknown): Config
53
+ mount(ctx: PetRendererContext, config: Config): PetRendererHandle
54
+ }