dsh-offwork 1.1.0 → 1.1.1
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 +9 -0
- package/README.md +19 -16
- 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 +502 -38
- package/client.source.js +85 -21
- package/package.json +4 -1
- package/pet-interactions.js +3 -3
- package/pet-motion.js +110 -0
- package/pet-surfaces.js +2 -2
- package/pet-video.js +231 -0
- package/pet-view.js +61 -10
- package/tests/pet-character.test.mjs +8 -2
- package/tests/pet-motion.test.mjs +139 -0
- package/tests/pet-video.test.mjs +337 -0
- package/tools/build.mjs +28 -2
- package/tools/check-package.mjs +11 -0
- package/ui-utils.js +7 -1
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',
|
|
@@ -111,12 +125,34 @@ export function petArtwork(
|
|
|
111
125
|
reaction = '',
|
|
112
126
|
modelId = 'xiaoxie-original',
|
|
113
127
|
sprites = {},
|
|
128
|
+
interactive = false,
|
|
114
129
|
) {
|
|
115
130
|
const { pose, animate } = presentation;
|
|
116
131
|
const model = petModel(modelId);
|
|
117
|
-
if (model.renderer
|
|
132
|
+
if (['image', 'video'].includes(model.renderer) && sprites[model.id]) {
|
|
133
|
+
if (model.renderer === 'video' && interactive)
|
|
134
|
+
return h(
|
|
135
|
+
'span',
|
|
136
|
+
{
|
|
137
|
+
key: model.id,
|
|
138
|
+
className: 'owc-pet-art owc-pet-video-art',
|
|
139
|
+
'aria-hidden': true,
|
|
140
|
+
'data-interactive': true,
|
|
141
|
+
'data-pose': pose,
|
|
142
|
+
'data-animate': animate,
|
|
143
|
+
style: { width: size * model.widthScale, height: size * model.heightScale },
|
|
144
|
+
},
|
|
145
|
+
h('img', {
|
|
146
|
+
src: sprites[model.id],
|
|
147
|
+
alt: '',
|
|
148
|
+
draggable: false,
|
|
149
|
+
className: 'owc-pet-video-poster',
|
|
150
|
+
}),
|
|
151
|
+
h('span', { className: 'owc-pet-video-layer' }),
|
|
152
|
+
);
|
|
118
153
|
return h('img', {
|
|
119
|
-
key: model.id + ':' + pose + ':' + reaction,
|
|
154
|
+
key: interactive ? model.id : model.id + ':' + pose + ':' + reaction,
|
|
155
|
+
'data-interactive': interactive || undefined,
|
|
120
156
|
src: sprites[model.id],
|
|
121
157
|
alt: '',
|
|
122
158
|
'aria-hidden': true,
|
|
@@ -127,12 +163,14 @@ export function petArtwork(
|
|
|
127
163
|
'data-pose': pose,
|
|
128
164
|
'data-animate': animate,
|
|
129
165
|
});
|
|
166
|
+
}
|
|
130
167
|
const asleep = ['quiet', 'rest'].includes(pose);
|
|
131
168
|
const path = (d, props = {}) => h('path', { d, ...props });
|
|
132
169
|
return h(
|
|
133
170
|
'svg',
|
|
134
171
|
{
|
|
135
|
-
key: pose + ':' + reaction,
|
|
172
|
+
key: interactive ? model.id : pose + ':' + reaction,
|
|
173
|
+
'data-interactive': interactive || undefined,
|
|
136
174
|
viewBox: '0 0 160 176',
|
|
137
175
|
width: size,
|
|
138
176
|
height: size * 1.1,
|
|
@@ -214,8 +252,10 @@ export function petArtwork(
|
|
|
214
252
|
|
|
215
253
|
export const PET_MESSAGES = {
|
|
216
254
|
zh: {
|
|
217
|
-
|
|
255
|
+
petGreet: '与角色互动',
|
|
218
256
|
petCharacter: '角色形象',
|
|
257
|
+
petWhaleActions: '鲸蓝少女 · 长裙动态',
|
|
258
|
+
petWhaleOrnateActions: '鲸蓝少女 · 短裙动态',
|
|
219
259
|
petWhaleDress: '鲸蓝少女 · 长裙',
|
|
220
260
|
petWhaleOrnate: '鲸蓝少女 · 礼裙',
|
|
221
261
|
petDroplet: '小歇 · 水滴',
|
|
@@ -223,13 +263,15 @@ export const PET_MESSAGES = {
|
|
|
223
263
|
petQuick: '日常陪伴',
|
|
224
264
|
petDetails: '下班与收入',
|
|
225
265
|
petSize: '宠物大小',
|
|
226
|
-
petHint: '
|
|
266
|
+
petHint: '左键互动 · 右键菜单 · 拖动移动',
|
|
227
267
|
petMode: '宠物模式',
|
|
228
268
|
petClock: '下班倒计时',
|
|
229
269
|
},
|
|
230
270
|
en: {
|
|
231
|
-
|
|
271
|
+
petGreet: 'Interact with character',
|
|
232
272
|
petCharacter: 'Character',
|
|
273
|
+
petWhaleActions: 'Blue whale · Animated long dress',
|
|
274
|
+
petWhaleOrnateActions: 'Blue whale · Animated short dress',
|
|
233
275
|
petWhaleDress: 'Blue whale · Long dress',
|
|
234
276
|
petWhaleOrnate: 'Blue whale · Ornate dress',
|
|
235
277
|
petDroplet: 'Xiaoxie · Droplet',
|
|
@@ -237,7 +279,7 @@ export const PET_MESSAGES = {
|
|
|
237
279
|
petQuick: 'Daily companion',
|
|
238
280
|
petDetails: 'Clock and earnings',
|
|
239
281
|
petSize: 'Pet size',
|
|
240
|
-
petHint: 'Click
|
|
282
|
+
petHint: 'Click to interact · right-click for menu · drag to move',
|
|
241
283
|
petMode: 'Pet mode',
|
|
242
284
|
petClock: 'Shift countdown',
|
|
243
285
|
},
|
|
@@ -249,13 +291,22 @@ export const PET_STYLE = `
|
|
|
249
291
|
.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
292
|
.owc-v2 button.owc-pet-handle:active{cursor:grabbing}
|
|
251
293
|
.owc-pet-art{overflow:visible;pointer-events:none;filter:drop-shadow(0 3px 5px #32557418)}
|
|
294
|
+
.owc-pet-video-art{display:block;position:relative}
|
|
295
|
+
.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}
|
|
296
|
+
.owc-pet-video-poster{opacity:1;transition:opacity .12s linear}
|
|
297
|
+
.owc-pet-video-layer video{opacity:0;transition:opacity .12s linear}
|
|
298
|
+
.owc-pet-video-art[data-video-playing=true] .owc-pet-video-poster{opacity:0}
|
|
299
|
+
.owc-pet-video-art[data-video-playing=true] .owc-pet-video-layer video{opacity:1}
|
|
252
300
|
.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}
|
|
301
|
+
.owc-pet-portrait[data-animate=true]:not([data-interactive=true]){animation:owc-portrait-thanks .65s ease-in-out 2}
|
|
302
|
+
.owc-pet-art[data-interactive=true]{transform-origin:50% 90%}
|
|
303
|
+
.owc-pet-art[data-interactive=true][data-motion=idle]:not([data-action]){animation:owc-pet-breathe 4.5s ease-in-out infinite}
|
|
304
|
+
@keyframes owc-pet-breathe{0%,100%{transform:translateY(0) scale(1)}50%{transform:translateY(-2px) scale(1.006)}}
|
|
254
305
|
@keyframes owc-portrait-thanks{0%,100%{transform:translateY(0)}45%{transform:translateY(-3px) rotate(-1deg)}75%{transform:rotate(1deg)}}
|
|
255
306
|
.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}
|
|
307
|
+
.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
308
|
@keyframes owc-pet-thanks{0%,100%{transform:translateY(0)}45%{transform:translateY(-4px) rotate(-3deg)}75%{transform:rotate(2deg)}}
|
|
258
|
-
.owc-v2
|
|
309
|
+
.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
310
|
.owc-pet-clock-label{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
260
311
|
.owc-pet-clock strong{flex:none}
|
|
261
312
|
.owc-pet-panel{position:fixed;border-radius:20px;cursor:default;touch-action:manipulation;max-width:calc(100vw - 24px)}
|
|
@@ -15,7 +15,13 @@ test('existing preferences gain the long dress without losing privacy, position
|
|
|
15
15
|
const next = normalizeView(old);
|
|
16
16
|
assert.equal(next.petModel, 'whale-dress');
|
|
17
17
|
for (const k of Object.keys(old)) assert.deepEqual(next[k], old[k]);
|
|
18
|
-
for (const id of [
|
|
18
|
+
for (const id of [
|
|
19
|
+
'whale-dress',
|
|
20
|
+
'whale-ornate',
|
|
21
|
+
'whale-actions',
|
|
22
|
+
'whale-ornate-actions',
|
|
23
|
+
'xiaoxie-original',
|
|
24
|
+
])
|
|
19
25
|
assert.equal(normalizeView({ ...next, petModel: id }).petModel, id);
|
|
20
26
|
for (const id of ['unknown', '__proto__', 'toString']) {
|
|
21
27
|
assert.equal(normalizeView({ petModel: id }).petModel, 'whale-dress');
|
|
@@ -23,7 +29,7 @@ test('existing preferences gain the long dress without losing privacy, position
|
|
|
23
29
|
}
|
|
24
30
|
});
|
|
25
31
|
test('portrait layout fits the complete figure and badge in narrow, short and offset viewports', () => {
|
|
26
|
-
for (const id of ['whale-dress', 'whale-ornate'])
|
|
32
|
+
for (const id of ['whale-dress', 'whale-ornate', 'whale-actions', 'whale-ornate-actions'])
|
|
27
33
|
for (const size of [80, 132, 180])
|
|
28
34
|
for (const viewport of [
|
|
29
35
|
{ width: 1440, height: 1000 },
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createPetMotion } from '../pet-motion.js';
|
|
4
|
+
import { petArtwork } from '../pet-view.js';
|
|
5
|
+
|
|
6
|
+
function fixture(options) {
|
|
7
|
+
const document = Object.assign(new EventTarget(), { hidden: false });
|
|
8
|
+
const media = Object.assign(new EventTarget(), { matches: false });
|
|
9
|
+
const runs = [];
|
|
10
|
+
const element = {
|
|
11
|
+
dataset: {},
|
|
12
|
+
style: {},
|
|
13
|
+
animate(frames, timing) {
|
|
14
|
+
let finish, reject;
|
|
15
|
+
const finished = new Promise((resolve, fail) => {
|
|
16
|
+
finish = resolve;
|
|
17
|
+
reject = fail;
|
|
18
|
+
});
|
|
19
|
+
const run = {
|
|
20
|
+
frames,
|
|
21
|
+
timing,
|
|
22
|
+
finished,
|
|
23
|
+
finish,
|
|
24
|
+
cancelled: false,
|
|
25
|
+
cancel() {
|
|
26
|
+
this.cancelled = true;
|
|
27
|
+
reject(new Error('cancelled'));
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
runs.push(run);
|
|
31
|
+
return run;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const controller = createPetMotion(element, { document, matchMedia: () => media }, options);
|
|
35
|
+
return { document, media, runs, element, controller };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('explicit reactions interrupt safely and old completion cannot clear the next action', async () => {
|
|
39
|
+
const { controller: c, element: e, runs } = fixture();
|
|
40
|
+
assert.equal(e.dataset.motion, 'idle');
|
|
41
|
+
assert.equal(c.play('look'), true);
|
|
42
|
+
assert.equal(c.play('greet'), true);
|
|
43
|
+
await Promise.resolve();
|
|
44
|
+
assert.equal(runs[0].cancelled, true);
|
|
45
|
+
assert.equal(e.dataset.action, 'greet');
|
|
46
|
+
c.play('celebrate');
|
|
47
|
+
assert.equal(c.play('look'), false);
|
|
48
|
+
assert.equal(c.play('greet'), false);
|
|
49
|
+
await Promise.resolve();
|
|
50
|
+
assert.equal(e.dataset.action, 'celebrate');
|
|
51
|
+
runs.at(-1).finish();
|
|
52
|
+
await Promise.resolve();
|
|
53
|
+
assert.equal(e.dataset.action, undefined);
|
|
54
|
+
assert.equal(e.dataset.motion, 'idle');
|
|
55
|
+
c.destroy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('motion preference, visibility and live reduced-motion changes stop actions without replay', async () => {
|
|
59
|
+
const { controller: c, element: e, document: d, media, runs } = fixture();
|
|
60
|
+
c.play('celebrate');
|
|
61
|
+
d.hidden = true;
|
|
62
|
+
d.dispatchEvent(new Event('visibilitychange'));
|
|
63
|
+
assert.equal(e.dataset.motion, 'off');
|
|
64
|
+
assert.equal(e.dataset.action, undefined);
|
|
65
|
+
assert.equal(runs[0].cancelled, true);
|
|
66
|
+
d.hidden = false;
|
|
67
|
+
d.dispatchEvent(new Event('visibilitychange'));
|
|
68
|
+
assert.equal(e.dataset.motion, 'idle');
|
|
69
|
+
assert.equal(runs.length, 1);
|
|
70
|
+
media.matches = true;
|
|
71
|
+
media.dispatchEvent(new Event('change'));
|
|
72
|
+
assert.equal(c.play('greet'), false);
|
|
73
|
+
assert.equal(e.dataset.motion, 'off');
|
|
74
|
+
media.matches = false;
|
|
75
|
+
media.dispatchEvent(new Event('change'));
|
|
76
|
+
c.configure({ enabled: false, resting: false });
|
|
77
|
+
assert.equal(c.play('celebrate'), false);
|
|
78
|
+
c.configure({ enabled: true, resting: false });
|
|
79
|
+
c.play('celebrate');
|
|
80
|
+
c.configure({ enabled: true, resting: true });
|
|
81
|
+
assert.equal(e.dataset.action, undefined);
|
|
82
|
+
assert.equal(e.dataset.motion, 'off');
|
|
83
|
+
assert.equal(c.play('greet'), true);
|
|
84
|
+
c.destroy();
|
|
85
|
+
d.dispatchEvent(new Event('visibilitychange'));
|
|
86
|
+
assert.equal(e.dataset.motion, 'off');
|
|
87
|
+
assert.equal(c.play('greet'), false);
|
|
88
|
+
await Promise.resolve();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('drag cancels reactions, release settles once, cancellation stays still', async () => {
|
|
92
|
+
const { controller: c, element: e, runs } = fixture();
|
|
93
|
+
c.play('greet');
|
|
94
|
+
c.drag(-20);
|
|
95
|
+
assert.equal(runs[0].cancelled, true);
|
|
96
|
+
assert.equal(e.dataset.action, 'drag');
|
|
97
|
+
assert.match(e.style.transform, /rotate\(-2deg\)/);
|
|
98
|
+
assert.equal(c.play('look'), false);
|
|
99
|
+
c.release();
|
|
100
|
+
assert.equal(e.style.transform, '');
|
|
101
|
+
assert.equal(e.dataset.action, 'settle');
|
|
102
|
+
c.drag(20);
|
|
103
|
+
c.release(true);
|
|
104
|
+
assert.equal(e.dataset.action, undefined);
|
|
105
|
+
assert.equal(runs.length, 2);
|
|
106
|
+
c.destroy();
|
|
107
|
+
await Promise.resolve();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('missing or failed animation API retains usable static artwork', () => {
|
|
111
|
+
const { controller: c, element: e } = fixture();
|
|
112
|
+
e.animate = undefined;
|
|
113
|
+
assert.equal(c.play('greet'), false);
|
|
114
|
+
e.animate = () => {
|
|
115
|
+
throw new Error('unavailable');
|
|
116
|
+
};
|
|
117
|
+
assert.equal(c.play('greet'), false);
|
|
118
|
+
assert.equal(e.dataset.action, undefined);
|
|
119
|
+
assert.equal(c.play('__proto__'), false);
|
|
120
|
+
c.destroy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('interactive artwork keeps its DOM identity across clock poses and drinking feedback', () => {
|
|
124
|
+
const h = (type, props) => ({ type, props });
|
|
125
|
+
for (const model of ['whale-dress', 'xiaoxie-original']) {
|
|
126
|
+
const render = (pose, token) =>
|
|
127
|
+
petArtwork(
|
|
128
|
+
h,
|
|
129
|
+
{ pose, animate: true },
|
|
130
|
+
132,
|
|
131
|
+
token,
|
|
132
|
+
model,
|
|
133
|
+
{ 'whale-dress': 'data:image/webp;base64,test' },
|
|
134
|
+
true,
|
|
135
|
+
);
|
|
136
|
+
assert.equal(render('calm', '').props.key, render('happy', 'drink-1').props.key);
|
|
137
|
+
assert.equal(render('calm', '').props['data-interactive'], true);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createPetVideoMotion, petVideoAlphaSupported } from '../pet-video.js';
|
|
4
|
+
|
|
5
|
+
const flush = async () => {
|
|
6
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
7
|
+
};
|
|
8
|
+
function fixture({ alpha = true, rejectPlay = false, idle = false } = {}) {
|
|
9
|
+
const media = Object.assign(new EventTarget(), { matches: false });
|
|
10
|
+
const document = Object.assign(new EventTarget(), { hidden: false });
|
|
11
|
+
const videos = [],
|
|
12
|
+
timers = new Map(),
|
|
13
|
+
calls = [];
|
|
14
|
+
let nextTimer = 0;
|
|
15
|
+
document.createElement = (tag) => {
|
|
16
|
+
if (tag === 'canvas')
|
|
17
|
+
return {
|
|
18
|
+
getContext: () => ({
|
|
19
|
+
drawImage() {},
|
|
20
|
+
getImageData: (x) => ({ data: [0, 0, 0, x === 1 || !alpha ? 255 : 0] }),
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
const video = {
|
|
24
|
+
style: {},
|
|
25
|
+
paused: false,
|
|
26
|
+
duration: 5,
|
|
27
|
+
removed: false,
|
|
28
|
+
canPlayType: () => 'probably',
|
|
29
|
+
setAttribute() {},
|
|
30
|
+
removeAttribute(name) {
|
|
31
|
+
delete this[name];
|
|
32
|
+
},
|
|
33
|
+
pause() {
|
|
34
|
+
this.paused = true;
|
|
35
|
+
},
|
|
36
|
+
load() {
|
|
37
|
+
if (this.src === 'probe') queueMicrotask(() => this.onloadeddata?.());
|
|
38
|
+
},
|
|
39
|
+
play() {
|
|
40
|
+
return rejectPlay ? Promise.reject(new Error('blocked')) : Promise.resolve();
|
|
41
|
+
},
|
|
42
|
+
remove() {
|
|
43
|
+
this.removed = true;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
videos.push(video);
|
|
47
|
+
return video;
|
|
48
|
+
};
|
|
49
|
+
const host = {
|
|
50
|
+
document,
|
|
51
|
+
matchMedia: () => media,
|
|
52
|
+
setTimeout(fn, ms) {
|
|
53
|
+
const id = ++nextTimer;
|
|
54
|
+
timers.set(id, { fn, ms });
|
|
55
|
+
return id;
|
|
56
|
+
},
|
|
57
|
+
clearTimeout(id) {
|
|
58
|
+
timers.delete(id);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const element = { dataset: {}, querySelector: () => ({ appendChild() {} }) };
|
|
62
|
+
const base = {
|
|
63
|
+
play(action) {
|
|
64
|
+
calls.push(action);
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
configure() {},
|
|
68
|
+
release() {
|
|
69
|
+
delete element.dataset.action;
|
|
70
|
+
},
|
|
71
|
+
drag() {
|
|
72
|
+
element.dataset.action = 'drag';
|
|
73
|
+
},
|
|
74
|
+
destroy() {
|
|
75
|
+
calls.push('destroy');
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const motion = createPetVideoMotion(
|
|
79
|
+
element,
|
|
80
|
+
host,
|
|
81
|
+
base,
|
|
82
|
+
{ greet: 'greet-data', celebrate: 'drink-data', ...(idle ? { idle: 'idle-data' } : {}) },
|
|
83
|
+
'probe',
|
|
84
|
+
);
|
|
85
|
+
motion.configure({ enabled: true, resting: false });
|
|
86
|
+
return { host, document, media, element, base, motion, videos, timers, calls };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
test('alpha support checks decoded pixels rather than trusting WebM codec support', async () => {
|
|
90
|
+
for (const alpha of [true, false]) {
|
|
91
|
+
const f = fixture({ alpha });
|
|
92
|
+
assert.equal(await petVideoAlphaSupported(f.host, 'probe'), alpha);
|
|
93
|
+
assert.equal(f.videos.length, 1);
|
|
94
|
+
assert.equal(f.videos[0].src, undefined);
|
|
95
|
+
assert.equal(f.timers.size, 0);
|
|
96
|
+
f.motion.destroy();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('clips reveal only after playing; accepted drinking interrupts greeting and stale events cannot stop it', async () => {
|
|
101
|
+
const f = fixture();
|
|
102
|
+
f.motion.play('greet');
|
|
103
|
+
await flush();
|
|
104
|
+
const greet = f.videos.at(-1),
|
|
105
|
+
staleEnd = greet.onended;
|
|
106
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
107
|
+
greet.onplaying();
|
|
108
|
+
assert.equal(f.element.dataset.videoPlaying, 'true');
|
|
109
|
+
assert.equal(greet.muted, true);
|
|
110
|
+
assert.equal(greet.playsInline, true);
|
|
111
|
+
assert.equal(f.motion.play('greet'), false);
|
|
112
|
+
f.motion.play('celebrate');
|
|
113
|
+
await flush();
|
|
114
|
+
const drink = f.videos.at(-1);
|
|
115
|
+
drink.onplaying();
|
|
116
|
+
assert.equal(greet.removed, true);
|
|
117
|
+
staleEnd();
|
|
118
|
+
assert.equal(f.element.dataset.action, 'celebrate');
|
|
119
|
+
assert.equal(f.motion.play('greet'), false);
|
|
120
|
+
assert.equal(f.motion.play('look'), false);
|
|
121
|
+
drink.onended();
|
|
122
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
123
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
124
|
+
for (const timer of [...f.timers.values()]) if (timer.ms === 150) timer.fn();
|
|
125
|
+
assert.equal(drink.removed, true);
|
|
126
|
+
f.motion.destroy();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('motion changes and disposal cancel pending probes without late playback', async () => {
|
|
130
|
+
const f = fixture();
|
|
131
|
+
f.motion.play('greet');
|
|
132
|
+
f.motion.configure({ enabled: false, resting: false });
|
|
133
|
+
await flush();
|
|
134
|
+
assert.equal(f.videos.length, 1);
|
|
135
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
136
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
137
|
+
f.motion.play('greet');
|
|
138
|
+
await flush();
|
|
139
|
+
const oldPlaying = f.videos.at(-1).onplaying;
|
|
140
|
+
f.motion.destroy();
|
|
141
|
+
oldPlaying();
|
|
142
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
143
|
+
assert.equal(f.timers.size, 0);
|
|
144
|
+
assert.equal(f.motion.play('greet'), false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('background, reduced motion, quiet time and dragging stop media without replay', async () => {
|
|
148
|
+
const f = fixture();
|
|
149
|
+
await flush();
|
|
150
|
+
for (const kind of ['hidden', 'reduce', 'quiet', 'drag']) {
|
|
151
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
152
|
+
f.motion.play('celebrate');
|
|
153
|
+
await flush();
|
|
154
|
+
const video = f.videos.at(-1);
|
|
155
|
+
video.onplaying();
|
|
156
|
+
if (kind === 'hidden') {
|
|
157
|
+
f.document.hidden = true;
|
|
158
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
159
|
+
}
|
|
160
|
+
if (kind === 'reduce') {
|
|
161
|
+
f.media.matches = true;
|
|
162
|
+
f.media.dispatchEvent(new Event('change'));
|
|
163
|
+
}
|
|
164
|
+
if (kind === 'quiet') f.motion.configure({ enabled: true, resting: true });
|
|
165
|
+
if (kind === 'drag') f.motion.drag(10);
|
|
166
|
+
assert.equal(video.removed, true);
|
|
167
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
168
|
+
const count = f.videos.length;
|
|
169
|
+
f.document.hidden = false;
|
|
170
|
+
f.media.matches = false;
|
|
171
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
172
|
+
f.media.dispatchEvent(new Event('change'));
|
|
173
|
+
f.motion.release(true);
|
|
174
|
+
await flush();
|
|
175
|
+
assert.equal(f.videos.length, count);
|
|
176
|
+
}
|
|
177
|
+
f.motion.destroy();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('unsupported alpha, rejected playback and a stalled clip fall back without repeatedly loading bad media', async () => {
|
|
181
|
+
for (const options of [{ alpha: false }, { rejectPlay: true }, {}]) {
|
|
182
|
+
const f = fixture(options);
|
|
183
|
+
f.motion.play('greet');
|
|
184
|
+
await flush();
|
|
185
|
+
if (!options.alpha && options.alpha !== false && !options.rejectPlay) {
|
|
186
|
+
const watchdog = [...f.timers.values()].find((t) => t.ms === 9000);
|
|
187
|
+
watchdog.fn();
|
|
188
|
+
await flush();
|
|
189
|
+
}
|
|
190
|
+
assert.deepEqual(f.calls, ['greet']);
|
|
191
|
+
assert.equal(f.element.dataset.videoPlaying, undefined);
|
|
192
|
+
const count = f.videos.length;
|
|
193
|
+
f.motion.play('greet');
|
|
194
|
+
await flush();
|
|
195
|
+
assert.equal(f.videos.length, count);
|
|
196
|
+
assert.deepEqual(f.calls, ['greet', 'greet']);
|
|
197
|
+
f.motion.destroy();
|
|
198
|
+
assert.equal(f.timers.size, 0);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
async function fireTimer(f, milliseconds) {
|
|
203
|
+
const found = [...f.timers].find(([, timer]) => timer.ms === milliseconds);
|
|
204
|
+
assert.ok(found, `Expected ${milliseconds}ms timer`);
|
|
205
|
+
f.timers.delete(found[0]);
|
|
206
|
+
found[1].fn();
|
|
207
|
+
await flush();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
test('idle loops are interrupted by actions and resume once after action completion', async () => {
|
|
211
|
+
const f = fixture({ idle: true });
|
|
212
|
+
await flush();
|
|
213
|
+
const idle = f.videos.at(-1);
|
|
214
|
+
idle.onplaying();
|
|
215
|
+
assert.equal(idle.loop, true);
|
|
216
|
+
assert.equal(f.timers.size, 0);
|
|
217
|
+
assert.equal(f.motion.play('look'), false);
|
|
218
|
+
f.motion.play('greet');
|
|
219
|
+
await flush();
|
|
220
|
+
const greet = f.videos.at(-1);
|
|
221
|
+
greet.onplaying();
|
|
222
|
+
assert.equal(idle.removed, true);
|
|
223
|
+
assert.equal(greet.loop, false);
|
|
224
|
+
const oldEnd = greet.onended;
|
|
225
|
+
f.motion.play('celebrate');
|
|
226
|
+
await flush();
|
|
227
|
+
const drink = f.videos.at(-1);
|
|
228
|
+
drink.duration = 7.5;
|
|
229
|
+
drink.onplaying();
|
|
230
|
+
assert.ok([...f.timers.values()].some((t) => t.ms === 9500));
|
|
231
|
+
oldEnd();
|
|
232
|
+
assert.equal(f.element.dataset.action, 'celebrate');
|
|
233
|
+
drink.onended();
|
|
234
|
+
await fireTimer(f, 0);
|
|
235
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
236
|
+
assert.equal(f.videos.at(-1).loop, true);
|
|
237
|
+
f.motion.destroy();
|
|
238
|
+
assert.equal(f.timers.size, 0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('returning from hidden or reduced-motion state starts idle rather than replaying an action', async () => {
|
|
242
|
+
const f = fixture({ idle: true });
|
|
243
|
+
await flush();
|
|
244
|
+
f.motion.play('greet');
|
|
245
|
+
await flush();
|
|
246
|
+
f.document.hidden = true;
|
|
247
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
248
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
249
|
+
f.document.hidden = false;
|
|
250
|
+
f.document.dispatchEvent(new Event('visibilitychange'));
|
|
251
|
+
await fireTimer(f, 0);
|
|
252
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
253
|
+
f.media.matches = true;
|
|
254
|
+
f.media.dispatchEvent(new Event('change'));
|
|
255
|
+
const count = f.videos.length;
|
|
256
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
257
|
+
await flush();
|
|
258
|
+
assert.equal(f.videos.length, count);
|
|
259
|
+
f.media.matches = false;
|
|
260
|
+
f.media.dispatchEvent(new Event('change'));
|
|
261
|
+
await fireTimer(f, 0);
|
|
262
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
263
|
+
f.motion.destroy();
|
|
264
|
+
assert.equal(f.timers.size, 0);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test('quiet state and a held drag suppress idle until they finish', async () => {
|
|
268
|
+
const f = fixture({ idle: true });
|
|
269
|
+
await flush();
|
|
270
|
+
f.motion.configure({ enabled: true, resting: true });
|
|
271
|
+
const count = f.videos.length;
|
|
272
|
+
await flush();
|
|
273
|
+
assert.equal(f.videos.length, count);
|
|
274
|
+
f.motion.play('greet');
|
|
275
|
+
await flush();
|
|
276
|
+
f.videos.at(-1).onended();
|
|
277
|
+
await fireTimer(f, 0);
|
|
278
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
279
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
280
|
+
await flush();
|
|
281
|
+
f.motion.drag(1);
|
|
282
|
+
const before = f.videos.length;
|
|
283
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
284
|
+
await flush();
|
|
285
|
+
assert.equal(f.videos.length, before);
|
|
286
|
+
assert.equal(f.element.dataset.action, 'drag');
|
|
287
|
+
f.motion.release(false);
|
|
288
|
+
await fireTimer(f, 650);
|
|
289
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
290
|
+
f.motion.destroy();
|
|
291
|
+
assert.equal(f.timers.size, 0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('a stalled idle clip fails closed without disabling usable action clips', async () => {
|
|
295
|
+
const f = fixture({ idle: true });
|
|
296
|
+
await flush();
|
|
297
|
+
const idle = f.videos.at(-1);
|
|
298
|
+
idle.onplaying();
|
|
299
|
+
idle.onwaiting();
|
|
300
|
+
await fireTimer(f, 4000);
|
|
301
|
+
assert.equal(idle.removed, true);
|
|
302
|
+
const count = f.videos.length;
|
|
303
|
+
f.motion.configure({ enabled: true, resting: false });
|
|
304
|
+
await flush();
|
|
305
|
+
assert.equal(f.videos.length, count);
|
|
306
|
+
f.motion.play('greet');
|
|
307
|
+
await flush();
|
|
308
|
+
const greet = f.videos.at(-1);
|
|
309
|
+
greet.onplaying();
|
|
310
|
+
greet.onended();
|
|
311
|
+
await flush();
|
|
312
|
+
assert.equal(f.element.dataset.action, undefined);
|
|
313
|
+
f.motion.destroy();
|
|
314
|
+
assert.equal(f.timers.size, 0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('failed action fallback suspends idle during CSS feedback and then returns to idle', async () => {
|
|
318
|
+
const f = fixture({ idle: true });
|
|
319
|
+
await flush();
|
|
320
|
+
f.motion.play('greet');
|
|
321
|
+
await flush();
|
|
322
|
+
f.videos.at(-1).onerror();
|
|
323
|
+
assert.deepEqual(f.calls, ['greet']);
|
|
324
|
+
await fireTimer(f, 1400);
|
|
325
|
+
const idle = f.videos.at(-1);
|
|
326
|
+
idle.onplaying();
|
|
327
|
+
const count = f.videos.length;
|
|
328
|
+
f.motion.play('greet');
|
|
329
|
+
await flush();
|
|
330
|
+
assert.equal(idle.removed, true);
|
|
331
|
+
assert.equal(f.videos.length, count);
|
|
332
|
+
assert.deepEqual(f.calls, ['greet', 'greet']);
|
|
333
|
+
await fireTimer(f, 1400);
|
|
334
|
+
assert.equal(f.videos.at(-1).src, 'idle-data');
|
|
335
|
+
f.motion.destroy();
|
|
336
|
+
assert.equal(f.timers.size, 0);
|
|
337
|
+
});
|
package/tools/build.mjs
CHANGED
|
@@ -7,6 +7,8 @@ const moduleNames = [
|
|
|
7
7
|
'ui-utils.js',
|
|
8
8
|
'companion.js',
|
|
9
9
|
'pet-view.js',
|
|
10
|
+
'pet-motion.js',
|
|
11
|
+
'pet-video.js',
|
|
10
12
|
'pet-interactions.js',
|
|
11
13
|
'pet-surfaces.js',
|
|
12
14
|
'hydration.js',
|
|
@@ -32,7 +34,7 @@ const spriteMarker = '/* PET_SPRITES */';
|
|
|
32
34
|
if (entry.split(spriteMarker).length !== 2) throw new Error('Expected one sprite marker');
|
|
33
35
|
const sprites = Object.fromEntries(
|
|
34
36
|
await Promise.all(
|
|
35
|
-
['whale-dress', 'whale-ornate'].map(async (id) => {
|
|
37
|
+
['whale-dress', 'whale-ornate', 'whale-actions', 'whale-ornate-actions'].map(async (id) => {
|
|
36
38
|
const bytes = await readFile(new URL(`assets/characters/${id}.webp`, root));
|
|
37
39
|
if (bytes.toString('ascii', 8, 12) !== 'WEBP' || bytes.length > 200000)
|
|
38
40
|
throw new Error(`Invalid sprite: ${id}`);
|
|
@@ -40,11 +42,35 @@ const sprites = Object.fromEntries(
|
|
|
40
42
|
}),
|
|
41
43
|
),
|
|
42
44
|
);
|
|
45
|
+
const clipMarker = '/* PET_CLIPS */';
|
|
46
|
+
if (entry.split(clipMarker).length !== 2) throw new Error('Expected one clip marker');
|
|
47
|
+
async function clip(name, maximum) {
|
|
48
|
+
const bytes = await readFile(new URL(`assets/animations/${name}.webm`, root));
|
|
49
|
+
if (bytes.readUInt32BE(0) !== 0x1a45dfa3 || bytes.length > maximum)
|
|
50
|
+
throw new Error(`Invalid or oversized WebM: ${name}`);
|
|
51
|
+
return 'data:video/webm;base64,' + bytes.toString('base64');
|
|
52
|
+
}
|
|
53
|
+
const [probe, greet, celebrate, ornateIdle, ornateGreet, ornateDrink] = await Promise.all([
|
|
54
|
+
clip('alpha-probe', 10000),
|
|
55
|
+
clip('whale-greet', 2000000),
|
|
56
|
+
clip('whale-drink', 2000000),
|
|
57
|
+
clip('ornate-idle', 2000000),
|
|
58
|
+
clip('ornate-greet', 2000000),
|
|
59
|
+
clip('ornate-drink', 2000000),
|
|
60
|
+
]);
|
|
61
|
+
const clips = {
|
|
62
|
+
probe,
|
|
63
|
+
models: {
|
|
64
|
+
'whale-actions': { greet, celebrate },
|
|
65
|
+
'whale-ornate-actions': { idle: ornateIdle, greet: ornateGreet, celebrate: ornateDrink },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
43
68
|
const result =
|
|
44
69
|
banner +
|
|
45
70
|
entry
|
|
46
71
|
.replace(marker, pure)
|
|
47
|
-
.replace(spriteMarker, `const PET_SPRITES = ${JSON.stringify(sprites)};`)
|
|
72
|
+
.replace(spriteMarker, () => `const PET_SPRITES = ${JSON.stringify(sprites)};`)
|
|
73
|
+
.replace(clipMarker, () => `const PET_CLIPS = ${JSON.stringify(clips)};`);
|
|
48
74
|
const output = new URL('client.js', root);
|
|
49
75
|
|
|
50
76
|
if (process.argv.includes('--check')) {
|