@bitefoundry/retrieviewer 0.0.9 → 0.0.10

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.
@@ -1,18 +1,22 @@
1
1
  {
2
- "version": 2,
2
+ "version": 6,
3
3
  "width": 320,
4
4
  "height": 320,
5
- "frames": 13,
5
+ "frames": 45,
6
6
  "clips": [
7
7
  "tug",
8
- "sleep"
8
+ "sleep",
9
+ "scramble",
10
+ "pickup-rear",
11
+ "pickup-side",
12
+ "carry-trot"
9
13
  ],
10
- "gzipBytes": 129148,
11
- "residentPixelBytes": 341139,
14
+ "gzipBytes": 457052,
15
+ "residentPixelBytes": 1302445,
12
16
  "sourceSheetSha256": "10fde9dacca5cb45acd68f387e7ed3bcf88cd72cf0f9c4220d31d819db91731e",
13
17
  "neutralSha256": "19f89a8bcb5a937e528593a8f36db846a0d45a5ee398f1b0873c056e5be2fa0a",
14
- "encodedSha256": "35327cc103b1f7ac2ff1b09ed98658fd8ff1dcef322de9c9b18071a4358b7b87",
15
- "method": "Built-in imagegen authored tug keys. One fixed scale, ground/center translation only. Exact existing Q5 seat first/last. No expanded tween film or live body deformation.",
18
+ "encodedSha256": "f4e7604e3a581f5b610eb0f7873deee317a6e2ad0ff548106db2e64f6bead89b",
19
+ "method": "Approved momentum poses, single depth pursuit, exact source endpoints; one common scale per authored batch, no runtime image warp or expanded film.",
16
20
  "registration": {
17
21
  "sheetSize": [
18
22
  1774,
@@ -136,5 +140,327 @@
136
140
  "belly": "e1967d9156a119d04e25fd6428fe5dcdbd2156c5e4eba0347217847874006109",
137
141
  "curl": "10c9a85701af3587c48c1d8158972a07026e79a18a2375d86108538eda05aae7",
138
142
  "chin": "e971eaa6861b597f0dba3a487403cee5f120cba0a9f9584d831b5634d096dee7"
143
+ },
144
+ "scramble": {
145
+ "frames": [
146
+ 25,
147
+ 26,
148
+ 27,
149
+ 28,
150
+ 29,
151
+ 30,
152
+ 31,
153
+ 32,
154
+ 33,
155
+ 34,
156
+ 35,
157
+ 36
158
+ ],
159
+ "times": [
160
+ 0,
161
+ 0.035,
162
+ 0.07,
163
+ 0.105,
164
+ 0.14,
165
+ 0.19,
166
+ 0.25,
167
+ 0.32,
168
+ 0.39,
169
+ 0.48,
170
+ 0.57,
171
+ 0.65
172
+ ],
173
+ "duration": 0.65,
174
+ "floorOffsets": [
175
+ 0,
176
+ 0,
177
+ 0,
178
+ 0,
179
+ 0,
180
+ 0,
181
+ 0,
182
+ 0,
183
+ 0,
184
+ 0,
185
+ 0,
186
+ 0
187
+ ],
188
+ "entryView": "rear",
189
+ "entryPhase": 0.25,
190
+ "trajectory": "single-pass-target",
191
+ "brakeSeconds": 0.19,
192
+ "catchSeconds": 0.29,
193
+ "catchSpeedRatio": 0.24,
194
+ "registration": {
195
+ "commonScale": 0.7160493827160493,
196
+ "commonDx": 39.580246913580254,
197
+ "wholeFrameFloorTranslations": true,
198
+ "approvedBatchScale": 0.47313240809175766,
199
+ "approvedBatchDx": 1.1088664421997976,
200
+ "approvedOriginalKeys": [
201
+ 2,
202
+ 4,
203
+ 6,
204
+ 8
205
+ ],
206
+ "perPoseScale": false,
207
+ "bodyCompositing": false,
208
+ "nonlinearWarp": false,
209
+ "exactSourceEndpoints": true
210
+ }
211
+ },
212
+ "pickups": {
213
+ "rear": {
214
+ "frames": [
215
+ 13,
216
+ 14,
217
+ 15,
218
+ 16,
219
+ 17,
220
+ 18
221
+ ],
222
+ "times": [
223
+ 0,
224
+ 0.09,
225
+ 0.18,
226
+ 0.28,
227
+ 0.4,
228
+ 0.52
229
+ ],
230
+ "duration": 0.52,
231
+ "floorOffsets": [
232
+ 0,
233
+ 2,
234
+ 2,
235
+ 1,
236
+ 1,
237
+ 0
238
+ ]
239
+ },
240
+ "front": {
241
+ "frames": [
242
+ 19,
243
+ 20,
244
+ 21,
245
+ 22,
246
+ 23,
247
+ 24
248
+ ],
249
+ "times": [
250
+ 0,
251
+ 0.09,
252
+ 0.18,
253
+ 0.28,
254
+ 0.4,
255
+ 0.52
256
+ ],
257
+ "duration": 0.52,
258
+ "floorOffsets": [
259
+ -4,
260
+ 1,
261
+ 1,
262
+ 1,
263
+ 1,
264
+ -4
265
+ ],
266
+ "mouth": [
267
+ [
268
+ 255,
269
+ 151
270
+ ],
271
+ [
272
+ 250,
273
+ 225
274
+ ],
275
+ [
276
+ 280,
277
+ 275
278
+ ],
279
+ [
280
+ 274,
281
+ 287
282
+ ],
283
+ [
284
+ 268,
285
+ 238
286
+ ],
287
+ [
288
+ 255,
289
+ 151
290
+ ]
291
+ ]
292
+ }
293
+ },
294
+ "sourceComfort": {
295
+ "ref": "9fc9628b6e42bd5da1efb02521ed4ab6e59f7763",
296
+ "encodedSha256": "35327cc103b1f7ac2ff1b09ed98658fd8ff1dcef322de9c9b18071a4358b7b87",
297
+ "frames": 13,
298
+ "gzipBytes": 129148
299
+ },
300
+ "approvedArtwork": "Owner-approved whole-body momentum study, 2026-10-23",
301
+ "sourceSheetHashes": {
302
+ "rear": "7beaf6a3250590563d98af860c369c91993d5aea6b7b901f368d3992d120b09b",
303
+ "breakdowns": "7a565ccf2aa54f38a89979826ecc7d2d77110abbcd1bfbe6ab2cb4d86093daf2"
304
+ },
305
+ "carryTrot": {
306
+ "frames": [
307
+ 37,
308
+ 38,
309
+ 39,
310
+ 40
311
+ ],
312
+ "times": [
313
+ 0,
314
+ 0.25,
315
+ 0.5,
316
+ 0.75
317
+ ],
318
+ "duration": 1,
319
+ "cyclic": true,
320
+ "floorOffsets": [
321
+ 0,
322
+ 0,
323
+ 0,
324
+ 0
325
+ ],
326
+ "mouth": [
327
+ [
328
+ 198,
329
+ 177
330
+ ],
331
+ [
332
+ 197,
333
+ 179
334
+ ],
335
+ [
336
+ 189,
337
+ 178
338
+ ],
339
+ [
340
+ 190,
341
+ 179
342
+ ]
343
+ ],
344
+ "registration": {
345
+ "commonScale": 0.6843853820598007,
346
+ "commonDx": 68.1594684385382,
347
+ "perPoseScale": false,
348
+ "bodyCompositing": false,
349
+ "wholeFrameFloorTranslation": true,
350
+ "selectedSourceKeys": [
351
+ 0,
352
+ 1,
353
+ 2,
354
+ 6,
355
+ 8,
356
+ 9,
357
+ 10,
358
+ 11
359
+ ]
360
+ },
361
+ "settle": {
362
+ "frames": [
363
+ 41,
364
+ 42,
365
+ 43,
366
+ 44
367
+ ],
368
+ "times": [
369
+ 0,
370
+ 0.07,
371
+ 0.14,
372
+ 0.22
373
+ ],
374
+ "duration": 0.22,
375
+ "floorOffsets": [
376
+ 0,
377
+ 0,
378
+ 0,
379
+ 0
380
+ ],
381
+ "mouth": [
382
+ [
383
+ 203,
384
+ 179
385
+ ],
386
+ [
387
+ 194,
388
+ 180
389
+ ],
390
+ [
391
+ 191,
392
+ 180
393
+ ],
394
+ [
395
+ 195,
396
+ 183
397
+ ]
398
+ ]
399
+ },
400
+ "feet": [
401
+ [
402
+ {
403
+ "id": "fore-left",
404
+ "x": 168,
405
+ "y": 260,
406
+ "stance": false
407
+ },
408
+ {
409
+ "id": "fore-right",
410
+ "x": 220,
411
+ "y": 298,
412
+ "stance": true
413
+ }
414
+ ],
415
+ [
416
+ {
417
+ "id": "fore-left",
418
+ "x": 174,
419
+ "y": 267,
420
+ "stance": false
421
+ },
422
+ {
423
+ "id": "fore-right",
424
+ "x": 210,
425
+ "y": 298,
426
+ "stance": true
427
+ }
428
+ ],
429
+ [
430
+ {
431
+ "id": "fore-left",
432
+ "x": 164,
433
+ "y": 298,
434
+ "stance": true
435
+ },
436
+ {
437
+ "id": "fore-right",
438
+ "x": 217,
439
+ "y": 260,
440
+ "stance": false
441
+ }
442
+ ],
443
+ [
444
+ {
445
+ "id": "fore-left",
446
+ "x": 160,
447
+ "y": 298,
448
+ "stance": true
449
+ },
450
+ {
451
+ "id": "fore-right",
452
+ "x": 216,
453
+ "y": 270,
454
+ "stance": false
455
+ }
456
+ ]
457
+ ]
458
+ },
459
+ "carrySource": {
460
+ "encodedSha256": "efa81c6f464646d101873223869cebf5ca28c80529ad4ab26380d3b97cc2af54",
461
+ "frames": 37,
462
+ "gzipBytes": 375968,
463
+ "residentPixelBytes": 1043388,
464
+ "sheetSha256": "43e41a678d348439bf989535bf30761cc23ac9d4ca049705a9a651e1e8b61ee0"
139
465
  }
140
466
  }
@@ -4,30 +4,33 @@
4
4
  // Exception: an accepted ball throw reacts at once via actor.chase (the actor owns safe
5
5
  // interruption); actors without chase fall back to go at the next safe endpoint.
6
6
  // Parameters are authored for feel, not canine science.
7
+ import {paperCameraPoint,paperGroundTarget,PAPER_SCREEN,PAPER_CAMERA} from './puppy-paper-world.mjs';
8
+ import {createBallPath,createFetchPatterns} from './puppy-companion-ball.mjs';
7
9
 
8
10
  const SAFE=new Set(['idle','down']);
11
+ export const FETCH_RETURN_STYLES=Object.freeze(['auto','brake','trot','run-trot']);
9
12
  const HOME={x:0,z:.2};
10
13
  const BALL_TARGET={x:0,z:-4};
11
14
  const OFFER={x:0,z:.3};
12
15
  // Hand-fed treat marker, relative to the puppy's current root (no travel for treats).
13
16
  const TREAT_OFFSET={z:.08,height:.25};
14
17
  const COOLDOWN={ball:6,treat:15,tug:20};
15
- const FLIGHT=.65,BALL_PEAK=.75,CARRY_HEIGHT=.19,SETTLE_FALLBACK=.6,MAX_DT=.25,ARRIVE=.05;
18
+ const CARRY_HEIGHT=.19,SETTLE_FALLBACK=.6,MAX_DT=.25,ARRIVE=.05;
16
19
  // Active simulation seconds, never a wall-clock timer that wakes a hidden pet.
17
20
  export const BUTTERFLY_RULES=Object.freeze({firstMin:240,firstSpread:240,cooldownMin:720,cooldownSpread:480,maxVisible:18});
18
21
  const LABEL={
19
22
  start:'기다리는 중',pause:'잠시 느긋하게 둘러보는 중',lie:'엎드리는 중',rest:'엎드려 쉬는 중',recover:'앉아서 기운 차리는 중',
20
23
  sit:'앉는 중',watch:'앉아서 지켜보는 중',look:'고개를 돌려 살피는 중',observe:'가만히 주변 관찰 중',
21
- stroll:'옆으로 살짝 걸어가는 중',sniff:'잠깐 멈춰 주변을 살피는 중',dash:'저 멀리 달려가는 중',far:'멀리서 둘러보는 중',home:'제자리로 돌아오는 중',
24
+ stroll:'옆으로 살짝 걸어가는 중',sniff:'잠깐 멈춰 주변을 살피는 중',dash:'저 멀리 달려가는 중',far:'멀리서 둘러보는 중',home:'제자리로 돌아오는 중',nest:'쉴 자리를 찾아가는 중',
22
25
  patrol:'주변을 왕복 산책하는 중',scan:'양쪽을 번갈아 살피는 중',roll:'느긋하게 뒹구는 중',play:'신나서 한 바퀴 도는 중',checkin:'가까이 와서 함께 쉬는 중',
23
26
  belly:'배를 보이고 발라당 쉬는 중',sleep:'새근새근 잠자는 중',tug:'장난감을 물고 당기는 중',
24
27
  butterflyNotice:'나비를 발견하고 귀를 쫑긋하는 중',butterfly:'나비를 폴짝폴짝 쫓는 중',butterflyBye:'날아가는 나비를 바라보는 중',
25
- fetch:'공을 쫓아가는 중',pickup:'공을 무는 중',carry:'공을 물고 돌아오는 중',drop:'공을 내려놓는 중',offer:'공을 내려놓고 기다리는 중',
28
+ fetch:'공을 쫓아가는 중',miss:'굴러가는 공을 한 번 놓친 중',reChase:'공을 다시 쫓아가는 중',tumble:'우당탕 균형을 되찾는 중',pickup:'공을 무는 중',carry:'공을 물고 돌아오는 중',drop:'공을 내려놓는 중',offer:'공을 내려놓고 기다리는 중',
26
29
  treatSit:'간식 앞에 앉는 중',chew:'간식 먹는 중'
27
30
  };
28
31
  const PENDING_LABEL={treat:'곧 간식',tug:'곧 터그놀이'};
29
32
  // Baked interaction clips, played only while holding at the shared seated endpoint.
30
- const GESTURE={'ball-pickup':1.4,'ball-drop':1.2,'treat-eat':2.4};
33
+ const GESTURE={'ball-pickup':.65,'ball-drop':.65,'treat-eat':2.4};
31
34
  const clamp=(v,a,b)=>Math.min(b,Math.max(a,v));
32
35
 
33
36
  function mulberry32(seed){
@@ -35,13 +38,21 @@ function mulberry32(seed){
35
38
  return()=>{a=a+0x6d2b79f5>>>0;let t=a;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296;};
36
39
  }
37
40
 
38
- export function createCompanionLife(actor,{seed=731,energy=.8}={}){
41
+ export function createCompanionLife(actor,{seed=731,energy=.8,returnStyle='auto'}={}){
39
42
  for(const name of ['update','sample','go','come','sit','lie','look','walk','roll','rest'])
40
43
  if(!actor||typeof actor[name]!=='function')throw TypeError(`actor.${name} must be a function`);
41
44
  if(!Number.isInteger(seed))throw TypeError('seed must be an integer');
42
45
  if(!Number.isFinite(energy)||energy<0||energy>1)throw RangeError('energy must be within 0..1');
46
+ const validateReturnStyle=style=>{if(!FETCH_RETURN_STYLES.includes(style))throw RangeError('returnStyle must be auto, brake, trot or run-trot');if(['trot','run-trot'].includes(style)&&!actor.canCarryTrot)throw Error('Carry-trot artwork is not available');};
47
+ validateReturnStyle(returnStyle);
43
48
 
44
49
  const rand=mulberry32(seed),range=(a,b)=>a+(b-a)*rand(),side=()=>rand()<.5?'left':'right';
50
+ // Spatial choices get their own stream, like the rare visitor. Existing gait,
51
+ // energy and interaction timing do not depend on how a resting spot is picked.
52
+ const spaceRand=mulberry32(seed^0x28ae91c7),spaceRange=(a,b)=>a+(b-a)*spaceRand();
53
+ const toyRand=mulberry32(seed^0x6c82b14d),chooseFetch=createFetchPatterns(toyRand,{canTumble:actor.canScramble===true});
54
+ const returnRand=mulberry32(seed^0x73bca165);let lastReturnMode=null;
55
+ let placeSide=spaceRand()<.5?-1:1,outings=0,nearSeconds=0,lastCheckin=-Infinity;
45
56
  // Separate stream: the rare encounter does not perturb ordinary episodes/gaits.
46
57
  const encounterRand=mulberry32(seed^0x4bf17a93),encounterRange=(a,b)=>a+(b-a)*encounterRand();
47
58
  let nextButterfly=BUTTERFLY_RULES.firstMin+encounterRange(0,BUTTERFLY_RULES.firstSpread),butterfly=null;
@@ -51,6 +62,7 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
51
62
  let clock=0,decisions=0,lastEpisode=null,quiet=0;
52
63
  let queue=[],hold=0,holdIntent=null,waiting=null,gesture=null;
53
64
  let intent='start',label=LABEL.start,pending=null,active=null,ball=null,treat=null;
65
+ let fetchPlan=null;
54
66
 
55
67
  const cmd=(name,args,step,when)=>({cmd:name,args,intent:step,label:LABEL[step],when});
56
68
  const wait=(seconds,step)=>({hold:seconds,intent:step,label:LABEL[step]});
@@ -64,6 +76,22 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
64
76
  const sit=step=>cmd('sit',[],step,()=>!seatedAnyVariant());
65
77
  const lie=()=>cmd('lie',[],'lie',()=>actor.state!=='down');
66
78
  const walkTo=x=>cmd('walk',()=>{const dx=x-actor.sample(clock).x;return [dx<0?'left':'right',Math.abs(dx)];},'patrol',()=>Math.abs(x-actor.sample(clock).x)>.002);
79
+ const roomRadius=z=>Math.max(.12,paperCameraPoint(0,0,z).depth*PAPER_SCREEN.widthMeters/(2*PAPER_SCREEN.viewDistance)-PAPER_CAMERA.canvasMeters/2);
80
+ const place=nap=>{
81
+ // A nearer side nook keeps a sleeping face readable; outings also use the
82
+ // middle/far ground. X expands with camera depth, not a fixed world corridor.
83
+ const bands=[[1.4,3],[4,8],[.7,1.4],[9,15]],band=bands[outings++%bands.length];
84
+ const z=-(nap?spaceRange(.7,2.6):spaceRange(...band));placeSide*=-1;
85
+ return paperGroundTarget(placeSide*roomRadius(z)*spaceRange(.5,.8),z);
86
+ };
87
+ const travelTo=(target,step)=>cmd('go',target,step,()=>{const s=actor.sample(clock);return Math.hypot(s.x-target[0],s.z-target[1])>.12;});
88
+ const quietPlace=()=>{
89
+ const s=actor.sample(clock);
90
+ // Often stay in the nook just discovered, instead of shuffling before every
91
+ // nap. A front-and-center or very distant puppy first finds a quieter spot.
92
+ if(s.z<=-.5&&s.z>=-3.5&&Math.abs(s.x)>roomRadius(s.z)*.3&&spaceRand()<.65)return [];
93
+ return [travelTo(place(true),'nest')];
94
+ };
67
95
  const butterflyPoint=()=>{
68
96
  if(!butterfly)return null;
69
97
  const elapsed=clock-butterfly.since,p=clamp(elapsed/.65,0,1),ease=p*p*(3-2*p);
@@ -74,8 +102,9 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
74
102
  phase:((clock-butterfly.start)*5)%1,opacity:1-leaving};
75
103
  };
76
104
  const butterflyEpisode=()=>{
77
- const s=actor.sample(clock),direction=encounterRand()<.5?-1:1;
78
- const targets=[clamp(s.x+direction*.36,-.5,.5),clamp(s.x-direction*.38,-.5,.5),clamp(s.x+direction*.24,-.5,.5)];
105
+ const s=actor.sample(clock),radius=roomRadius(s.z);
106
+ const direction=s.x>radius-.3?-1:s.x< -radius+.3?1:encounterRand()<.5?-1:1;
107
+ const targets=[clamp(s.x+direction*.36,-radius,radius),clamp(s.x-direction*.38,-radius,radius),clamp(s.x+direction*.24,-radius,radius)];
79
108
  const z=s.z;
80
109
  const moveFly=x=>fx(()=>{
81
110
  const from=butterflyPoint()??{x:s.x-direction*.28,z};
@@ -88,29 +117,29 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
88
117
  return [sit('butterflyNotice'),moveFly(targets[0]),cmd('look',[direction<0?'right':'left'],'butterflyNotice'),wait(.8,'butterflyNotice'),
89
118
  ...targets.flatMap((x,i)=>[...(i?[moveFly(x)]:[]),cmd(typeof actor.chase==='function'?'chase':'go',[x,z],'butterfly'),wait(.3,'butterfly')]),
90
119
  fx(()=>{if(butterfly)butterfly.leaving=clock;}),wait(1.1,'butterflyBye'),fx(()=>{butterfly=null;needs.energy-=.12;needs.curiosity-=.25;}),
91
- home(),sit('recover'),wait(range(3,6),'recover')];
120
+ sit('recover'),wait(range(3,6),'recover')];
92
121
  };
93
122
 
94
123
  // Each episode is a short coherent plan; a pause always leads in so autonomy never stalls.
95
124
  const EPISODES={
96
- rest:()=>[lie(),wait(range(10,18),'rest'),sit('recover'),wait(range(3,6),'recover')],
125
+ rest:()=>[...quietPlace(),lie(),wait(range(10,18),'rest'),sit('recover'),wait(range(3,6),'recover')],
97
126
  watch:()=>[sit('sit'),cmd('look',[side()],'look'),wait(range(3,9),'watch'),fx(()=>{needs.social-=.3;needs.curiosity+=.1;})],
98
127
  observe:()=>[sit('sit'),cmd('look',[side()],'look'),wait(range(3,9),'observe'),fx(()=>{needs.curiosity-=.15;})],
99
- stroll:()=>[cmd('walk',[side(),range(.08,.18)],'stroll'),wait(range(2,4),'sniff'),home(),fx(()=>{needs.energy-=.08;needs.curiosity-=.2;})],
100
- dash:()=>[cmd('go',[(rand()*2-1)*.18,-range(3,8)],'dash'),wait(range(1.5,3),'far'),home(),fx(()=>{needs.energy-=.3;needs.curiosity-=.4;})],
128
+ stroll:()=>[cmd('walk',[side(),range(.22,.5)],'stroll'),wait(range(4,9),'sniff'),fx(()=>{needs.energy-=.08;needs.curiosity-=.2;})],
129
+ dash:()=>[travelTo(place(false),'dash'),sit('far'),wait(range(8,16),'far'),fx(()=>{needs.energy-=.22;needs.curiosity-=.3;})],
101
130
  scan:()=>[sit('sit'),cmd('look',['left'],'scan'),wait(range(.5,1.5),'scan'),cmd('look',['right'],'scan'),wait(range(2,4),'observe'),fx(()=>{needs.curiosity-=.25;})],
102
131
  patrol:()=>{const x=actor.sample(clock).x,d=range(.16,.3)*(rand()<.5?-1:1);return [walkTo(x+d),wait(range(.6,1.2),'sniff'),walkTo(x-d),wait(range(.6,1.2),'sniff'),walkTo(x),wait(range(1,3),'observe'),fx(()=>{needs.energy-=.1;needs.curiosity-=.25;})];},
103
132
  roll:()=>[lie(),wait(range(.7,1.5),'rest'),cmd('roll',[],'roll'),wait(range(2,5),'watch'),fx(()=>{needs.energy-=.08;needs.curiosity-=.15;})],
104
133
  checkin:()=>[home(),sit('sit'),cmd('look',[side()],'look'),wait(range(5,10),'checkin'),fx(()=>{needs.social-=.4;})],
105
134
  play:()=>[cmd('spin',[],'play'),wait(range(1,2),'pause'),sit('sit'),wait(range(2,4),'watch'),fx(()=>{needs.energy-=.12;needs.curiosity-=.2;})],
106
135
  belly:()=>[cmd('belly',[range(5,9)],'belly'),wait(range(2,4),'watch'),fx(()=>{needs.energy+=.08;needs.curiosity-=.15;})],
107
- sleep:()=>[cmd('sleep',[range(65,110),['side','curl','chin','belly'][Math.floor(rand()*4)],rand()<.5],'sleep'),wait(range(2,4),'recover'),fx(()=>{needs.energy+=.3;})],
136
+ sleep:()=>[...quietPlace(),cmd('sleep',[range(65,110),['side','curl','chin','belly'][Math.floor(rand()*4)],rand()<.5],'sleep'),wait(range(2,4),'recover'),fx(()=>{needs.energy+=.3;})],
108
137
  butterfly:butterflyEpisode,
109
138
  };
110
139
  const weights=()=>{
111
140
  const {energy:e,social:s,curiosity:c}=needs;
112
141
  return {rest:e<.35?6:(1-e)*1.2,watch:.8+s,observe:.6+c*.6,stroll:1.2*c*e,dash:e>.55?1.2*e*c:0,
113
- scan:.35+c*.4,patrol:e>.25?.7*c*e:0,checkin:.25+s*.7,
142
+ scan:.35+c*.4,patrol:e>.25?.7*c*e:0,checkin:clock-lastCheckin>=90?.25+s*.7:0,
114
143
  roll:episodeCooldowns.roll===0&&e>.45?.35*e:0,
115
144
  play:episodeCooldowns.play===0&&e>.6&&typeof actor.spin==='function'?.25*e*c:0,
116
145
  belly:episodeCooldowns.belly===0&&typeof actor.belly==='function'?.35:0,
@@ -120,6 +149,9 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
120
149
  const w=weights();if(lastEpisode)w[lastEpisode]=0;
121
150
  const kinds=Object.keys(w),total=kinds.reduce((sum,k)=>sum+w[k],0);
122
151
  let pick=rand()*total,kind=kinds.find(k=>(pick-=w[k])<0)??'observe';
152
+ // A bounded spell near the viewer, not a permanent home magnet. Let an active
153
+ // authored motion finish first; only the next episode changes its destination.
154
+ if(nearSeconds>=40&&needs.energy>.4&&lastEpisode!=='dash')kind='dash';
123
155
  // Quiet-time sleep pressure is independent of energy restored by sitting.
124
156
  // Wait for the current coherent episode to end; never yank a running puppy.
125
157
  if(quiet>=120&&needs.drowsiness>=.35&&episodeCooldowns.sleep===0&&lastEpisode!=='sleep'&&typeof actor.sleep==='function')kind='sleep';
@@ -128,6 +160,7 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
128
160
  kind='butterfly';nextButterfly=clock+BUTTERFLY_RULES.cooldownMin+encounterRange(0,BUTTERFLY_RULES.cooldownSpread);
129
161
  }
130
162
  lastEpisode=kind;decisions++;
163
+ if(kind==='checkin')lastCheckin=clock;
131
164
  if(kind==='roll')episodeCooldowns.roll=90;
132
165
  if(kind==='play')episodeCooldowns.play=120;
133
166
  if(kind==='belly')episodeCooldowns.belly=80;
@@ -135,15 +168,22 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
135
168
  queue=[wait(range(3,9),'pause'),...EPISODES[kind]()];
136
169
  }
137
170
 
138
- const landed=()=>ball?.mode==='flight'&&clock-ball.start>=FLIGHT;
139
- const arrived=()=>{const s=actor.sample(clock);return SAFE.has(actor.state)&&Math.hypot(s.x-BALL_TARGET.x,s.z-BALL_TARGET.z)<ARRIVE;};
171
+ const landed=()=>ball?.mode==='flight'&&ball.path.at(clock-ball.start).settled;
172
+ const arrived=()=>{const s=actor.sample(clock),target=fetchPlan.pickupRoot;return SAFE.has(actor.state)&&Math.hypot(s.x-target.x,s.z-target.z)<ARRIVE;};
173
+ const fetchGo=typeof actor.chase==='function'?'chase':'go';
174
+ const directionalPickup=()=>{const s=actor.sample(clock);return actor.canPickup===true&&(s.seatProgress??0)===0&&['rear','front'].includes(s.view);};
140
175
  // The leading go is dropped when interact() already dispatched the chase; pickup waits for both arrival and landing.
141
176
  const BALL_STEPS=()=>[
142
- cmd('go',[BALL_TARGET.x,BALL_TARGET.z],'fetch'),{until:()=>landed()&&arrived()},
143
- fx(()=>{ball={mode:'ground',x:BALL_TARGET.x,z:BALL_TARGET.z};}),
144
- cmd('sit',[],'pickup'),play('ball-pickup','pickup'),fx(()=>{ball={mode:'carried'};}),cmd('go',[HOME.x,HOME.z],'carry'),
145
- cmd('sit',[],'drop',unseated),play('ball-drop','drop'),
146
- fx(()=>{ball={mode:'ground',x:OFFER.x,z:OFFER.z};needs.social-=.4;}),wait(3,'offer'),fx(()=>{ball=null;})
177
+ cmd(fetchGo,[fetchPlan.first.x,fetchPlan.first.z,{scramble:fetchPlan.pattern==='tumble'}],'fetch'),
178
+ ...(fetchPlan.pattern==='miss'?[wait(.12,'miss'),cmd(fetchGo,[fetchPlan.pickupRoot.x,fetchPlan.pickupRoot.z],'reChase')]:[]),
179
+ ...(fetchPlan.pattern==='tumble'&&!actor.canSinglePassScramble?[...actor.canMovingScramble?[]:[cmd('scramble',[],'tumble')],cmd(fetchGo,[fetchPlan.pickupRoot.x,fetchPlan.pickupRoot.z],'reChase')]:[]),
180
+ {until:()=>landed()&&arrived()},
181
+ fx(()=>{ball={mode:'ground',...fetchPlan.stop};}),
182
+ cmd('pickup',[],'pickup',directionalPickup),
183
+ cmd('sit',[{urgent:true}],'pickup',()=>!directionalPickup()&&unseated()),{...play('ball-pickup','pickup'),when:()=>!directionalPickup()},
184
+ fx(()=>{ball={mode:'carried'};}),cmd(fetchGo,[HOME.x,HOME.z,{returnMode:fetchPlan.returnMode}],'carry'),
185
+ cmd('sit',[{urgent:true}],'drop',unseated),play('ball-drop','drop'),
186
+ fx(()=>{ball={mode:'dropped',start:clock,path:createBallPath({from:OFFER,landing:OFFER,stop:{x:OFFER.x+.035,z:OFFER.z+.06},flight:0,peak:0,deceleration:.2})};needs.social-=.4;}),wait(3,'offer'),fx(()=>{ball=null;fetchPlan=null;})
147
187
  ];
148
188
  // Hand-feed where the puppy already is: sit only if not front-seated, never go/walk/chase/come.
149
189
  const TREAT_STEPS=()=>[
@@ -158,12 +198,14 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
158
198
  active=pending;pending=null;decisions++;hold=0;
159
199
  queue=active==='ball'?BALL_STEPS():active==='tug'?TUG_STEPS():TREAT_STEPS();
160
200
  }
161
- function finishInteraction(){cooldowns[active]=COOLDOWN[active];active=null;}
201
+ function finishInteraction(){cooldowns[active]=COOLDOWN[active];if(active==='ball')lastCheckin=clock;active=null;}
162
202
 
163
203
  function drift(dt){
164
204
  const resting=holdIntent==='rest'||holdIntent==='recover'||actor.state==='sleep'||intent==='belly';
165
205
  needs.energy+=resting?.04*dt:-.004*dt;
166
206
  const asleep=actor.state==='sleep';
207
+ if(!active&&!pending&&!asleep){const position=actor.sample(clock);nearSeconds=position.z>-.5&&Math.abs(position.x)<.32?nearSeconds+dt:0;}
208
+ else nearSeconds=0;
167
209
  needs.drowsiness+=asleep?-dt/60:dt/240;
168
210
  quiet=active||pending||asleep?0:quiet+dt;
169
211
  if(!active){needs.curiosity+=.01*dt;needs.social+=.008*dt;}
@@ -227,11 +269,35 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
227
269
  return true;
228
270
  }
229
271
  // Ball: launch now and drop obsolete controller plans; the actor's own state is left to chase.
230
- active='ball';decisions++;ball={mode:'flight',start:clock};
272
+ const throwPosition=actor.sample(clock),pattern=chooseFetch(throwPosition.z>=-1.09);
273
+ const landing={...BALL_TARGET},stop={...BALL_TARGET};
274
+ if(pattern!=='clean')stop.z-=2.5+toyRand()*2;
275
+ let first=pattern==='miss'?{...landing}:{...stop};
276
+ if(pattern==='tumble'){
277
+ if(actor.canSinglePassScramble){
278
+ // One forward pursuit in depth. No shallow side detour, overshoot target,
279
+ // reversal or second chase just to expose an unrelated lateral drawing.
280
+ Object.assign(landing,{x:throwPosition.x,z:throwPosition.z-1.15});Object.assign(stop,{x:throwPosition.x,z:landing.z-1.1});first={...stop};
281
+ }else{
282
+ const sign=throwPosition.x>.1?-1:throwPosition.x<-.1?1:toyRand()<.5?-1:1,z=-1.25,radius=roomRadius(z);
283
+ Object.assign(landing,{x:sign*radius*.1,z});Object.assign(stop,{x:sign*radius*.28,z});first={x:sign*radius*.75,z};
284
+ }
285
+ }
286
+ let pickupRoot={...stop};
287
+ if(actor.canPickup===true){
288
+ const from=Math.hypot(first.x-stop.x,first.z-stop.z)<1e-6?throwPosition:first,dx=stop.x-from.x,dz=stop.z-from.z,d=Math.hypot(dx,dz);
289
+ if(d>.001){const target=paperGroundTarget(stop.x-dx/d*.17,stop.z-dz/d*.17);pickupRoot={x:target[0],z:target[1]};}
290
+ if(pattern==='clean'||pattern==='rolling'||pattern==='tumble'&&actor.canSinglePassScramble)first={...pickupRoot};
291
+ }
292
+ const returns=actor.canCarryTrot?FETCH_RETURN_STYLES.slice(1).filter(mode=>mode!==lastReturnMode):['brake'];
293
+ const returnMode=returnStyle==='auto'?returns[Math.floor(returnRand()*returns.length)]:returnStyle;
294
+ lastReturnMode=returnMode;
295
+ fetchPlan={pattern,stop,first,pickupRoot,returnMode};
296
+ active='ball';decisions++;ball={mode:'flight',start:clock,path:createBallPath({landing,stop})};
231
297
  queue=BALL_STEPS();hold=0;holdIntent=null;gesture=null;waiting=null;
232
298
  intent='fetch';label=LABEL.fetch;
233
299
  const urgent=typeof actor.chase==='function';
234
- if((urgent||SAFE.has(actor.state))&&actor[urgent?'chase':'go'](BALL_TARGET.x,BALL_TARGET.z)!==false)queue.shift();
300
+ if((urgent||SAFE.has(actor.state))&&actor[urgent?'chase':'go'](first.x,first.z,{scramble:pattern==='tumble'})!==false)queue.shift();
235
301
  return true;
236
302
  }
237
303
 
@@ -242,22 +308,24 @@ export function createCompanionLife(actor,{seed=731,energy=.8}={}){
242
308
  }
243
309
  function ballReport(){
244
310
  if(!ball)return null;
245
- if(ball.mode==='flight'){
246
- const p=clamp((clock-ball.start)/FLIGHT,0,1);
247
- if(p===1)return {x:BALL_TARGET.x,z:BALL_TARGET.z,height:0,carried:false};
248
- return {x:HOME.x+(BALL_TARGET.x-HOME.x)*p,z:HOME.z+(BALL_TARGET.z-HOME.z)*p,height:4*BALL_PEAK*p*(1-p),carried:false};
249
- }
311
+ const pose=actor.sample(clock);
312
+ // On a rear pickup the muzzle/ball is occluded behind the connected body.
313
+ // Do not draw the ground ball through the hindquarters to make it visible.
314
+ if(pose.state==='pickup'&&pose.view==='rear')return null;
315
+ if(pose.state==='pickup'&&pose.comfortPose?.progress>=.5)return {x:pose.x,z:pose.z,height:CARRY_HEIGHT,carried:true};
316
+ if(ball.mode==='flight'||ball.mode==='dropped')return {...ball.path.at(clock-ball.start),carried:false};
250
317
  if(ball.mode==='carried'){const s=actor.sample(clock);return {x:s.x,z:s.z,height:CARRY_HEIGHT,carried:true};}
251
318
  return {x:ball.x,z:ball.z,height:0,carried:false};
252
319
  }
253
320
 
254
321
  return {
255
322
  update,interact,
323
+ setReturnStyle(style){validateReturnStyle(style);returnStyle=style;return style;},
256
324
  get report(){
257
325
  const interaction=active??pending;
258
326
  return {
259
- intent,label:pending?`${label} · ${PENDING_LABEL[pending]}`:label,episode:lastEpisode,interaction,busy:interaction!==null,
260
- ball:ballReport(),treat:treatReport(),butterfly:butterflyPoint(),tug:actor.state==='tug'?{progress:actor.sample(clock).comfortPose?.progress??0,grip:1}:null,
327
+ intent:actor.state==='scramble'&&fetchPlan?'tumble':intent,label:pending?`${label} · ${PENDING_LABEL[pending]}`:actor.state==='scramble'&&fetchPlan?LABEL.tumble:label,episode:lastEpisode,interaction,busy:interaction!==null,
328
+ ball:ballReport(),fetchPattern:fetchPlan?.pattern??null,returnStyle,returnMode:fetchPlan?.returnMode??null,treat:treatReport(),butterfly:butterflyPoint(),tug:actor.state==='tug'?{progress:actor.sample(clock).comfortPose?.progress??0,grip:1}:null,
261
329
  snore:actor.state==='sleep'?((clock%3.6)/3.6):null,energy:needs.energy,drowsiness:needs.drowsiness,decisions,cooldowns:{...cooldowns},
262
330
  // Local hold clock only: progress freezes between updates; null outside the stationary hold.
263
331
  gesture:gesture&&SAFE.has(actor.state)?{clip:gesture.clip,progress:clamp(gesture.elapsed/gesture.duration,0,1)}:null
@@ -47,10 +47,12 @@ export function paintCompanionObjects(pixels,width,height,projection,report,stat
47
47
  const up=projection.project(o.x,(o.height??0)+o.radius,o.z);ry=Math.abs(up.y-p.y)*height;
48
48
  }
49
49
  if(![cx,cy,rx,ry].every(Number.isFinite)||rx<=0||ry<=0)continue;
50
+ const spin=o.spin??0,cos=Math.cos(spin),sin=Math.sin(spin);
50
51
  for(let y=Math.max(0,Math.floor(cy-ry));y<Math.min(height,Math.ceil(cy+ry));y++)
51
52
  for(let x=Math.max(0,Math.floor(cx-rx));x<Math.min(width,Math.ceil(cx+rx));x++){
52
53
  const u=(x+.5-cx)/rx,v=(y+.5-cy)/ry;if(u*u+v*v>1)continue;
53
- const tone=o.kind==='treat'?((x+y)%3?190:85):Math.abs(v+.25*u)<.18?85:225;
54
+ const stripe=(v*cos+u*sin)+.25*(u*cos-v*sin);
55
+ const tone=o.kind==='treat'?((x+y)%3?190:85):Math.abs(stripe)<.18?85:225;
54
56
  const j=(y*width+x)*4;pixels[j]=pixels[j+1]=pixels[j+2]=tone;
55
57
  }
56
58
  }
@@ -2,14 +2,17 @@
2
2
  // fractional edge coverage. No temporal blur, face warp or minimum sprite size.
3
3
  export function createPaperAreaSampler(n=320){
4
4
  if(!Number.isInteger(n)||n<2||n>640)throw Error('Invalid area source size');
5
- const pitch=n+1,sums=new Float64Array(pitch*pitch);
5
+ // Byte images have an integer sum <=640*640*255, exactly representable in
6
+ // uint32. Allocate only when minification is actually used. Fractional/custom
7
+ // inputs retain the old Float64 arithmetic rather than truncating their art.
8
+ const pitch=n+1;let sums=null;
6
9
  const integral=(x,y)=>{
7
10
  x=Math.max(0,Math.min(n,x));y=Math.max(0,Math.min(n,y));
8
11
  const ix=Math.min(n-1,Math.floor(x)),iy=Math.min(n-1,Math.floor(y)),fx=x-ix,fy=y-iy,k=iy*pitch+ix;
9
12
  return (sums[k]*(1-fx)+sums[k+1]*fx)*(1-fy)+(sums[k+pitch]*(1-fx)+sums[k+pitch+1]*fx)*fy;
10
13
  };
11
14
  return{
12
- prepare(image){if(image.length!==n*n*4)throw Error('Invalid area source');sums.fill(0);for(let y=0;y<n;y++){let row=0;for(let x=0;x<n;x++){row+=image[(y*n+x)*4];sums[(y+1)*pitch+x+1]=sums[y*pitch+x+1]+row;}}},
13
- sample(x,y,dx,dy){if(![x,y,dx,dy].every(Number.isFinite)||Math.min(dx,dy)<=0)throw Error('Invalid area footprint');const l=x-dx/2,r=x+dx/2,t=y-dy/2,b=y+dy/2;return Math.max(0,(integral(r,b)-integral(l,b)-integral(r,t)+integral(l,t))/(dx*dy));}
15
+ prepare(image){if(image.length!==n*n*4)throw Error('Invalid area source');const Type=image instanceof Uint8Array||image instanceof Uint8ClampedArray?Uint32Array:Float64Array;if(!(sums instanceof Type))sums=new Type(pitch*pitch);else sums.fill(0);for(let y=0;y<n;y++){let row=0;for(let x=0;x<n;x++){row+=image[(y*n+x)*4];sums[(y+1)*pitch+x+1]=sums[y*pitch+x+1]+row;}}},
16
+ sample(x,y,dx,dy){if(![x,y,dx,dy].every(Number.isFinite)||Math.min(dx,dy)<=0)throw Error('Invalid area footprint');if(!sums)return 0;const l=x-dx/2,r=x+dx/2,t=y-dy/2,b=y+dy/2;return Math.max(0,(integral(r,b)-integral(l,b)-integral(r,t)+integral(l,t))/(dx*dy));}
14
17
  };
15
18
  }