@linxin666/dsh-pet 0.3.2 → 0.3.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 +25 -7
- package/README.zh.md +25 -7
- package/assets/ouo-neko/pet.json +31 -0
- package/assets/ouo-neko/previews/failed.gif +0 -0
- package/assets/ouo-neko/previews/idle.gif +0 -0
- package/assets/ouo-neko/previews/jumping.gif +0 -0
- package/assets/ouo-neko/previews/review.gif +0 -0
- package/assets/ouo-neko/previews/running-left.gif +0 -0
- package/assets/ouo-neko/previews/running-right.gif +0 -0
- package/assets/ouo-neko/previews/running.gif +0 -0
- package/assets/ouo-neko/previews/waiting.gif +0 -0
- package/assets/ouo-neko/previews/waving.gif +0 -0
- package/assets/ouo-neko/spritesheet.webp +0 -0
- package/contracts/pet-manifest-v2.schema.json +102 -1
- package/lib/client.js +830 -26
- package/lib/client.js.map +1 -1
- package/lib/index.js +1226 -24
- package/lib/types/client/PetDockEntry.d.ts +3 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +18 -2
- package/lib/types/client/PetSprite.d.ts +23 -0
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +34 -29
- package/lib/types/client/PluginSettingsCard.d.ts +2 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +2 -2
- package/lib/types/client/drag-stream.d.ts +19 -0
- package/lib/types/client/drag-stream.d.ts.map +1 -0
- package/lib/types/client/drag-stream.js +26 -0
- package/lib/types/client/gameplay-hud.d.ts +50 -0
- package/lib/types/client/gameplay-hud.d.ts.map +1 -0
- package/lib/types/client/gameplay-hud.js +274 -0
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +16 -0
- package/lib/types/client/locales.d.ts +34 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -0
- package/lib/types/client/pet-store.d.ts +7 -1
- package/lib/types/client/pet-store.d.ts.map +1 -1
- package/lib/types/client/pet-store.js +4 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts +29 -0
- package/lib/types/client/renderers/Frames2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/Frames2dVisualMount.js +83 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +6 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -1
- package/lib/types/client/renderers/PetRendererSwitch.js +14 -1
- package/lib/types/client/renderers/frames2d.d.ts +36 -0
- package/lib/types/client/renderers/frames2d.d.ts.map +1 -0
- package/lib/types/client/renderers/frames2d.js +196 -0
- package/lib/types/client/settings-form.d.ts +1 -1
- package/lib/types/client/settings-form.js +1 -1
- package/lib/types/client/telemetry.d.ts +27 -0
- package/lib/types/client/telemetry.d.ts.map +1 -0
- package/lib/types/client/telemetry.js +97 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.d.ts.map +1 -0
- package/lib/types/defaults.js +5 -0
- package/lib/types/dsh-home.d.ts.map +1 -1
- package/lib/types/dsh-home.js +9 -3
- package/lib/types/gameplay.d.ts +182 -0
- package/lib/types/gameplay.d.ts.map +1 -0
- package/lib/types/gameplay.js +618 -0
- package/lib/types/http.d.ts +1 -1
- package/lib/types/http.js +1 -1
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/ledger.d.ts +14 -0
- package/lib/types/ledger.d.ts.map +1 -1
- package/lib/types/ledger.js +30 -0
- package/lib/types/loopback.d.ts +1 -1
- package/lib/types/loopback.js +1 -1
- package/lib/types/manifest-v2.d.ts +38 -1
- package/lib/types/manifest-v2.d.ts.map +1 -1
- package/lib/types/manifest-v2.js +182 -2
- package/lib/types/mount-once.d.ts +1 -1
- package/lib/types/mount-once.js +2 -2
- package/lib/types/persist.d.ts +4 -4
- package/lib/types/persist.d.ts.map +1 -1
- package/lib/types/persist.js +41 -4
- package/lib/types/registry.d.ts +29 -0
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +170 -1
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +22 -0
- package/lib/types/service.d.ts +56 -0
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +181 -0
- package/package.json +6 -3
- package/src/client/PetDockEntry.test.tsx +6 -0
- package/src/client/PetDockEntry.tsx +32 -1
- package/src/client/PetSprite.test.tsx +36 -6
- package/src/client/PetSprite.tsx +42 -21
- package/src/client/PluginSettingsCard.tsx +4 -2
- package/src/client/drag-stream.ts +35 -0
- package/src/client/gameplay-hud.test.tsx +338 -0
- package/src/client/gameplay-hud.tsx +403 -0
- package/src/client/index.ts +23 -2
- package/src/client/locales.ts +36 -0
- package/src/client/pet-store.ts +10 -1
- package/src/client/pet.module.css +192 -29
- package/src/client/renderers/Frames2dVisualMount.tsx +111 -0
- package/src/client/renderers/PetRendererSwitch.tsx +28 -1
- package/src/client/renderers/frames2d.test.ts +137 -0
- package/src/client/renderers/frames2d.ts +216 -0
- package/src/client/settings-card.module.css +5 -0
- package/src/client/settings-form.ts +1 -1
- package/src/client/telemetry.ts +105 -0
- package/src/defaults.ts +7 -0
- package/src/dsh-home.ts +9 -3
- package/src/gameplay.test.ts +239 -0
- package/src/gameplay.ts +692 -0
- package/src/http.ts +1 -1
- package/src/index.ts +1 -1
- package/src/ledger.test.ts +16 -0
- package/src/ledger.ts +31 -0
- package/src/loopback.ts +1 -1
- package/src/manifest-v2.test.ts +130 -1
- package/src/manifest-v2.ts +181 -2
- package/src/mount-once.ts +2 -2
- package/src/persist.test.ts +3 -0
- package/src/persist.ts +43 -6
- package/src/registry.test.ts +168 -1
- package/src/registry.ts +199 -1
- package/src/routes.ts +19 -0
- package/src/service.ts +208 -0
|
@@ -21,33 +21,6 @@
|
|
|
21
21
|
flex: 0 0 auto;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
.closeButton {
|
|
25
|
-
position: absolute;
|
|
26
|
-
top: 4px;
|
|
27
|
-
right: 4px;
|
|
28
|
-
z-index: 2;
|
|
29
|
-
width: 24px;
|
|
30
|
-
height: 24px;
|
|
31
|
-
padding: 0;
|
|
32
|
-
border: 1px solid rgba(226, 232, 255, 0.72);
|
|
33
|
-
border-radius: 999px;
|
|
34
|
-
color: #f8fafc;
|
|
35
|
-
background: rgba(7, 11, 26, 0.78);
|
|
36
|
-
font: 600 18px/20px sans-serif;
|
|
37
|
-
cursor: pointer;
|
|
38
|
-
touch-action: manipulation;
|
|
39
|
-
transition: background 120ms ease, box-shadow 120ms ease;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
.closeButton:hover {
|
|
43
|
-
background: rgba(44, 62, 126, 0.95);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
.closeButton:focus-visible {
|
|
47
|
-
outline: none;
|
|
48
|
-
box-shadow: 0 0 0 2px rgba(126, 152, 255, 0.95);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
24
|
.bubble {
|
|
52
25
|
position: absolute;
|
|
53
26
|
bottom: 100%;
|
|
@@ -436,8 +409,198 @@
|
|
|
436
409
|
|
|
437
410
|
.action,
|
|
438
411
|
.summon,
|
|
439
|
-
.bubbleMore
|
|
440
|
-
.closeButton {
|
|
412
|
+
.bubbleMore {
|
|
441
413
|
transition: none;
|
|
442
414
|
}
|
|
443
415
|
}
|
|
416
|
+
|
|
417
|
+
/* ------------------------------------------------------------------ */
|
|
418
|
+
/* Gameplay HUD (miku-pet generalization): toggle, card, bars, shop. */
|
|
419
|
+
/* ------------------------------------------------------------------ */
|
|
420
|
+
|
|
421
|
+
.gameplayHud {
|
|
422
|
+
position: absolute;
|
|
423
|
+
left: 0;
|
|
424
|
+
bottom: 0;
|
|
425
|
+
width: 0;
|
|
426
|
+
height: 0;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.gameplayModeChip {
|
|
430
|
+
position: absolute;
|
|
431
|
+
left: 0;
|
|
432
|
+
top: 0;
|
|
433
|
+
transform: translateY(-100%) translateY(-4px);
|
|
434
|
+
padding: 1px 8px;
|
|
435
|
+
border-radius: 999px;
|
|
436
|
+
background: rgba(126, 152, 255, 0.18);
|
|
437
|
+
color: #cdd7ff;
|
|
438
|
+
font-size: 10px;
|
|
439
|
+
white-space: nowrap;
|
|
440
|
+
pointer-events: none;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.gameplayCard {
|
|
444
|
+
position: absolute;
|
|
445
|
+
left: 0;
|
|
446
|
+
bottom: 0;
|
|
447
|
+
/* The card opens BESIDE the sprite (right side when the space allows,
|
|
448
|
+
otherwise left), vertically centered and clamped to the sprite's height,
|
|
449
|
+
so it never enters the bubble band above or the hover-panel band below.
|
|
450
|
+
The transform and max-height are set inline from measured space by the
|
|
451
|
+
GameplayHud placement effect; these are pre-measure fallbacks. */
|
|
452
|
+
transform: none;
|
|
453
|
+
max-height: 340px;
|
|
454
|
+
overflow-y: auto;
|
|
455
|
+
min-width: 180px;
|
|
456
|
+
padding: 10px 12px;
|
|
457
|
+
border: 1px solid rgba(126, 152, 255, 0.25);
|
|
458
|
+
border-radius: 10px;
|
|
459
|
+
background: rgba(7, 11, 26, 0.92);
|
|
460
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
461
|
+
color: #dfe6ff;
|
|
462
|
+
font-size: 12px;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.gameplayBars {
|
|
466
|
+
display: flex;
|
|
467
|
+
flex-direction: column;
|
|
468
|
+
gap: 4px;
|
|
469
|
+
margin-bottom: 8px;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.gameplayBarRow {
|
|
473
|
+
display: flex;
|
|
474
|
+
align-items: center;
|
|
475
|
+
gap: 6px;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
.gameplayBarLabel {
|
|
479
|
+
width: 34px;
|
|
480
|
+
flex-shrink: 0;
|
|
481
|
+
color: #9aa8d8;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.gameplayBarTrack {
|
|
485
|
+
flex: 1;
|
|
486
|
+
height: 6px;
|
|
487
|
+
border-radius: 3px;
|
|
488
|
+
background: rgba(126, 152, 255, 0.15);
|
|
489
|
+
overflow: hidden;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.gameplayBarFill {
|
|
493
|
+
display: block;
|
|
494
|
+
height: 100%;
|
|
495
|
+
border-radius: 3px;
|
|
496
|
+
background: linear-gradient(90deg, #7e98ff, #9db4ff);
|
|
497
|
+
transition: width 300ms ease;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
.gameplayActions {
|
|
501
|
+
display: flex;
|
|
502
|
+
flex-wrap: wrap;
|
|
503
|
+
gap: 6px;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
.gameplayShopItems {
|
|
507
|
+
display: grid;
|
|
508
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
509
|
+
gap: 8px;
|
|
510
|
+
margin-bottom: 8px;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
.gameplayShopItem {
|
|
514
|
+
display: flex;
|
|
515
|
+
flex-direction: column;
|
|
516
|
+
align-items: center;
|
|
517
|
+
gap: 4px;
|
|
518
|
+
padding: 8px 6px;
|
|
519
|
+
border: 1px solid rgba(126, 152, 255, 0.25);
|
|
520
|
+
border-radius: 8px;
|
|
521
|
+
background: rgba(126, 152, 255, 0.08);
|
|
522
|
+
color: inherit;
|
|
523
|
+
cursor: pointer;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
.gameplayShopItem:hover {
|
|
527
|
+
border-color: rgba(126, 152, 255, 0.7);
|
|
528
|
+
background: rgba(126, 152, 255, 0.16);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
.gameplayShopItemImage {
|
|
532
|
+
width: 36px;
|
|
533
|
+
height: 36px;
|
|
534
|
+
object-fit: contain;
|
|
535
|
+
image-rendering: pixelated;
|
|
536
|
+
pointer-events: none;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.gameplayShopItemLabel {
|
|
540
|
+
font-size: 11px;
|
|
541
|
+
text-align: center;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
.gameplayShopItemPrice {
|
|
545
|
+
font-size: 10px;
|
|
546
|
+
color: #ffd27e;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.gameplayClose {
|
|
550
|
+
position: absolute;
|
|
551
|
+
top: 4px;
|
|
552
|
+
right: 8px;
|
|
553
|
+
border: none;
|
|
554
|
+
background: none;
|
|
555
|
+
color: #9aa8d8;
|
|
556
|
+
font-size: 14px;
|
|
557
|
+
cursor: pointer;
|
|
558
|
+
padding: 0 2px;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.gameplayClose:hover {
|
|
562
|
+
color: #ffffff;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
.gameplayFloat {
|
|
566
|
+
position: absolute;
|
|
567
|
+
left: 50%;
|
|
568
|
+
top: -8px;
|
|
569
|
+
transform: translateX(-50%);
|
|
570
|
+
padding: 2px 10px;
|
|
571
|
+
border-radius: 999px;
|
|
572
|
+
background: rgba(255, 210, 126, 0.92);
|
|
573
|
+
color: #3a2a00;
|
|
574
|
+
font-size: 11px;
|
|
575
|
+
white-space: nowrap;
|
|
576
|
+
pointer-events: none;
|
|
577
|
+
animation: pet-gameplay-float 1100ms ease-out forwards;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
@keyframes pet-gameplay-float {
|
|
581
|
+
from {
|
|
582
|
+
opacity: 0;
|
|
583
|
+
transform: translateX(-50%) translateY(6px);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
20% {
|
|
587
|
+
opacity: 1;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
to {
|
|
591
|
+
opacity: 0;
|
|
592
|
+
transform: translateX(-50%) translateY(-18px);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
@media (prefers-reduced-motion: reduce) {
|
|
597
|
+
.gameplayFloat {
|
|
598
|
+
animation: none;
|
|
599
|
+
opacity: 1;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.gameplayBarFill {
|
|
603
|
+
transition: none;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frames2d visual mount — the React bridge between the pet center chrome
|
|
3
|
+
* and the imperative frames2d renderer, mirroring the live2d mount. The
|
|
4
|
+
* bridge owns the contract context (asset base, phase stream, interaction
|
|
5
|
+
* write-back, activation cleanups), feeds the polled phase into the stream,
|
|
6
|
+
* forwards the chrome's drag gesture onto the conventional 'drag' track
|
|
7
|
+
* (when the pet declares one), and renders the localized fallback card when
|
|
8
|
+
* the served config is invalid.
|
|
9
|
+
* @module @linxin666/dsh-pet/client/renderers/Frames2dVisualMount
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useRef, useState, type ReactElement } from 'react'
|
|
13
|
+
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
14
|
+
import type { PetDefinition } from '../../registry.ts'
|
|
15
|
+
import type { ActivityPhase } from '../../state.ts'
|
|
16
|
+
import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
|
|
17
|
+
import type { DragStream } from '../drag-stream.ts'
|
|
18
|
+
import type { PetRendererContext } from '../../contracts/renderer.ts'
|
|
19
|
+
import { defaultPetRendererRegistry } from './registry.ts'
|
|
20
|
+
import type { Frames2dRendererHandle } from './frames2d.ts'
|
|
21
|
+
import type { GameplayBus } from '../gameplay-hud.tsx'
|
|
22
|
+
import type { NS } from '../locales.ts'
|
|
23
|
+
|
|
24
|
+
/** Mount the frames2d renderer as the sprite's visual (inside the chrome). */
|
|
25
|
+
export function Frames2dVisualMount(props: {
|
|
26
|
+
definition: PetDefinition
|
|
27
|
+
phase: ActivityPhase
|
|
28
|
+
onPet: () => void
|
|
29
|
+
/** Chrome drag gesture stream (the renderer switch owns it). */
|
|
30
|
+
drag: DragStream
|
|
31
|
+
/** Gameplay coordination bus; the mount registers its track override. */
|
|
32
|
+
bus?: GameplayBus
|
|
33
|
+
t: PropsLocale<typeof NS>['t']
|
|
34
|
+
}): ReactElement {
|
|
35
|
+
const containerRef = useRef<HTMLDivElement | null>(null)
|
|
36
|
+
const streamRef = useRef<PhaseStream | null>(null)
|
|
37
|
+
const handleRef = useRef<Frames2dRendererHandle | null>(null)
|
|
38
|
+
const [invalid, setInvalid] = useState(false)
|
|
39
|
+
|
|
40
|
+
// One activation per pet definition: build the contract context and mount.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setInvalid(false)
|
|
43
|
+
const container = containerRef.current
|
|
44
|
+
const frames2d = props.definition.frames2d
|
|
45
|
+
if (container === null || frames2d === undefined) return undefined
|
|
46
|
+
streamRef.current ??= createPhaseStream(props.phase)
|
|
47
|
+
const cleanups: (() => void)[] = []
|
|
48
|
+
const ctx: PetRendererContext = {
|
|
49
|
+
petId: props.definition.id,
|
|
50
|
+
assetBase: '/pet/' + encodeURIComponent(props.definition.id),
|
|
51
|
+
container,
|
|
52
|
+
phase: streamRef.current,
|
|
53
|
+
interact: props.onPet,
|
|
54
|
+
onCleanup: (fn) => { cleanups.push(fn) },
|
|
55
|
+
}
|
|
56
|
+
let handle: Frames2dRendererHandle
|
|
57
|
+
try {
|
|
58
|
+
handle = defaultPetRendererRegistry.mount('frames2d', ctx, frames2d) as Frames2dRendererHandle
|
|
59
|
+
} catch {
|
|
60
|
+
setInvalid(true)
|
|
61
|
+
return () => { for (const fn of cleanups.splice(0)) fn() }
|
|
62
|
+
}
|
|
63
|
+
handleRef.current = handle
|
|
64
|
+
// The gameplay HUD steers one shared override slot through the bus;
|
|
65
|
+
// mode rules (work blocks drag, sleep wakes on it) keep the two
|
|
66
|
+
// producers from fighting over the slot.
|
|
67
|
+
if (props.bus !== undefined) {
|
|
68
|
+
const gameplayBus = props.bus
|
|
69
|
+
gameplayBus.setTrack = (track) => { handleRef.current?.setState(track) }
|
|
70
|
+
cleanups.push(() => { gameplayBus.setTrack = undefined })
|
|
71
|
+
}
|
|
72
|
+
// The drag gesture drives the conventional 'drag' track when declared.
|
|
73
|
+
// On release, a declared gameplay.dragEndState (miku: standup) plays
|
|
74
|
+
// once; its fallback auto-releases the override back to the phase map.
|
|
75
|
+
const dragTrack = props.definition.gameplay?.dragState ?? (frames2d.tracks.drag === undefined ? undefined : 'drag')
|
|
76
|
+
const offDrag = props.drag.subscribe((dragging) => {
|
|
77
|
+
if (dragTrack === undefined) return
|
|
78
|
+
if (dragging) {
|
|
79
|
+
handle.setState(dragTrack)
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
handle.setState(props.definition.gameplay?.dragEndState)
|
|
83
|
+
})
|
|
84
|
+
cleanups.push(offDrag)
|
|
85
|
+
return () => {
|
|
86
|
+
handleRef.current = null
|
|
87
|
+
for (const fn of cleanups.splice(0)) fn()
|
|
88
|
+
handle.dispose()
|
|
89
|
+
}
|
|
90
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- one activation per pet identity
|
|
91
|
+
}, [props.definition])
|
|
92
|
+
|
|
93
|
+
// Feed the polled phase into the activation's stream (change-only).
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
streamRef.current?.push(props.phase)
|
|
96
|
+
}, [props.phase])
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
ref={containerRef}
|
|
101
|
+
data-dsh-pet-frames2d={props.definition.id}
|
|
102
|
+
style={{ width: '100%', height: '100%', pointerEvents: 'none' }}
|
|
103
|
+
>
|
|
104
|
+
{invalid && (
|
|
105
|
+
<span data-dsh-pet-frames2d-error="invalid-config">
|
|
106
|
+
{props.t('pet.renderer.unavailable', { renderer: 'frames2d' })}
|
|
107
|
+
</span>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -8,13 +8,16 @@
|
|
|
8
8
|
* @module @linxin666/dsh-pet/client/renderers/PetRendererSwitch
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react'
|
|
11
|
+
import { cloneElement, isValidElement, useRef, type ReactElement, type ReactNode } from 'react'
|
|
12
12
|
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
|
13
13
|
import type { PetDefinition } from '../../registry.ts'
|
|
14
14
|
import type { ActivityPhase } from '../../state.ts'
|
|
15
15
|
import type { PetSpriteProps } from '../PetSprite.tsx'
|
|
16
|
+
import { createDragStream, type DragStream } from '../drag-stream.ts'
|
|
17
|
+
import type { GameplayBus } from '../gameplay-hud.tsx'
|
|
16
18
|
import { defaultPetRendererRegistry } from './registry.ts'
|
|
17
19
|
import { Live2dVisualMount } from './live2d/Live2dVisualMount.tsx'
|
|
20
|
+
import { Frames2dVisualMount } from './Frames2dVisualMount.tsx'
|
|
18
21
|
import type { NS } from '../locales.ts'
|
|
19
22
|
|
|
20
23
|
/** Dispatch one pet definition to its renderer; unknown kinds get a card. */
|
|
@@ -24,11 +27,35 @@ export function PetRendererSwitch(props: {
|
|
|
24
27
|
phase: ActivityPhase
|
|
25
28
|
/** The chrome's pet interaction (affinity write-back owner). */
|
|
26
29
|
onPet: () => void
|
|
30
|
+
/** External drag stream (gameplay HUD shares it); created locally otherwise. */
|
|
31
|
+
drag?: DragStream
|
|
32
|
+
/** Gameplay coordination bus forwarded to the frames2d visual mount. */
|
|
33
|
+
bus?: GameplayBus
|
|
27
34
|
t: PropsLocale<typeof NS>['t']
|
|
28
35
|
children?: ReactNode
|
|
29
36
|
}): ReactElement {
|
|
30
37
|
const renderer = props.definition.renderer ?? 'sprite2d'
|
|
38
|
+
// The frames2d drag stream lives one level up: the chrome (PetSprite)
|
|
39
|
+
// reports the gesture through onDraggingChange, the visual subscribes.
|
|
40
|
+
const dragRef = useRef<{ id: string; stream: ReturnType<typeof createDragStream> } | null>(null)
|
|
41
|
+
if (dragRef.current === null || dragRef.current.id !== props.definition.id) {
|
|
42
|
+
dragRef.current = { id: props.definition.id, stream: createDragStream() }
|
|
43
|
+
}
|
|
44
|
+
const drag = props.drag ?? dragRef.current.stream
|
|
31
45
|
if (renderer === 'sprite2d') return <>{props.children}</>
|
|
46
|
+
if (renderer === 'frames2d' && defaultPetRendererRegistry.has('frames2d') && isValidElement<PetSpriteProps>(props.children)) {
|
|
47
|
+
const visual = (
|
|
48
|
+
<Frames2dVisualMount
|
|
49
|
+
definition={props.definition}
|
|
50
|
+
phase={props.phase}
|
|
51
|
+
onPet={props.onPet}
|
|
52
|
+
drag={drag}
|
|
53
|
+
{...(props.bus === undefined ? {} : { bus: props.bus })}
|
|
54
|
+
t={props.t}
|
|
55
|
+
/>
|
|
56
|
+
)
|
|
57
|
+
return cloneElement(props.children, { visual, onDraggingChange: (dragging: boolean) => drag.push(dragging) })
|
|
58
|
+
}
|
|
32
59
|
if (renderer === 'live2d' && defaultPetRendererRegistry.has('live2d') && isValidElement<PetSpriteProps>(props.children)) {
|
|
33
60
|
const visual = (
|
|
34
61
|
<Live2dVisualMount
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
/**
|
|
3
|
+
* frames2d renderer unit tests — playback chain, phase mapping, gameplay
|
|
4
|
+
* override, fallback settling, and the stall watchdog, all on fake timers.
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import { frames2dRenderer, type Frames2dRendererHandle, type PetFrames2dConfig } from './frames2d.ts'
|
|
8
|
+
import { createPhaseStream, type PhaseStream } from '../phase-stream.ts'
|
|
9
|
+
import type { PetRendererContext } from '../../contracts/renderer.ts'
|
|
10
|
+
import type { ActivityPhase } from '../../state.ts'
|
|
11
|
+
|
|
12
|
+
const CONFIG: PetFrames2dConfig = {
|
|
13
|
+
tracks: {
|
|
14
|
+
idle: { frames: ['/pet/miku/idle/1.webp', '/pet/miku/idle/2.webp'], durations: [100, 100], loop: true },
|
|
15
|
+
work: { frames: ['/pet/miku/work/1.webp', '/pet/miku/work/2.webp'], durations: [100, 100], loop: true },
|
|
16
|
+
happy: { frames: ['/pet/miku/happy/1.webp'], durations: [100], loop: false },
|
|
17
|
+
standup: { frames: ['/pet/miku/standup/1.webp'], durations: [100], loop: false, fallback: 'idle' },
|
|
18
|
+
},
|
|
19
|
+
phases: { idle: 'idle', thinking: 'work', done: 'happy' },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setup(): { ctx: PetRendererContext; stream: PhaseStream; img: () => HTMLImageElement } {
|
|
23
|
+
const container = document.createElement('div')
|
|
24
|
+
document.body.appendChild(container)
|
|
25
|
+
const stream = createPhaseStream('idle')
|
|
26
|
+
const cleanups: (() => void)[] = []
|
|
27
|
+
const ctx: PetRendererContext = {
|
|
28
|
+
petId: 'miku',
|
|
29
|
+
assetBase: '/pet/miku',
|
|
30
|
+
container,
|
|
31
|
+
phase: stream,
|
|
32
|
+
interact: () => {},
|
|
33
|
+
onCleanup: (fn) => { cleanups.push(fn) },
|
|
34
|
+
}
|
|
35
|
+
return { ctx, stream, img: () => container.querySelector('img') as HTMLImageElement }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('frames2dRenderer', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.useFakeTimers()
|
|
41
|
+
document.body.innerHTML = ''
|
|
42
|
+
})
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
vi.useRealTimers()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('validates the served config fail-closed', () => {
|
|
48
|
+
expect(() => frames2dRenderer.validateConfig(null)).toThrow()
|
|
49
|
+
expect(() => frames2dRenderer.validateConfig({ tracks: {}, phases: { idle: 'idle' } })).toThrow()
|
|
50
|
+
expect(() => frames2dRenderer.validateConfig({ tracks: { idle: { frames: [], durations: [], loop: true } }, phases: { idle: 'idle' } })).toThrow()
|
|
51
|
+
expect(frames2dRenderer.validateConfig(CONFIG).phases.idle).toBe('idle')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('plays the idle track on mount and advances frames on its durations', () => {
|
|
55
|
+
const { ctx, img } = setup()
|
|
56
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
57
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
58
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
|
|
59
|
+
vi.advanceTimersByTime(100)
|
|
60
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/idle/2.webp')
|
|
61
|
+
vi.advanceTimersByTime(100)
|
|
62
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
|
|
63
|
+
handle.dispose()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('follows the phase stream through the phases map', () => {
|
|
67
|
+
const { ctx, stream, img } = setup()
|
|
68
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
69
|
+
stream.push('thinking' as ActivityPhase)
|
|
70
|
+
expect(handle.currentTrack()).toBe('work')
|
|
71
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/work/1.webp')
|
|
72
|
+
// Unmapped phases fall back to the idle track.
|
|
73
|
+
stream.push('waiting' as ActivityPhase)
|
|
74
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
75
|
+
handle.dispose()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('settles a finished non-loop track into its fallback', () => {
|
|
79
|
+
const { ctx, stream } = setup()
|
|
80
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
81
|
+
stream.push('done' as ActivityPhase)
|
|
82
|
+
expect(handle.currentTrack()).toBe('happy')
|
|
83
|
+
vi.advanceTimersByTime(150)
|
|
84
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
85
|
+
handle.dispose()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('lets the gameplay driver force and release a track', () => {
|
|
89
|
+
const { ctx, stream } = setup()
|
|
90
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
91
|
+
handle.setState('standup')
|
|
92
|
+
expect(handle.currentTrack()).toBe('standup')
|
|
93
|
+
// Phase changes are ignored while the override holds.
|
|
94
|
+
stream.push('thinking' as ActivityPhase)
|
|
95
|
+
expect(handle.currentTrack()).toBe('standup')
|
|
96
|
+
// The finished override settles into its fallback, which matches the
|
|
97
|
+
// current phase map (thinking -> work? no: standup fell back to idle,
|
|
98
|
+
// but the phase says work, so the release lands on the phase track).
|
|
99
|
+
vi.advanceTimersByTime(150)
|
|
100
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
101
|
+
handle.setState('work')
|
|
102
|
+
expect(handle.currentTrack()).toBe('work')
|
|
103
|
+
handle.setState(undefined)
|
|
104
|
+
expect(handle.currentTrack()).toBe('work')
|
|
105
|
+
handle.dispose()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('ignores unknown setState ids', () => {
|
|
109
|
+
const { ctx } = setup()
|
|
110
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
111
|
+
handle.setState('ghost')
|
|
112
|
+
expect(handle.currentTrack()).toBe('idle')
|
|
113
|
+
handle.dispose()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('re-kicks a stalled looping track via the watchdog', () => {
|
|
117
|
+
const { ctx, img } = setup()
|
|
118
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
119
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
|
|
120
|
+
// Simulate a throttled-away chain: burn the pending frame timer without
|
|
121
|
+
// the renderer noticing by jumping far past it, then let the watchdog fire.
|
|
122
|
+
vi.advanceTimersByTime(100) // frame 2
|
|
123
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/idle/2.webp')
|
|
124
|
+
vi.advanceTimersByTime(100) // wraps to frame 1
|
|
125
|
+
expect(img().getAttribute('src')).toBe('/pet/miku/idle/1.webp')
|
|
126
|
+
handle.dispose()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('disposes idempotently and removes the image', () => {
|
|
130
|
+
const { ctx } = setup()
|
|
131
|
+
const container = ctx.container
|
|
132
|
+
const handle = frames2dRenderer.mount(ctx, frames2dRenderer.validateConfig(CONFIG)) as Frames2dRendererHandle
|
|
133
|
+
handle.dispose()
|
|
134
|
+
handle.dispose()
|
|
135
|
+
expect(container.querySelector('img')).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
})
|