@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
package/src/client/PetSprite.tsx
CHANGED
|
@@ -10,17 +10,18 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
13
|
-
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode, ReactPortal } from 'react'
|
|
13
|
+
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactElement, ReactNode, ReactPortal } from 'react'
|
|
14
14
|
import { createPortal } from 'react-dom'
|
|
15
15
|
import clsx from 'clsx'
|
|
16
16
|
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
|
17
17
|
import type { PetDisplayConfig } from '../persist.ts'
|
|
18
18
|
import type { PetStateView } from '../service.ts'
|
|
19
19
|
import type { PetDefinition } from '../registry.ts'
|
|
20
|
+
import type { DecorationView } from '../contracts/status-decoration.ts'
|
|
20
21
|
import type { PetFeedback } from './pet-store.ts'
|
|
21
22
|
import { framePosition, rowOfTrack, trimTrack } from './spritesheet.ts'
|
|
22
23
|
import { sequenceFrameAt } from './sequences.ts'
|
|
23
|
-
import { animationForPhase, type PetAnimation } from '../state.ts'
|
|
24
|
+
import { animationForPhase, type ActivityPhase, type PetAnimation } from '../state.ts'
|
|
24
25
|
import { NS } from './locales.ts'
|
|
25
26
|
import styles from './pet.module.css'
|
|
26
27
|
|
|
@@ -63,6 +64,89 @@ function clampOffset(value: number, max: number): number {
|
|
|
63
64
|
return Math.max(0, Math.min(max, value))
|
|
64
65
|
}
|
|
65
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The status decoration ornament (pet-center M5, #567). Renders the active
|
|
69
|
+
* phase's frame segment as a CSS-background strip at a compact bubble
|
|
70
|
+
* height; prefers-reduced-motion holds the segment's first frame, and a
|
|
71
|
+
* missing or undecodable asset simply paints nothing (CSS background
|
|
72
|
+
* failure) — the bubble text is never disturbed. The span is aria-hidden;
|
|
73
|
+
* the bubble keeps its own semantics untouched.
|
|
74
|
+
*/
|
|
75
|
+
function StatusOrnament(props: { decoration: DecorationView; phase: ActivityPhase }): ReactElement | null {
|
|
76
|
+
const { decoration, phase } = props
|
|
77
|
+
const segment = decoration.phases[phase]
|
|
78
|
+
const shown = segment !== undefined && segment !== 'hide'
|
|
79
|
+
const segmentKey = segment !== undefined && segment !== 'hide' ? segment.from + ':' + segment.to : 'none'
|
|
80
|
+
const spanRef = useRef<HTMLSpanElement | null>(null)
|
|
81
|
+
const scale = 18 / decoration.cell.height
|
|
82
|
+
const frameWidth = Math.round(decoration.cell.width * scale)
|
|
83
|
+
const stripWidth = decoration.columns * frameWidth
|
|
84
|
+
// Value-stable dependency key: the host serves a fresh DecorationView
|
|
85
|
+
// object on every state poll (2 s), so the effect must not depend on the
|
|
86
|
+
// object identity — otherwise each poll would cancel and restart the
|
|
87
|
+
// frame loop and the animation would jump back to its first frame.
|
|
88
|
+
const durationsKey = decoration.durations.join(',')
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (segment === undefined || segment === 'hide') return
|
|
91
|
+
const el = spanRef.current
|
|
92
|
+
if (el === null) return
|
|
93
|
+
const position = (index: number): string => (-index * frameWidth) + 'px 0px'
|
|
94
|
+
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
95
|
+
el.style.backgroundPosition = position(segment.from)
|
|
96
|
+
// A single-frame segment (from === to) has nothing to animate: with
|
|
97
|
+
// loop=true the wrap branch would reset index to the same frame and the
|
|
98
|
+
// tick would keep rescheduling a no-op rAF forever. Settle on the one
|
|
99
|
+
// frame instead — same as the reduced-motion static hold.
|
|
100
|
+
if (reduceMotion || segment.from === segment.to) return
|
|
101
|
+
let raf = 0
|
|
102
|
+
let index = segment.from
|
|
103
|
+
let elapsed = 0
|
|
104
|
+
let last = performance.now()
|
|
105
|
+
const tick = (ts: number): void => {
|
|
106
|
+
const delta = ts - last
|
|
107
|
+
last = ts
|
|
108
|
+
elapsed += delta
|
|
109
|
+
const duration = decoration.durations[index] ?? 160
|
|
110
|
+
if (elapsed >= duration) {
|
|
111
|
+
elapsed = 0
|
|
112
|
+
if (index < segment.to) index += 1
|
|
113
|
+
else if (decoration.loop) index = segment.from
|
|
114
|
+
// Only advance the background when the frame actually changes:
|
|
115
|
+
// the segment's frame rate (duration ms, typically 90-160) is far
|
|
116
|
+
// below the rAF cadence, so writing the same position every frame
|
|
117
|
+
// would churn style recalculations for no visual change.
|
|
118
|
+
el.style.backgroundPosition = position(index)
|
|
119
|
+
}
|
|
120
|
+
// A non-looping segment settles on its last frame; stop scheduling
|
|
121
|
+
// instead of repainting the same position every frame.
|
|
122
|
+
if (!decoration.loop && index === segment.to) return
|
|
123
|
+
raf = requestAnimationFrame(tick)
|
|
124
|
+
}
|
|
125
|
+
raf = requestAnimationFrame(tick)
|
|
126
|
+
return () => cancelAnimationFrame(raf)
|
|
127
|
+
}, [shown, segmentKey, frameWidth, decoration.loop, durationsKey])
|
|
128
|
+
if (!shown) return null
|
|
129
|
+
return (
|
|
130
|
+
<span
|
|
131
|
+
ref={spanRef}
|
|
132
|
+
aria-hidden="true"
|
|
133
|
+
data-dsh-pet-decoration={decoration.id}
|
|
134
|
+
style={{
|
|
135
|
+
display: 'inline-block',
|
|
136
|
+
width: frameWidth,
|
|
137
|
+
height: 18,
|
|
138
|
+
marginRight: 6,
|
|
139
|
+
verticalAlign: 'middle',
|
|
140
|
+
flexShrink: 0,
|
|
141
|
+
backgroundImage: 'url(' + decoration.entryUrl + ')',
|
|
142
|
+
backgroundSize: stripWidth + 'px 18px',
|
|
143
|
+
backgroundRepeat: 'no-repeat',
|
|
144
|
+
backgroundPosition: '0px 0px',
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
66
150
|
/**
|
|
67
151
|
* The floating pet. The spritesheet frame advances on requestAnimationFrame
|
|
68
152
|
* with per-frame durations from the definition's tracks; the atlas image is
|
|
@@ -110,6 +194,33 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
110
194
|
const rows = definition.rows
|
|
111
195
|
const tracks = definition.tracks
|
|
112
196
|
const sequences = definition.sequences
|
|
197
|
+
// Hover-panel chrome from the pet's voice pack (pet-center M4, issue
|
|
198
|
+
// #677): every slot falls back to the i18n dictionary when unset. Stat
|
|
199
|
+
// formats carry {rank}/{n}/{points} placeholders the host validated.
|
|
200
|
+
const panel = definition.panel
|
|
201
|
+
const panelLabel = (slot: 'feed' | 'rename' | 'hide' | 'confirm', i18n: string): string =>
|
|
202
|
+
panel?.labels?.[slot] ?? i18n
|
|
203
|
+
const panelStat = (
|
|
204
|
+
slot: 'rank' | 'treats' | 'points',
|
|
205
|
+
i18nKey: 'pet.rank' | 'pet.treats' | 'pet.points',
|
|
206
|
+
values: Record<string, string | number>,
|
|
207
|
+
): string => {
|
|
208
|
+
const format = panel?.stats?.[slot] ?? props.t(i18nKey, values)
|
|
209
|
+
if (panel?.stats?.[slot] === undefined) return format
|
|
210
|
+
// The host whitelists {rank}/{n}/{points} in every stat slot, so a pack
|
|
211
|
+
// format may reference any of them; substitute all three live values
|
|
212
|
+
// (the slot's own value plus the siblings) instead of only the slot's.
|
|
213
|
+
const all: Record<string, string | number> = {
|
|
214
|
+
rank: snapshot?.affinity.rank ?? '?',
|
|
215
|
+
n: snapshot?.treats.stocked ?? 0,
|
|
216
|
+
points: snapshot?.affinity.points ?? 0,
|
|
217
|
+
}
|
|
218
|
+
let text = format
|
|
219
|
+
for (const [name, value] of Object.entries(all)) text = text.replaceAll('{' + name + '}', String(value))
|
|
220
|
+
return text
|
|
221
|
+
}
|
|
222
|
+
const panelShows = (action: 'feed' | 'rename' | 'hide'): boolean =>
|
|
223
|
+
panel?.actions === undefined || panel.actions.includes(action)
|
|
113
224
|
|
|
114
225
|
// Load the atlas once; the definition carries the authoritative per-row
|
|
115
226
|
// frame counts and per-track durations, so nothing else is fetched. A
|
|
@@ -287,6 +398,8 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
287
398
|
const whisper = feedback === null ? snapshot?.whisper : undefined
|
|
288
399
|
const bubblePresent = feedback !== null || sessionBubbles.length > 0 || statusBubble !== undefined || whisper !== undefined
|
|
289
400
|
const displayName = snapshot?.name ?? definition.displayName
|
|
401
|
+
// The host-served status decoration (M5, #567); absent = text-only bubbles.
|
|
402
|
+
const decoration = snapshot?.decoration
|
|
290
403
|
|
|
291
404
|
// A settled session list can no longer stay pinned open.
|
|
292
405
|
useEffect(() => {
|
|
@@ -408,6 +521,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
408
521
|
title={props.t('pet.openSessionHint')}
|
|
409
522
|
onClick={() => { props.onOpenSession(session.sessionId) }}
|
|
410
523
|
>
|
|
524
|
+
{index === 0 && !speaksWhisper && decoration !== undefined && (
|
|
525
|
+
<StatusOrnament decoration={decoration} phase={phase} />
|
|
526
|
+
)}
|
|
411
527
|
{speaksWhisper ? whisper : session.bubble}
|
|
412
528
|
</button>
|
|
413
529
|
)
|
|
@@ -446,6 +562,9 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
446
562
|
role="status"
|
|
447
563
|
aria-live="polite"
|
|
448
564
|
>
|
|
565
|
+
{whisper === undefined && decoration !== undefined && (
|
|
566
|
+
<StatusOrnament decoration={decoration} phase={phase} />
|
|
567
|
+
)}
|
|
449
568
|
{whisper ?? statusBubble}
|
|
450
569
|
</div>
|
|
451
570
|
)}
|
|
@@ -507,39 +626,45 @@ export function PetSprite(props: PetSpriteProps): ReactPortal {
|
|
|
507
626
|
}
|
|
508
627
|
}}
|
|
509
628
|
>
|
|
510
|
-
{props.t('pet.confirm')}
|
|
629
|
+
{panelLabel('confirm', props.t('pet.confirm'))}
|
|
511
630
|
</button>
|
|
512
631
|
</div>
|
|
513
632
|
) : (
|
|
514
633
|
<>
|
|
515
634
|
<div className={styles.rankRow}>
|
|
516
635
|
<span className={styles.nameCell}>{displayName}</span>
|
|
517
|
-
<span className={styles.statRank}>{
|
|
636
|
+
<span className={styles.statRank}>{panelStat('rank', 'pet.rank', { rank: snapshot?.affinity.rank ?? '?' })}</span>
|
|
518
637
|
</div>
|
|
519
638
|
<div className={styles.rankRow}>
|
|
520
|
-
<span className={styles.statTreats}>{
|
|
521
|
-
<span className={styles.statPoints}>{
|
|
639
|
+
<span className={styles.statTreats}>{panelStat('treats', 'pet.treats', { n: snapshot?.treats.stocked ?? 0 })}</span>
|
|
640
|
+
<span className={styles.statPoints}>{panelStat('points', 'pet.points', { points: snapshot?.affinity.points ?? 0 })}</span>
|
|
522
641
|
</div>
|
|
523
642
|
<div className={styles.actions}>
|
|
524
|
-
|
|
525
|
-
{props.
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
643
|
+
{panelShows('feed') && (
|
|
644
|
+
<button type="button" className={styles.action} onClick={props.onFeed}>
|
|
645
|
+
{panelLabel('feed', props.t('pet.feed'))}
|
|
646
|
+
</button>
|
|
647
|
+
)}
|
|
648
|
+
{panelShows('rename') && (
|
|
649
|
+
<button
|
|
650
|
+
type="button"
|
|
651
|
+
className={styles.action}
|
|
652
|
+
onClick={() => {
|
|
653
|
+
// Cancel any pending hide so the rename box cannot
|
|
654
|
+
// unmount right as the user starts typing (#303).
|
|
655
|
+
clearHideTimer()
|
|
656
|
+
setNameDraft(displayName)
|
|
657
|
+
setRenaming(true)
|
|
658
|
+
}}
|
|
659
|
+
>
|
|
660
|
+
{panelLabel('rename', props.t('pet.rename'))}
|
|
661
|
+
</button>
|
|
662
|
+
)}
|
|
663
|
+
{panelShows('hide') && (
|
|
664
|
+
<button type="button" className={styles.action} onClick={props.onHide}>
|
|
665
|
+
{panelLabel('hide', props.t('pet.hide'))}
|
|
666
|
+
</button>
|
|
667
|
+
)}
|
|
543
668
|
</div>
|
|
544
669
|
</>
|
|
545
670
|
)}
|
package/src/client/locales.ts
CHANGED
|
@@ -34,6 +34,8 @@ export const zh = {
|
|
|
34
34
|
'settings.petHint': '选择显示哪只宠物;每只宠物独立命名,可在宠物悬浮面板改名。',
|
|
35
35
|
'settings.enabled': '启用宠物',
|
|
36
36
|
'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
|
|
37
|
+
'settings.decoration': '状态装饰',
|
|
38
|
+
'settings.decorationHint': '在宠物状态气泡里显示喷水鲸鱼等状态装饰;关闭后气泡只剩文字。',
|
|
37
39
|
'settings.visible': '显示宠物',
|
|
38
40
|
'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
|
|
39
41
|
'settings.size': '大小(px)',
|
|
@@ -87,6 +89,8 @@ export const en = {
|
|
|
87
89
|
'settings.petHint': 'Choose which pet shows. Names are stored per pet; rename from the pet hover panel.',
|
|
88
90
|
'settings.enabled': 'Enable the pet',
|
|
89
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.',
|
|
90
94
|
'settings.visible': 'Show the pet',
|
|
91
95
|
'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
|
|
92
96
|
'settings.size': 'Size (px)',
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, cleanup, render } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
5
|
+
import type { PetRenderer, PetRendererContext } from '../../../contracts/renderer.ts'
|
|
6
|
+
import type { PetDefinition } from '../../../registry.ts'
|
|
7
|
+
import { defaultPetRendererRegistry } from '../registry.ts'
|
|
8
|
+
import type { Live2dErrorCode } from '../live2d.ts'
|
|
9
|
+
import { Live2dVisualMount } from './Live2dVisualMount.tsx'
|
|
10
|
+
|
|
11
|
+
const t = ((key: string) => key) as PropsLocale<'pet'>['t']
|
|
12
|
+
const noop = (): void => {}
|
|
13
|
+
|
|
14
|
+
function definition(id: string): PetDefinition {
|
|
15
|
+
return {
|
|
16
|
+
id,
|
|
17
|
+
displayName: id,
|
|
18
|
+
description: '',
|
|
19
|
+
renderer: 'live2d',
|
|
20
|
+
live2d: { modelPath: id + '.model3.json', modelUrl: '/pet/' + id + '/' + id + '.model3.json', motions: { idle: 'Idle' } },
|
|
21
|
+
} as unknown as PetDefinition
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('Live2dVisualMount', () => {
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
cleanup()
|
|
27
|
+
defaultPetRendererRegistry.clear()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('clears a failed activation error when switching to another pet', () => {
|
|
31
|
+
const errorSinks: ((code: Live2dErrorCode) => void)[] = []
|
|
32
|
+
const disposes: ReturnType<typeof vi.fn>[] = []
|
|
33
|
+
defaultPetRendererRegistry.register({
|
|
34
|
+
id: 'live2d',
|
|
35
|
+
apiVersion: 'test',
|
|
36
|
+
validateConfig: (config) => config,
|
|
37
|
+
mount: (ctx: PetRendererContext) => {
|
|
38
|
+
const canvas = document.createElement('canvas')
|
|
39
|
+
ctx.container.appendChild(canvas)
|
|
40
|
+
const dispose = vi.fn(() => { canvas.remove() })
|
|
41
|
+
disposes.push(dispose)
|
|
42
|
+
return {
|
|
43
|
+
dispose,
|
|
44
|
+
tap: () => {},
|
|
45
|
+
onError: (listener: (code: Live2dErrorCode) => void) => { errorSinks.push(listener) },
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
} as PetRenderer)
|
|
49
|
+
|
|
50
|
+
const first = definition('broken')
|
|
51
|
+
const second = definition('healthy')
|
|
52
|
+
const { container, rerender, unmount } = render(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
|
|
53
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(1)
|
|
54
|
+
act(() => { errorSinks[0]?.('load-failed') })
|
|
55
|
+
expect(container.querySelector('[data-dsh-pet-live2d-error="load-failed"]')).toBeTruthy()
|
|
56
|
+
|
|
57
|
+
rerender(<Live2dVisualMount definition={second} phase="idle" onPet={noop} t={t} />)
|
|
58
|
+
expect(disposes[0]).toHaveBeenCalledTimes(1)
|
|
59
|
+
expect(container.querySelector('[data-dsh-pet-live2d="healthy"]')).toBeTruthy()
|
|
60
|
+
expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
|
|
61
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(1)
|
|
62
|
+
|
|
63
|
+
rerender(<Live2dVisualMount definition={first} phase="idle" onPet={noop} t={t} />)
|
|
64
|
+
expect(disposes[1]).toHaveBeenCalledTimes(1)
|
|
65
|
+
expect(container.querySelector('[data-dsh-pet-live2d="broken"]')).toBeTruthy()
|
|
66
|
+
expect(container.querySelector('[data-dsh-pet-live2d-error]')).toBeNull()
|
|
67
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(1)
|
|
68
|
+
unmount()
|
|
69
|
+
expect(disposes[2]).toHaveBeenCalledTimes(1)
|
|
70
|
+
expect(container.querySelectorAll('canvas')).toHaveLength(0)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -33,6 +33,7 @@ export function Live2dVisualMount(props: {
|
|
|
33
33
|
|
|
34
34
|
// One activation per pet definition: build the contract context and mount.
|
|
35
35
|
useEffect(() => {
|
|
36
|
+
setError(null)
|
|
36
37
|
const container = containerRef.current
|
|
37
38
|
const live2d = props.definition.live2d
|
|
38
39
|
if (container === null || live2d === undefined) return undefined
|
|
@@ -21,13 +21,18 @@ const VENDOR_URL = '/api/pet/runtime/live2d-vendor.js'
|
|
|
21
21
|
export interface Live2dVendorApp {
|
|
22
22
|
canvas: HTMLCanvasElement
|
|
23
23
|
stage: { addChild(child: unknown): unknown }
|
|
24
|
-
renderer: {
|
|
24
|
+
renderer: {
|
|
25
|
+
readonly width: number
|
|
26
|
+
readonly height: number
|
|
27
|
+
resize(width: number, height: number): void
|
|
28
|
+
}
|
|
25
29
|
init(options: Record<string, unknown>): Promise<void>
|
|
26
|
-
destroy(removeView?: boolean, options?: Record<string, unknown>): void
|
|
30
|
+
destroy(rendererOptions?: boolean | { removeView?: boolean; releaseGlobalResources?: boolean }, options?: Record<string, unknown>): void
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/** The Live2DModel slice the renderer uses. */
|
|
30
34
|
export interface Live2dVendorModel {
|
|
35
|
+
automator: { autoUpdate: boolean }
|
|
31
36
|
anchor: { set(x: number, y?: number): void }
|
|
32
37
|
position: { set(x: number, y: number): void }
|
|
33
38
|
scale: { set(x: number, y?: number): void }
|
|
@@ -43,6 +48,7 @@ export interface Live2dVendorModel {
|
|
|
43
48
|
expression(name?: string): unknown
|
|
44
49
|
hitTest(x: number, y: number): string[]
|
|
45
50
|
on(event: string, fn: () => void): unknown
|
|
51
|
+
destroy(options?: { children?: boolean; texture?: boolean; baseTexture?: boolean }): void
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
/** The vendor bundle global (window.__dshPetLive2d). */
|