@linxin666/dsh-pet 0.3.5 → 0.3.9
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 +4 -0
- package/README.zh.md +4 -0
- package/contracts/voice-pack-v1.schema.json +30 -0
- package/cordis.patch.yml +1 -1
- package/lib/client.js +330 -103
- package/lib/client.js.map +1 -1
- package/lib/index.js +145 -28
- package/lib/invariant.js +1 -1
- package/lib/live2d-vendor.js +106 -95
- package/lib/live2d-vendor.js.map +1 -1
- package/lib/{state-DrMX22GL.js → state-NvDEoCln.js} +5 -2
- package/lib/types/access.d.ts +5 -4
- package/lib/types/access.d.ts.map +1 -1
- package/lib/types/access.js +2 -12
- package/lib/types/affinity.d.ts +2 -0
- package/lib/types/affinity.d.ts.map +1 -1
- package/lib/types/affinity.js +12 -0
- package/lib/types/announce.d.ts +53 -0
- package/lib/types/announce.d.ts.map +1 -0
- package/lib/types/announce.js +80 -0
- package/lib/types/client/PetDockEntry.d.ts +14 -5
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +6 -5
- package/lib/types/client/PetSettingsCard.d.ts +2 -1
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSprite.d.ts +8 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +36 -5
- package/lib/types/client/index.d.ts +8 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +11 -5
- package/lib/types/client/locales.d.ts +20 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +20 -2
- package/lib/types/client/pet-store.d.ts +1 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +1 -1
- package/lib/types/client/renderers/frames2d.d.ts +12 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -1
- package/lib/types/client/renderers/frames2d.js +143 -18
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +10 -9
- package/lib/types/ledger.d.ts +4 -2
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +4 -4
- package/lib/types/pair-access.d.ts +35 -0
- package/lib/types/pair-access.d.ts.map +1 -0
- package/lib/types/pair-access.js +19 -0
- package/lib/types/remarks.d.ts +1 -1
- package/lib/types/remarks.d.ts.map +1 -1
- package/lib/types/remarks.js +11 -2
- package/lib/types/service.d.ts +18 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +26 -1
- package/lib/types/voice-pack.d.ts +5 -0
- package/lib/types/voice-pack.d.ts.map +1 -1
- package/lib/types/voice-pack.js +36 -2
- package/package.json +17 -13
- package/src/access.ts +7 -27
- package/src/affinity.ts +13 -0
- package/src/announce.ts +103 -0
- package/src/client/PetDockEntry.test.tsx +1 -1
- package/src/client/PetDockEntry.tsx +15 -4
- package/src/client/PetSettingsCard.tsx +2 -1
- package/src/client/PetSprite.test.tsx +36 -0
- package/src/client/PetSprite.tsx +75 -4
- package/src/client/gameplay-hud.test.tsx +1 -1
- package/src/client/index.test.tsx +2 -2
- package/src/client/index.ts +17 -6
- package/src/client/locales.ts +20 -2
- package/src/client/pet-store.ts +2 -2
- package/src/client/pet.module.css +104 -0
- package/src/client/renderers/frames2d.test.ts +101 -0
- package/src/client/renderers/frames2d.ts +138 -18
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/index.ts +17 -15
- package/src/ledger.ts +6 -4
- package/src/pair-access.ts +49 -0
- package/src/remarks.test.ts +10 -0
- package/src/remarks.ts +9 -2
- package/src/service.ts +32 -2
- package/src/voice-pack.test.ts +39 -0
- package/src/voice-pack.ts +37 -2
package/src/client/locales.ts
CHANGED
|
@@ -15,6 +15,15 @@ export const zh = {
|
|
|
15
15
|
'pet.namePlaceholder': '输入新名字',
|
|
16
16
|
'pet.summon': '召唤{name}',
|
|
17
17
|
'pet.rank': '亲密度 {rank}',
|
|
18
|
+
'pet.rank.name.幼鲸': '幼鲸',
|
|
19
|
+
'pet.rank.name.伙伴': '伙伴',
|
|
20
|
+
'pet.rank.name.挚友': '挚友',
|
|
21
|
+
'pet.rank.name.深海羁绊': '深海羁绊',
|
|
22
|
+
'pet.rank.name.心有灵犀': '心有灵犀',
|
|
23
|
+
'pet.rank.name.传说羁绊': '传说羁绊',
|
|
24
|
+
'pet.rank.name.神话羁绊': '神话羁绊',
|
|
25
|
+
'pet.rank.name.永恒之契': '永恒之契',
|
|
26
|
+
'pet.rank.name.鲸生共渡': '鲸生共渡',
|
|
18
27
|
'pet.points': '{points} 点',
|
|
19
28
|
'pet.treats': '小鱼干 ×{n}',
|
|
20
29
|
'pet.state.loading': '宠物正在赶来…',
|
|
@@ -67,7 +76,7 @@ export const zh = {
|
|
|
67
76
|
'settings.off': '关',
|
|
68
77
|
'settings.overridden': '已覆盖',
|
|
69
78
|
'settings.reset': '恢复默认',
|
|
70
|
-
'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑
|
|
79
|
+
'settings.notExposed': '当前 DSH 版本未向设置页暴露本插件的配置命名空间,表单不可用。可编辑 $DSH_HOME/settings.yaml 直接配置,或确认提供该命名空间的插件已挂载其设置域并重启。',
|
|
71
80
|
'settings.readOnly': '当前部署的设置只读。',
|
|
72
81
|
'settings.expand': '展开设置',
|
|
73
82
|
'settings.collapse': '收起设置',
|
|
@@ -88,6 +97,15 @@ export const en = {
|
|
|
88
97
|
'pet.namePlaceholder': 'Enter a new name',
|
|
89
98
|
'pet.summon': 'Summon {name}',
|
|
90
99
|
'pet.rank': 'Affinity {rank}',
|
|
100
|
+
'pet.rank.name.幼鲸': 'Baby Whale',
|
|
101
|
+
'pet.rank.name.伙伴': 'Companion',
|
|
102
|
+
'pet.rank.name.挚友': 'Close Friend',
|
|
103
|
+
'pet.rank.name.深海羁绊': 'Deep Sea Bond',
|
|
104
|
+
'pet.rank.name.心有灵犀': 'Kindred Spirit',
|
|
105
|
+
'pet.rank.name.传说羁绊': 'Legendary Bond',
|
|
106
|
+
'pet.rank.name.神话羁绊': 'Mythic Bond',
|
|
107
|
+
'pet.rank.name.永恒之契': 'Eternal Covenant',
|
|
108
|
+
'pet.rank.name.鲸生共渡': 'Lifelong Companion',
|
|
91
109
|
'pet.points': '{points} pts',
|
|
92
110
|
'pet.treats': 'Treats ×{n}',
|
|
93
111
|
'pet.state.loading': 'The pet is on its way…',
|
|
@@ -140,7 +158,7 @@ export const en = {
|
|
|
140
158
|
'settings.off': 'Off',
|
|
141
159
|
'settings.overridden': 'Overridden',
|
|
142
160
|
'settings.reset': 'Reset to default',
|
|
143
|
-
'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit
|
|
161
|
+
'settings.notExposed': 'This DSH version does not expose this plugin\'s settings namespace to the configuration page, so the form is unavailable. Edit $DSH_HOME/settings.yaml directly, or confirm that the plugin owning the namespace is mounted with its settings domain and restart.',
|
|
144
162
|
'settings.readOnly': 'This deployment stores settings read-only.',
|
|
145
163
|
'settings.expand': 'Show settings',
|
|
146
164
|
'settings.collapse': 'Hide settings',
|
package/src/client/pet-store.ts
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* @module @linxin666/dsh-pet/client/pet-store
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { defineStore } from '@deepseek-ai/dsh-client-
|
|
10
|
-
import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-
|
|
9
|
+
import { defineStore } from '@deepseek-ai/dsh-client-store'
|
|
10
|
+
import type { EngineStoreHandle, EngineStoreInstance } from '@deepseek-ai/dsh-client-store'
|
|
11
11
|
import type { PetGameplayStateView, PetStateView } from '../service.ts'
|
|
12
12
|
import type { PetInteraction } from '../affinity.ts'
|
|
13
13
|
import type { PetDefinition } from '../registry.ts'
|
|
@@ -604,3 +604,107 @@
|
|
|
604
604
|
}
|
|
605
605
|
}
|
|
606
606
|
|
|
607
|
+
|
|
608
|
+
/* 公告气泡(dsh-usage 联动):兄弟插件推送的结构化事实(余额 / 套餐用量)。
|
|
609
|
+
与状态气泡同一片蓝黑玻璃家族,但自有版式:标题 + 数值两端对齐,
|
|
610
|
+
套餐窗口带微型计量条,色调按 ok/warn/low 染描边与计量条。
|
|
611
|
+
自带长驻动画(进场后保持),不走 2.6s 反馈气泡的淡出轨道。 */
|
|
612
|
+
.bubbleUsage {
|
|
613
|
+
display: flex;
|
|
614
|
+
flex-direction: column;
|
|
615
|
+
align-items: stretch;
|
|
616
|
+
gap: 3px;
|
|
617
|
+
min-width: 128px;
|
|
618
|
+
max-width: min(280px, calc(100vw - 24px));
|
|
619
|
+
padding: 6px 12px;
|
|
620
|
+
white-space: nowrap;
|
|
621
|
+
background: linear-gradient(160deg, rgba(19, 28, 54, 0.92), rgba(7, 11, 26, 0.95));
|
|
622
|
+
border: 1px solid rgba(126, 152, 255, 0.45);
|
|
623
|
+
box-shadow:
|
|
624
|
+
0 4px 12px rgba(2, 6, 23, 0.35),
|
|
625
|
+
inset 0 1px 0 rgba(226, 232, 255, 0.1),
|
|
626
|
+
0 0 12px rgba(77, 107, 254, 0.18);
|
|
627
|
+
backdrop-filter: blur(8px);
|
|
628
|
+
letter-spacing: 0.02em;
|
|
629
|
+
animation: pet-usage-in 280ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.bubbleUsageHead {
|
|
633
|
+
display: flex;
|
|
634
|
+
align-items: baseline;
|
|
635
|
+
justify-content: space-between;
|
|
636
|
+
gap: 10px;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
.bubbleUsageTitle {
|
|
640
|
+
overflow: hidden;
|
|
641
|
+
text-overflow: ellipsis;
|
|
642
|
+
opacity: 0.85;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.bubbleUsageValue {
|
|
646
|
+
font-weight: 600;
|
|
647
|
+
font-variant-numeric: tabular-nums;
|
|
648
|
+
flex-shrink: 0;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
.bubbleUsageNote {
|
|
652
|
+
overflow: hidden;
|
|
653
|
+
text-overflow: ellipsis;
|
|
654
|
+
font-size: 10px;
|
|
655
|
+
opacity: 0.55;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
.bubbleUsageMeter {
|
|
659
|
+
display: block;
|
|
660
|
+
height: 4px;
|
|
661
|
+
border-radius: 999px;
|
|
662
|
+
background: rgba(226, 232, 255, 0.14);
|
|
663
|
+
overflow: hidden;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
.bubbleUsageMeterFill {
|
|
667
|
+
display: block;
|
|
668
|
+
height: 100%;
|
|
669
|
+
border-radius: 999px;
|
|
670
|
+
background: #6ee7b7;
|
|
671
|
+
transition: width 300ms ease;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/* 色调:ok 沿用品牌蓝描边;warn 琥珀、low 珊瑚红,染描边与计量条。 */
|
|
675
|
+
.bubbleUsageOk {
|
|
676
|
+
border-color: rgba(126, 152, 255, 0.45);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
.bubbleUsageWarn {
|
|
680
|
+
border-color: rgba(251, 191, 36, 0.55);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.bubbleUsageWarn .bubbleUsageMeterFill {
|
|
684
|
+
background: #fbbf24;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
.bubbleUsageLow {
|
|
688
|
+
border-color: rgba(248, 113, 113, 0.6);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
.bubbleUsageLow .bubbleUsageMeterFill {
|
|
692
|
+
background: #f87171;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
@keyframes pet-usage-in {
|
|
696
|
+
from {
|
|
697
|
+
opacity: 0;
|
|
698
|
+
transform: translateY(6px) scale(0.96);
|
|
699
|
+
}
|
|
700
|
+
to {
|
|
701
|
+
opacity: 1;
|
|
702
|
+
transform: translateY(0) scale(1);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
@media (prefers-reduced-motion: reduce) {
|
|
707
|
+
.bubbleUsage {
|
|
708
|
+
animation: none;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
@@ -135,3 +135,104 @@ describe('frames2dRenderer', () => {
|
|
|
135
135
|
expect(container.querySelector('img')).toBeNull()
|
|
136
136
|
})
|
|
137
137
|
})
|
|
138
|
+
|
|
139
|
+
describe('frames2dRenderer canvas bitmap path', () => {
|
|
140
|
+
// The canvas branch needs createImageBitmap + fetch + a real 2D context;
|
|
141
|
+
// jsdom has none of them, so each test installs fakes and restores after.
|
|
142
|
+
const flush = async (): Promise<void> => {
|
|
143
|
+
await Promise.resolve()
|
|
144
|
+
await Promise.resolve()
|
|
145
|
+
await Promise.resolve()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let draws = 0
|
|
149
|
+
const bitmaps: { width: number; height: number; close: ReturnType<typeof vi.fn> }[] = []
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
vi.useFakeTimers()
|
|
153
|
+
document.body.innerHTML = ''
|
|
154
|
+
draws = 0
|
|
155
|
+
bitmaps.length = 0
|
|
156
|
+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, blob: async () => ({}) })))
|
|
157
|
+
vi.stubGlobal('createImageBitmap', vi.fn(async () => {
|
|
158
|
+
const bmp = { width: 32, height: 32, close: vi.fn() }
|
|
159
|
+
bitmaps.push(bmp)
|
|
160
|
+
return bmp
|
|
161
|
+
}))
|
|
162
|
+
;(HTMLCanvasElement.prototype as unknown as { getContext: unknown }).getContext = () => ({
|
|
163
|
+
clearRect: () => {},
|
|
164
|
+
drawImage: () => {
|
|
165
|
+
draws += 1
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
vi.unstubAllGlobals()
|
|
172
|
+
Reflect.deleteProperty(HTMLCanvasElement.prototype as unknown as Record<string, unknown>, 'getContext')
|
|
173
|
+
vi.useRealTimers()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
function canvasSetup() {
|
|
177
|
+
const container = document.createElement('div')
|
|
178
|
+
document.body.appendChild(container)
|
|
179
|
+
const ctx: PetRendererContext = {
|
|
180
|
+
petId: 'miku',
|
|
181
|
+
assetBase: '/pet/miku',
|
|
182
|
+
container,
|
|
183
|
+
phase: createPhaseStream('idle'),
|
|
184
|
+
interact: () => {},
|
|
185
|
+
onCleanup: () => {},
|
|
186
|
+
}
|
|
187
|
+
return { ctx, container }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
it('mounts a canvas instead of an img and paints advanced frames via drawImage', async () => {
|
|
191
|
+
const { ctx, container } = canvasSetup()
|
|
192
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
193
|
+
expect(container.querySelector('canvas')).not.toBeNull()
|
|
194
|
+
expect(container.querySelector('img')).toBeNull()
|
|
195
|
+
await flush()
|
|
196
|
+
// Warm pass decoded every configured frame exactly once; the first frame
|
|
197
|
+
// of the idle track painted during mount.
|
|
198
|
+
expect(bitmaps.length).toBe(6)
|
|
199
|
+
const paintsAtMount = draws
|
|
200
|
+
expect(paintsAtMount).toBeGreaterThanOrEqual(1)
|
|
201
|
+
vi.advanceTimersByTime(100)
|
|
202
|
+
await flush()
|
|
203
|
+
expect(draws).toBeGreaterThan(paintsAtMount)
|
|
204
|
+
handle.dispose()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('sizes the backing store from decoded dimensions and releases bitmaps on dispose', async () => {
|
|
208
|
+
const { ctx, container } = canvasSetup()
|
|
209
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
210
|
+
await flush()
|
|
211
|
+
const canvas = container.querySelector('canvas')!
|
|
212
|
+
expect(canvas.width).toBe(32)
|
|
213
|
+
expect(canvas.height).toBe(32)
|
|
214
|
+
handle.dispose()
|
|
215
|
+
await flush()
|
|
216
|
+
for (const bmp of bitmaps) expect(bmp.close).toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('keeps painting through a phase switch without touching the DOM tree', async () => {
|
|
220
|
+
const { container } = canvasSetup()
|
|
221
|
+
const stream = createPhaseStream('idle')
|
|
222
|
+
const ctx: PetRendererContext = {
|
|
223
|
+
petId: 'miku', assetBase: '/pet/miku', container, phase: stream, interact: () => {}, onCleanup: () => {},
|
|
224
|
+
}
|
|
225
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
226
|
+
await flush()
|
|
227
|
+
const nodesBefore = container.querySelectorAll('*').length
|
|
228
|
+
stream.push('thinking' as ActivityPhase)
|
|
229
|
+
expect(handle.currentTrack()).toBe('work')
|
|
230
|
+
await flush()
|
|
231
|
+
vi.advanceTimersByTime(100)
|
|
232
|
+
await flush()
|
|
233
|
+
// Steady-state playback is DOM-mutation-free: no element added or removed.
|
|
234
|
+
expect(container.querySelectorAll('*').length).toBe(nodesBefore)
|
|
235
|
+
expect(draws).toBeGreaterThanOrEqual(3)
|
|
236
|
+
handle.dispose()
|
|
237
|
+
})
|
|
238
|
+
})
|
|
@@ -8,6 +8,18 @@
|
|
|
8
8
|
* phase-mapped track. Rendering never throws: a broken track list degrades
|
|
9
9
|
* to the first decodable frame, and the 1.2 s stall watchdog re-kicks the
|
|
10
10
|
* playback chain after timer throttling.
|
|
11
|
+
*
|
|
12
|
+
* Frame presentation has two modes picked once at mount by capability
|
|
13
|
+
* probing:
|
|
14
|
+
* - Canvas bitmap buffer (default where createImageBitmap/fetch/2D context
|
|
15
|
+
* exist): every frame is decoded exactly once into an ImageBitmap during
|
|
16
|
+
* the warm pass and drawn onto one <canvas> - steady-state playback issues
|
|
17
|
+
* zero DOM mutations and zero re-decodes (measured hotspot: swapping
|
|
18
|
+
* <img>.src per frame drove image decode + invalidation every tick).
|
|
19
|
+
* - Classic <img> fallback (jsdom/tests or missing APIs): identical to the
|
|
20
|
+
* historical behavior - cache-warm Image elements plus guarded src swaps,
|
|
21
|
+
* so environments without modern decoding keep working unchanged.
|
|
22
|
+
*
|
|
11
23
|
* @module @linxin666/dsh-pet/client/renderers/frames2d
|
|
12
24
|
*/
|
|
13
25
|
|
|
@@ -69,6 +81,12 @@ function validateFrames2dConfig(config: unknown): PetFrames2dConfig {
|
|
|
69
81
|
return { tracks, phases: phases as PetFrames2dConfig['phases'] }
|
|
70
82
|
}
|
|
71
83
|
|
|
84
|
+
interface DecodedFrame {
|
|
85
|
+
source: ImageBitmap | HTMLImageElement
|
|
86
|
+
width: number
|
|
87
|
+
height: number
|
|
88
|
+
}
|
|
89
|
+
|
|
72
90
|
export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
73
91
|
id: 'frames2d',
|
|
74
92
|
apiVersion: PET_RENDERER_API_VERSION,
|
|
@@ -79,23 +97,78 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
79
97
|
&& typeof window.matchMedia === 'function'
|
|
80
98
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
81
99
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
img
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
for (const url of track.frames) {
|
|
96
|
-
const pre = new Image()
|
|
97
|
-
pre.src = url
|
|
100
|
+
// Capability probe: the canvas path needs decode-to-bitmap, same-origin
|
|
101
|
+
// fetch, and a real 2D context (jsdom returns null and falls back below).
|
|
102
|
+
let canvas: HTMLCanvasElement | null = null
|
|
103
|
+
let context2d: CanvasRenderingContext2D | null = null
|
|
104
|
+
let img: HTMLImageElement | null = null
|
|
105
|
+
try {
|
|
106
|
+
if (typeof createImageBitmap === 'function' && typeof fetch === 'function') {
|
|
107
|
+
const probe = document.createElement('canvas')
|
|
108
|
+
const c2d = probe.getContext('2d')
|
|
109
|
+
if (c2d !== null) {
|
|
110
|
+
canvas = probe
|
|
111
|
+
context2d = c2d
|
|
112
|
+
}
|
|
98
113
|
}
|
|
114
|
+
} catch {
|
|
115
|
+
canvas = null
|
|
116
|
+
context2d = null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (canvas !== null && context2d !== null) {
|
|
120
|
+
canvas.dataset.dshPetFrames2d = ctx.petId
|
|
121
|
+
canvas.draggable = false
|
|
122
|
+
canvas.style.width = '100%'
|
|
123
|
+
canvas.style.height = '100%'
|
|
124
|
+
canvas.style.objectFit = 'contain'
|
|
125
|
+
canvas.style.pointerEvents = 'none'
|
|
126
|
+
ctx.container.appendChild(canvas)
|
|
127
|
+
} else {
|
|
128
|
+
img = document.createElement('img')
|
|
129
|
+
img.dataset.dshPetFrames2d = ctx.petId
|
|
130
|
+
img.alt = ''
|
|
131
|
+
img.draggable = false
|
|
132
|
+
img.style.width = '100%'
|
|
133
|
+
img.style.height = '100%'
|
|
134
|
+
img.style.objectFit = 'contain'
|
|
135
|
+
img.style.pointerEvents = 'none'
|
|
136
|
+
ctx.container.appendChild(img)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Decode cache: one concurrent decode per URL, memoized forever (frames
|
|
140
|
+
// are tiny same-origin webp files; failures resolve undefined and keep
|
|
141
|
+
// the last painted frame instead of breaking playback).
|
|
142
|
+
const decoding = new Map<string, Promise<DecodedFrame | undefined>>()
|
|
143
|
+
const decodedAll: Promise<void>[] = []
|
|
144
|
+
const loadFrame = (url: string): Promise<DecodedFrame | undefined> => {
|
|
145
|
+
const cached = decoding.get(url)
|
|
146
|
+
if (cached !== undefined) return cached
|
|
147
|
+
const job: Promise<DecodedFrame | undefined> = (async () => {
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(url)
|
|
150
|
+
if (!response.ok) throw new Error('http ' + response.status)
|
|
151
|
+
const bitmap = await createImageBitmap(await response.blob())
|
|
152
|
+
return { source: bitmap, width: bitmap.width, height: bitmap.height }
|
|
153
|
+
} catch {
|
|
154
|
+
// Fail-open: classic Image decode keeps non-modern runtimes alive.
|
|
155
|
+
return await new Promise<DecodedFrame | undefined>((resolve) => {
|
|
156
|
+
try {
|
|
157
|
+
const pre = new Image()
|
|
158
|
+
pre.onload = (): void => {
|
|
159
|
+
resolve(pre.naturalWidth > 0 ? { source: pre, width: pre.naturalWidth, height: pre.naturalHeight } : undefined)
|
|
160
|
+
}
|
|
161
|
+
pre.onerror = (): void => resolve(undefined)
|
|
162
|
+
pre.src = url
|
|
163
|
+
} catch {
|
|
164
|
+
resolve(undefined)
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
})()
|
|
169
|
+
decoding.set(url, job)
|
|
170
|
+
decodedAll.push(job.then(() => undefined, () => undefined))
|
|
171
|
+
return job
|
|
99
172
|
}
|
|
100
173
|
|
|
101
174
|
let disposed = false
|
|
@@ -105,16 +178,42 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
105
178
|
let frameIndex = 0
|
|
106
179
|
let lastAdvance = Date.now()
|
|
107
180
|
let override: string | undefined
|
|
181
|
+
let drawToken = 0
|
|
182
|
+
let lastDrawnUrl: string | undefined
|
|
108
183
|
|
|
109
184
|
const trackForPhase = (phase: ActivityPhase): string => {
|
|
110
185
|
const mapped = config.phases[phase]
|
|
111
186
|
return mapped !== undefined && config.tracks[mapped] !== undefined ? mapped : config.phases.idle
|
|
112
187
|
}
|
|
113
188
|
|
|
189
|
+
/** Canvas path: paints the newest requested frame; stale draws drop out. */
|
|
190
|
+
const paintCanvas = (url: string): void => {
|
|
191
|
+
if (context2d === null || canvas === null) return
|
|
192
|
+
const myToken = ++drawToken
|
|
193
|
+
void loadFrame(url).then((frame) => {
|
|
194
|
+
if (disposed || frame === undefined || myToken !== drawToken) return
|
|
195
|
+
if (lastDrawnUrl === url) return
|
|
196
|
+
lastDrawnUrl = url
|
|
197
|
+
// Resizing clears the canvas, so size only when it actually differs.
|
|
198
|
+
if (canvas.width !== frame.width || canvas.height !== frame.height) {
|
|
199
|
+
canvas.width = frame.width
|
|
200
|
+
canvas.height = frame.height
|
|
201
|
+
} else {
|
|
202
|
+
context2d.clearRect(0, 0, canvas.width, canvas.height)
|
|
203
|
+
}
|
|
204
|
+
context2d.drawImage(frame.source as CanvasImageSource, 0, 0)
|
|
205
|
+
}).catch(() => { /* keep last frame */ })
|
|
206
|
+
}
|
|
207
|
+
|
|
114
208
|
const show = (trackId: string, index: number): void => {
|
|
115
209
|
const def = config.tracks[trackId]
|
|
116
210
|
const url = def?.frames[index]
|
|
117
|
-
if (url
|
|
211
|
+
if (url === undefined) return
|
|
212
|
+
if (img !== null) {
|
|
213
|
+
if (img.getAttribute('src') !== url) img.src = url
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
paintCanvas(url)
|
|
118
217
|
}
|
|
119
218
|
|
|
120
219
|
const schedule = (ms: number): void => {
|
|
@@ -180,6 +279,13 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
180
279
|
}, WATCHDOG_MS)
|
|
181
280
|
}
|
|
182
281
|
|
|
282
|
+
// Warm pass: decode every frame up front (tiny same-origin webp files)
|
|
283
|
+
// so loops and phase switches never wait on a first decode - same intent
|
|
284
|
+
// as the historical Image-cache warm loop, now feeding the decode cache.
|
|
285
|
+
for (const warmTrack of Object.values(config.tracks)) {
|
|
286
|
+
for (const warmUrl of warmTrack.frames) void loadFrame(warmUrl)
|
|
287
|
+
}
|
|
288
|
+
|
|
183
289
|
play(track)
|
|
184
290
|
|
|
185
291
|
let disposedOnce = false
|
|
@@ -190,7 +296,21 @@ export const frames2dRenderer: PetRenderer<PetFrames2dConfig> = {
|
|
|
190
296
|
unsubscribe()
|
|
191
297
|
if (timer !== undefined) clearTimeout(timer)
|
|
192
298
|
if (watchdog !== undefined) clearInterval(watchdog)
|
|
193
|
-
|
|
299
|
+
// Release decoded bitmaps after pending decodes settle; close() is
|
|
300
|
+
// browser-only, so guard it for exotic hosts.
|
|
301
|
+
void Promise.allSettled(decodedAll).then(() => {
|
|
302
|
+
for (const job of decoding.values()) {
|
|
303
|
+
void job.then((frame) => {
|
|
304
|
+
try {
|
|
305
|
+
const maybeClose = (frame?.source as { close?: () => void } | undefined)?.close
|
|
306
|
+
if (typeof maybeClose === 'function' && frame !== undefined) maybeClose.call(frame.source)
|
|
307
|
+
} catch { /* already released */ }
|
|
308
|
+
}).catch(() => { /* never rejects anyway */ })
|
|
309
|
+
}
|
|
310
|
+
decoding.clear()
|
|
311
|
+
})
|
|
312
|
+
canvas?.remove()
|
|
313
|
+
img?.remove()
|
|
194
314
|
}
|
|
195
315
|
ctx.onCleanup(dispose)
|
|
196
316
|
|