@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
package/src/client/locales.ts
CHANGED
|
@@ -19,16 +19,23 @@ export const zh = {
|
|
|
19
19
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
20
|
'pet.state.loading': '宠物正在赶来…',
|
|
21
21
|
'pet.state.error': '宠物迷路了(连接失败)',
|
|
22
|
+
'pet.renderer.unavailable': '这只宠物需要的渲染器({renderer})在当前版本不可用。',
|
|
23
|
+
'pet.live2d.core-missing': 'Live2D 核心未安装:请把官方 live2dcubismcore.min.js 放入 $DSH_HOME/pets/.runtime/ 后刷新(步骤见宠物插件 README)。',
|
|
24
|
+
'pet.live2d.vendor-missing': 'Live2D 组件缺失,请升级宠物插件。',
|
|
25
|
+
'pet.live2d.load-failed': 'Live2D 模型加载失败,请检查该宠物目录的完整性。',
|
|
22
26
|
'pet.openSessionHint': '点击跳转到对应会话',
|
|
23
27
|
'pet.moreSessions': '展开其余 {n} 个会话的气泡',
|
|
24
28
|
'pet.collapseSessions': '收起会话气泡',
|
|
25
29
|
// 一级设置页(settings.section 席位)。
|
|
26
30
|
'settings.title': '宠物',
|
|
31
|
+
'settings.diagnosticsTitle': '宠物目录诊断',
|
|
27
32
|
'settings.description': '选择宠物并调整它的显示布局。',
|
|
28
33
|
'settings.pet': '宠物',
|
|
29
34
|
'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
|
|
30
35
|
'settings.enabled': '启用宠物',
|
|
31
36
|
'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
|
|
37
|
+
'settings.decoration': '状态装饰',
|
|
38
|
+
'settings.decorationHint': '在宠物状态气泡里显示喷水鲸鱼等状态装饰;关闭后气泡只剩文字。',
|
|
32
39
|
'settings.visible': '显示宠物',
|
|
33
40
|
'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
|
|
34
41
|
'settings.size': '大小(px)',
|
|
@@ -67,16 +74,23 @@ export const en = {
|
|
|
67
74
|
'pet.treats': 'Treats ×{n}',
|
|
68
75
|
'pet.state.loading': 'The pet is on its way…',
|
|
69
76
|
'pet.state.error': 'The pet is lost (connection failed)',
|
|
77
|
+
'pet.renderer.unavailable': 'This pet needs a renderer ({renderer}) that is not available in this build.',
|
|
78
|
+
'pet.live2d.core-missing': 'Live2D Cubism Core is not installed: place the official live2dcubismcore.min.js under $DSH_HOME/pets/.runtime/ and refresh (see the pet plugin README).',
|
|
79
|
+
'pet.live2d.vendor-missing': 'The Live2D component is missing; please update the pet plugin.',
|
|
80
|
+
'pet.live2d.load-failed': 'The Live2D model failed to load; check the pet directory is complete.',
|
|
70
81
|
'pet.openSessionHint': 'Click to jump to this session',
|
|
71
82
|
'pet.moreSessions': 'Expand {n} more session bubbles',
|
|
72
83
|
'pet.collapseSessions': 'Collapse session bubbles',
|
|
73
84
|
// First-level settings section (the `settings.section` seat).
|
|
74
85
|
'settings.title': 'Pet',
|
|
86
|
+
'settings.diagnosticsTitle': 'Pet directory diagnostics',
|
|
75
87
|
'settings.description': 'Pick a pet and tune its display layout.',
|
|
76
88
|
'settings.pet': 'Pet',
|
|
77
89
|
'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
|
|
78
90
|
'settings.enabled': 'Enable the pet',
|
|
79
91
|
'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
|
|
92
|
+
'settings.decoration': 'Status decoration',
|
|
93
|
+
'settings.decorationHint': 'Show ornaments like the spouting whale inside the pet status bubbles; when off, bubbles stay text-only.',
|
|
80
94
|
'settings.visible': 'Show the pet',
|
|
81
95
|
'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
|
|
82
96
|
'settings.size': 'Size (px)',
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { createPhaseStream } from './phase-stream.ts'
|
|
4
|
+
import { RendererRegistry } from './renderers/registry.ts'
|
|
5
|
+
import { PET_RENDERER_API_VERSION, type PetRenderer, type PetRendererContext } from '../contracts/renderer.ts'
|
|
6
|
+
import type { ActivityPhase } from '../state.ts'
|
|
7
|
+
|
|
8
|
+
describe('createPhaseStream', () => {
|
|
9
|
+
it('dispatches on change only and supports unsubscribe', () => {
|
|
10
|
+
const stream = createPhaseStream()
|
|
11
|
+
const seen: ActivityPhase[] = []
|
|
12
|
+
const off = stream.subscribe(phase => seen.push(phase))
|
|
13
|
+
stream.push('idle') // unchanged: no dispatch
|
|
14
|
+
stream.push('thinking')
|
|
15
|
+
stream.push('thinking') // unchanged: no dispatch
|
|
16
|
+
stream.push('done')
|
|
17
|
+
expect(seen).toEqual(['thinking', 'done'])
|
|
18
|
+
expect(stream.get()).toBe('done')
|
|
19
|
+
off()
|
|
20
|
+
stream.push('idle')
|
|
21
|
+
expect(seen).toHaveLength(2) // no dispatch after unsubscribe
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function fakeContext(): PetRendererContext & { cleanups: Array<() => void> } {
|
|
26
|
+
const cleanups: Array<() => void> = []
|
|
27
|
+
return {
|
|
28
|
+
petId: 'test',
|
|
29
|
+
assetBase: '/pet/test',
|
|
30
|
+
container: document.createElement('div'),
|
|
31
|
+
phase: { get: () => 'idle', subscribe: () => () => {} },
|
|
32
|
+
interact: () => {},
|
|
33
|
+
onCleanup: fn => cleanups.push(fn),
|
|
34
|
+
cleanups,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('RendererRegistry', () => {
|
|
39
|
+
it('mounts a registered renderer with its validated config', () => {
|
|
40
|
+
const registry = new RendererRegistry()
|
|
41
|
+
const calls: unknown[] = []
|
|
42
|
+
const renderer: PetRenderer<{ tag: string }> = {
|
|
43
|
+
id: 'sprite2d',
|
|
44
|
+
apiVersion: PET_RENDERER_API_VERSION,
|
|
45
|
+
validateConfig: config => ({ tag: String((config as { tag: string }).tag) }),
|
|
46
|
+
mount: (ctx, config) => {
|
|
47
|
+
calls.push(config.tag)
|
|
48
|
+
return { dispose: () => calls.push('disposed') }
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
registry.register(renderer)
|
|
52
|
+
expect(registry.kinds()).toEqual(['sprite2d'])
|
|
53
|
+
const handle = registry.mount('sprite2d', fakeContext(), { tag: 'hello' })
|
|
54
|
+
expect(calls).toEqual(['hello'])
|
|
55
|
+
handle.dispose()
|
|
56
|
+
expect(calls).toEqual(['hello', 'disposed'])
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('renders a diagnostic card for an unknown renderer instead of blanking', () => {
|
|
60
|
+
const registry = new RendererRegistry()
|
|
61
|
+
const ctx = fakeContext()
|
|
62
|
+
const handle = registry.mount('live2d', ctx, {})
|
|
63
|
+
const note = ctx.container.querySelector('[data-dsh-pet-renderer-fallback]')
|
|
64
|
+
expect(note?.textContent).toContain('live2d')
|
|
65
|
+
expect(note?.textContent).toContain('supported')
|
|
66
|
+
handle.dispose()
|
|
67
|
+
handle.dispose() // idempotent
|
|
68
|
+
expect(ctx.container.querySelector('[data-dsh-pet-renderer-fallback]')).toBeNull()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('contract constants', () => {
|
|
73
|
+
it('locks the renderer apiVersion', () => {
|
|
74
|
+
expect(PET_RENDERER_API_VERSION).toBe('x-org.linxin666.pet-center/v1alpha1')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase stream — bridges the polled host snapshots onto the renderer
|
|
3
|
+
* contract's { get, subscribe } shape (pet-center M2 P4, issue #623). The
|
|
4
|
+
* existing poll loop pushes each snapshot's ActivityPhase; subscribers are
|
|
5
|
+
* dispatched on CHANGE only (phase transitions are sparse — done/failed hold
|
|
6
|
+
* a timed window before falling back to idle — and renderers like Live2D pay
|
|
7
|
+
* per transition, not per tick).
|
|
8
|
+
* @module @linxin666/dsh-pet/client/phase-stream
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ActivityPhase } from '../state.ts'
|
|
12
|
+
|
|
13
|
+
/** The renderer-facing phase stream. */
|
|
14
|
+
export interface PhaseStream {
|
|
15
|
+
/** The latest pushed phase. */
|
|
16
|
+
get(): ActivityPhase
|
|
17
|
+
/** Subscribe to phase changes; returns the unsubscribe. */
|
|
18
|
+
subscribe(listener: (phase: ActivityPhase) => void): () => void
|
|
19
|
+
/** Feed a fresh snapshot phase; no-op when unchanged. */
|
|
20
|
+
push(phase: ActivityPhase): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Create the stream (one per pet entry lifetime, owned by the plugin body). */
|
|
24
|
+
export function createPhaseStream(initial: ActivityPhase = 'idle'): PhaseStream {
|
|
25
|
+
let current = initial
|
|
26
|
+
const listeners = new Set<(phase: ActivityPhase) => void>()
|
|
27
|
+
return {
|
|
28
|
+
get: () => current,
|
|
29
|
+
subscribe(listener) {
|
|
30
|
+
listeners.add(listener)
|
|
31
|
+
return () => { listeners.delete(listener) }
|
|
32
|
+
},
|
|
33
|
+
push(phase) {
|
|
34
|
+
if (phase === current) return
|
|
35
|
+
current = phase
|
|
36
|
+
for (const listener of [...listeners]) listener(phase)
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* PetRendererSwitch — the client dispatch seam (pet-center M2 P5, issue #623).
|
|
4
|
+
* sprite2d hands through to the sprite; an unservable renderer gets a clear
|
|
5
|
+
* diagnostic card instead of a blank pet.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { cleanup, render } from '@testing-library/react'
|
|
9
|
+
import { afterEach } from 'vitest'
|
|
10
|
+
import { createElement } from 'react'
|
|
11
|
+
import { PetRendererSwitch } from './PetRendererSwitch.tsx'
|
|
12
|
+
import { defaultPetRendererRegistry } from './registry.ts'
|
|
13
|
+
import type { PetRenderer } from '../../contracts/renderer.ts'
|
|
14
|
+
import type { PetDefinition } from '../../registry.ts'
|
|
15
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
16
|
+
|
|
17
|
+
function definition(renderer?: string): PetDefinition {
|
|
18
|
+
return { id: 'x', displayName: 'X', description: '', renderer, cell: { width: 1, height: 1 }, columns: 8, rows: [], atlasRows: 9, tracks: {}, atlasUrl: '', manifestUrl: '' } as unknown as PetDefinition
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const t = ((key: string, vars?: Record<string, string>) => key + (vars ? ' ' + JSON.stringify(vars) : '')) as unknown as PropsLocale<'pet'>['t']
|
|
22
|
+
const noop = (): void => {}
|
|
23
|
+
|
|
24
|
+
describe('PetRendererSwitch', () => {
|
|
25
|
+
afterEach(cleanup)
|
|
26
|
+
it('hands sprite2d definitions straight to the sprite', () => {
|
|
27
|
+
const { container } = render(createElement(PetRendererSwitch, { definition: definition('sprite2d'), phase: 'idle', onPet: noop, t }, createElement('span', { 'data-testid': 'sprite' })))
|
|
28
|
+
expect(container.querySelector('[data-testid="sprite"]')).toBeTruthy()
|
|
29
|
+
expect(document.querySelector('[data-dsh-pet-renderer-fallback]')).toBeNull()
|
|
30
|
+
})
|
|
31
|
+
it('treats a missing renderer as sprite2d (pre-M2 data)', () => {
|
|
32
|
+
const { container } = render(createElement(PetRendererSwitch, { definition: definition(undefined), phase: 'idle', onPet: noop, t }, createElement('span', { 'data-testid': 'sprite' })))
|
|
33
|
+
expect(container.querySelector('[data-testid="sprite"]')).toBeTruthy()
|
|
34
|
+
})
|
|
35
|
+
it('renders a diagnostic card for renderers this build cannot serve', () => {
|
|
36
|
+
const { container } = render(createElement(PetRendererSwitch, { definition: definition('live2d'), phase: 'idle', onPet: noop, t }, createElement('span', { 'data-testid': 'sprite' })))
|
|
37
|
+
expect(container.querySelector('[data-testid="sprite"]')).toBeNull()
|
|
38
|
+
const card = document.querySelector('[data-dsh-pet-renderer-fallback="live2d"]')
|
|
39
|
+
expect(card?.textContent).toContain('live2d')
|
|
40
|
+
})
|
|
41
|
+
it('injects the live2d visual into the sprite chrome once the renderer is registered (M3)', () => {
|
|
42
|
+
defaultPetRendererRegistry.register({
|
|
43
|
+
id: 'live2d',
|
|
44
|
+
apiVersion: 'test',
|
|
45
|
+
validateConfig: (config) => config,
|
|
46
|
+
mount: () => ({ dispose: () => {} }),
|
|
47
|
+
} as PetRenderer)
|
|
48
|
+
try {
|
|
49
|
+
const def = {
|
|
50
|
+
...definition('live2d'),
|
|
51
|
+
live2d: { modelUrl: '/pet/x/x.model3.json', modelPath: 'x.model3.json', motions: { idle: 'Idle' } },
|
|
52
|
+
} as PetDefinition
|
|
53
|
+
let captured: { visual?: unknown } | undefined
|
|
54
|
+
const Probe = (props: { visual?: unknown }): null => {
|
|
55
|
+
captured = props
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
render(createElement(PetRendererSwitch, { definition: def, phase: 'idle', onPet: noop, t }, createElement(Probe)))
|
|
59
|
+
expect(captured).toBeDefined()
|
|
60
|
+
expect(captured!.visual).toBeTruthy()
|
|
61
|
+
expect(document.querySelector('[data-dsh-pet-renderer-fallback]')).toBeNull()
|
|
62
|
+
} finally {
|
|
63
|
+
defaultPetRendererRegistry.clear()
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Renderer switch — the client dispatch seam of the pet center (issue #623,
|
|
3
|
+
* milestone M2 P5 / M3). The pet's manifest picks the renderer: sprite2d
|
|
4
|
+
* hands straight through to the sprite; live2d injects its visual INTO the
|
|
5
|
+
* sprite chrome (the dock, bubbles and panel belong to the pet center, not
|
|
6
|
+
* the renderer); a renderer this build cannot serve renders a clear
|
|
7
|
+
* diagnostic card instead of blanking.
|
|
8
|
+
* @module @linxin666/dsh-pet/client/renderers/PetRendererSwitch
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
|
|
12
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
13
|
+
import type { PetDefinition } from '../../registry.ts'
|
|
14
|
+
import type { ActivityPhase } from '../../state.ts'
|
|
15
|
+
import type { PetSpriteProps } from '../PetSprite.tsx'
|
|
16
|
+
import { defaultPetRendererRegistry } from './registry.ts'
|
|
17
|
+
import { Live2dVisualMount } from './live2d/Live2dVisualMount.tsx'
|
|
18
|
+
import type { NS } from '../locales.ts'
|
|
19
|
+
|
|
20
|
+
/** Dispatch one pet definition to its renderer; unknown kinds get a card. */
|
|
21
|
+
export function PetRendererSwitch(props: {
|
|
22
|
+
definition: PetDefinition
|
|
23
|
+
/** Current activity phase (fed to renderer visuals). */
|
|
24
|
+
phase: ActivityPhase
|
|
25
|
+
/** The chrome's pet interaction (affinity write-back owner). */
|
|
26
|
+
onPet: () => void
|
|
27
|
+
t: PropsLocale<typeof NS>['t']
|
|
28
|
+
children?: ReactNode
|
|
29
|
+
}): ReactElement {
|
|
30
|
+
const renderer = props.definition.renderer ?? 'sprite2d'
|
|
31
|
+
if (renderer === 'sprite2d') return <>{props.children}</>
|
|
32
|
+
if (renderer === 'live2d' && defaultPetRendererRegistry.has('live2d') && isValidElement<PetSpriteProps>(props.children)) {
|
|
33
|
+
const visual = (
|
|
34
|
+
<Live2dVisualMount
|
|
35
|
+
definition={props.definition}
|
|
36
|
+
phase={props.phase}
|
|
37
|
+
onPet={props.onPet}
|
|
38
|
+
t={props.t}
|
|
39
|
+
/>
|
|
40
|
+
)
|
|
41
|
+
return cloneElement(props.children, { visual })
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<span data-dsh-pet-renderer-fallback={renderer}>
|
|
45
|
+
{props.t('pet.renderer.unavailable', { renderer })}
|
|
46
|
+
</span>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live2D visual mount (pet-center M3) — the React bridge between the pet
|
|
3
|
+
* center chrome and the imperative live2d renderer. The bridge owns the
|
|
4
|
+
* contract context (asset base, phase stream, interaction write-back,
|
|
5
|
+
* activation cleanups), feeds the polled phase into the stream, forwards
|
|
6
|
+
* sub-4px taps as hit-test coordinates, and renders the localized error
|
|
7
|
+
* card when the renderer reports a fatal boot failure.
|
|
8
|
+
* @module @linxin666/dsh-pet/client/renderers/live2d/Live2dVisualMount
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useRef, useState, type ReactElement } from 'react'
|
|
12
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
13
|
+
import type { PetDefinition } from '../../../registry.ts'
|
|
14
|
+
import type { ActivityPhase } from '../../../state.ts'
|
|
15
|
+
import { createPhaseStream, type PhaseStream } from '../../phase-stream.ts'
|
|
16
|
+
import type { PetRendererContext } from '../../../contracts/renderer.ts'
|
|
17
|
+
import { defaultPetRendererRegistry } from '../registry.ts'
|
|
18
|
+
import type { Live2dErrorCode, Live2dRendererHandle } from '../live2d.ts'
|
|
19
|
+
import type { NS } from '../../locales.ts'
|
|
20
|
+
|
|
21
|
+
/** Mount the live2d renderer as the sprite's visual (inside the chrome). */
|
|
22
|
+
export function Live2dVisualMount(props: {
|
|
23
|
+
definition: PetDefinition
|
|
24
|
+
phase: ActivityPhase
|
|
25
|
+
onPet: () => void
|
|
26
|
+
t: PropsLocale<typeof NS>['t']
|
|
27
|
+
}): ReactElement {
|
|
28
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
29
|
+
const streamRef = useRef<PhaseStream | null>(null)
|
|
30
|
+
const handleRef = useRef<Live2dRendererHandle | null>(null)
|
|
31
|
+
const downRef = useRef<{ x: number; y: number } | null>(null)
|
|
32
|
+
const [error, setError] = useState<Live2dErrorCode | null>(null)
|
|
33
|
+
|
|
34
|
+
// One activation per pet definition: build the contract context and mount.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const container = containerRef.current
|
|
37
|
+
const live2d = props.definition.live2d
|
|
38
|
+
if (container === null || live2d === undefined) return undefined
|
|
39
|
+
streamRef.current ??= createPhaseStream(props.phase)
|
|
40
|
+
const cleanups: (() => void)[] = []
|
|
41
|
+
const ctx: PetRendererContext = {
|
|
42
|
+
petId: props.definition.id,
|
|
43
|
+
assetBase: '/pet/' + encodeURIComponent(props.definition.id),
|
|
44
|
+
container,
|
|
45
|
+
phase: streamRef.current,
|
|
46
|
+
interact: props.onPet,
|
|
47
|
+
onCleanup: (fn) => { cleanups.push(fn) },
|
|
48
|
+
}
|
|
49
|
+
let handle: Live2dRendererHandle
|
|
50
|
+
try {
|
|
51
|
+
handle = defaultPetRendererRegistry.mount('live2d', ctx, live2d) as Live2dRendererHandle
|
|
52
|
+
} catch {
|
|
53
|
+
setError('load-failed')
|
|
54
|
+
return () => { for (const fn of cleanups.splice(0)) fn() }
|
|
55
|
+
}
|
|
56
|
+
handleRef.current = handle
|
|
57
|
+
handle.onError?.(setError)
|
|
58
|
+
return () => {
|
|
59
|
+
handleRef.current = null
|
|
60
|
+
for (const fn of cleanups.splice(0)) fn()
|
|
61
|
+
handle.dispose()
|
|
62
|
+
}
|
|
63
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one activation per pet identity
|
|
64
|
+
}, [props.definition])
|
|
65
|
+
|
|
66
|
+
// Feed the polled phase into the activation's stream (change-only).
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
streamRef.current?.push(props.phase)
|
|
69
|
+
}, [props.phase])
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div
|
|
73
|
+
ref={containerRef}
|
|
74
|
+
data-dsh-pet-live2d={props.definition.id}
|
|
75
|
+
style={{ width: '100%', height: '100%' }}
|
|
76
|
+
onPointerDown={(e) => { downRef.current = { x: e.clientX, y: e.clientY } }}
|
|
77
|
+
onPointerUp={(e) => {
|
|
78
|
+
const down = downRef.current
|
|
79
|
+
downRef.current = null
|
|
80
|
+
if (down === null) return
|
|
81
|
+
// A moved pointer is a drag (the chrome owns it), not a tap.
|
|
82
|
+
if (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4) return
|
|
83
|
+
const rect = e.currentTarget.getBoundingClientRect()
|
|
84
|
+
handleRef.current?.tap(e.clientX - rect.left, e.clientY - rect.top)
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{error !== null && (
|
|
88
|
+
<span data-dsh-pet-live2d-error={error}>
|
|
89
|
+
{error === 'core-missing'
|
|
90
|
+
? props.t('pet.live2d.core-missing')
|
|
91
|
+
: error === 'vendor-missing'
|
|
92
|
+
? props.t('pet.live2d.vendor-missing')
|
|
93
|
+
: props.t('pet.live2d.load-failed')}
|
|
94
|
+
</span>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* Live2D runtime loader tests (pet-center M3) — globals short-circuit, probe
|
|
4
|
+
* injection success/failure, and the runtime-route URLs the host serves.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
7
|
+
import { ensureCubismCore, ensureLive2dVendor, resetLive2dRuntime, type Live2dVendor } from './runtime.ts'
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
resetLive2dRuntime()
|
|
11
|
+
delete window.Live2DCubismCore
|
|
12
|
+
delete window.__dshPetLive2d
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
describe('ensureCubismCore', () => {
|
|
16
|
+
it('short-circuits when the global already exists', async () => {
|
|
17
|
+
window.Live2DCubismCore = {}
|
|
18
|
+
await expect(ensureCubismCore({ inject: () => Promise.reject(new Error('must not inject')) })).resolves.toBe(true)
|
|
19
|
+
})
|
|
20
|
+
it('injects the runtime-route core URL and checks the global afterwards', async () => {
|
|
21
|
+
const seen: string[] = []
|
|
22
|
+
const ok = await ensureCubismCore({
|
|
23
|
+
inject: (src) => {
|
|
24
|
+
seen.push(src)
|
|
25
|
+
window.Live2DCubismCore = {}
|
|
26
|
+
return Promise.resolve()
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
expect(ok).toBe(true)
|
|
30
|
+
expect(seen).toEqual(['/api/pet/runtime/live2dcubismcore.min.js'])
|
|
31
|
+
})
|
|
32
|
+
it('resolves false when the user has not installed the core', async () => {
|
|
33
|
+
await expect(ensureCubismCore({ inject: () => Promise.reject(new Error('404')) })).resolves.toBe(false)
|
|
34
|
+
// Even a successful script load without the global means absence.
|
|
35
|
+
await expect(ensureCubismCore({ inject: () => Promise.resolve() })).resolves.toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('ensureLive2dVendor', () => {
|
|
40
|
+
it('short-circuits on the existing global', async () => {
|
|
41
|
+
const vendor = { marker: true } as unknown as Live2dVendor
|
|
42
|
+
window.__dshPetLive2d = vendor
|
|
43
|
+
await expect(ensureLive2dVendor({ inject: () => Promise.reject(new Error('must not inject')) })).resolves.toBe(vendor)
|
|
44
|
+
})
|
|
45
|
+
it('injects the vendor URL and returns the global it sets', async () => {
|
|
46
|
+
const seen: string[] = []
|
|
47
|
+
const vendor = { marker: true } as unknown as Live2dVendor
|
|
48
|
+
const loaded = await ensureLive2dVendor({
|
|
49
|
+
inject: (src) => {
|
|
50
|
+
seen.push(src)
|
|
51
|
+
window.__dshPetLive2d = vendor
|
|
52
|
+
return Promise.resolve()
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
expect(loaded).toBe(vendor)
|
|
56
|
+
expect(seen).toEqual(['/api/pet/runtime/live2d-vendor.js'])
|
|
57
|
+
})
|
|
58
|
+
it('resolves undefined when the vendor file is unavailable', async () => {
|
|
59
|
+
await expect(ensureLive2dVendor({ inject: () => Promise.reject(new Error('404')) })).resolves.toBeUndefined()
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live2D runtime loading (pet-center M3) — the two scripts a live2d mount
|
|
3
|
+
* needs, fetched lazily through the plugin's own runtime route: the
|
|
4
|
+
* user-supplied Cubism Core (proprietary; the plugin never bundles or
|
|
5
|
+
* downloads it — issue #623 M1 §0) and the plugin-shipped MIT vendor bundle
|
|
6
|
+
* (pixi.js + untitled-pixi-live2d-engine). Each loads at most once per page;
|
|
7
|
+
* concurrent mounts share the in-flight promise, and a failure is cached as
|
|
8
|
+
* 'absent' so a broken install stops retrying the network every mount.
|
|
9
|
+
*
|
|
10
|
+
* The vendor surface below is the structural slice the renderer consumes;
|
|
11
|
+
* the real objects come from 'window.__dshPetLive2d' (lib/live2d-vendor.js),
|
|
12
|
+
* so this module never imports pixi — the client bundle stays lean.
|
|
13
|
+
* @module @linxin666/dsh-pet/client/renderers/live2d/runtime
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Runtime file URLs the host serves ('/api/pet/runtime/<name>', M3-2). */
|
|
17
|
+
const CORE_URL = '/api/pet/runtime/live2dcubismcore.min.js'
|
|
18
|
+
const VENDOR_URL = '/api/pet/runtime/live2d-vendor.js'
|
|
19
|
+
|
|
20
|
+
/** The pixi Application slice the renderer uses. */
|
|
21
|
+
export interface Live2dVendorApp {
|
|
22
|
+
canvas: HTMLCanvasElement
|
|
23
|
+
stage: { addChild(child: unknown): unknown }
|
|
24
|
+
renderer: { readonly width: number; readonly height: number }
|
|
25
|
+
init(options: Record<string, unknown>): Promise<void>
|
|
26
|
+
destroy(removeView?: boolean, options?: Record<string, unknown>): void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The Live2DModel slice the renderer uses. */
|
|
30
|
+
export interface Live2dVendorModel {
|
|
31
|
+
anchor: { set(x: number, y?: number): void }
|
|
32
|
+
position: { set(x: number, y: number): void }
|
|
33
|
+
scale: { set(x: number, y?: number): void }
|
|
34
|
+
readonly width: number
|
|
35
|
+
readonly height: number
|
|
36
|
+
internalModel: {
|
|
37
|
+
settings: {
|
|
38
|
+
motions?: Record<string, unknown[]>
|
|
39
|
+
hitAreas?: readonly { Name?: string }[]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
motion(group: string, index?: number): Promise<unknown>
|
|
43
|
+
expression(name?: string): unknown
|
|
44
|
+
hitTest(x: number, y: number): string[]
|
|
45
|
+
on(event: string, fn: () => void): unknown
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The vendor bundle global (window.__dshPetLive2d). */
|
|
49
|
+
export interface Live2dVendor {
|
|
50
|
+
Application: new () => Live2dVendorApp
|
|
51
|
+
extensions: { add(...items: unknown[]): void }
|
|
52
|
+
Live2DPlugin: unknown
|
|
53
|
+
configureCubismSDK(options: Record<string, unknown>): void
|
|
54
|
+
Live2DModel: { from(source: string, options?: Record<string, unknown>): Promise<Live2dVendorModel> }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
declare global {
|
|
58
|
+
interface Window {
|
|
59
|
+
Live2DCubismCore?: unknown
|
|
60
|
+
__dshPetLive2d?: Live2dVendor
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Injects one classic script tag; resolves on load, rejects on error. */
|
|
65
|
+
type ScriptInjector = (src: string) => Promise<void>
|
|
66
|
+
|
|
67
|
+
const defaultInjector: ScriptInjector = (src) => new Promise<void>((resolve, reject) => {
|
|
68
|
+
const tag = document.createElement('script')
|
|
69
|
+
tag.src = src
|
|
70
|
+
tag.onload = () => resolve()
|
|
71
|
+
tag.onerror = () => reject(new Error('script failed to load: ' + src))
|
|
72
|
+
document.head.appendChild(tag)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
/** Test seam: swap the network for a stub injector. */
|
|
76
|
+
export interface Live2dRuntimeProbe {
|
|
77
|
+
inject?: ScriptInjector
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let corePromise: Promise<boolean> | undefined
|
|
81
|
+
let vendorPromise: Promise<Live2dVendor | undefined> | undefined
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ensure the Cubism Core global exists, injecting the runtime-route script
|
|
85
|
+
* once when absent. Resolves false when the user has not installed the core
|
|
86
|
+
* (a normal state — the renderer turns it into install guidance).
|
|
87
|
+
*/
|
|
88
|
+
export function ensureCubismCore(probe: Live2dRuntimeProbe = {}): Promise<boolean> {
|
|
89
|
+
if (typeof window !== 'undefined' && window.Live2DCubismCore !== undefined) return Promise.resolve(true)
|
|
90
|
+
if (probe.inject !== undefined) {
|
|
91
|
+
return probe.inject(CORE_URL)
|
|
92
|
+
.then(() => typeof window !== 'undefined' && window.Live2DCubismCore !== undefined)
|
|
93
|
+
.catch(() => false)
|
|
94
|
+
}
|
|
95
|
+
corePromise ??= defaultInjector(CORE_URL)
|
|
96
|
+
.then(() => typeof window !== 'undefined' && window.Live2DCubismCore !== undefined)
|
|
97
|
+
.catch(() => false)
|
|
98
|
+
return corePromise
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Ensure the plugin vendor bundle global exists (same caching discipline). */
|
|
102
|
+
export function ensureLive2dVendor(probe: Live2dRuntimeProbe = {}): Promise<Live2dVendor | undefined> {
|
|
103
|
+
if (typeof window !== 'undefined' && window.__dshPetLive2d !== undefined) return Promise.resolve(window.__dshPetLive2d)
|
|
104
|
+
if (probe.inject !== undefined) {
|
|
105
|
+
return probe.inject(VENDOR_URL)
|
|
106
|
+
.then(() => typeof window !== 'undefined' ? window.__dshPetLive2d : undefined)
|
|
107
|
+
.catch(() => undefined)
|
|
108
|
+
}
|
|
109
|
+
vendorPromise ??= defaultInjector(VENDOR_URL)
|
|
110
|
+
.then(() => typeof window !== 'undefined' ? window.__dshPetLive2d : undefined)
|
|
111
|
+
.catch(() => undefined)
|
|
112
|
+
return vendorPromise
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Reset the cached script promises (tests). */
|
|
116
|
+
export function resetLive2dRuntime(): void {
|
|
117
|
+
corePromise = undefined
|
|
118
|
+
vendorPromise = undefined
|
|
119
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live2D vendor bundle entry (pet-center M3) — builds pixi.js plus the
|
|
3
|
+
* untitled-pixi-live2d-engine Cubism stack into lib/live2d-vendor.js, an IIFE
|
|
4
|
+
* exposing 'window.__dshPetLive2d'. The sprite-only client bundle never pays
|
|
5
|
+
* for this stack: the live2d renderer lazy-loads the vendor file through the
|
|
6
|
+
* plugin's own runtime route, after the user-supplied Cubism Core script
|
|
7
|
+
* ('window.Live2DCubismCore') is already present.
|
|
8
|
+
*
|
|
9
|
+
* Redistribution: pixi.js and untitled-pixi-live2d-engine are MIT-licensed
|
|
10
|
+
* and may ship inside this plugin; the proprietary Cubism Core is NEVER
|
|
11
|
+
* bundled (issue #623, milestone M1 §0).
|
|
12
|
+
*/
|
|
13
|
+
export { Application, Point, extensions } from 'pixi.js'
|
|
14
|
+
export { configureCubismSDK, Live2DModel, Live2DPlugin } from 'untitled-pixi-live2d-engine/cubism'
|