@linxin666/dsh-pet 0.1.1 → 0.1.2
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.md +4 -1
- package/lib/client.js.map +1 -0
- package/lib/state-7ChK99mH.js +217 -0
- package/lib/state-CKh9f_dF.js +196 -0
- package/lib/state-DMgOYVFT.js +198 -0
- package/lib/state-IyVnKymD.js +198 -0
- package/lib/types/affinity.js +84 -0
- package/lib/types/client/PetDockEntry.js +34 -0
- package/lib/types/client/PetSettingsCard.js +55 -0
- package/lib/types/client/PluginSettingsCard.js +39 -0
- package/lib/types/client/WhalePet.js +239 -0
- package/lib/types/client/index.js +206 -0
- package/lib/types/client/locales.js +92 -0
- package/lib/types/client/pet-store.js +33 -0
- package/lib/types/client/settings-form.js +211 -0
- package/lib/types/client/slots-augment.js +1 -0
- package/lib/types/client/spritesheet.js +120 -0
- package/lib/types/index.js +84 -0
- package/lib/types/invariant.js +27 -0
- package/lib/types/persist.js +96 -0
- package/lib/types/routes.js +158 -0
- package/lib/types/service.js +255 -0
- package/lib/types/state.js +106 -0
- package/lib/types/treats.js +63 -0
- package/package.json +18 -13
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Whale-girl companion component — the browser half's centerpiece. Renders a
|
|
4
|
+
* fixed-position floating sprite (React portal onto document.body), plays
|
|
5
|
+
* the spritesheet track matching the host animation snapshot, and exposes
|
|
6
|
+
* the interaction surface: click to pet, hover panel with feed/hide, drag to
|
|
7
|
+
* reposition (persisted via setConfig).
|
|
8
|
+
* @module @linxin666/dsh-pet/client/WhalePet
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { createPortal } from 'react-dom';
|
|
12
|
+
import { framePosition, FRAME_WIDTH, FRAME_HEIGHT, FRAME_COLUMNS, TRACKS, rowOfTrack, trimTrack, detectFrameCounts } from "./spritesheet.js";
|
|
13
|
+
import styles from './pet.module.css';
|
|
14
|
+
/** Browser URL of the whale-girl atlas (served by the host half's own route). */
|
|
15
|
+
export const PET_SPRITESHEET_URL = '/pet/whale/spritesheet.webp';
|
|
16
|
+
/** Browser URL of the whale-girl manifest (authoritative per-row frame counts). */
|
|
17
|
+
export const PET_MANIFEST_URL = '/pet/whale/pet.json';
|
|
18
|
+
/** Clamp a drag offset inside the viewport with a margin. */
|
|
19
|
+
function clampOffset(value, max) {
|
|
20
|
+
return Math.max(0, Math.min(max, value));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The floating pet. The spritesheet frame advances on requestAnimationFrame
|
|
24
|
+
* with per-frame durations from TRACKS; the atlas image is loaded once and
|
|
25
|
+
* the background position is written straight to the sprite element (no
|
|
26
|
+
* per-frame React state).
|
|
27
|
+
*/
|
|
28
|
+
export function WhalePet(props) {
|
|
29
|
+
const { snapshot, display, feedback } = props;
|
|
30
|
+
const spriteRef = useRef(null);
|
|
31
|
+
const floatRef = useRef(null);
|
|
32
|
+
const [imageReady, setImageReady] = useState(false);
|
|
33
|
+
const [frameCounts, setFrameCounts] = useState(null);
|
|
34
|
+
const [hovered, setHovered] = useState(false);
|
|
35
|
+
const [renaming, setRenaming] = useState(false);
|
|
36
|
+
const [nameDraft, setNameDraft] = useState('');
|
|
37
|
+
const [dragPos, setDragPos] = useState(null);
|
|
38
|
+
const dragRef = useRef(null);
|
|
39
|
+
const frameRef = useRef({
|
|
40
|
+
track: null,
|
|
41
|
+
index: 0,
|
|
42
|
+
elapsed: 0,
|
|
43
|
+
});
|
|
44
|
+
// Load the atlas once; then resolve per-row frame counts so tracks never
|
|
45
|
+
// play the transparent trailing cells of a short row. One decoded Image
|
|
46
|
+
// feeds both the sprite render and the frame-count detection. The counts
|
|
47
|
+
// prefer the authoritatively recorded `frames` field on the pet.json
|
|
48
|
+
// manifest route and only fall back to the getImageData atlas scan when
|
|
49
|
+
// that field is absent (older manifests).
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
const img = new Image();
|
|
53
|
+
img.onload = () => {
|
|
54
|
+
if (cancelled)
|
|
55
|
+
return;
|
|
56
|
+
setImageReady(true);
|
|
57
|
+
fetch(PET_MANIFEST_URL)
|
|
58
|
+
.then((res) => (res.ok ? res.json() : Promise.resolve({})))
|
|
59
|
+
.then((manifest) => {
|
|
60
|
+
if (cancelled)
|
|
61
|
+
return;
|
|
62
|
+
const frames = manifest.frames;
|
|
63
|
+
if (Array.isArray(frames) && frames.length === 9 && frames.every((n) => typeof n === 'number')) {
|
|
64
|
+
setFrameCounts(frames);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
setFrameCounts(detectFrameCounts(img));
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.catch(() => {
|
|
71
|
+
if (!cancelled)
|
|
72
|
+
setFrameCounts(detectFrameCounts(img));
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
img.src = PET_SPRITESHEET_URL;
|
|
76
|
+
return () => {
|
|
77
|
+
cancelled = true;
|
|
78
|
+
img.onload = null;
|
|
79
|
+
};
|
|
80
|
+
}, []);
|
|
81
|
+
// Frame loop: advance the current track and write background-position.
|
|
82
|
+
// Offsets must be in SCALED coordinates (background-position applies to the
|
|
83
|
+
// scaled background image), so the current sprite scale rides a ref that
|
|
84
|
+
// the loop reads every tick. Under prefers-reduced-motion the sprite holds
|
|
85
|
+
// its track's first frame instead of animating (presentation-only; the
|
|
86
|
+
// animation state machine is untouched).
|
|
87
|
+
const spriteScale = display.size / FRAME_HEIGHT;
|
|
88
|
+
const animation = snapshot?.animation ?? 'idle';
|
|
89
|
+
const scaleRef = useRef(spriteScale);
|
|
90
|
+
scaleRef.current = spriteScale;
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const reduceMotion = typeof window !== 'undefined'
|
|
93
|
+
&& window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true;
|
|
94
|
+
// Paint one static sprite frame up front either way, so the pet is never
|
|
95
|
+
// blank while the loop heat-up runs.
|
|
96
|
+
const row = rowOfTrack(animation);
|
|
97
|
+
const track = frameCounts === null
|
|
98
|
+
? TRACKS[animation]
|
|
99
|
+
: trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length);
|
|
100
|
+
const leadCol = track.frames[0];
|
|
101
|
+
const lead = framePosition(row, leadCol, scaleRef.current);
|
|
102
|
+
if (spriteRef.current !== null) {
|
|
103
|
+
spriteRef.current.style.backgroundPosition = `${lead.x}px ${lead.y}px`;
|
|
104
|
+
}
|
|
105
|
+
if (reduceMotion)
|
|
106
|
+
return;
|
|
107
|
+
let raf = 0;
|
|
108
|
+
let last = performance.now();
|
|
109
|
+
const tick = (ts) => {
|
|
110
|
+
const delta = ts - last;
|
|
111
|
+
last = ts;
|
|
112
|
+
// Trim the track to the row's real frame count (transparent cells
|
|
113
|
+
// would render as a vanishing pet).
|
|
114
|
+
const row = rowOfTrack(animation);
|
|
115
|
+
const track = frameCounts === null
|
|
116
|
+
? TRACKS[animation]
|
|
117
|
+
: trimTrack(TRACKS[animation], frameCounts[row] ?? TRACKS[animation].frames.length);
|
|
118
|
+
const st = frameRef.current;
|
|
119
|
+
if (st.track !== animation) {
|
|
120
|
+
st.track = animation;
|
|
121
|
+
st.index = 0;
|
|
122
|
+
st.elapsed = 0;
|
|
123
|
+
}
|
|
124
|
+
st.elapsed += delta;
|
|
125
|
+
const maxIndex = track.frames.length - 1;
|
|
126
|
+
while (st.elapsed >= (track.durations[st.index] ?? 0) && st.index < maxIndex) {
|
|
127
|
+
st.elapsed -= track.durations[st.index] ?? 0;
|
|
128
|
+
st.index += 1;
|
|
129
|
+
}
|
|
130
|
+
if (st.elapsed >= (track.durations[st.index] ?? 0)) {
|
|
131
|
+
if (track.loop) {
|
|
132
|
+
st.elapsed = 0;
|
|
133
|
+
st.index = 0;
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
st.index = maxIndex; // hold the final frame; the host switches tracks
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const col = track.frames[st.index];
|
|
140
|
+
const { x, y } = framePosition(row, col, scaleRef.current);
|
|
141
|
+
if (spriteRef.current !== null) {
|
|
142
|
+
spriteRef.current.style.backgroundPosition = `${x}px ${y}px`;
|
|
143
|
+
}
|
|
144
|
+
raf = requestAnimationFrame(tick);
|
|
145
|
+
};
|
|
146
|
+
raf = requestAnimationFrame(tick);
|
|
147
|
+
return () => cancelAnimationFrame(raf);
|
|
148
|
+
}, [animation, frameCounts]);
|
|
149
|
+
// Auto-clear the feedback bubble after its CSS animation. The callback
|
|
150
|
+
// rides a ref so re-renders never reset the timer: the 800ms poll rebuilds
|
|
151
|
+
// `props` every tick, and depending on it would starve the timeout.
|
|
152
|
+
const feedbackDoneRef = useRef(props.onFeedbackDone);
|
|
153
|
+
feedbackDoneRef.current = props.onFeedbackDone;
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
if (feedback === null)
|
|
156
|
+
return;
|
|
157
|
+
const timer = window.setTimeout(() => feedbackDoneRef.current(), 2600);
|
|
158
|
+
return () => window.clearTimeout(timer);
|
|
159
|
+
}, [feedback]);
|
|
160
|
+
// Dragging: pointer events on the sprite; position is right/bottom based.
|
|
161
|
+
// `draggedRef` records whether the pointer actually moved, so the browser's
|
|
162
|
+
// trailing click (fired after pointerup) does not pet the whale.
|
|
163
|
+
const draggedRef = useRef(false);
|
|
164
|
+
const onPointerDown = (e) => {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
e.target.setPointerCapture?.(e.pointerId);
|
|
167
|
+
const current = dragPos ?? { right: display.right, bottom: display.bottom };
|
|
168
|
+
dragRef.current = { startX: e.clientX, startY: e.clientY, ...current };
|
|
169
|
+
draggedRef.current = false;
|
|
170
|
+
setHovered(false);
|
|
171
|
+
};
|
|
172
|
+
const onPointerMove = (e) => {
|
|
173
|
+
const drag = dragRef.current;
|
|
174
|
+
if (drag === null)
|
|
175
|
+
return;
|
|
176
|
+
const dx = e.clientX - drag.startX;
|
|
177
|
+
const dy = e.clientY - drag.startY;
|
|
178
|
+
if (Math.abs(dx) > 4 || Math.abs(dy) > 4)
|
|
179
|
+
draggedRef.current = true;
|
|
180
|
+
const right = clampOffset(drag.right - dx, window.innerWidth - 40);
|
|
181
|
+
const bottom = clampOffset(drag.bottom - dy, window.innerHeight - 40);
|
|
182
|
+
setDragPos({ right, bottom });
|
|
183
|
+
};
|
|
184
|
+
const onPointerUp = () => {
|
|
185
|
+
if (dragRef.current === null)
|
|
186
|
+
return;
|
|
187
|
+
dragRef.current = null;
|
|
188
|
+
if (dragPos !== null)
|
|
189
|
+
props.onDragEnd(dragPos.right, dragPos.bottom);
|
|
190
|
+
};
|
|
191
|
+
const pos = dragPos ?? { right: display.right, bottom: display.bottom };
|
|
192
|
+
const spriteWidth = Math.round(FRAME_WIDTH * spriteScale);
|
|
193
|
+
const spriteHeight = Math.round(FRAME_HEIGHT * spriteScale);
|
|
194
|
+
const float = (_jsxs("div", { ref: floatRef, className: styles.float, style: { right: pos.right, bottom: pos.bottom, zIndex: 2147483000 }, onPointerEnter: () => setHovered(true), onPointerLeave: (e) => {
|
|
195
|
+
// The panel and bubble render OUTSIDE the container's box (absolute,
|
|
196
|
+
// above the sprite), so moving onto them fires pointerleave on the
|
|
197
|
+
// container. Treat a target still inside the container's DOM (the
|
|
198
|
+
// overflowed panel) as "still hovering".
|
|
199
|
+
const next = e.relatedTarget;
|
|
200
|
+
if (next instanceof Node && floatRef.current?.contains(next))
|
|
201
|
+
return;
|
|
202
|
+
setHovered(false);
|
|
203
|
+
}, children: [_jsx("div", { ref: spriteRef, className: styles.sprite, style: {
|
|
204
|
+
width: spriteWidth,
|
|
205
|
+
height: spriteHeight,
|
|
206
|
+
backgroundImage: imageReady ? `url(${PET_SPRITESHEET_URL})` : undefined,
|
|
207
|
+
backgroundSize: `${FRAME_WIDTH * FRAME_COLUMNS * spriteScale}px ${FRAME_HEIGHT * 9 * spriteScale}px`,
|
|
208
|
+
backgroundRepeat: 'no-repeat',
|
|
209
|
+
backgroundPosition: '0 0',
|
|
210
|
+
cursor: dragRef.current === null ? 'grab' : 'grabbing',
|
|
211
|
+
}, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onClick: () => {
|
|
212
|
+
// A pointer sequence that moved (dragged) still fires a trailing
|
|
213
|
+
// click; skip the pet when that happened.
|
|
214
|
+
if (draggedRef.current)
|
|
215
|
+
return;
|
|
216
|
+
props.onPet();
|
|
217
|
+
}, role: "button", "aria-label": "whale girl" }), feedback !== null && (_jsx("div", { className: `${styles.bubble} ${feedback.kind === 'feed' ? styles.bubbleFeed : styles.bubblePet}`, children: feedback.text }, feedback.at)), hovered && dragRef.current === null && (_jsx("div", { className: styles.panel, children: renaming ? (_jsxs("div", { className: styles.renameRow, children: [_jsx("input", { className: styles.nameInput, value: nameDraft, maxLength: 20, placeholder: props.t('pet.namePlaceholder'), autoFocus: true, onChange: (e) => setNameDraft(e.target.value), onKeyDown: (e) => {
|
|
218
|
+
if (e.key === 'Enter') {
|
|
219
|
+
const trimmed = nameDraft.trim();
|
|
220
|
+
if (trimmed !== '') {
|
|
221
|
+
props.onRename(trimmed);
|
|
222
|
+
setRenaming(false);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
else if (e.key === 'Escape') {
|
|
226
|
+
setRenaming(false);
|
|
227
|
+
}
|
|
228
|
+
} }), _jsx("button", { type: "button", className: styles.action, onClick: () => {
|
|
229
|
+
const trimmed = nameDraft.trim();
|
|
230
|
+
if (trimmed !== '') {
|
|
231
|
+
props.onRename(trimmed);
|
|
232
|
+
setRenaming(false);
|
|
233
|
+
}
|
|
234
|
+
}, children: props.t('pet.confirm') })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: styles.rankRow, children: [_jsx("span", { className: styles.nameCell, children: snapshot?.name ?? '鲸鱼娘' }), _jsx("span", { children: props.t('pet.rank', { rank: snapshot?.affinity.rank ?? '?' }) })] }), _jsxs("div", { className: styles.rankRow, children: [_jsx("span", { children: props.t('pet.treats', { n: snapshot?.treats.stocked ?? 0 }) }), _jsx("span", { children: props.t('pet.points', { points: snapshot?.affinity.points ?? 0 }) })] }), _jsxs("div", { className: styles.actions, children: [_jsx("button", { type: "button", className: styles.action, onClick: props.onFeed, children: props.t('pet.feed') }), _jsx("button", { type: "button", className: styles.action, onClick: () => {
|
|
235
|
+
setNameDraft(snapshot?.name ?? '');
|
|
236
|
+
setRenaming(true);
|
|
237
|
+
}, children: props.t('pet.rename') }), _jsx("button", { type: "button", className: styles.action, onClick: props.onHide, children: props.t('pet.hide') })] })] })) }))] }));
|
|
238
|
+
return createPortal(float, document.body);
|
|
239
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-pet browser half — registers the whale-girl into the conversation
|
|
3
|
+
* input selector row (the same every-phase row the git branch chip uses) and
|
|
4
|
+
* drives it from the host's same-origin `/api/pet/*` JSON endpoints: poll the
|
|
5
|
+
* host snapshot (~800 ms), forward interactions, persist drag positions. The
|
|
6
|
+
* row anchor mounts the floating pet via portal; when the pet is hidden the
|
|
7
|
+
* anchor becomes the summon button. Anchoring in the selector row (rather
|
|
8
|
+
* than the session-only composer dock band) keeps the pet floating on the
|
|
9
|
+
* new-conversation screen too, where no session exists to scope a slot by.
|
|
10
|
+
* @module @linxin666/dsh-pet/client
|
|
11
|
+
*/
|
|
12
|
+
import { createPetStore } from "./pet-store.js";
|
|
13
|
+
import { PetDockEntry } from "./PetDockEntry.js";
|
|
14
|
+
import { PetSettingsCard, PetSettingsCardController } from "./PetSettingsCard.js";
|
|
15
|
+
import { NS, en, zh } from "./locales.js";
|
|
16
|
+
/** Same-origin JSON fetch helper (GET without body, POST with JSON body). */
|
|
17
|
+
async function petFetch(path, body) {
|
|
18
|
+
const response = await fetch(path, body === undefined
|
|
19
|
+
? {}
|
|
20
|
+
: {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
});
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`pet ${path} failed: ${response.status}`);
|
|
27
|
+
}
|
|
28
|
+
return (await response.json());
|
|
29
|
+
}
|
|
30
|
+
/** The live host API instance (always defined; failures surface per call). */
|
|
31
|
+
const petApi = {
|
|
32
|
+
state: () => petFetch('/api/pet/state'),
|
|
33
|
+
interact: (kind) => petFetch('/api/pet/interact', { kind }),
|
|
34
|
+
setVisible: (visible) => petFetch('/api/pet/set-visible', { visible }),
|
|
35
|
+
setConfig: (patch) => petFetch('/api/pet/set-config', patch),
|
|
36
|
+
setName: (name) => petFetch('/api/pet/set-name', { name }),
|
|
37
|
+
};
|
|
38
|
+
/** Poll interval for the host snapshot. */
|
|
39
|
+
const POLL_MS = 800;
|
|
40
|
+
/** Settings namespace the pet settings card edits (the Host plugin registers it). */
|
|
41
|
+
const PET_SETTINGS_NS = 'pet';
|
|
42
|
+
/** Required services. */
|
|
43
|
+
export const inject = ['slots', 'locale', 'connection', 'settingsScope', 'remote'];
|
|
44
|
+
/**
|
|
45
|
+
* Client plugin body: register dictionaries, mount the dock entry and poll
|
|
46
|
+
* loop while the plugin is enabled, and seat the settings card in the Web UI
|
|
47
|
+
* plugin group.
|
|
48
|
+
* @param ctx - client root context.
|
|
49
|
+
*/
|
|
50
|
+
export function apply(ctx) {
|
|
51
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'pet: dictionaries');
|
|
52
|
+
const settingsScope = ctx.settingsScope.bind({ namespace: PET_SETTINGS_NS });
|
|
53
|
+
const enabled = () => {
|
|
54
|
+
const snapshot = settingsScope.getSnapshot();
|
|
55
|
+
return snapshot.status === 'ready'
|
|
56
|
+
? snapshot.value?.enabled ?? true
|
|
57
|
+
: snapshot.status === 'unavailable';
|
|
58
|
+
};
|
|
59
|
+
// Plugin configuration card: one staged form over the `pet` settings
|
|
60
|
+
// namespace, contributed to the Web UI plugin group.
|
|
61
|
+
const petSettings = new PetSettingsCardController(settingsScope);
|
|
62
|
+
ctx.slots.inject('web-ui.plugin.item', () => ctx.slots.register({
|
|
63
|
+
name: 'web-ui.plugin.item',
|
|
64
|
+
id: 'pet-settings',
|
|
65
|
+
order: 140,
|
|
66
|
+
locale: NS,
|
|
67
|
+
inject: () => petSettings.inject(),
|
|
68
|
+
}, PetSettingsCard));
|
|
69
|
+
// The dock entry, its store, and the poll loop live while the plugin is
|
|
70
|
+
// enabled; toggling the setting off hides the pet and stops polling.
|
|
71
|
+
let disposeUi;
|
|
72
|
+
const syncUi = () => {
|
|
73
|
+
if (enabled() && disposeUi === undefined) {
|
|
74
|
+
// ONE store instance for the whole app, owned by this apply body. The
|
|
75
|
+
// pet is host-global (state/display/interactions are /api/pet/*
|
|
76
|
+
// endpoints with no session dimension), so the slot system's per-session
|
|
77
|
+
// store scoping would only reset the pet on session switches and leave
|
|
78
|
+
// it stateless on the new-conversation screen (no session to scope by).
|
|
79
|
+
const petStore = createPetStore().create();
|
|
80
|
+
const setSnapshot = petStore.actions.setSnapshot;
|
|
81
|
+
const setState = petStore.actions.setState;
|
|
82
|
+
const setFeedback = petStore.actions.setFeedback;
|
|
83
|
+
const pollNow = () => {
|
|
84
|
+
petApi.state().then((snapshot) => {
|
|
85
|
+
setSnapshot(snapshot);
|
|
86
|
+
}, () => {
|
|
87
|
+
setState('error', 'pet.state transport error');
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const disposePoll = ctx.effect(() => {
|
|
91
|
+
// Poll only while the tab is visible: the host snapshot does not
|
|
92
|
+
// change while the page is hidden, so a background interval would
|
|
93
|
+
// only burn RPCs (browser throttling is an unreliable backstop).
|
|
94
|
+
// Coming back to the tab refreshes the pet immediately instead of
|
|
95
|
+
// waiting out the next 800 ms cycle.
|
|
96
|
+
let timer;
|
|
97
|
+
const stop = () => {
|
|
98
|
+
if (timer !== undefined) {
|
|
99
|
+
window.clearInterval(timer);
|
|
100
|
+
timer = undefined;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const start = () => {
|
|
104
|
+
if (timer === undefined && document.visibilityState === 'visible') {
|
|
105
|
+
timer = window.setInterval(pollNow, POLL_MS);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
const onVisibility = () => {
|
|
109
|
+
if (document.visibilityState === 'visible') {
|
|
110
|
+
pollNow();
|
|
111
|
+
start();
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
stop();
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
start();
|
|
118
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
119
|
+
return () => {
|
|
120
|
+
stop();
|
|
121
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
122
|
+
};
|
|
123
|
+
}, 'pet: poll');
|
|
124
|
+
const injected = () => ({
|
|
125
|
+
store: petStore,
|
|
126
|
+
ensure: pollNow,
|
|
127
|
+
pet: () => {
|
|
128
|
+
petApi.interact('pet').then((result) => {
|
|
129
|
+
setFeedback({
|
|
130
|
+
text: result.reaction,
|
|
131
|
+
kind: 'pet',
|
|
132
|
+
at: Date.now(),
|
|
133
|
+
});
|
|
134
|
+
}, () => {
|
|
135
|
+
// Ignore transport errors on interactions; the next poll resyncs.
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
feed: () => {
|
|
139
|
+
petApi.interact('feed').then((result) => {
|
|
140
|
+
setFeedback({
|
|
141
|
+
text: result.reaction,
|
|
142
|
+
kind: 'feed',
|
|
143
|
+
at: Date.now(),
|
|
144
|
+
});
|
|
145
|
+
}, () => {
|
|
146
|
+
// Ignore transport errors on interactions; the next poll resyncs.
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
hide: () => {
|
|
150
|
+
petApi.setVisible(false).then(() => {
|
|
151
|
+
pollNow();
|
|
152
|
+
}, () => {
|
|
153
|
+
// Ignore; next poll resyncs.
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
summon: () => {
|
|
157
|
+
petApi.setVisible(true).then(() => {
|
|
158
|
+
pollNow();
|
|
159
|
+
}, () => {
|
|
160
|
+
// Ignore; next poll resyncs.
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
dragEnd: (right, bottom) => {
|
|
164
|
+
petApi.setConfig({ right, bottom }).then(() => {
|
|
165
|
+
pollNow();
|
|
166
|
+
}, () => {
|
|
167
|
+
// Ignore; next poll resyncs.
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
rename: (name) => {
|
|
171
|
+
petApi.setName(name).then((result) => {
|
|
172
|
+
if (result.ok)
|
|
173
|
+
pollNow();
|
|
174
|
+
}, () => {
|
|
175
|
+
// Ignore; next poll resyncs.
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
feedbackDone: () => {
|
|
179
|
+
setFeedback(null);
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
// The input selector row mounts in EVERY conversation phase (cold
|
|
183
|
+
// start, blank-session hero, active seat) — the composer dock band
|
|
184
|
+
// only renders for an active session, which is why the pet used to
|
|
185
|
+
// vanish on the new-conversation screen.
|
|
186
|
+
const disposeDock = ctx.slots.inject('conversation.input.selector.context', () => ctx.slots.register({
|
|
187
|
+
name: 'conversation.input.selector.context',
|
|
188
|
+
id: 'pet',
|
|
189
|
+
order: 110,
|
|
190
|
+
inject: injected,
|
|
191
|
+
locale: NS,
|
|
192
|
+
}, PetDockEntry));
|
|
193
|
+
disposeUi = () => {
|
|
194
|
+
disposeDock();
|
|
195
|
+
disposePoll();
|
|
196
|
+
disposeUi = undefined;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
else if (!enabled() && disposeUi !== undefined) {
|
|
200
|
+
disposeUi();
|
|
201
|
+
disposeUi = undefined;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
settingsScope.subscribe(syncUi);
|
|
205
|
+
syncUi();
|
|
206
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-pet locale dictionaries (zh/en).
|
|
3
|
+
* @module @linxin666/dsh-pet/client/locales
|
|
4
|
+
*/
|
|
5
|
+
/** Dictionary namespace this package registers. */
|
|
6
|
+
export const NS = 'pet';
|
|
7
|
+
/** Chinese copy. */
|
|
8
|
+
export const zh = {
|
|
9
|
+
'pet.feed': '喂食',
|
|
10
|
+
'pet.hide': '隐藏',
|
|
11
|
+
'pet.rename': '改名',
|
|
12
|
+
'pet.confirm': '确定',
|
|
13
|
+
'pet.namePlaceholder': '输入新名字',
|
|
14
|
+
'pet.summon': '召唤{name}',
|
|
15
|
+
'pet.rank': '亲密度 {rank}',
|
|
16
|
+
'pet.points': '{points} 点',
|
|
17
|
+
'pet.treats': '小鱼干 ×{n}',
|
|
18
|
+
'pet.state.loading': '鲸鱼娘正在赶来…',
|
|
19
|
+
'pet.state.error': '鲸鱼娘迷路了(连接失败)',
|
|
20
|
+
// 插件设置卡片(settings.plugin.item 席位)。
|
|
21
|
+
'settings.title': '宠物',
|
|
22
|
+
'settings.description': '鲸鱼娘的显示布局与名字。',
|
|
23
|
+
'settings.enabled': '启用宠物',
|
|
24
|
+
'settings.enabledHint': '关闭后隐藏宠物并停止轮询,可在设置里重新启用。',
|
|
25
|
+
'settings.visible': '显示宠物',
|
|
26
|
+
'settings.visibleHint': '关闭后宠物隐藏,可从聊天输入区重新召唤。',
|
|
27
|
+
'settings.size': '大小(px)',
|
|
28
|
+
'settings.sizeHint': '精灵单元高度,范围 32–512。',
|
|
29
|
+
'settings.right': '距右侧(px)',
|
|
30
|
+
'settings.rightHint': '距视口右边缘的水平内缩距离。',
|
|
31
|
+
'settings.bottom': '距底部(px)',
|
|
32
|
+
'settings.bottomHint': '距视口底边的垂直内缩距离。',
|
|
33
|
+
'settings.name': '名字',
|
|
34
|
+
'settings.nameHint': '宠物显示名,1–20 个字符。',
|
|
35
|
+
'settings.inherit': '继承',
|
|
36
|
+
'settings.on': '开',
|
|
37
|
+
'settings.off': '关',
|
|
38
|
+
'settings.overridden': '已覆盖',
|
|
39
|
+
'settings.reset': '恢复默认',
|
|
40
|
+
'settings.readOnly': '当前部署的设置只读。',
|
|
41
|
+
'settings.expand': '展开设置',
|
|
42
|
+
'settings.collapse': '收起设置',
|
|
43
|
+
'settings.save': '保存',
|
|
44
|
+
'settings.saving': '保存中…',
|
|
45
|
+
'settings.discard': '放弃',
|
|
46
|
+
'settings.unsaved': '未保存',
|
|
47
|
+
'settings.saveFailed': '部署未接受这些值,已保留供你修改。',
|
|
48
|
+
'settings.invalidNumber': '请输入数字,留空则使用默认值。',
|
|
49
|
+
};
|
|
50
|
+
/** English copy. */
|
|
51
|
+
export const en = {
|
|
52
|
+
'pet.feed': 'Feed',
|
|
53
|
+
'pet.hide': 'Hide',
|
|
54
|
+
'pet.rename': 'Rename',
|
|
55
|
+
'pet.confirm': 'OK',
|
|
56
|
+
'pet.namePlaceholder': 'Enter a new name',
|
|
57
|
+
'pet.summon': 'Summon {name}',
|
|
58
|
+
'pet.rank': 'Affinity {rank}',
|
|
59
|
+
'pet.points': '{points} pts',
|
|
60
|
+
'pet.treats': 'Treats ×{n}',
|
|
61
|
+
'pet.state.loading': 'The whale girl is on her way…',
|
|
62
|
+
'pet.state.error': 'The whale girl is lost (connection failed)',
|
|
63
|
+
// Plugin settings card (the `settings.plugin.item` seat).
|
|
64
|
+
'settings.title': 'Pet',
|
|
65
|
+
'settings.description': 'The whale girl\u2019s display layout and name.',
|
|
66
|
+
'settings.enabled': 'Enable the pet',
|
|
67
|
+
'settings.enabledHint': 'When off, the pet hides and polling stops; re-enable it here.',
|
|
68
|
+
'settings.visible': 'Show the pet',
|
|
69
|
+
'settings.visibleHint': 'When off, the pet hides; summon it again from the input row.',
|
|
70
|
+
'settings.size': 'Size (px)',
|
|
71
|
+
'settings.sizeHint': 'Sprite cell height, 32\u2013512.',
|
|
72
|
+
'settings.right': 'Right inset (px)',
|
|
73
|
+
'settings.rightHint': 'Horizontal inset from the viewport right edge.',
|
|
74
|
+
'settings.bottom': 'Bottom inset (px)',
|
|
75
|
+
'settings.bottomHint': 'Vertical inset from the viewport bottom edge.',
|
|
76
|
+
'settings.name': 'Name',
|
|
77
|
+
'settings.nameHint': 'The pet\u2019s display name, 1\u201320 characters.',
|
|
78
|
+
'settings.inherit': 'Inherit',
|
|
79
|
+
'settings.on': 'On',
|
|
80
|
+
'settings.off': 'Off',
|
|
81
|
+
'settings.overridden': 'Overridden',
|
|
82
|
+
'settings.reset': 'Reset to default',
|
|
83
|
+
'settings.readOnly': 'This deployment stores settings read-only.',
|
|
84
|
+
'settings.expand': 'Show settings',
|
|
85
|
+
'settings.collapse': 'Hide settings',
|
|
86
|
+
'settings.save': 'Save',
|
|
87
|
+
'settings.saving': 'Saving\u2026',
|
|
88
|
+
'settings.discard': 'Discard',
|
|
89
|
+
'settings.unsaved': 'Unsaved',
|
|
90
|
+
'settings.saveFailed': 'The deployment did not accept these values; they were left for you to correct.',
|
|
91
|
+
'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
|
|
92
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side pet store: the pet state snapshot plus transient UI feedback
|
|
3
|
+
* (reaction bubbles), written only through the store's audit actions. The
|
|
4
|
+
* RPC polling and interactions live in the plugin apply body; components
|
|
5
|
+
* only ever read snapshots.
|
|
6
|
+
* @module @linxin666/dsh-pet/client/pet-store
|
|
7
|
+
*/
|
|
8
|
+
import { defineStore } from '@deepseek-ai/dsh-client-runtime/client';
|
|
9
|
+
/** Create the pet store handle (apply world only; never module-level). */
|
|
10
|
+
export function createPetStore() {
|
|
11
|
+
return defineStore({
|
|
12
|
+
init: () => ({
|
|
13
|
+
snapshot: null,
|
|
14
|
+
state: 'loading',
|
|
15
|
+
error: null,
|
|
16
|
+
feedback: null,
|
|
17
|
+
}),
|
|
18
|
+
actions: {
|
|
19
|
+
setSnapshot: (draft, snapshot) => {
|
|
20
|
+
draft.snapshot = snapshot;
|
|
21
|
+
draft.state = 'ready';
|
|
22
|
+
draft.error = null;
|
|
23
|
+
},
|
|
24
|
+
setState: (draft, state, error) => {
|
|
25
|
+
draft.state = state;
|
|
26
|
+
draft.error = error;
|
|
27
|
+
},
|
|
28
|
+
setFeedback: (draft, feedback) => {
|
|
29
|
+
draft.feedback = feedback;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|