@linxin666/dsh-pet 0.2.2 → 0.2.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 +129 -3
- package/README.zh.md +129 -3
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/contracts/pet-manifest-v2.schema.json +281 -0
- package/contracts/status-decoration-v1.schema.json +144 -0
- package/contracts/voice-pack-v1.schema.json +234 -0
- package/lib/client.js +581 -38
- package/lib/client.js.map +1 -1
- package/lib/index.js +1684 -59
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/chatter.d.ts +61 -3
- package/lib/types/chatter.d.ts.map +1 -1
- package/lib/types/chatter.js +71 -12
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +14 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +24 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +120 -12
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +14 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +192 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- package/lib/types/contracts/status-decoration.d.ts +85 -0
- package/lib/types/contracts/status-decoration.d.ts.map +1 -0
- package/lib/types/contracts/status-decoration.js +21 -0
- package/lib/types/decoration.d.ts +39 -0
- package/lib/types/decoration.d.ts.map +1 -0
- package/lib/types/decoration.js +210 -0
- package/lib/types/event-projection.d.ts +8 -3
- package/lib/types/event-projection.d.ts.map +1 -1
- package/lib/types/event-projection.js +9 -4
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +2 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +112 -5
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +395 -38
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +312 -13
- package/lib/types/service.d.ts +38 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +46 -3
- package/lib/types/voice-pack.d.ts +98 -0
- package/lib/types/voice-pack.d.ts.map +1 -0
- package/lib/types/voice-pack.js +384 -0
- package/package.json +13 -10
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +57 -0
- package/src/client/PetSprite.test.tsx +265 -12
- package/src/client/PetSprite.tsx +165 -33
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +14 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +263 -0
- package/src/client/renderers/live2d.ts +231 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-form.ts +8 -6
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/contracts/status-decoration.ts +78 -0
- package/src/decoration.test.ts +178 -0
- package/src/decoration.ts +220 -0
- package/src/event-projection.ts +10 -5
- package/src/index.ts +2 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +438 -2
- package/src/registry.ts +470 -38
- package/src/routes.ts +328 -14
- package/src/service.ts +65 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
|
@@ -0,0 +1,263 @@
|
|
|
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(
|
|
79
|
+
model: FakeModel,
|
|
80
|
+
app: FakeApp,
|
|
81
|
+
from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = () => Promise.resolve(model),
|
|
82
|
+
): unknown {
|
|
83
|
+
return {
|
|
84
|
+
Application: class { constructor() { return app } },
|
|
85
|
+
extensions: { add: () => {} },
|
|
86
|
+
Live2DPlugin: {},
|
|
87
|
+
configureCubismSDK: () => {},
|
|
88
|
+
Live2DModel: { from },
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const CONFIG = { modelUrl: '/pet/haru/haru.model3.json', motions: { idle: 'Idle', thinking: 'TapBody' } }
|
|
93
|
+
|
|
94
|
+
function makeCtx(): { ctx: PetRendererContext; stream: PhaseStream; container: HTMLDivElement } {
|
|
95
|
+
const container = document.createElement('div')
|
|
96
|
+
const stream = createPhaseStream('idle')
|
|
97
|
+
return {
|
|
98
|
+
container,
|
|
99
|
+
stream,
|
|
100
|
+
ctx: {
|
|
101
|
+
petId: 'haru',
|
|
102
|
+
assetBase: '/pet/haru',
|
|
103
|
+
container,
|
|
104
|
+
phase: stream,
|
|
105
|
+
interact: () => {},
|
|
106
|
+
onCleanup: () => {},
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function flush(): Promise<void> {
|
|
112
|
+
for (let index = 0; index < 20; index += 1) await Promise.resolve()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe('live2dRenderer', () => {
|
|
116
|
+
beforeEach(() => {
|
|
117
|
+
runtime.core = true
|
|
118
|
+
resetLive2dRenderer()
|
|
119
|
+
})
|
|
120
|
+
afterEach(() => {
|
|
121
|
+
vi.restoreAllMocks()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('boots the model and maps phases onto motion groups with idle fallback', async () => {
|
|
125
|
+
const model = fakeModel()
|
|
126
|
+
const app = fakeApp()
|
|
127
|
+
runtime.vendor = fakeVendor(model, app)
|
|
128
|
+
vi.spyOn(Math, 'random').mockReturnValue(0.99)
|
|
129
|
+
const { ctx, stream, container } = makeCtx()
|
|
130
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
131
|
+
await flush()
|
|
132
|
+
expect(container.querySelector('canvas')).toBeTruthy()
|
|
133
|
+
expect(app.added).toBe(model)
|
|
134
|
+
// idle plays on boot: group 'Idle', random index floor(0.99 * 2) = 1.
|
|
135
|
+
expect(model.calls).toContainEqual(['motion', 'Idle', 1])
|
|
136
|
+
// auto-fit: min(160/1000, 174/1200) * 0.92 = 0.1334
|
|
137
|
+
const scaleCall = model.calls.find(call => call[0] === 'scale')
|
|
138
|
+
expect(scaleCall?.[1]).toBeCloseTo(0.145 * 0.92, 4)
|
|
139
|
+
stream.push('thinking')
|
|
140
|
+
expect(model.calls).toContainEqual(['motion', 'TapBody', 0])
|
|
141
|
+
stream.push('tool') // unmapped phase falls back to idle
|
|
142
|
+
expect(model.calls.filter(call => call[0] === 'motion').at(-1)).toEqual(['motion', 'Idle', 1])
|
|
143
|
+
handle.dispose()
|
|
144
|
+
expect(app.destroyed).toBe(1)
|
|
145
|
+
handle.dispose() // idempotent
|
|
146
|
+
expect(app.destroyed).toBe(1)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('uses one automatic texture LOD instead of the default full mip chain', async () => {
|
|
150
|
+
const model = fakeModel()
|
|
151
|
+
const app = fakeApp()
|
|
152
|
+
const from = vi.fn(async () => model)
|
|
153
|
+
runtime.vendor = fakeVendor(model, app, from)
|
|
154
|
+
const { ctx } = makeCtx()
|
|
155
|
+
|
|
156
|
+
live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
157
|
+
await flush()
|
|
158
|
+
|
|
159
|
+
expect(from).toHaveBeenCalledWith(CONFIG.modelUrl, {
|
|
160
|
+
autoHitTest: false,
|
|
161
|
+
autoFocus: false,
|
|
162
|
+
textureOptions: { lod: 'single-auto' },
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('falls back to the idle group when a mapped group is absent from the model', async () => {
|
|
167
|
+
const model = fakeModel({ Idle: [{}] })
|
|
168
|
+
runtime.vendor = fakeVendor(model, fakeApp())
|
|
169
|
+
const { ctx, stream } = makeCtx()
|
|
170
|
+
live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, motions: { idle: 'Idle', done: 'Nope' } }))
|
|
171
|
+
await flush()
|
|
172
|
+
model.calls.length = 0
|
|
173
|
+
stream.push('done')
|
|
174
|
+
expect(model.calls).toContainEqual(['motion', 'Idle', 0])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('layers the phase expression when the manifest declares one', async () => {
|
|
178
|
+
const model = fakeModel()
|
|
179
|
+
runtime.vendor = fakeVendor(model, fakeApp())
|
|
180
|
+
const { ctx, stream } = makeCtx()
|
|
181
|
+
live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, expressions: { thinking: 'F01' } }))
|
|
182
|
+
await flush()
|
|
183
|
+
stream.push('thinking')
|
|
184
|
+
expect(model.calls).toContainEqual(['expression', 'F01'])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('plays the tap group on a declared hit area and resumes the phase group on finish', async () => {
|
|
188
|
+
const model = fakeModel()
|
|
189
|
+
runtime.vendor = fakeVendor(model, fakeApp())
|
|
190
|
+
const { ctx } = makeCtx()
|
|
191
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, hitAreas: ['Body'] })) as Live2dRendererHandle
|
|
192
|
+
await flush()
|
|
193
|
+
model.calls.length = 0
|
|
194
|
+
handle.tap(40, 60)
|
|
195
|
+
expect(model.calls).toContainEqual(['hitTest', 40, 60])
|
|
196
|
+
expect(model.calls).toContainEqual(['motion', 'TapBody', 0])
|
|
197
|
+
model.calls.length = 0
|
|
198
|
+
model.listeners.motionFinish?.()
|
|
199
|
+
expect(model.calls).toContainEqual(['motion', 'Idle', expect.any(Number)])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('ignores taps outside the declared hit areas', async () => {
|
|
203
|
+
const model = fakeModel()
|
|
204
|
+
runtime.vendor = fakeVendor(model, fakeApp())
|
|
205
|
+
const { ctx } = makeCtx()
|
|
206
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig({ ...CONFIG, hitAreas: ['Head'] })) as Live2dRendererHandle
|
|
207
|
+
await flush()
|
|
208
|
+
model.calls.length = 0
|
|
209
|
+
handle.tap(10, 10)
|
|
210
|
+
expect(model.calls).toContainEqual(['hitTest', 10, 10])
|
|
211
|
+
expect(model.calls.some(call => call[0] === 'motion')).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('reports core-missing when the user has not installed the Cubism Core', async () => {
|
|
215
|
+
runtime.core = false
|
|
216
|
+
runtime.vendor = fakeVendor(fakeModel(), fakeApp())
|
|
217
|
+
const { ctx } = makeCtx()
|
|
218
|
+
let code: string | undefined
|
|
219
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG)) as Live2dRendererHandle
|
|
220
|
+
handle.onError((next) => { code = next })
|
|
221
|
+
await flush()
|
|
222
|
+
expect(code).toBe('core-missing')
|
|
223
|
+
handle.dispose()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('reports load-failed when the model fetch rejects', async () => {
|
|
227
|
+
const model = fakeModel()
|
|
228
|
+
const app = fakeApp()
|
|
229
|
+
runtime.vendor = {
|
|
230
|
+
Application: class { constructor() { return app } },
|
|
231
|
+
extensions: { add: () => {} },
|
|
232
|
+
Live2DPlugin: {},
|
|
233
|
+
configureCubismSDK: () => {},
|
|
234
|
+
Live2DModel: { from: () => Promise.reject(new Error('404')) },
|
|
235
|
+
}
|
|
236
|
+
const { ctx } = makeCtx()
|
|
237
|
+
let code: string | undefined
|
|
238
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG)) as Live2dRendererHandle
|
|
239
|
+
handle.onError((next) => { code = next })
|
|
240
|
+
await flush()
|
|
241
|
+
expect(code).toBe('load-failed')
|
|
242
|
+
handle.dispose()
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('never appends a canvas when disposed mid-boot', async () => {
|
|
246
|
+
const model = fakeModel()
|
|
247
|
+
const app = fakeApp()
|
|
248
|
+
runtime.vendor = fakeVendor(model, app)
|
|
249
|
+
const { ctx, container } = makeCtx()
|
|
250
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
251
|
+
handle.dispose()
|
|
252
|
+
await flush()
|
|
253
|
+
expect(container.querySelector('canvas')).toBeNull()
|
|
254
|
+
// The app created mid-boot is destroyed exactly once (no leak), never twice.
|
|
255
|
+
expect(app.destroyed).toBe(1)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('validateConfig is fail-closed', () => {
|
|
259
|
+
expect(() => live2dRenderer.validateConfig({})).toThrow('modelUrl')
|
|
260
|
+
expect(() => live2dRenderer.validateConfig({ modelUrl: '/x' })).toThrow('motions.idle')
|
|
261
|
+
expect(live2dRenderer.validateConfig(CONFIG)).toBe(CONFIG)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
/**
|
|
64
|
+
* Keep one screen-appropriate atlas LOD instead of asking Pixi for the
|
|
65
|
+
* engine's default full mip chain. A user model can legitimately carry an
|
|
66
|
+
* 8192px texture while the pet itself is only a few hundred pixels tall;
|
|
67
|
+
* `single-auto` preserves the source for larger renders and generates one
|
|
68
|
+
* downsampled atlas only when the effective on-screen scale warrants it.
|
|
69
|
+
*/
|
|
70
|
+
const TEXTURE_OPTIONS = { lod: 'single-auto' } as const
|
|
71
|
+
|
|
72
|
+
let vendorConfigured = false
|
|
73
|
+
|
|
74
|
+
/** Configure pixi extensions + the Cubism SDK once per page. */
|
|
75
|
+
function configureOnce(vendor: Live2dVendor): void {
|
|
76
|
+
if (vendorConfigured) return
|
|
77
|
+
vendorConfigured = true
|
|
78
|
+
vendor.extensions.add(vendor.Live2DPlugin)
|
|
79
|
+
vendor.configureCubismSDK({ memorySizeMB: 32 })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Reset module state (tests). */
|
|
83
|
+
export function resetLive2dRenderer(): void {
|
|
84
|
+
vendorConfigured = false
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Fail-closed config validation (contract: unknown manifest block in). */
|
|
88
|
+
function validateLive2dConfig(config: unknown): PetLive2dConfig {
|
|
89
|
+
if (typeof config !== 'object' || config === null) throw new Error('live2d config is not an object')
|
|
90
|
+
const source = config as Record<string, unknown>
|
|
91
|
+
if (typeof source.modelUrl !== 'string' || source.modelUrl === '') throw new Error('live2d config modelUrl is required')
|
|
92
|
+
const motions = source.motions
|
|
93
|
+
if (typeof motions !== 'object' || motions === null || typeof (motions as Record<string, unknown>).idle !== 'string') {
|
|
94
|
+
throw new Error('live2d config motions.idle is required')
|
|
95
|
+
}
|
|
96
|
+
return config as PetLive2dConfig
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The live2d renderer implementation. */
|
|
100
|
+
export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
101
|
+
id: 'live2d',
|
|
102
|
+
apiVersion: PET_RENDERER_API_VERSION,
|
|
103
|
+
validateConfig: validateLive2dConfig,
|
|
104
|
+
mount(ctx: PetRendererContext, config: PetLive2dConfig): Live2dRendererHandle {
|
|
105
|
+
let disposed = false
|
|
106
|
+
let app: Live2dVendorApp | undefined
|
|
107
|
+
let model: Live2dVendorModel | undefined
|
|
108
|
+
let errorListener: ((code: Live2dErrorCode) => void) | undefined
|
|
109
|
+
let unsubscribe: (() => void) | undefined
|
|
110
|
+
/** The motion group the current phase maps to (resume target after taps). */
|
|
111
|
+
let phaseGroup: string = config.motions.idle
|
|
112
|
+
let tapPlaying = false
|
|
113
|
+
|
|
114
|
+
const playGroup = (group: string): void => {
|
|
115
|
+
if (model === undefined) return
|
|
116
|
+
const groups = model.internalModel.settings.motions ?? {}
|
|
117
|
+
const count = Array.isArray(groups[group]) ? groups[group]!.length : 0
|
|
118
|
+
if (count === 0) {
|
|
119
|
+
// A mapped-but-absent group falls back to idle (never blank motion).
|
|
120
|
+
if (group !== config.motions.idle) playGroup(config.motions.idle)
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
const index = count > 1 ? Math.floor(Math.random() * count) : 0
|
|
124
|
+
void model.motion(group, index)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const applyPhase = (phase: ActivityPhase): void => {
|
|
128
|
+
phaseGroup = config.motions[phase] ?? config.motions.idle
|
|
129
|
+
playGroup(phaseGroup)
|
|
130
|
+
const expression = config.expressions?.[phase]
|
|
131
|
+
if (expression !== undefined && model !== undefined) void model.expression(expression)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const boot = async (): Promise<void> => {
|
|
135
|
+
if (!await ensureCubismCore()) {
|
|
136
|
+
if (!disposed) errorListener?.('core-missing')
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const vendor = await ensureLive2dVendor()
|
|
140
|
+
if (vendor === undefined) {
|
|
141
|
+
if (!disposed) errorListener?.('vendor-missing')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
configureOnce(vendor)
|
|
145
|
+
const pixiApp = new vendor.Application()
|
|
146
|
+
await pixiApp.init({
|
|
147
|
+
width: Math.max(1, ctx.container.clientWidth || 160),
|
|
148
|
+
height: Math.max(1, ctx.container.clientHeight || 174),
|
|
149
|
+
backgroundAlpha: 0,
|
|
150
|
+
antialias: true,
|
|
151
|
+
autoDensity: true,
|
|
152
|
+
preference: 'webgl',
|
|
153
|
+
})
|
|
154
|
+
if (disposed) {
|
|
155
|
+
pixiApp.destroy(true, { children: true })
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
pixiApp.canvas.style.display = 'block'
|
|
159
|
+
pixiApp.canvas.style.width = '100%'
|
|
160
|
+
pixiApp.canvas.style.height = '100%'
|
|
161
|
+
ctx.container.appendChild(pixiApp.canvas)
|
|
162
|
+
const loaded = await vendor.Live2DModel.from(config.modelUrl, {
|
|
163
|
+
autoHitTest: false,
|
|
164
|
+
autoFocus: false,
|
|
165
|
+
textureOptions: TEXTURE_OPTIONS,
|
|
166
|
+
})
|
|
167
|
+
if (disposed) {
|
|
168
|
+
pixiApp.destroy(true, { children: true })
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
app = pixiApp
|
|
172
|
+
model = loaded
|
|
173
|
+
// Auto-fit the model into the container; the manifest scale multiplies
|
|
174
|
+
// the fit and translate offsets from the center anchor.
|
|
175
|
+
const fit = Math.min(
|
|
176
|
+
pixiApp.renderer.width / Math.max(1, loaded.width),
|
|
177
|
+
pixiApp.renderer.height / Math.max(1, loaded.height),
|
|
178
|
+
) * 0.92
|
|
179
|
+
loaded.scale.set(fit * (config.scale ?? 1))
|
|
180
|
+
loaded.anchor.set(0.5)
|
|
181
|
+
loaded.position.set(
|
|
182
|
+
pixiApp.renderer.width / 2 + (config.translate?.x ?? 0),
|
|
183
|
+
pixiApp.renderer.height / 2 + (config.translate?.y ?? 0),
|
|
184
|
+
)
|
|
185
|
+
pixiApp.stage.addChild(loaded)
|
|
186
|
+
// Resume the phase group once a tap motion finishes playing.
|
|
187
|
+
loaded.on('motionFinish', () => {
|
|
188
|
+
if (tapPlaying) {
|
|
189
|
+
tapPlaying = false
|
|
190
|
+
playGroup(phaseGroup)
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
applyPhase(ctx.phase.get())
|
|
194
|
+
unsubscribe = ctx.phase.subscribe(applyPhase)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
void boot().catch(() => {
|
|
198
|
+
if (!disposed) errorListener?.('load-failed')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
dispose() {
|
|
203
|
+
if (disposed) return
|
|
204
|
+
disposed = true
|
|
205
|
+
unsubscribe?.()
|
|
206
|
+
model = undefined
|
|
207
|
+
if (app !== undefined) {
|
|
208
|
+
app.destroy(true, { children: true })
|
|
209
|
+
app = undefined
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
tap(x: number, y: number) {
|
|
213
|
+
const current = model
|
|
214
|
+
if (disposed || current === undefined) return
|
|
215
|
+
const hits = current.hitTest(x, y)
|
|
216
|
+
const allowed = config.hitAreas
|
|
217
|
+
const hit = allowed === undefined ? hits.length > 0 : hits.some(name => allowed.includes(name))
|
|
218
|
+
if (!hit) return
|
|
219
|
+
const groups = current.internalModel.settings.motions ?? {}
|
|
220
|
+
const group = groups[TAP_GROUP]
|
|
221
|
+
if (!Array.isArray(group) || group.length === 0) return
|
|
222
|
+
tapPlaying = true
|
|
223
|
+
const index = group.length > 1 ? Math.floor(Math.random() * group.length) : 0
|
|
224
|
+
void current.motion(TAP_GROUP, index)
|
|
225
|
+
},
|
|
226
|
+
onError(listener: (code: Live2dErrorCode) => void) {
|
|
227
|
+
errorListener = listener
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
}
|
|
@@ -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()
|
|
@@ -333,9 +333,11 @@ export class CardForm<T> {
|
|
|
333
333
|
const valid = plan.filter(item => item.run !== undefined)
|
|
334
334
|
if (plan.length === 0 || this.saving || valid.length !== plan.length) return
|
|
335
335
|
const plannedWrites = valid.map(item => item.op)
|
|
336
|
-
// Snapshot the
|
|
337
|
-
// flight
|
|
338
|
-
|
|
336
|
+
// Snapshot the staged entries this save writes, so an edit staged while it
|
|
337
|
+
// is in flight (which replaces the same key) survives: only delete the key
|
|
338
|
+
// when the entry is still the one this save started from.
|
|
339
|
+
const pending = new Map<string, StagedEdit | undefined>()
|
|
340
|
+
for (const item of plan) pending.set(item.field, this.staged.get(item.field))
|
|
339
341
|
this.saving = true
|
|
340
342
|
this.failed = false
|
|
341
343
|
this.failedReason = undefined
|
|
@@ -356,11 +358,11 @@ export class CardForm<T> {
|
|
|
356
358
|
if (await item.run!()) landed.add(item.field)
|
|
357
359
|
}
|
|
358
360
|
}
|
|
359
|
-
for (const field of
|
|
360
|
-
if (landed.has(field)) this.staged.delete(field)
|
|
361
|
+
for (const [field, before] of pending) {
|
|
362
|
+
if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field)
|
|
361
363
|
}
|
|
362
364
|
this.saving = false
|
|
363
|
-
this.failed = landed.size !==
|
|
365
|
+
this.failed = landed.size !== pending.size
|
|
364
366
|
this.publish()
|
|
365
367
|
}
|
|
366
368
|
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|