@linxin666/dsh-pet 0.2.3 → 0.2.5
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 +65 -2
- package/README.zh.md +65 -2
- package/assets/decorations/whale/decoration.json +20 -0
- package/assets/decorations/whale/whale-frames.png +0 -0
- 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 +251 -43
- package/lib/client.js.map +1 -1
- package/lib/index.js +1139 -100
- package/lib/types/access.d.ts +16 -0
- package/lib/types/access.d.ts.map +1 -0
- package/lib/types/access.js +20 -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/PetSettingsCard.d.ts +4 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +11 -3
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +111 -4
- package/lib/types/client/locales.d.ts +4 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.d.ts.map +1 -1
- package/lib/types/client/renderers/live2d.js +133 -25
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- 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/image-dimensions.d.ts +26 -0
- package/lib/types/image-dimensions.d.ts.map +1 -0
- package/lib/types/image-dimensions.js +77 -0
- package/lib/types/index.d.ts +2 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +3 -1
- package/lib/types/registry.d.ts +55 -2
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +240 -16
- package/lib/types/routes.d.ts +6 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +163 -32
- package/lib/types/service.d.ts +33 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +42 -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 +11 -10
- package/src/access.ts +40 -0
- package/src/chatter.test.ts +89 -2
- package/src/chatter.ts +120 -14
- package/src/client/PetSettingsCard.tsx +26 -2
- package/src/client/PetSprite.test.tsx +325 -12
- package/src/client/PetSprite.tsx +150 -25
- package/src/client/locales.ts +4 -0
- package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
- package/src/client/renderers/live2d/runtime.ts +8 -2
- package/src/client/renderers/live2d.test.ts +233 -9
- package/src/client/renderers/live2d.ts +143 -30
- package/src/client/settings-form.ts +8 -6
- 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/image-dimensions.test.ts +85 -0
- package/src/image-dimensions.ts +76 -0
- package/src/index.ts +3 -1
- package/src/registry.test.ts +276 -0
- package/src/registry.ts +274 -15
- package/src/routes.ts +162 -33
- package/src/service.ts +59 -3
- package/src/voice-pack.test.ts +216 -0
- package/src/voice-pack.ts +413 -0
|
@@ -20,7 +20,10 @@ import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
|
|
|
20
20
|
import type { PetRendererContext } from '../../contracts/renderer.ts'
|
|
21
21
|
|
|
22
22
|
interface FakeModel {
|
|
23
|
+
automator: { autoUpdate: boolean }
|
|
23
24
|
calls: unknown[][]
|
|
25
|
+
destroyCalls: Record<string, unknown>[]
|
|
26
|
+
destroyed: number
|
|
24
27
|
listeners: Record<string, () => void>
|
|
25
28
|
width: number
|
|
26
29
|
height: number
|
|
@@ -32,21 +35,58 @@ interface FakeModel {
|
|
|
32
35
|
expression(name?: string): void
|
|
33
36
|
hitTest(x: number, y: number): string[]
|
|
34
37
|
on(event: string, fn: () => void): void
|
|
38
|
+
destroy(options?: Record<string, unknown>): void
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
interface FakeApp {
|
|
38
42
|
canvas: HTMLCanvasElement
|
|
39
43
|
stage: { addChild(child: unknown): void }
|
|
40
|
-
renderer: { width: number; height: number }
|
|
44
|
+
renderer: { width: number; height: number; resize(width: number, height: number): void }
|
|
41
45
|
init(options: Record<string, unknown>): Promise<void>
|
|
42
|
-
destroy(): void
|
|
46
|
+
destroy(rendererOptions?: boolean | { removeView?: boolean; releaseGlobalResources?: boolean }, options?: Record<string, unknown>): void
|
|
43
47
|
destroyed: number
|
|
48
|
+
destroyCalls: unknown[][]
|
|
44
49
|
added: unknown
|
|
50
|
+
resizeCalls: [number, number][]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
class FakeResizeObserver {
|
|
54
|
+
static instances: FakeResizeObserver[] = []
|
|
55
|
+
|
|
56
|
+
readonly targets = new Set<Element>()
|
|
57
|
+
disconnected = false
|
|
58
|
+
|
|
59
|
+
constructor(private readonly callback: ResizeObserverCallback) {
|
|
60
|
+
FakeResizeObserver.instances.push(this)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
observe(target: Element): void {
|
|
64
|
+
this.targets.add(target)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
unobserve(target: Element): void {
|
|
68
|
+
this.targets.delete(target)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
disconnect(): void {
|
|
72
|
+
this.disconnected = true
|
|
73
|
+
this.targets.clear()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
emit(target: Element, width: number, height: number): void {
|
|
77
|
+
this.callback([{
|
|
78
|
+
target,
|
|
79
|
+
contentRect: { width, height },
|
|
80
|
+
} as ResizeObserverEntry], this as unknown as ResizeObserver)
|
|
81
|
+
}
|
|
45
82
|
}
|
|
46
83
|
|
|
47
84
|
function fakeModel(motions: Record<string, unknown[]> = { Idle: [{}, {}], TapBody: [{}] }): FakeModel {
|
|
48
85
|
const model: FakeModel = {
|
|
86
|
+
automator: { autoUpdate: false },
|
|
49
87
|
calls: [],
|
|
88
|
+
destroyCalls: [],
|
|
89
|
+
destroyed: 0,
|
|
50
90
|
listeners: {},
|
|
51
91
|
width: 1000,
|
|
52
92
|
height: 1200,
|
|
@@ -58,41 +98,94 @@ function fakeModel(motions: Record<string, unknown[]> = { Idle: [{}, {}], TapBod
|
|
|
58
98
|
expression: (name) => { model.calls.push(['expression', name]) },
|
|
59
99
|
hitTest: (x, y) => { model.calls.push(['hitTest', x, y]); return ['Body'] },
|
|
60
100
|
on: (event, fn) => { model.listeners[event] = fn },
|
|
101
|
+
destroy: (options = {}) => {
|
|
102
|
+
model.destroyed += 1
|
|
103
|
+
model.destroyCalls.push(options)
|
|
104
|
+
model.automator.autoUpdate = false
|
|
105
|
+
},
|
|
61
106
|
}
|
|
62
107
|
return model
|
|
63
108
|
}
|
|
64
109
|
|
|
65
110
|
function fakeApp(): FakeApp {
|
|
111
|
+
const canvas = document.createElement('canvas')
|
|
66
112
|
const app: FakeApp = {
|
|
67
|
-
canvas
|
|
113
|
+
canvas,
|
|
68
114
|
stage: { addChild: (child) => { app.added = child } },
|
|
69
|
-
renderer: {
|
|
70
|
-
|
|
71
|
-
|
|
115
|
+
renderer: {
|
|
116
|
+
width: 160,
|
|
117
|
+
height: 174,
|
|
118
|
+
resize: (width, height) => {
|
|
119
|
+
app.renderer.width = width
|
|
120
|
+
app.renderer.height = height
|
|
121
|
+
canvas.width = width
|
|
122
|
+
canvas.height = height
|
|
123
|
+
app.resizeCalls.push([width, height])
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
init: (options) => {
|
|
127
|
+
app.renderer.width = Number(options.width)
|
|
128
|
+
app.renderer.height = Number(options.height)
|
|
129
|
+
canvas.width = app.renderer.width
|
|
130
|
+
canvas.height = app.renderer.height
|
|
131
|
+
return Promise.resolve()
|
|
132
|
+
},
|
|
133
|
+
destroy: (rendererOptions, options) => {
|
|
134
|
+
app.destroyed += 1
|
|
135
|
+
app.destroyCalls.push([rendererOptions, options])
|
|
136
|
+
if (rendererOptions === true || (typeof rendererOptions === 'object' && rendererOptions.removeView === true)) app.canvas.remove()
|
|
137
|
+
if (options?.children === true && typeof (app.added as { destroy?: unknown } | undefined)?.destroy === 'function') {
|
|
138
|
+
;(app.added as { destroy(options?: Record<string, unknown>): void }).destroy(options)
|
|
139
|
+
}
|
|
140
|
+
},
|
|
72
141
|
destroyed: 0,
|
|
142
|
+
destroyCalls: [],
|
|
73
143
|
added: undefined,
|
|
144
|
+
resizeCalls: [],
|
|
74
145
|
}
|
|
75
146
|
return app
|
|
76
147
|
}
|
|
77
148
|
|
|
78
|
-
function fakeVendor(
|
|
149
|
+
function fakeVendor(
|
|
150
|
+
model: FakeModel,
|
|
151
|
+
app: FakeApp,
|
|
152
|
+
from: (source: string, options?: Record<string, unknown>) => Promise<FakeModel> = (_source, options) => {
|
|
153
|
+
model.calls.push(['from', options])
|
|
154
|
+
return Promise.resolve(model)
|
|
155
|
+
},
|
|
156
|
+
): unknown {
|
|
79
157
|
return {
|
|
80
158
|
Application: class { constructor() { return app } },
|
|
81
159
|
extensions: { add: () => {} },
|
|
82
160
|
Live2DPlugin: {},
|
|
83
161
|
configureCubismSDK: () => {},
|
|
84
|
-
Live2DModel: { from
|
|
162
|
+
Live2DModel: { from },
|
|
85
163
|
}
|
|
86
164
|
}
|
|
87
165
|
|
|
88
166
|
const CONFIG = { modelUrl: '/pet/haru/haru.model3.json', motions: { idle: 'Idle', thinking: 'TapBody' } }
|
|
89
167
|
|
|
90
|
-
function makeCtx(
|
|
168
|
+
function makeCtx(width = 0, height = 0): {
|
|
169
|
+
ctx: PetRendererContext
|
|
170
|
+
stream: PhaseStream
|
|
171
|
+
container: HTMLDivElement
|
|
172
|
+
setSize(nextWidth: number, nextHeight: number): void
|
|
173
|
+
} {
|
|
91
174
|
const container = document.createElement('div')
|
|
175
|
+
let clientWidth = width
|
|
176
|
+
let clientHeight = height
|
|
177
|
+
Object.defineProperties(container, {
|
|
178
|
+
clientWidth: { configurable: true, get: () => clientWidth },
|
|
179
|
+
clientHeight: { configurable: true, get: () => clientHeight },
|
|
180
|
+
})
|
|
92
181
|
const stream = createPhaseStream('idle')
|
|
93
182
|
return {
|
|
94
183
|
container,
|
|
95
184
|
stream,
|
|
185
|
+
setSize(nextWidth, nextHeight) {
|
|
186
|
+
clientWidth = nextWidth
|
|
187
|
+
clientHeight = nextHeight
|
|
188
|
+
},
|
|
96
189
|
ctx: {
|
|
97
190
|
petId: 'haru',
|
|
98
191
|
assetBase: '/pet/haru',
|
|
@@ -111,9 +204,12 @@ async function flush(): Promise<void> {
|
|
|
111
204
|
describe('live2dRenderer', () => {
|
|
112
205
|
beforeEach(() => {
|
|
113
206
|
runtime.core = true
|
|
207
|
+
FakeResizeObserver.instances = []
|
|
208
|
+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
|
|
114
209
|
resetLive2dRenderer()
|
|
115
210
|
})
|
|
116
211
|
afterEach(() => {
|
|
212
|
+
vi.unstubAllGlobals()
|
|
117
213
|
vi.restoreAllMocks()
|
|
118
214
|
})
|
|
119
215
|
|
|
@@ -127,6 +223,8 @@ describe('live2dRenderer', () => {
|
|
|
127
223
|
await flush()
|
|
128
224
|
expect(container.querySelector('canvas')).toBeTruthy()
|
|
129
225
|
expect(app.added).toBe(model)
|
|
226
|
+
expect(model.calls).toContainEqual(['from', expect.objectContaining({ autoUpdate: false, autoHitTest: false, autoFocus: false })])
|
|
227
|
+
expect(model.automator.autoUpdate).toBe(true)
|
|
130
228
|
// idle plays on boot: group 'Idle', random index floor(0.99 * 2) = 1.
|
|
131
229
|
expect(model.calls).toContainEqual(['motion', 'Idle', 1])
|
|
132
230
|
// auto-fit: min(160/1000, 174/1200) * 0.92 = 0.1334
|
|
@@ -138,8 +236,98 @@ describe('live2dRenderer', () => {
|
|
|
138
236
|
expect(model.calls.filter(call => call[0] === 'motion').at(-1)).toEqual(['motion', 'Idle', 1])
|
|
139
237
|
handle.dispose()
|
|
140
238
|
expect(app.destroyed).toBe(1)
|
|
239
|
+
expect(app.destroyCalls[0]?.[0]).toEqual({ removeView: true })
|
|
240
|
+
expect(model.destroyed).toBe(1)
|
|
241
|
+
expect(model.destroyCalls).toContainEqual({ children: true })
|
|
242
|
+
expect(model.automator.autoUpdate).toBe(false)
|
|
141
243
|
handle.dispose() // idempotent
|
|
142
244
|
expect(app.destroyed).toBe(1)
|
|
245
|
+
expect(app.destroyCalls[0]?.[0]).toEqual({ removeView: true })
|
|
246
|
+
expect(model.destroyed).toBe(1)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('uses one automatic texture LOD instead of the default full mip chain', async () => {
|
|
250
|
+
const model = fakeModel()
|
|
251
|
+
const app = fakeApp()
|
|
252
|
+
const from = vi.fn(async () => model)
|
|
253
|
+
runtime.vendor = fakeVendor(model, app, from)
|
|
254
|
+
const { ctx } = makeCtx()
|
|
255
|
+
|
|
256
|
+
live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
257
|
+
await flush()
|
|
258
|
+
|
|
259
|
+
expect(from).toHaveBeenCalledWith(CONFIG.modelUrl, {
|
|
260
|
+
autoUpdate: false,
|
|
261
|
+
autoHitTest: false,
|
|
262
|
+
autoFocus: false,
|
|
263
|
+
textureOptions: { lod: 'single-auto' },
|
|
264
|
+
})
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('keeps the Pixi canvas and model layout in sync with display-size changes', async () => {
|
|
268
|
+
const model = fakeModel()
|
|
269
|
+
const app = fakeApp()
|
|
270
|
+
runtime.vendor = fakeVendor(model, app)
|
|
271
|
+
const { ctx, container, setSize } = makeCtx(148, 160)
|
|
272
|
+
|
|
273
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG)) as Live2dRendererHandle
|
|
274
|
+
await flush()
|
|
275
|
+
expect(app.canvas.width).toBe(148)
|
|
276
|
+
expect(app.canvas.height).toBe(160)
|
|
277
|
+
expect(model.calls).toContainEqual(['position', 74, 80])
|
|
278
|
+
|
|
279
|
+
model.calls.length = 0
|
|
280
|
+
setSize(295, 320)
|
|
281
|
+
FakeResizeObserver.instances[0]?.emit(container, 295, 320)
|
|
282
|
+
|
|
283
|
+
expect(app.resizeCalls).toEqual([[295, 320]])
|
|
284
|
+
expect(app.canvas.width).toBe(295)
|
|
285
|
+
expect(app.canvas.height).toBe(320)
|
|
286
|
+
expect(model.calls).toContainEqual(['scale', expect.closeTo(0.245333, 5)])
|
|
287
|
+
expect(model.calls).toContainEqual(['position', 147.5, 160])
|
|
288
|
+
handle.tap(147.5, 160)
|
|
289
|
+
expect(model.calls).toContainEqual(['hitTest', 147.5, 160])
|
|
290
|
+
|
|
291
|
+
handle.dispose()
|
|
292
|
+
expect(FakeResizeObserver.instances[0]?.disconnected).toBe(true)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it('uses the latest renderer size when the model finishes loading', async () => {
|
|
296
|
+
const model = fakeModel()
|
|
297
|
+
const app = fakeApp()
|
|
298
|
+
let resolveModel!: (value: FakeModel) => void
|
|
299
|
+
const pendingModel = new Promise<FakeModel>((resolve) => { resolveModel = resolve })
|
|
300
|
+
runtime.vendor = fakeVendor(model, app, () => pendingModel)
|
|
301
|
+
const { ctx, container, setSize } = makeCtx(148, 160)
|
|
302
|
+
|
|
303
|
+
live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
304
|
+
await flush()
|
|
305
|
+
setSize(295, 320)
|
|
306
|
+
FakeResizeObserver.instances[0]?.emit(container, 295, 320)
|
|
307
|
+
resolveModel(model)
|
|
308
|
+
await flush()
|
|
309
|
+
|
|
310
|
+
expect(app.resizeCalls).toEqual([[295, 320]])
|
|
311
|
+
expect(model.calls).toContainEqual(['position', 147.5, 160])
|
|
312
|
+
expect(model.calls).toContainEqual(['scale', expect.closeTo(0.245333, 5)])
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('ignores zero-size observations and stops resizing after disposal', async () => {
|
|
316
|
+
const model = fakeModel()
|
|
317
|
+
const app = fakeApp()
|
|
318
|
+
runtime.vendor = fakeVendor(model, app)
|
|
319
|
+
const { ctx, container } = makeCtx(148, 160)
|
|
320
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
321
|
+
await flush()
|
|
322
|
+
const observer = FakeResizeObserver.instances[0]
|
|
323
|
+
|
|
324
|
+
observer?.emit(container, 0, 0)
|
|
325
|
+
observer?.emit(container, 148, 160)
|
|
326
|
+
expect(app.resizeCalls).toEqual([])
|
|
327
|
+
|
|
328
|
+
handle.dispose()
|
|
329
|
+
observer?.emit(container, 295, 320)
|
|
330
|
+
expect(app.resizeCalls).toEqual([])
|
|
143
331
|
})
|
|
144
332
|
|
|
145
333
|
it('falls back to the idle group when a mapped group is absent from the model', async () => {
|
|
@@ -218,7 +406,43 @@ describe('live2dRenderer', () => {
|
|
|
218
406
|
handle.onError((next) => { code = next })
|
|
219
407
|
await flush()
|
|
220
408
|
expect(code).toBe('load-failed')
|
|
409
|
+
expect(app.destroyed).toBe(1)
|
|
410
|
+
expect(ctx.container.querySelector('canvas')).toBeNull()
|
|
221
411
|
handle.dispose()
|
|
412
|
+
expect(app.destroyed).toBe(1)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('destroys both sides exactly once when disposed while model loading is pending', async () => {
|
|
416
|
+
const model = fakeModel()
|
|
417
|
+
const app = fakeApp()
|
|
418
|
+
let resolveModel!: (value: FakeModel) => void
|
|
419
|
+
const pendingModel = new Promise<FakeModel>((resolve) => { resolveModel = resolve })
|
|
420
|
+
const from = vi.fn(() => pendingModel)
|
|
421
|
+
runtime.vendor = {
|
|
422
|
+
Application: class { constructor() { return app } },
|
|
423
|
+
extensions: { add: () => {} },
|
|
424
|
+
Live2DPlugin: {},
|
|
425
|
+
configureCubismSDK: () => {},
|
|
426
|
+
Live2DModel: { from },
|
|
427
|
+
}
|
|
428
|
+
const { ctx, container } = makeCtx()
|
|
429
|
+
const handle = live2dRenderer.mount(ctx, live2dRenderer.validateConfig(CONFIG))
|
|
430
|
+
let error: string | undefined
|
|
431
|
+
;(handle as Live2dRendererHandle).onError((code) => { error = code })
|
|
432
|
+
await flush()
|
|
433
|
+
expect(from).toHaveBeenCalledTimes(1)
|
|
434
|
+
expect(container.querySelector('canvas')).toBeTruthy()
|
|
435
|
+
handle.dispose()
|
|
436
|
+
expect(app.destroyed).toBe(1)
|
|
437
|
+
expect(container.querySelector('canvas')).toBeNull()
|
|
438
|
+
resolveModel(model)
|
|
439
|
+
await flush()
|
|
440
|
+
expect(app.destroyed).toBe(1)
|
|
441
|
+
expect(model.destroyed).toBe(1)
|
|
442
|
+
expect(model.destroyCalls).toContainEqual({ children: true })
|
|
443
|
+
expect(model.automator.autoUpdate).toBe(false)
|
|
444
|
+
expect(app.added).toBeUndefined()
|
|
445
|
+
expect(error).toBeUndefined()
|
|
222
446
|
})
|
|
223
447
|
|
|
224
448
|
it('never appends a canvas when disposed mid-boot', async () => {
|
|
@@ -60,6 +60,52 @@ export interface Live2dRendererHandle extends PetRendererHandle {
|
|
|
60
60
|
/** The de-facto tap-motion group of Cubism sample models. */
|
|
61
61
|
const TAP_GROUP = 'TapBody'
|
|
62
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
|
+
/** Recursively release the activation without invalidating shared texture caches. */
|
|
72
|
+
const DESTROY_OPTIONS = { children: true } as const
|
|
73
|
+
/** Remove only this activation's canvas; `true` would release Pixi globals. */
|
|
74
|
+
const RENDERER_DESTROY_OPTIONS = { removeView: true } as const
|
|
75
|
+
|
|
76
|
+
interface Live2dModelSize {
|
|
77
|
+
width: number
|
|
78
|
+
height: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ignore hidden/zero boxes and keep Pixi dimensions stable and integral. */
|
|
82
|
+
function normalizeRendererSize(width: number, height: number): Live2dModelSize | undefined {
|
|
83
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return undefined
|
|
84
|
+
return {
|
|
85
|
+
width: Math.max(1, Math.round(width)),
|
|
86
|
+
height: Math.max(1, Math.round(height)),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Fit the model from its unscaled dimensions into the current Pixi screen. */
|
|
91
|
+
function layoutModel(
|
|
92
|
+
app: Live2dVendorApp,
|
|
93
|
+
model: Live2dVendorModel,
|
|
94
|
+
sourceSize: Live2dModelSize,
|
|
95
|
+
config: PetLive2dConfig,
|
|
96
|
+
): void {
|
|
97
|
+
const fit = Math.min(
|
|
98
|
+
app.renderer.width / sourceSize.width,
|
|
99
|
+
app.renderer.height / sourceSize.height,
|
|
100
|
+
) * 0.92
|
|
101
|
+
model.scale.set(fit * (config.scale ?? 1))
|
|
102
|
+
model.anchor.set(0.5)
|
|
103
|
+
model.position.set(
|
|
104
|
+
app.renderer.width / 2 + (config.translate?.x ?? 0),
|
|
105
|
+
app.renderer.height / 2 + (config.translate?.y ?? 0),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
63
109
|
let vendorConfigured = false
|
|
64
110
|
|
|
65
111
|
/** Configure pixi extensions + the Cubism SDK once per page. */
|
|
@@ -96,12 +142,64 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
|
96
142
|
let disposed = false
|
|
97
143
|
let app: Live2dVendorApp | undefined
|
|
98
144
|
let model: Live2dVendorModel | undefined
|
|
145
|
+
let modelAttached = false
|
|
146
|
+
let modelSourceSize: Live2dModelSize | undefined
|
|
147
|
+
let resizeObserver: ResizeObserver | undefined
|
|
148
|
+
let resizeTracking = false
|
|
99
149
|
let errorListener: ((code: Live2dErrorCode) => void) | undefined
|
|
100
150
|
let unsubscribe: (() => void) | undefined
|
|
101
151
|
/** The motion group the current phase maps to (resume target after taps). */
|
|
102
152
|
let phaseGroup: string = config.motions.idle
|
|
103
153
|
let tapPlaying = false
|
|
104
154
|
|
|
155
|
+
const stopResizeTracking = (): void => {
|
|
156
|
+
resizeTracking = false
|
|
157
|
+
resizeObserver?.disconnect()
|
|
158
|
+
resizeObserver = undefined
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const resizeRenderer = (pixiApp: Live2dVendorApp, width: number, height: number): void => {
|
|
162
|
+
const next = normalizeRendererSize(width, height)
|
|
163
|
+
if (disposed || !resizeTracking || next === undefined) return
|
|
164
|
+
if (pixiApp.renderer.width === next.width && pixiApp.renderer.height === next.height) return
|
|
165
|
+
pixiApp.renderer.resize(next.width, next.height)
|
|
166
|
+
if (model !== undefined && modelSourceSize !== undefined) {
|
|
167
|
+
layoutModel(pixiApp, model, modelSourceSize, config)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const trackContainerSize = (pixiApp: Live2dVendorApp): void => {
|
|
172
|
+
if (typeof ResizeObserver === 'undefined') return
|
|
173
|
+
resizeTracking = true
|
|
174
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
175
|
+
const entry = entries.find(candidate => candidate.target === ctx.container)
|
|
176
|
+
if (entry === undefined) return
|
|
177
|
+
resizeRenderer(pixiApp, entry.contentRect.width, entry.contentRect.height)
|
|
178
|
+
})
|
|
179
|
+
resizeObserver.observe(ctx.container)
|
|
180
|
+
// Catch a synchronous layout change between init() and observe().
|
|
181
|
+
resizeRenderer(pixiApp, ctx.container.clientWidth, ctx.container.clientHeight)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Release every resource currently owned by this activation exactly once. */
|
|
185
|
+
const destroyResources = (): void => {
|
|
186
|
+
stopResizeTracking()
|
|
187
|
+
unsubscribe?.()
|
|
188
|
+
unsubscribe = undefined
|
|
189
|
+
const currentApp = app
|
|
190
|
+
const currentModel = model
|
|
191
|
+
const modelOwnedByApp = currentApp !== undefined && modelAttached
|
|
192
|
+
app = undefined
|
|
193
|
+
model = undefined
|
|
194
|
+
modelSourceSize = undefined
|
|
195
|
+
modelAttached = false
|
|
196
|
+
try {
|
|
197
|
+
if (currentModel !== undefined && !modelOwnedByApp) currentModel.destroy(DESTROY_OPTIONS)
|
|
198
|
+
} finally {
|
|
199
|
+
currentApp?.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
105
203
|
const playGroup = (group: string): void => {
|
|
106
204
|
if (model === undefined) return
|
|
107
205
|
const groups = model.internalModel.settings.motions ?? {}
|
|
@@ -134,42 +232,58 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
|
134
232
|
}
|
|
135
233
|
configureOnce(vendor)
|
|
136
234
|
const pixiApp = new vendor.Application()
|
|
137
|
-
|
|
138
|
-
width:
|
|
139
|
-
height:
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
235
|
+
const initialSize = normalizeRendererSize(ctx.container.clientWidth, ctx.container.clientHeight) ?? {
|
|
236
|
+
width: 160,
|
|
237
|
+
height: 174,
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
await pixiApp.init({
|
|
241
|
+
width: initialSize.width,
|
|
242
|
+
height: initialSize.height,
|
|
243
|
+
backgroundAlpha: 0,
|
|
244
|
+
antialias: true,
|
|
245
|
+
autoDensity: true,
|
|
246
|
+
preference: 'webgl',
|
|
247
|
+
})
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// init() can fail after allocating a partial renderer; cleanup is
|
|
250
|
+
// best-effort because Pixi may not consider that partial app ready.
|
|
251
|
+
try { pixiApp.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS) } catch {}
|
|
252
|
+
throw error
|
|
253
|
+
}
|
|
145
254
|
if (disposed) {
|
|
146
|
-
pixiApp.destroy(
|
|
255
|
+
pixiApp.destroy(RENDERER_DESTROY_OPTIONS, DESTROY_OPTIONS)
|
|
147
256
|
return
|
|
148
257
|
}
|
|
258
|
+
app = pixiApp
|
|
149
259
|
pixiApp.canvas.style.display = 'block'
|
|
150
260
|
pixiApp.canvas.style.width = '100%'
|
|
151
261
|
pixiApp.canvas.style.height = '100%'
|
|
152
262
|
ctx.container.appendChild(pixiApp.canvas)
|
|
153
|
-
|
|
263
|
+
// Keep a model that rejects during setup off Ticker.shared; from()
|
|
264
|
+
// does not expose that partial instance to callers for disposal.
|
|
265
|
+
trackContainerSize(pixiApp)
|
|
266
|
+
const loaded = await vendor.Live2DModel.from(config.modelUrl, {
|
|
267
|
+
autoUpdate: false,
|
|
268
|
+
autoHitTest: false,
|
|
269
|
+
autoFocus: false,
|
|
270
|
+
textureOptions: TEXTURE_OPTIONS,
|
|
271
|
+
})
|
|
272
|
+
model = loaded
|
|
154
273
|
if (disposed) {
|
|
155
|
-
|
|
274
|
+
destroyResources()
|
|
156
275
|
return
|
|
157
276
|
}
|
|
158
|
-
|
|
159
|
-
|
|
277
|
+
modelSourceSize = {
|
|
278
|
+
width: Math.max(1, loaded.width),
|
|
279
|
+
height: Math.max(1, loaded.height),
|
|
280
|
+
}
|
|
160
281
|
// Auto-fit the model into the container; the manifest scale multiplies
|
|
161
282
|
// the fit and translate offsets from the center anchor.
|
|
162
|
-
|
|
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
|
-
)
|
|
283
|
+
layoutModel(pixiApp, loaded, modelSourceSize, config)
|
|
172
284
|
pixiApp.stage.addChild(loaded)
|
|
285
|
+
modelAttached = true
|
|
286
|
+
loaded.automator.autoUpdate = true
|
|
173
287
|
// Resume the phase group once a tap motion finishes playing.
|
|
174
288
|
loaded.on('motionFinish', () => {
|
|
175
289
|
if (tapPlaying) {
|
|
@@ -182,19 +296,18 @@ export const live2dRenderer: PetRenderer<PetLive2dConfig> = {
|
|
|
182
296
|
}
|
|
183
297
|
|
|
184
298
|
void boot().catch(() => {
|
|
185
|
-
|
|
299
|
+
try {
|
|
300
|
+
destroyResources()
|
|
301
|
+
} finally {
|
|
302
|
+
if (!disposed) errorListener?.('load-failed')
|
|
303
|
+
}
|
|
186
304
|
})
|
|
187
305
|
|
|
188
306
|
return {
|
|
189
307
|
dispose() {
|
|
190
308
|
if (disposed) return
|
|
191
309
|
disposed = true
|
|
192
|
-
|
|
193
|
-
model = undefined
|
|
194
|
-
if (app !== undefined) {
|
|
195
|
-
app.destroy(true, { children: true })
|
|
196
|
-
app = undefined
|
|
197
|
-
}
|
|
310
|
+
destroyResources()
|
|
198
311
|
},
|
|
199
312
|
tap(x: number, y: number) {
|
|
200
313
|
const current = model
|
|
@@ -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
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-decoration contract — the L3 extension slot for small status
|
|
3
|
+
* ornaments (issue #623 milestone M5, protocol #567, first reference
|
|
4
|
+
* implementation #463). A decoration is an INDEPENDENT content entry (own
|
|
5
|
+
* id, own directory, own descriptor) whose PNG/WebP sprite strip ornaments
|
|
6
|
+
* the pet center's status bubble chrome; it never touches the pet
|
|
7
|
+
* manifests and never changes the bubble's semantics.
|
|
8
|
+
*
|
|
9
|
+
* Adopted disciplines (#623): entry assets are PNG/WebP sprite strips only
|
|
10
|
+
* (no SVG, no CSS animation); the bubble's own role=status/aria-live (or
|
|
11
|
+
* session-bubble button semantics) always stays intact and the ornament is
|
|
12
|
+
* aria-hidden; load failure or prefers-reduced-motion degrades to no
|
|
13
|
+
* ornament or the static first frame.
|
|
14
|
+
*
|
|
15
|
+
* The ActivityPhase stream the pet center owns drives the ornament: each
|
|
16
|
+
* phase binds to a frame segment (inclusive from/to indices into the
|
|
17
|
+
* strip) or to 'hide' (no ornament for that phase; the default).
|
|
18
|
+
* @module @linxin666/dsh-pet/contracts/status-decoration
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ActivityPhase } from '../state.ts'
|
|
22
|
+
|
|
23
|
+
/** Contract version decorations declare against (independent of manifests). */
|
|
24
|
+
export const PET_DECORATION_API_VERSION = 'x-org.linxin666.pet-center/status-decoration-v1' as const
|
|
25
|
+
|
|
26
|
+
/** One phase binding: a frame segment, or hidden. */
|
|
27
|
+
export type PhaseSegment = { from: number; to: number } | 'hide'
|
|
28
|
+
|
|
29
|
+
/** The ActivityPhase -> frame-segment binding table (unmapped phases hide). */
|
|
30
|
+
export type PhaseBindings = Partial<Record<ActivityPhase, PhaseSegment>>
|
|
31
|
+
|
|
32
|
+
/** Normalized decoration descriptor as the registry consumes it. */
|
|
33
|
+
export interface DecorationManifest {
|
|
34
|
+
decorationManifestVersion: 1
|
|
35
|
+
id: string
|
|
36
|
+
displayName: string
|
|
37
|
+
license: string
|
|
38
|
+
/** Strip path relative to the descriptor directory (PNG/WebP). */
|
|
39
|
+
entry: string
|
|
40
|
+
cell: { width: number; height: number }
|
|
41
|
+
columns: number
|
|
42
|
+
/** Per-frame duration ms (same length as columns, or a single constant). */
|
|
43
|
+
durations: number[]
|
|
44
|
+
loop: boolean
|
|
45
|
+
phases: PhaseBindings
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One structured diagnostic emitted while parsing a descriptor. */
|
|
49
|
+
export interface DecorationDiagnostic {
|
|
50
|
+
level: 'error' | 'warning'
|
|
51
|
+
message: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Parse outcome: a usable descriptor plus diagnostics, or rejection. */
|
|
55
|
+
export type DecorationManifestParse =
|
|
56
|
+
| { ok: true; manifest: DecorationManifest; diagnostics: DecorationDiagnostic[] }
|
|
57
|
+
| { ok: false; diagnostics: DecorationDiagnostic[] }
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The decoration block the host serves inside the pet state view — exactly
|
|
61
|
+
* what the browser half needs to render the ornament, nothing more. The
|
|
62
|
+
* apiVersion rides the wire so a future protocol revision can be detected
|
|
63
|
+
* and negotiated by clients (review-spd follow-up, pet-center M5).
|
|
64
|
+
*/
|
|
65
|
+
export interface DecorationView {
|
|
66
|
+
/** The protocol version this view speaks (PET_DECORATION_API_VERSION). */
|
|
67
|
+
apiVersion: typeof PET_DECORATION_API_VERSION
|
|
68
|
+
id: string
|
|
69
|
+
/** Same-origin URL prefix of the decoration's assets. */
|
|
70
|
+
assetBase: string
|
|
71
|
+
/** Browser URL of the strip. */
|
|
72
|
+
entryUrl: string
|
|
73
|
+
cell: { width: number; height: number }
|
|
74
|
+
columns: number
|
|
75
|
+
durations: number[]
|
|
76
|
+
loop: boolean
|
|
77
|
+
phases: PhaseBindings
|
|
78
|
+
}
|