dsh-offwork 1.1.0 → 1.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/CHANGELOG.md +21 -0
- package/README.md +50 -32
- package/assets/animations/NOTICE.md +15 -0
- package/assets/animations/alpha-probe.webm +0 -0
- package/assets/animations/ornate-drink.webm +0 -0
- package/assets/animations/ornate-greet.webm +0 -0
- package/assets/animations/ornate-idle.webm +0 -0
- package/assets/animations/whale-drink.webm +0 -0
- package/assets/animations/whale-greet.webm +0 -0
- package/assets/characters/NOTICE.md +4 -0
- package/assets/characters/whale-actions.webp +0 -0
- package/assets/characters/whale-ornate-actions.webp +0 -0
- package/client.js +2970 -770
- package/client.source.js +1668 -444
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/index.js +2 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/media-manifest.js +29 -0
- package/media-server.js +123 -0
- package/package.json +9 -2
- package/pet-interactions.js +78 -7
- package/pet-motion.js +111 -0
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +130 -6
- package/pet-video.js +380 -0
- package/pet-view.js +129 -13
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +6 -0
- package/tests/hydration-extras.test.mjs +168 -204
- package/tests/hydration-store.test.mjs +199 -213
- package/tests/hydration.test.mjs +116 -186
- package/tests/media-server.test.mjs +179 -0
- package/tests/pet-character.test.mjs +8 -2
- package/tests/pet-motion.test.mjs +139 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +503 -0
- package/tools/build.mjs +62 -13
- package/tools/check-package.mjs +19 -0
- package/ui-utils.js +51 -1
package/pet-video.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Independent media presentation; never writes settings or hydration records.
|
|
2
|
+
const PET_VIDEO_SUPPORT = new WeakMap();
|
|
3
|
+
|
|
4
|
+
export function petClipUrls(models, baseURI) {
|
|
5
|
+
try {
|
|
6
|
+
const base = new URL(baseURI);
|
|
7
|
+
if (!['http:', 'https:'].includes(base.protocol) || base.username || base.password) return {};
|
|
8
|
+
const prefix = new URL('offwork-clock/media/', base);
|
|
9
|
+
return Object.fromEntries(
|
|
10
|
+
Object.entries(models).map(([model, actions]) => [
|
|
11
|
+
model,
|
|
12
|
+
Object.fromEntries(
|
|
13
|
+
Object.entries(actions).flatMap(([action, path]) => {
|
|
14
|
+
if (typeof path !== 'string' || !path.startsWith('offwork-clock/media/')) return [];
|
|
15
|
+
const url = new URL(path, base);
|
|
16
|
+
return url.origin === base.origin &&
|
|
17
|
+
url.pathname.startsWith(prefix.pathname) &&
|
|
18
|
+
!url.search &&
|
|
19
|
+
!url.hash
|
|
20
|
+
? [[action, url.href]]
|
|
21
|
+
: [];
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
]),
|
|
25
|
+
);
|
|
26
|
+
} catch {
|
|
27
|
+
return {};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function petVideoAlphaSupported(host, source) {
|
|
32
|
+
if (PET_VIDEO_SUPPORT.has(host)) return PET_VIDEO_SUPPORT.get(host);
|
|
33
|
+
const result = new Promise((resolve) => {
|
|
34
|
+
const video = host.document.createElement('video');
|
|
35
|
+
let timer,
|
|
36
|
+
frame,
|
|
37
|
+
delayed,
|
|
38
|
+
done = false;
|
|
39
|
+
const finish = (supported) => {
|
|
40
|
+
if (done) return;
|
|
41
|
+
done = true;
|
|
42
|
+
host.clearTimeout(timer);
|
|
43
|
+
host.clearTimeout(delayed);
|
|
44
|
+
if (frame !== undefined) video.cancelVideoFrameCallback?.(frame);
|
|
45
|
+
video.onloadeddata = video.onerror = null;
|
|
46
|
+
video.pause();
|
|
47
|
+
video.removeAttribute('src');
|
|
48
|
+
video.load();
|
|
49
|
+
resolve(supported);
|
|
50
|
+
};
|
|
51
|
+
if (!source || !video.canPlayType('video/webm; codecs="vp9"')) return finish(false);
|
|
52
|
+
video.muted = true;
|
|
53
|
+
video.playsInline = true;
|
|
54
|
+
video.preload = 'auto';
|
|
55
|
+
const inspect = (presented = false) => {
|
|
56
|
+
if (done) return true;
|
|
57
|
+
try {
|
|
58
|
+
const canvas = host.document.createElement('canvas');
|
|
59
|
+
canvas.width = canvas.height = 16;
|
|
60
|
+
const context = canvas.getContext('2d', { willReadFrequently: true });
|
|
61
|
+
context.drawImage(video, 0, 0, 16, 16);
|
|
62
|
+
const solid = context.getImageData(1, 1, 1, 1).data[3];
|
|
63
|
+
const clear = context.getImageData(14, 14, 1, 1).data[3];
|
|
64
|
+
if (solid > 240 && clear < 8) {
|
|
65
|
+
finish(true);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
if (presented && solid > 240 && clear > 240) {
|
|
69
|
+
finish(false);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* A frame can be undecoded when loadeddata fires. */
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
};
|
|
77
|
+
video.onloadeddata = () => {
|
|
78
|
+
if (inspect()) return;
|
|
79
|
+
if (video.requestVideoFrameCallback)
|
|
80
|
+
frame = video.requestVideoFrameCallback(() => inspect(true));
|
|
81
|
+
const poll = () => {
|
|
82
|
+
if (!inspect(true)) delayed = host.setTimeout(poll, 80);
|
|
83
|
+
};
|
|
84
|
+
delayed = host.setTimeout(poll, 80);
|
|
85
|
+
try {
|
|
86
|
+
Promise.resolve(video.play()).catch(() => {
|
|
87
|
+
if (!inspect()) finish(null);
|
|
88
|
+
});
|
|
89
|
+
} catch {
|
|
90
|
+
if (!inspect()) finish(null);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
video.onerror = () => finish(null);
|
|
94
|
+
timer = host.setTimeout(() => {
|
|
95
|
+
if (!inspect()) finish(null);
|
|
96
|
+
}, 5000);
|
|
97
|
+
video.src = source;
|
|
98
|
+
video.load();
|
|
99
|
+
});
|
|
100
|
+
PET_VIDEO_SUPPORT.set(host, result);
|
|
101
|
+
result.then((supported) => {
|
|
102
|
+
if (supported === null && PET_VIDEO_SUPPORT.get(host) === result)
|
|
103
|
+
PET_VIDEO_SUPPORT.delete(host);
|
|
104
|
+
});
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function createPetVideoMotion(element, host, base, clips, probeSource, options = {}) {
|
|
109
|
+
const layer = element.querySelector('.owc-pet-video-layer');
|
|
110
|
+
const poster = element.querySelector('.owc-pet-video-poster');
|
|
111
|
+
if (!layer || !clips) return base;
|
|
112
|
+
const document = host.document;
|
|
113
|
+
const reduced = host.matchMedia?.('(prefers-reduced-motion: reduce)');
|
|
114
|
+
const autoIdle = options.autoIdle !== false;
|
|
115
|
+
const useFallback = options.fallback !== false;
|
|
116
|
+
const failed = new Set(),
|
|
117
|
+
retired = new Map();
|
|
118
|
+
let enabled = false,
|
|
119
|
+
resting = false,
|
|
120
|
+
dragging = false,
|
|
121
|
+
unsupported = false;
|
|
122
|
+
let destroyed = false,
|
|
123
|
+
idleTimer = null,
|
|
124
|
+
current = null,
|
|
125
|
+
displayed = null,
|
|
126
|
+
lastState = '';
|
|
127
|
+
const allowed = () => enabled && !destroyed && !document.hidden && !reduced?.matches;
|
|
128
|
+
const canIdle = () =>
|
|
129
|
+
autoIdle &&
|
|
130
|
+
allowed() &&
|
|
131
|
+
!resting &&
|
|
132
|
+
!dragging &&
|
|
133
|
+
!unsupported &&
|
|
134
|
+
Object.hasOwn(clips, 'idle') &&
|
|
135
|
+
!failed.has('idle');
|
|
136
|
+
function notify(phase, action = null) {
|
|
137
|
+
if (destroyed || lastState === phase + ':' + action) return;
|
|
138
|
+
lastState = phase + ':' + action;
|
|
139
|
+
options.onStateChange?.({ phase, action });
|
|
140
|
+
}
|
|
141
|
+
function clearIdleTimer() {
|
|
142
|
+
host.clearTimeout(idleTimer);
|
|
143
|
+
idleTimer = null;
|
|
144
|
+
}
|
|
145
|
+
function startIdle() {
|
|
146
|
+
if (canIdle() && !current) play('idle');
|
|
147
|
+
}
|
|
148
|
+
function scheduleIdle(delay = 0) {
|
|
149
|
+
clearIdleTimer();
|
|
150
|
+
if (!canIdle()) return;
|
|
151
|
+
idleTimer = host.setTimeout(() => {
|
|
152
|
+
idleTimer = null;
|
|
153
|
+
startIdle();
|
|
154
|
+
}, delay);
|
|
155
|
+
}
|
|
156
|
+
function dispose(run) {
|
|
157
|
+
if (!run || run.disposed) return;
|
|
158
|
+
run.disposed = true;
|
|
159
|
+
host.clearTimeout(run.timer);
|
|
160
|
+
const video = run.video;
|
|
161
|
+
if (!video) return;
|
|
162
|
+
video.onplaying = video.onended = video.onerror = video.onwaiting = video.onstalled = null;
|
|
163
|
+
video.pause();
|
|
164
|
+
video.removeAttribute('src');
|
|
165
|
+
video.load();
|
|
166
|
+
video.remove();
|
|
167
|
+
}
|
|
168
|
+
function flushRetired() {
|
|
169
|
+
for (const [run, timer] of retired) {
|
|
170
|
+
host.clearTimeout(timer);
|
|
171
|
+
dispose(run);
|
|
172
|
+
}
|
|
173
|
+
retired.clear();
|
|
174
|
+
}
|
|
175
|
+
function retire(run, fade) {
|
|
176
|
+
if (!run?.video || !fade || !allowed()) return dispose(run);
|
|
177
|
+
host.clearTimeout(run.timer);
|
|
178
|
+
run.video.onplaying =
|
|
179
|
+
run.video.onended =
|
|
180
|
+
run.video.onerror =
|
|
181
|
+
run.video.onwaiting =
|
|
182
|
+
run.video.onstalled =
|
|
183
|
+
null;
|
|
184
|
+
run.video.pause();
|
|
185
|
+
run.video.style.opacity = '0';
|
|
186
|
+
const timer = host.setTimeout(() => {
|
|
187
|
+
retired.delete(run);
|
|
188
|
+
dispose(run);
|
|
189
|
+
}, 150);
|
|
190
|
+
retired.set(run, timer);
|
|
191
|
+
}
|
|
192
|
+
function settleVisibleFrame() {
|
|
193
|
+
if (!displayed?.video) {
|
|
194
|
+
if (poster?.style) {
|
|
195
|
+
poster.style.transition = 'none';
|
|
196
|
+
poster.style.opacity = '1';
|
|
197
|
+
poster.getBoundingClientRect?.();
|
|
198
|
+
poster.style.opacity = '';
|
|
199
|
+
poster.style.transition = '';
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
displayed.video.style.transition = 'none';
|
|
204
|
+
displayed.video.style.opacity = '1';
|
|
205
|
+
if (poster?.style) {
|
|
206
|
+
poster.style.transition = 'none';
|
|
207
|
+
poster.style.opacity = '0';
|
|
208
|
+
}
|
|
209
|
+
displayed.video.getBoundingClientRect?.();
|
|
210
|
+
displayed.video.style.transition = '';
|
|
211
|
+
if (poster?.style) {
|
|
212
|
+
poster.style.opacity = '';
|
|
213
|
+
poster.style.transition = '';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function stopMedia(fade = false) {
|
|
217
|
+
if (!fade && poster?.style) poster.style.transition = 'none';
|
|
218
|
+
clearIdleTimer();
|
|
219
|
+
flushRetired();
|
|
220
|
+
const pending = current,
|
|
221
|
+
visible = displayed;
|
|
222
|
+
current = displayed = null;
|
|
223
|
+
delete element.dataset.videoPlaying;
|
|
224
|
+
delete element.dataset.videoLoading;
|
|
225
|
+
delete element.dataset.action;
|
|
226
|
+
if (pending && pending !== visible) dispose(pending);
|
|
227
|
+
retire(visible, fade);
|
|
228
|
+
if (!fade && poster?.style) {
|
|
229
|
+
poster.getBoundingClientRect?.();
|
|
230
|
+
poster.style.transition = '';
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function fallback(run, broken = false) {
|
|
234
|
+
if (current !== run) return;
|
|
235
|
+
if (broken) failed.add(run.action);
|
|
236
|
+
stopMedia();
|
|
237
|
+
notify(unsupported ? 'unsupported' : 'unavailable', run.action);
|
|
238
|
+
if (allowed() && run.action !== 'idle' && useFallback) {
|
|
239
|
+
base.play(run.action);
|
|
240
|
+
scheduleIdle(1400);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function play(action) {
|
|
244
|
+
if (!allowed() || dragging || element.dataset.action === 'drag') return false;
|
|
245
|
+
if (action === 'idle' && (resting || (current && !options.interruptible) || unsupported))
|
|
246
|
+
return false;
|
|
247
|
+
if (
|
|
248
|
+
current &&
|
|
249
|
+
(action === 'look' ||
|
|
250
|
+
current.action === action ||
|
|
251
|
+
(!options.interruptible && current.action === 'celebrate' && action === 'greet'))
|
|
252
|
+
)
|
|
253
|
+
return false;
|
|
254
|
+
if (!Object.hasOwn(clips, action) || failed.has(action) || unsupported) {
|
|
255
|
+
if (action === 'idle') return false;
|
|
256
|
+
if (current?.action === 'idle') stopMedia();
|
|
257
|
+
const played = useFallback && base.play(action);
|
|
258
|
+
if (played) scheduleIdle(1400);
|
|
259
|
+
if (!played) notify(unsupported ? 'unsupported' : 'unavailable', action);
|
|
260
|
+
return !!played;
|
|
261
|
+
}
|
|
262
|
+
clearIdleTimer();
|
|
263
|
+
settleVisibleFrame();
|
|
264
|
+
flushRetired();
|
|
265
|
+
if (current && current !== displayed) dispose(current);
|
|
266
|
+
base.release(true);
|
|
267
|
+
const run = { action, video: null, timer: null, disposed: false };
|
|
268
|
+
current = run;
|
|
269
|
+
element.dataset.action = displayed?.action || action;
|
|
270
|
+
element.dataset.videoLoading = action;
|
|
271
|
+
notify('loading', action);
|
|
272
|
+
run.timer = host.setTimeout(() => fallback(run, true), 9000);
|
|
273
|
+
petVideoAlphaSupported(host, probeSource).then((supported) => {
|
|
274
|
+
if (current !== run || !allowed()) return;
|
|
275
|
+
if (supported !== true) {
|
|
276
|
+
unsupported = supported === false;
|
|
277
|
+
return fallback(run);
|
|
278
|
+
}
|
|
279
|
+
const video = document.createElement('video');
|
|
280
|
+
run.video = video;
|
|
281
|
+
video.muted = video.defaultMuted = true;
|
|
282
|
+
video.playsInline = true;
|
|
283
|
+
video.loop = action === 'idle';
|
|
284
|
+
video.disablePictureInPicture = true;
|
|
285
|
+
video.preload = 'auto';
|
|
286
|
+
video.style.opacity = '0';
|
|
287
|
+
video.setAttribute('aria-hidden', 'true');
|
|
288
|
+
video.setAttribute('playsinline', '');
|
|
289
|
+
video.setAttribute('tabindex', '-1');
|
|
290
|
+
video.onplaying = () => {
|
|
291
|
+
if (current !== run || !allowed()) return;
|
|
292
|
+
if (!Number.isFinite(video.duration) || video.duration <= 0 || video.duration > 15)
|
|
293
|
+
return fallback(run, true);
|
|
294
|
+
host.clearTimeout(run.timer);
|
|
295
|
+
run.timer =
|
|
296
|
+
action === 'idle'
|
|
297
|
+
? null
|
|
298
|
+
: host.setTimeout(() => fallback(run, true), video.duration * 1000 + 2000);
|
|
299
|
+
const previous = displayed;
|
|
300
|
+
displayed = run;
|
|
301
|
+
video.style.opacity = '1';
|
|
302
|
+
element.dataset.action = action;
|
|
303
|
+
element.dataset.videoPlaying = 'true';
|
|
304
|
+
delete element.dataset.videoLoading;
|
|
305
|
+
if (previous !== run) retire(previous, true);
|
|
306
|
+
notify('playing', action);
|
|
307
|
+
};
|
|
308
|
+
video.onwaiting = video.onstalled = () => {
|
|
309
|
+
if (current !== run) return;
|
|
310
|
+
host.clearTimeout(run.timer);
|
|
311
|
+
run.timer = host.setTimeout(() => fallback(run, true), 4000);
|
|
312
|
+
};
|
|
313
|
+
video.onended = () => {
|
|
314
|
+
if (current !== run) return;
|
|
315
|
+
host.clearTimeout(run.timer);
|
|
316
|
+
if (canIdle()) {
|
|
317
|
+
current = null;
|
|
318
|
+
if (displayed !== run) dispose(run);
|
|
319
|
+
scheduleIdle();
|
|
320
|
+
} else {
|
|
321
|
+
stopMedia(true);
|
|
322
|
+
notify('idle');
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
video.onerror = () => fallback(run, true);
|
|
326
|
+
video.src = clips[action];
|
|
327
|
+
layer.appendChild(video);
|
|
328
|
+
try {
|
|
329
|
+
Promise.resolve(video.play()).catch(() => fallback(run, true));
|
|
330
|
+
} catch {
|
|
331
|
+
fallback(run, true);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
function stop() {
|
|
337
|
+
stopMedia();
|
|
338
|
+
base.release(true);
|
|
339
|
+
notify('idle');
|
|
340
|
+
}
|
|
341
|
+
const visibility = () => {
|
|
342
|
+
if (!allowed()) stop();
|
|
343
|
+
else scheduleIdle();
|
|
344
|
+
};
|
|
345
|
+
document.addEventListener('visibilitychange', visibility);
|
|
346
|
+
reduced?.addEventListener?.('change', visibility);
|
|
347
|
+
return {
|
|
348
|
+
play,
|
|
349
|
+
stop,
|
|
350
|
+
retry(action) {
|
|
351
|
+
failed.delete(action);
|
|
352
|
+
return play(action);
|
|
353
|
+
},
|
|
354
|
+
configure(next) {
|
|
355
|
+
if (!next.enabled || (next.resting && !resting)) stopMedia();
|
|
356
|
+
enabled = next.enabled;
|
|
357
|
+
resting = next.resting;
|
|
358
|
+
base.configure(next);
|
|
359
|
+
if (!enabled) notify('idle');
|
|
360
|
+
startIdle();
|
|
361
|
+
},
|
|
362
|
+
drag(direction) {
|
|
363
|
+
dragging = true;
|
|
364
|
+
stopMedia();
|
|
365
|
+
base.drag(direction);
|
|
366
|
+
},
|
|
367
|
+
release(cancelled) {
|
|
368
|
+
dragging = false;
|
|
369
|
+
base.release(cancelled);
|
|
370
|
+
scheduleIdle(cancelled ? 0 : 650);
|
|
371
|
+
},
|
|
372
|
+
destroy() {
|
|
373
|
+
destroyed = true;
|
|
374
|
+
stopMedia();
|
|
375
|
+
document.removeEventListener('visibilitychange', visibility);
|
|
376
|
+
reduced?.removeEventListener?.('change', visibility);
|
|
377
|
+
base.destroy();
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
}
|
package/pet-view.js
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
export const PET_MODEL = Object.freeze({ id: 'xiaoxie-original', renderer: 'svg', license: 'MIT' });
|
|
3
3
|
|
|
4
4
|
export const PET_MODELS = Object.freeze({
|
|
5
|
+
'whale-ornate-actions': {
|
|
6
|
+
id: 'whale-ornate-actions',
|
|
7
|
+
renderer: 'video',
|
|
8
|
+
widthScale: 1.3125,
|
|
9
|
+
heightScale: 2.1,
|
|
10
|
+
label: 'petWhaleOrnateActions',
|
|
11
|
+
},
|
|
12
|
+
'whale-actions': {
|
|
13
|
+
id: 'whale-actions',
|
|
14
|
+
renderer: 'video',
|
|
15
|
+
widthScale: 1.3125,
|
|
16
|
+
heightScale: 2.1,
|
|
17
|
+
label: 'petWhaleActions',
|
|
18
|
+
},
|
|
5
19
|
'whale-dress': {
|
|
6
20
|
id: 'whale-dress',
|
|
7
21
|
renderer: 'image',
|
|
@@ -28,6 +42,8 @@ export function petLayout(
|
|
|
28
42
|
size = 132,
|
|
29
43
|
contentHeight = 420,
|
|
30
44
|
modelId = 'xiaoxie-original',
|
|
45
|
+
footerHeight = 30,
|
|
46
|
+
footerWidth = 148,
|
|
31
47
|
) {
|
|
32
48
|
const model = petModel(modelId);
|
|
33
49
|
const originX = viewport.left ?? 0,
|
|
@@ -41,14 +57,14 @@ export function petLayout(
|
|
|
41
57
|
Math.min(
|
|
42
58
|
size,
|
|
43
59
|
Math.max(1, (width - edge * 2) / model.widthScale),
|
|
44
|
-
Math.max(1, Math.floor((height - edge * 2 -
|
|
60
|
+
Math.max(1, Math.floor((height - edge * 2 - footerHeight) / model.heightScale)),
|
|
45
61
|
),
|
|
46
62
|
);
|
|
47
63
|
const petWidth = Math.min(
|
|
48
64
|
Math.max(1, width - edge * 2),
|
|
49
|
-
Math.max(
|
|
65
|
+
Math.max(footerWidth, artworkSize * model.widthScale),
|
|
50
66
|
);
|
|
51
|
-
const petHeight = Math.ceil(artworkSize * model.heightScale) +
|
|
67
|
+
const petHeight = Math.ceil(artworkSize * model.heightScale) + footerHeight;
|
|
52
68
|
const bound = (n, max) => Math.max(Math.min(edge, max), Math.min(n, max));
|
|
53
69
|
const x = bound(
|
|
54
70
|
Number.isFinite(position?.x) ? position.x - originX : width - petWidth - 32,
|
|
@@ -111,12 +127,34 @@ export function petArtwork(
|
|
|
111
127
|
reaction = '',
|
|
112
128
|
modelId = 'xiaoxie-original',
|
|
113
129
|
sprites = {},
|
|
130
|
+
interactive = false,
|
|
114
131
|
) {
|
|
115
132
|
const { pose, animate } = presentation;
|
|
116
133
|
const model = petModel(modelId);
|
|
117
|
-
if (model.renderer
|
|
134
|
+
if (['image', 'video'].includes(model.renderer) && sprites[model.id]) {
|
|
135
|
+
if (model.renderer === 'video' && interactive)
|
|
136
|
+
return h(
|
|
137
|
+
'span',
|
|
138
|
+
{
|
|
139
|
+
key: model.id,
|
|
140
|
+
className: 'owc-pet-art owc-pet-video-art',
|
|
141
|
+
'aria-hidden': true,
|
|
142
|
+
'data-interactive': true,
|
|
143
|
+
'data-pose': pose,
|
|
144
|
+
'data-animate': animate,
|
|
145
|
+
style: { width: size * model.widthScale, height: size * model.heightScale },
|
|
146
|
+
},
|
|
147
|
+
h('img', {
|
|
148
|
+
src: sprites[model.id],
|
|
149
|
+
alt: '',
|
|
150
|
+
draggable: false,
|
|
151
|
+
className: 'owc-pet-video-poster',
|
|
152
|
+
}),
|
|
153
|
+
h('span', { className: 'owc-pet-video-layer' }),
|
|
154
|
+
);
|
|
118
155
|
return h('img', {
|
|
119
|
-
key: model.id + ':' + pose + ':' + reaction,
|
|
156
|
+
key: interactive ? model.id : model.id + ':' + pose + ':' + reaction,
|
|
157
|
+
'data-interactive': interactive || undefined,
|
|
120
158
|
src: sprites[model.id],
|
|
121
159
|
alt: '',
|
|
122
160
|
'aria-hidden': true,
|
|
@@ -127,12 +165,14 @@ export function petArtwork(
|
|
|
127
165
|
'data-pose': pose,
|
|
128
166
|
'data-animate': animate,
|
|
129
167
|
});
|
|
168
|
+
}
|
|
130
169
|
const asleep = ['quiet', 'rest'].includes(pose);
|
|
131
170
|
const path = (d, props = {}) => h('path', { d, ...props });
|
|
132
171
|
return h(
|
|
133
172
|
'svg',
|
|
134
173
|
{
|
|
135
|
-
key: pose + ':' + reaction,
|
|
174
|
+
key: interactive ? model.id : pose + ':' + reaction,
|
|
175
|
+
'data-interactive': interactive || undefined,
|
|
136
176
|
viewBox: '0 0 160 176',
|
|
137
177
|
width: size,
|
|
138
178
|
height: size * 1.1,
|
|
@@ -214,8 +254,14 @@ export function petArtwork(
|
|
|
214
254
|
|
|
215
255
|
export const PET_MESSAGES = {
|
|
216
256
|
zh: {
|
|
217
|
-
|
|
257
|
+
petGreet: '与角色互动',
|
|
258
|
+
petNeedsSession: '等待会话',
|
|
259
|
+
petNeedsSetup: '请配置作息',
|
|
260
|
+
petLoadFailed: '读取失败',
|
|
261
|
+
petRetryHint: '右键菜单可重新读取设置',
|
|
218
262
|
petCharacter: '角色形象',
|
|
263
|
+
petWhaleActions: '鲸蓝少女 · 长裙动态',
|
|
264
|
+
petWhaleOrnateActions: '鲸蓝少女 · 短裙动态',
|
|
219
265
|
petWhaleDress: '鲸蓝少女 · 长裙',
|
|
220
266
|
petWhaleOrnate: '鲸蓝少女 · 礼裙',
|
|
221
267
|
petDroplet: '小歇 · 水滴',
|
|
@@ -223,13 +269,35 @@ export const PET_MESSAGES = {
|
|
|
223
269
|
petQuick: '日常陪伴',
|
|
224
270
|
petDetails: '下班与收入',
|
|
225
271
|
petSize: '宠物大小',
|
|
226
|
-
petHint: '
|
|
272
|
+
petHint: '左键互动 · 右键菜单 · 拖动移动',
|
|
227
273
|
petMode: '宠物模式',
|
|
228
274
|
petClock: '下班倒计时',
|
|
275
|
+
petPreviewActions: '动作试播',
|
|
276
|
+
petPreviewIdle: '待机',
|
|
277
|
+
petPreviewGreet: '挥手',
|
|
278
|
+
petPreviewDrink: '喝茶',
|
|
279
|
+
petPreviewStop: '停止',
|
|
280
|
+
petTryIdle: '试播待机',
|
|
281
|
+
petTryGreet: '试播挥手',
|
|
282
|
+
petTryDrink: '试播喝茶',
|
|
283
|
+
petStopPreview: '停止试播',
|
|
284
|
+
petPreviewHint: '仅预览,不记喝水次数,也不更换页面角色。',
|
|
285
|
+
petPreviewLoading: '正在准备动作…',
|
|
286
|
+
petPreviewPlaying: '正在试播',
|
|
287
|
+
petPreviewUnsupported: '当前浏览器不支持透明动作视频,已显示静态预览。',
|
|
288
|
+
petPreviewUnavailable: '动作暂时无法播放,已恢复静态预览。可以重试。',
|
|
289
|
+
petPreviewEnable: '勾选“角色动作反馈”后可试播;应用后才保存。',
|
|
290
|
+
petPreviewReduced: '系统已开启减少动态效果,当前显示静态预览。',
|
|
229
291
|
},
|
|
230
292
|
en: {
|
|
231
|
-
|
|
293
|
+
petGreet: 'Interact with character',
|
|
294
|
+
petNeedsSession: 'Open a session',
|
|
295
|
+
petNeedsSetup: 'Set your schedule',
|
|
296
|
+
petLoadFailed: 'Settings unavailable',
|
|
297
|
+
petRetryHint: 'Retry loading settings from the context menu',
|
|
232
298
|
petCharacter: 'Character',
|
|
299
|
+
petWhaleActions: 'Blue whale · Animated long dress',
|
|
300
|
+
petWhaleOrnateActions: 'Blue whale · Animated short dress',
|
|
233
301
|
petWhaleDress: 'Blue whale · Long dress',
|
|
234
302
|
petWhaleOrnate: 'Blue whale · Ornate dress',
|
|
235
303
|
petDroplet: 'Xiaoxie · Droplet',
|
|
@@ -237,9 +305,26 @@ export const PET_MESSAGES = {
|
|
|
237
305
|
petQuick: 'Daily companion',
|
|
238
306
|
petDetails: 'Clock and earnings',
|
|
239
307
|
petSize: 'Pet size',
|
|
240
|
-
petHint: 'Click
|
|
308
|
+
petHint: 'Click to interact · right-click for menu · drag to move',
|
|
241
309
|
petMode: 'Pet mode',
|
|
242
310
|
petClock: 'Shift countdown',
|
|
311
|
+
petPreviewActions: 'Preview actions',
|
|
312
|
+
petPreviewIdle: 'Idle',
|
|
313
|
+
petPreviewGreet: 'Wave',
|
|
314
|
+
petPreviewDrink: 'Drink',
|
|
315
|
+
petPreviewStop: 'Stop',
|
|
316
|
+
petTryIdle: 'Preview idle',
|
|
317
|
+
petTryGreet: 'Preview wave',
|
|
318
|
+
petTryDrink: 'Preview drinking',
|
|
319
|
+
petStopPreview: 'Stop preview',
|
|
320
|
+
petPreviewHint: 'Preview only: no drinking record or change to the page character.',
|
|
321
|
+
petPreviewLoading: 'Preparing action…',
|
|
322
|
+
petPreviewPlaying: 'Previewing',
|
|
323
|
+
petPreviewUnsupported:
|
|
324
|
+
'This browser cannot show transparent action videos. Showing a static preview.',
|
|
325
|
+
petPreviewUnavailable: 'Action unavailable. Static preview restored; you can retry.',
|
|
326
|
+
petPreviewEnable: 'Enable character reactions to preview. Apply to save the choice.',
|
|
327
|
+
petPreviewReduced: 'Reduced motion is enabled. Showing a static preview.',
|
|
243
328
|
},
|
|
244
329
|
};
|
|
245
330
|
|
|
@@ -249,15 +334,46 @@ export const PET_STYLE = `
|
|
|
249
334
|
.owc-v2 button.owc-pet-handle{padding:0;background:none!important;border-radius:50%;min-height:0;display:block;cursor:grab;touch-action:none;line-height:0}
|
|
250
335
|
.owc-v2 button.owc-pet-handle:active{cursor:grabbing}
|
|
251
336
|
.owc-pet-art{overflow:visible;pointer-events:none;filter:drop-shadow(0 3px 5px #32557418)}
|
|
337
|
+
.owc-pet-video-art{display:block;position:relative;isolation:isolate}
|
|
338
|
+
.owc-pet-video-poster,.owc-pet-video-layer,.owc-pet-video-layer video{position:absolute;inset:0;width:100%;height:100%;object-fit:contain;pointer-events:none}
|
|
339
|
+
.owc-pet-video-poster,.owc-pet-video-layer video{mix-blend-mode:plus-lighter}
|
|
340
|
+
.owc-pet-video-poster{opacity:1;transition:opacity .12s linear}
|
|
341
|
+
.owc-pet-video-layer video{opacity:0;transition:opacity .12s linear}
|
|
342
|
+
.owc-pet-video-art[data-video-playing=true] .owc-pet-video-poster{opacity:0}
|
|
343
|
+
.owc-pet-video-art[data-video-playing=true] .owc-pet-video-layer video{opacity:1}
|
|
252
344
|
.owc-pet-portrait{display:block;object-fit:contain;transform-origin:50% 90%}
|
|
253
|
-
.owc-pet-portrait[data-animate=true]{animation:owc-portrait-thanks .65s ease-in-out 2}
|
|
345
|
+
.owc-pet-portrait[data-animate=true]:not([data-interactive=true]){animation:owc-portrait-thanks .65s ease-in-out 2}
|
|
346
|
+
.owc-pet-art[data-interactive=true]{transform-origin:50% 90%}
|
|
347
|
+
.owc-pet-art[data-interactive=true][data-motion=idle]:not([data-action]){animation:owc-pet-breathe 4.5s ease-in-out infinite}
|
|
348
|
+
@keyframes owc-pet-breathe{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-2px) scale(1.006)}}
|
|
254
349
|
@keyframes owc-portrait-thanks{0%,100%{transform:translateY(0)}45%{transform:translateY(-3px) rotate(-1deg)}75%{transform:rotate(1deg)}}
|
|
255
350
|
.owc-pet-art[data-pose=offline]{opacity:.65}
|
|
256
|
-
.owc-pet-art[data-animate=true] .owc-pet-body{transform-origin:80px 150px;animation:owc-pet-thanks .65s ease-in-out 2}
|
|
351
|
+
.owc-pet-art[data-animate=true]:not([data-interactive=true]) .owc-pet-body{transform-origin:80px 150px;animation:owc-pet-thanks .65s ease-in-out 2}
|
|
257
352
|
@keyframes owc-pet-thanks{0%,100%{transform:translateY(0)}45%{transform:translateY(-4px) rotate(-3deg)}75%{transform:rotate(2deg)}}
|
|
258
|
-
.owc-v2
|
|
353
|
+
.owc-v2 .owc-pet-clock{display:flex;align-items:center;color:var(--owc-text);line-height:1.5;cursor:default;padding:4px 10px;min-height:26px;gap:5px;white-space:nowrap;border-radius:999px;font-size:11px;background:var(--dsw-alias-bg-overlay);border:1px solid color-mix(in srgb,var(--owc-accent) 25%,transparent);box-shadow:0 3px 12px #142e4812;font-variant-numeric:tabular-nums;max-width:100%;box-sizing:border-box;overflow:hidden}
|
|
259
354
|
.owc-pet-clock-label{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
260
355
|
.owc-pet-clock strong{flex:none}
|
|
356
|
+
.owc-v2 .owc-pet-timers{width:196px;max-width:100%;margin-top:4px;box-sizing:border-box;border:1px solid var(--owc-border);border-radius:13px;background:var(--owc-surface);box-shadow:var(--owc-shadow);overflow:hidden;font-variant-numeric:tabular-nums}
|
|
357
|
+
.owc-v2 .owc-pet-timers .owc-pet-clock{width:100%;height:34px;min-height:0;padding:5px 10px;gap:6px;border:0;border-radius:0;background:transparent;box-shadow:none;font-size:10px;color:var(--owc-muted)}
|
|
358
|
+
.owc-pet-timers .owc-pet-clock svg{flex-shrink:0}
|
|
359
|
+
.owc-pet-timers .owc-pet-clock strong{margin-left:auto;font-size:16px;line-height:1;color:var(--owc-text);letter-spacing:.2px}
|
|
360
|
+
.owc-pet-timer-pair{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);border-top:1px solid var(--owc-border)}
|
|
361
|
+
.owc-v2 .owc-pet-timer-row{display:flex;align-items:center;justify-content:center;gap:5px;box-sizing:border-box;min-width:0;height:28px;padding:3px 6px;font-size:11px;white-space:nowrap;color:var(--owc-muted)}
|
|
362
|
+
.owc-pet-timer-row+.owc-pet-timer-row{border-left:1px solid var(--owc-border)}
|
|
363
|
+
.owc-pet-timer-label{display:flex;align-items:center;flex-shrink:0}
|
|
364
|
+
.owc-pet-timer-label svg{flex-shrink:0}
|
|
365
|
+
.owc-pet-timer-row strong{min-width:0;overflow:hidden;text-overflow:ellipsis;font-weight:550;color:var(--owc-text)}
|
|
366
|
+
.owc-timer-pause{font-size:9px;margin-right:3px;color:var(--owc-muted)}
|
|
367
|
+
.owc-v2 .owc-pet-timer-row[data-countdown-due=true]{background:color-mix(in srgb,var(--owc-accent) 8%,transparent);color:color-mix(in srgb,var(--owc-accent) 55%,var(--owc-text))}
|
|
368
|
+
.owc-pet-timer-row[data-countdown-due=true] strong{color:inherit}
|
|
369
|
+
.owc-v2 [data-countdown-due=true]{color:var(--owc-accent);border-color:var(--owc-accent)}
|
|
370
|
+
.owc-countdown-card{padding:16px;display:flex;flex-direction:column;gap:14px}
|
|
371
|
+
.owc-countdown-value{font-size:32px;line-height:1.3;font-weight:650;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
|
|
372
|
+
.owc-countdown-actions{display:flex;flex-wrap:wrap;gap:8px}
|
|
373
|
+
.owc-countdown-actions button{flex:1;min-width:110px;min-height:40px}
|
|
374
|
+
.owc-countdown-card label{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px}
|
|
375
|
+
.owc-v2 .owc-countdown-card select{flex:0 0 120px;width:120px;max-width:120px}
|
|
376
|
+
.owc-countdown-card p{margin:0;font-size:12px;line-height:1.6;overflow-wrap:anywhere}
|
|
261
377
|
.owc-pet-panel{position:fixed;border-radius:20px;cursor:default;touch-action:manipulation;max-width:calc(100vw - 24px)}
|
|
262
378
|
.owc-pet-panel>.owc-content{max-height:inherit}
|
|
263
379
|
.owc-pet-panel .owc-glass{opacity:max(var(--owc-alpha),.94)}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { classicClockPresentation } from '../ui-utils.js';
|
|
4
|
+
import { calculate } from '../clock.js';
|
|
5
|
+
const settings = {
|
|
6
|
+
payMode: 'monthly',
|
|
7
|
+
monthlySalary: 10000,
|
|
8
|
+
workdaysPerMonth: 21.75,
|
|
9
|
+
dailySalary: 0,
|
|
10
|
+
start: '09:00',
|
|
11
|
+
end: '18:00',
|
|
12
|
+
breakStart: '12:00',
|
|
13
|
+
breakEnd: '13:00',
|
|
14
|
+
weekdays: [1, 2, 3, 4, 5],
|
|
15
|
+
timezone: 'Asia/Shanghai',
|
|
16
|
+
};
|
|
17
|
+
const present = (time, config = settings) => {
|
|
18
|
+
const now = Date.parse(time);
|
|
19
|
+
return classicClockPresentation({
|
|
20
|
+
hasSession: true,
|
|
21
|
+
loaded: true,
|
|
22
|
+
configured: true,
|
|
23
|
+
failed: false,
|
|
24
|
+
state: calculate(config, now),
|
|
25
|
+
now,
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
test('classic overview distinguishes startup, missing configuration and failed reads without a zero countdown', () => {
|
|
29
|
+
const base = {
|
|
30
|
+
hasSession: true,
|
|
31
|
+
loaded: true,
|
|
32
|
+
configured: true,
|
|
33
|
+
failed: false,
|
|
34
|
+
state: null,
|
|
35
|
+
now: 0,
|
|
36
|
+
};
|
|
37
|
+
for (const [patch, phase] of [
|
|
38
|
+
[{ hasSession: false }, 'waiting'],
|
|
39
|
+
[{ loaded: false }, 'loading'],
|
|
40
|
+
[{ configured: false }, 'setup'],
|
|
41
|
+
[{ failed: true }, 'error'],
|
|
42
|
+
]) {
|
|
43
|
+
const view = classicClockPresentation({ ...base, ...patch });
|
|
44
|
+
assert.equal(view.phase, phase);
|
|
45
|
+
assert.equal(view.seconds, null);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
test('classic overview shows the correct countdown target for before, working and lunch', () => {
|
|
49
|
+
assert.equal(present('2026-11-05T00:00:00Z').seconds, 3600);
|
|
50
|
+
const working = present('2026-11-05T02:00:00Z');
|
|
51
|
+
assert.equal(working.phase, 'working');
|
|
52
|
+
assert.equal(working.seconds, 8 * 3600);
|
|
53
|
+
const lunch = present('2026-11-05T04:30:00Z');
|
|
54
|
+
assert.equal(lunch.phase, 'lunch');
|
|
55
|
+
assert.equal(lunch.seconds, 1800);
|
|
56
|
+
});
|
|
57
|
+
test('completed shifts and rest days show a state instead of a meaningless zero', () => {
|
|
58
|
+
for (const [date, phase] of [
|
|
59
|
+
['2026-11-05T11:00:00Z', 'done'],
|
|
60
|
+
['2026-11-07T02:00:00Z', 'rest'],
|
|
61
|
+
]) {
|
|
62
|
+
const result = present(date);
|
|
63
|
+
assert.equal(result.phase, phase);
|
|
64
|
+
assert.equal(result.seconds, null);
|
|
65
|
+
}
|
|
66
|
+
const night = {
|
|
67
|
+
...settings,
|
|
68
|
+
start: '22:00',
|
|
69
|
+
end: '06:00',
|
|
70
|
+
breakStart: '02:00',
|
|
71
|
+
breakEnd: '02:00',
|
|
72
|
+
};
|
|
73
|
+
assert.equal(present('2026-11-05T15:00:00Z', night).seconds, 7 * 3600);
|
|
74
|
+
const completed = present('2026-11-05T23:00:00Z', night);
|
|
75
|
+
assert.equal(completed.phase, 'done');
|
|
76
|
+
assert.equal(completed.seconds, null);
|
|
77
|
+
});
|