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/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 === 'image' && sprites[model.id])
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
- petOpen: '打开宠物面板',
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
- petOpen: 'Open pet panel',
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 for water · right-click for menu · drag to move',
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 button.owc-pet-clock{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}
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 ['whale-dress', 'whale-ornate', 'xiaoxie-original'])
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')) {