@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.
Files changed (87) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +65 -2
  3. package/README.zh.md +65 -2
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/contracts/pet-manifest-v2.schema.json +281 -0
  7. package/contracts/status-decoration-v1.schema.json +144 -0
  8. package/contracts/voice-pack-v1.schema.json +234 -0
  9. package/lib/client.js +251 -43
  10. package/lib/client.js.map +1 -1
  11. package/lib/index.js +1139 -100
  12. package/lib/types/access.d.ts +16 -0
  13. package/lib/types/access.d.ts.map +1 -0
  14. package/lib/types/access.js +20 -0
  15. package/lib/types/chatter.d.ts +61 -3
  16. package/lib/types/chatter.d.ts.map +1 -1
  17. package/lib/types/chatter.js +71 -12
  18. package/lib/types/client/PetSettingsCard.d.ts +4 -0
  19. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  20. package/lib/types/client/PetSettingsCard.js +11 -3
  21. package/lib/types/client/PetSprite.d.ts.map +1 -1
  22. package/lib/types/client/PetSprite.js +111 -4
  23. package/lib/types/client/locales.d.ts +4 -0
  24. package/lib/types/client/locales.d.ts.map +1 -1
  25. package/lib/types/client/locales.js +4 -0
  26. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -1
  27. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +1 -0
  28. package/lib/types/client/renderers/live2d/runtime.d.ts +13 -1
  29. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -1
  30. package/lib/types/client/renderers/live2d.d.ts.map +1 -1
  31. package/lib/types/client/renderers/live2d.js +133 -25
  32. package/lib/types/client/settings-form.d.ts.map +1 -1
  33. package/lib/types/client/settings-form.js +9 -6
  34. package/lib/types/contracts/status-decoration.d.ts +85 -0
  35. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  36. package/lib/types/contracts/status-decoration.js +21 -0
  37. package/lib/types/decoration.d.ts +39 -0
  38. package/lib/types/decoration.d.ts.map +1 -0
  39. package/lib/types/decoration.js +210 -0
  40. package/lib/types/event-projection.d.ts +8 -3
  41. package/lib/types/event-projection.d.ts.map +1 -1
  42. package/lib/types/event-projection.js +9 -4
  43. package/lib/types/image-dimensions.d.ts +26 -0
  44. package/lib/types/image-dimensions.d.ts.map +1 -0
  45. package/lib/types/image-dimensions.js +77 -0
  46. package/lib/types/index.d.ts +2 -0
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +3 -1
  49. package/lib/types/registry.d.ts +55 -2
  50. package/lib/types/registry.d.ts.map +1 -1
  51. package/lib/types/registry.js +240 -16
  52. package/lib/types/routes.d.ts +6 -1
  53. package/lib/types/routes.d.ts.map +1 -1
  54. package/lib/types/routes.js +163 -32
  55. package/lib/types/service.d.ts +33 -0
  56. package/lib/types/service.d.ts.map +1 -1
  57. package/lib/types/service.js +42 -3
  58. package/lib/types/voice-pack.d.ts +98 -0
  59. package/lib/types/voice-pack.d.ts.map +1 -0
  60. package/lib/types/voice-pack.js +384 -0
  61. package/package.json +11 -10
  62. package/src/access.ts +40 -0
  63. package/src/chatter.test.ts +89 -2
  64. package/src/chatter.ts +120 -14
  65. package/src/client/PetSettingsCard.tsx +26 -2
  66. package/src/client/PetSprite.test.tsx +325 -12
  67. package/src/client/PetSprite.tsx +150 -25
  68. package/src/client/locales.ts +4 -0
  69. package/src/client/renderers/live2d/Live2dVisualMount.test.tsx +72 -0
  70. package/src/client/renderers/live2d/Live2dVisualMount.tsx +1 -0
  71. package/src/client/renderers/live2d/runtime.ts +8 -2
  72. package/src/client/renderers/live2d.test.ts +233 -9
  73. package/src/client/renderers/live2d.ts +143 -30
  74. package/src/client/settings-form.ts +8 -6
  75. package/src/contracts/status-decoration.ts +78 -0
  76. package/src/decoration.test.ts +178 -0
  77. package/src/decoration.ts +220 -0
  78. package/src/event-projection.ts +10 -5
  79. package/src/image-dimensions.test.ts +85 -0
  80. package/src/image-dimensions.ts +76 -0
  81. package/src/index.ts +3 -1
  82. package/src/registry.test.ts +276 -0
  83. package/src/registry.ts +274 -15
  84. package/src/routes.ts +162 -33
  85. package/src/service.ts +59 -3
  86. package/src/voice-pack.test.ts +216 -0
  87. 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: document.createElement('canvas'),
113
+ canvas,
68
114
  stage: { addChild: (child) => { app.added = child } },
69
- renderer: { width: 160, height: 174 },
70
- init: () => Promise.resolve(),
71
- destroy: () => { app.destroyed += 1 },
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(model: FakeModel, app: FakeApp): unknown {
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: () => Promise.resolve(model) },
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(): { ctx: PetRendererContext; stream: PhaseStream; container: HTMLDivElement } {
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
- 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
- })
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(true, { children: true })
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
- const loaded = await vendor.Live2DModel.from(config.modelUrl, { autoHitTest: false, autoFocus: false })
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
- pixiApp.destroy(true, { children: true })
274
+ destroyResources()
156
275
  return
157
276
  }
158
- app = pixiApp
159
- model = loaded
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
- 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
- )
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
- if (!disposed) errorListener?.('load-failed')
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
- unsubscribe?.()
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 fields this save writes, so edits staged while it is in
337
- // flight survive: only the staged keys this save actually wrote are cleared.
338
- const fields = new Set(plan.map(item => item.field))
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 fields) {
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 !== fields.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
+ }