@bitefoundry/retrieviewer 0.0.16 → 0.0.18

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.
Files changed (83) hide show
  1. package/ASSET-PROVENANCE.md +11 -0
  2. package/ASSETS-LICENSE.md +6 -0
  3. package/ATTRIBUTION.md +8 -0
  4. package/DESKTOP-PLATFORMS.md +1 -1
  5. package/EMBEDDING.md +15 -9
  6. package/INSTALLING.md +2 -1
  7. package/LICENSE +16 -16
  8. package/NATIVE-RUNTIME.md +89 -0
  9. package/PREREQUISITES.md +7 -0
  10. package/PUPPY-MEMORY.md +70 -0
  11. package/README.ko.md +13 -1
  12. package/README.md +14 -1
  13. package/RELEASING.md +36 -1
  14. package/THIRD-PARTY-NOTICES.md +8 -0
  15. package/WINDOWS-COMPANION.md +15 -6
  16. package/braille.mjs +2 -2
  17. package/integrations/native-windows/CMakeLists.txt +15 -0
  18. package/integrations/native-windows/THIRD-PARTY-LICENSES.txt +77 -0
  19. package/integrations/native-windows/application.manifest +8 -0
  20. package/integrations/native-windows/bin/win32-x64/retrieviewer-native.exe +0 -0
  21. package/integrations/native-windows/desktop.mjs +45 -0
  22. package/integrations/native-windows/host.cpp +84 -0
  23. package/integrations/native-windows/manifest.json +14 -0
  24. package/integrations/native-windows/native-control.hpp +41 -0
  25. package/integrations/native-windows/native-window.hpp +128 -0
  26. package/integrations/native-windows/runtime.bundle.mjs +4694 -0
  27. package/integrations/native-windows/runtime.mjs +55 -0
  28. package/integrations/native-windows/sources.json +23 -0
  29. package/integrations/purrviewer/ART.md +289 -0
  30. package/integrations/purrviewer/ASSETS-LICENSE.md +21 -0
  31. package/integrations/purrviewer/LICENSE +24 -0
  32. package/integrations/purrviewer/art/ginger.bin.gz +0 -0
  33. package/integrations/purrviewer/art/ginger.json +1707 -0
  34. package/integrations/purrviewer/art/ginger.rvz +0 -0
  35. package/integrations/purrviewer/src/contact.mjs +63 -0
  36. package/integrations/purrviewer/src/habitat.mjs +41 -0
  37. package/integrations/purrviewer/src/life.mjs +280 -0
  38. package/integrations/purrviewer/src/purrviewer-core.mjs +130 -0
  39. package/integrations/purrviewer/src/purrviewer.mjs +37 -0
  40. package/integrations/purrviewer/src/walk-cycle.mjs +3 -0
  41. package/integrations/purrviewer/src/world.mjs +216 -0
  42. package/integrations/purrviewer/vendor/retrieviewer/LICENSE-CODE +21 -0
  43. package/integrations/purrviewer/vendor/retrieviewer/braille.mjs +73 -0
  44. package/integrations/purrviewer/vendor/retrieviewer/puppy-frame-file.mjs +15 -0
  45. package/integrations/purrviewer/vendor/retrieviewer/puppy-frame-indexed.mjs +78 -0
  46. package/integrations/purrviewer/vendor/retrieviewer/puppy-frame-predictive.mjs +170 -0
  47. package/integrations/purrviewer/vendor/retrieviewer/puppy-frame-store.mjs +114 -0
  48. package/integrations/windows-overlay/desktop.mjs +11 -3
  49. package/integrations/windows-overlay/worker.mjs +9 -2
  50. package/package.json +53 -13
  51. package/pet-indexed-manifest.json +106 -0
  52. package/puppy-clean.mjs +2 -2
  53. package/puppy-companion-comfort-q5.rvz +0 -0
  54. package/puppy-companion-interactions-v3-q5.rvz +0 -0
  55. package/puppy-companion-sprites.mjs +2 -2
  56. package/puppy-frame-file.mjs +15 -0
  57. package/puppy-frame-indexed.mjs +78 -0
  58. package/puppy-frame-predictive.mjs +1 -1
  59. package/puppy-frame-store.mjs +6 -0
  60. package/puppy-paper-actions-v9-q5.rvz +0 -0
  61. package/puppy-paper-actions.mjs +2 -2
  62. package/puppy-paper-approach-q5.rvz +0 -0
  63. package/puppy-paper-arrival-seat-q5.rvz +0 -0
  64. package/puppy-paper-behaviors-q5.rvz +0 -0
  65. package/puppy-paper-behaviors.mjs +1 -1
  66. package/puppy-paper-contact.mjs +2 -2
  67. package/puppy-paper-rear-gallop-q5.rvz +0 -0
  68. package/puppy-paper-seat-q5.rvz +0 -0
  69. package/puppy-paper-seat.mjs +2 -2
  70. package/puppy-paper.mjs +12 -12
  71. package/puppy-stride-tweens-q5.rvz +0 -0
  72. package/retrieviewer-cli.mjs +10 -6
  73. package/retrieviewer.d.mts +1 -1
  74. package/retrieviewer.mjs +35 -14
  75. package/puppy-companion-comfort-q5.bin.gz +0 -0
  76. package/puppy-companion-interactions-v3-q5.bin.gz +0 -0
  77. package/puppy-paper-actions-v9-q5.bin.gz +0 -0
  78. package/puppy-paper-approach-q5.bin.gz +0 -0
  79. package/puppy-paper-arrival-seat-q5.bin.gz +0 -0
  80. package/puppy-paper-behaviors-q5.bin.gz +0 -0
  81. package/puppy-paper-rear-gallop-q5.bin.gz +0 -0
  82. package/puppy-paper-seat-q5.bin.gz +0 -0
  83. package/puppy-stride-tweens-q5.bin.gz +0 -0
@@ -0,0 +1,63 @@
1
+ // Bounded cat adapter for the MIT Retrieviewer image-space contact approach.
2
+ // Ground anchors adapt puppy-paper-contact.mjs; its license
3
+ // is retained at vendor/retrieviewer/LICENSE-CODE. No dog dimensions/art used.
4
+ import {projectCat,unprojectCatGround} from './world.mjs';
5
+ const viewport={width:320,height:180};
6
+ export function createCatContacts(track){
7
+ // Disjoint ownership disks include swing paws, so a planted toe cannot pull
8
+ // its neighbour. Bake their sizes once from this immutable source schedule.
9
+ const radii=track.feet.map(row=>row.map(foot=>Math.min(28,...row.filter(other=>other.id!==foot.id)
10
+ .map(other=>.44*Math.hypot(other.x-foot.x,other.y-foot.y)))));
11
+ const anchors=new Map();let feet=[],mirror=false,pose=0;
12
+ return {
13
+ reset(){anchors.clear();feet=[];},
14
+ update(sample,flipped){
15
+ mirror=flipped;const n=track.feet.length,phase=((sample.phase%1)+1)%1,index=Math.min(n-1,Math.floor(phase*n)),cycle=Math.floor(sample.phase);
16
+ pose=index;feet=track.feet[index];const p=projectCat(sample.x,0,sample.z,viewport),scale=p.size/320,sign=mirror?-1:1;
17
+ const active=new Set();
18
+ for(const [j,foot] of feet.entries()){
19
+ if(!foot.stance)continue;active.add(foot.id);
20
+ const start=track.starts[foot.id][index],episode=(cycle-(start>index?1:0))+'/'+start;
21
+ let anchor=anchors.get(foot.id);
22
+ if(!anchor||anchor.episode!==episode){
23
+ anchor={episode,world:unprojectCatGround(p.x+sign*(foot.x-160)*scale,p.y+(foot.y-290)*scale,viewport),released:false};anchors.set(foot.id,anchor);
24
+ }
25
+ // A small paw-only correction may reacquire its original world support
26
+ // when the next authored key fits. An incompatible key stays unwarped.
27
+ const target=projectCat(anchor.world.x,0,anchor.world.z,viewport);
28
+ const dx=160+sign*(target.x-p.x)/scale-foot.x,dy=290+(target.y-p.y)/scale-foot.y;
29
+ anchor.released=Math.hypot(dx,dy)>Math.min(6,radii[index][j]*.22)||Math.abs(dy)>3;
30
+ }
31
+ for(const id of anchors.keys())if(!active.has(id))anchors.delete(id);
32
+ },
33
+ pins(sample){
34
+ const p=projectCat(sample.x,0,sample.z,viewport),scale=p.size/320,sign=mirror?-1:1,pins=[];
35
+ for(const [j,foot] of feet.entries()){const anchor=anchors.get(foot.id);if(!foot.stance||!anchor||anchor.released)continue;
36
+ const target=projectCat(anchor.world.x,0,anchor.world.z,viewport);
37
+ pins.push({id:foot.id,radius:radii[pose][j],source:{x:foot.x,y:foot.y},target:{x:160+sign*(target.x-p.x)/scale,y:290+(target.y-p.y)/scale},world:{...anchor.world},episode:anchor.episode});
38
+ }
39
+ return pins;
40
+ }
41
+ };
42
+ }
43
+
44
+ export function footSourcePoint(x,y,pins,out={}){
45
+ out.x=x;out.y=y;
46
+ if(y<=225||!pins.length)return out;
47
+ for(const pin of pins){
48
+ const dx=pin.target.x-pin.source.x,dy=pin.target.y-pin.source.y,radius=pin.radius??28;
49
+ if(radius<=0||Math.hypot(dx,dy)>Math.min(6,.22*radius)||Math.abs(dy)>3)continue;
50
+ if(Math.hypot(x-pin.source.x,y-pin.source.y)>=radius)continue;
51
+ // Invert ONE compact forward translation with a rigid paw core. Its gradient
52
+ // norm is <= .22*1.5/.7 < .472, so it cannot fold or duplicate a toe. Outside
53
+ // its disk it is exactly identity. No winner-switch between overlapping warps.
54
+ let u=x-dx,v=y-dy;
55
+ for(let i=0;i<12;i++){
56
+ const distance=Math.hypot(u-pin.source.x,v-pin.source.y);
57
+ const t=Math.max(0,Math.min(1,(distance-radius*.3)/(radius*.7))),weight=1-t*t*(3-2*t);
58
+ u=x-dx*weight;v=y-dy*weight;
59
+ }
60
+ out.x=u;out.y=v;return out;
61
+ }
62
+ return out;
63
+ }
@@ -0,0 +1,41 @@
1
+ // Small code-native furniture shares the cat's meter space and fixed camera.
2
+ // No textures, canvas, retained bitmap or per-object simulation/timer.
3
+ import {projectCat} from './world.mjs';
4
+ export const CAT_HABITAT=Object.freeze({
5
+ tower:Object.freeze({x:-.61,z:-1.9,lowX:-.43,lowY:.21,highX:-.65,highY:.42,approachX:-.12}),
6
+ wheel:Object.freeze({x:.57,z:-1.9,radius:.42,catX:.53,stride:.32})
7
+ });
8
+ export function paintHabitat(pixels,width,height,angle=0){
9
+ const at=(x,y,value)=>{x=Math.round(x);y=Math.round(y);if(x<0||y<0||x>=width||y>=height)return;
10
+ const i=(y*width+x)*4;if(pixels[i])return;pixels[i]=pixels[i+1]=pixels[i+2]=value;};
11
+ const point=(x,y,z)=>projectCat(x,y,z,{width,height});
12
+ const line=(a,b,value=105)=>{const n=Math.max(1,Math.ceil(Math.hypot(b.x-a.x,b.y-a.y)));
13
+ for(let i=0;i<=n;i++)at(a.x+(b.x-a.x)*i/n,a.y+(b.y-a.y)*i/n,value);};
14
+ const edge=(x,y,z,xx,yy,zz,value)=>line(point(x,y,z),point(xx,yy,zz),value);
15
+ const t=CAT_HABITAT.tower,w=CAT_HABITAT.wheel;
16
+ const platform=(x,y,half)=>{
17
+ edge(x-half,y,t.z-.12,x+half,y,t.z-.12,150);
18
+ edge(x-half,y,t.z+.12,x+half,y,t.z+.12,205);
19
+ edge(x-half,y,t.z-.12,x-half,y,t.z+.12,150);
20
+ edge(x+half,y,t.z-.12,x+half,y,t.z+.12,150);
21
+ edge(x-half,y-.025,t.z+.12,x+half,y-.025,t.z+.12,100);
22
+ };
23
+ platform(t.x,0,.34);platform(t.lowX,t.lowY,.21);platform(t.highX,t.highY,.21);
24
+ for(const [x,top] of [[t.lowX,t.lowY],[t.highX,t.highY]]){
25
+ edge(x-.025,0,t.z,x-.025,top-.025,t.z,90);edge(x+.025,0,t.z,x+.025,top-.025,t.z,125);
26
+ for(let y=.025;y<top-.025;y+=.025)edge(x-.024,y,t.z,x+.024,y+.008,t.z,65);
27
+ }
28
+ for(const [z,value] of [[w.z-.08,65],[w.z,155]]){
29
+ let prev=point(w.x+w.radius,w.radius,z);
30
+ for(let i=1;i<=112;i++){const a=i*Math.PI*2/112,next=point(w.x+w.radius*Math.cos(a),w.radius+w.radius*Math.sin(a),z);line(prev,next,value);prev=next;}
31
+ }
32
+ // Rim markings move backwards at the tread while the cat faces screen right.
33
+ for(let i=0;i<24;i++){
34
+ const a=i*Math.PI*2/24-angle;
35
+ edge(w.x+(w.radius-.018)*Math.cos(a),w.radius+(w.radius-.018)*Math.sin(a),w.z,
36
+ w.x+(w.radius-.05)*Math.cos(a),w.radius+(w.radius-.05)*Math.sin(a),w.z,110);
37
+ }
38
+ edge(w.x-.25,-.025,w.z,w.x+.25,-.025,w.z,130);
39
+ edge(w.x-.25,-.025,w.z,w.x-.18,.045,w.z,95);
40
+ edge(w.x+.25,-.025,w.z,w.x+.18,.045,w.z,95);
41
+ }
@@ -0,0 +1,280 @@
1
+ // Authored companion cadence, not a biological model. The host owns motion/time.
2
+ import {CAT_HABITAT} from './habitat.mjs';
3
+ const GROUND = 1e-6;
4
+ const hold = (clip, duration) => ({clip, duration, elapsed: 0, progress: 0});
5
+ const matHold = (clip,duration) => ({...hold(clip,duration),mat:true});
6
+ const towerLoad=(duration,facing)=>({...hold('tower-load',duration),facing});
7
+ const travel = (clip, command) => ({clip, command, issued: false, acknowledged: false, progress: 0});
8
+ const stretchPhases = () => [hold('stretch-enter', 1.15), hold('stretch-reach', .65),
9
+ hold('stretch-hold', 1.25), hold('stretch-release', .7), hold('stretch-leave', 1.15)];
10
+
11
+ export function createCatLife({seed = 731} = {}) {
12
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) {
13
+ throw new RangeError('seed must be an unsigned 32-bit integer');
14
+ }
15
+ let randomState = seed;
16
+ const random = () => {
17
+ randomState = (randomState + 0x6d2b79f5) >>> 0;
18
+ let n = Math.imul(randomState ^ randomState >>> 15, randomState | 1);
19
+ n ^= n + Math.imul(n ^ n >>> 7, n | 61);
20
+ return ((n ^ n >>> 14) >>> 0) / 4294967296;
21
+ };
22
+ const range = (min, max) => min + random() * (max - min);
23
+ let clock = 0;
24
+ let lastChoice = 'observe';
25
+ let roamAfter = 20;
26
+ let playAfter = 90;
27
+ let companyAfter = 60, attentionUntil = 0, eyeAfter = 0;
28
+ let habitatAfter=100;
29
+ let pending = null;
30
+ let stopSent = false;
31
+ let episode = {intent: 'observe', label: 'Watching quietly', direct: false,
32
+ phases: [hold('sit', 1.2), hold('eye-greeting',3.4),hold('tail-swish', 3.6), hold('loaf-enter', .9),
33
+ hold('loaf-rest', range(12, 24)), hold('loaf-leave', .9), hold('sit', 2)]};
34
+ let index = 0;
35
+ const current = () => episode.phases[index];
36
+ const grounded = sample => !sample.airborne&&sample.y <= (sample.supportY??0)+GROUND;
37
+ const settled = sample => grounded(sample) && !sample.moving;
38
+
39
+ function toyPhases(sample, target) {
40
+ const dx = target.x - sample.x;
41
+ const dz = target.z - sample.z;
42
+ const distance = Math.hypot(dx, dz);
43
+ const phases = [];
44
+ if (distance > .35) {
45
+ phases.push(travel('creep', {kind: 'go', x: target.x - dx / distance * .3,
46
+ z: target.z - dz / distance * .3, gait: 'creep'}));
47
+ }
48
+ phases.push(hold('crouch', .65),
49
+ travel('pounce', {kind: 'pounce', x: target.x, z: target.z, height: .18}),
50
+ hold('land', .3), hold('bat', .9), hold('sit', 2.5));
51
+ return phases;
52
+ }
53
+
54
+ function localToy(sample) {
55
+ const direction=Math.abs(sample.x)>.3?(sample.x>0?-1:1):(random()<.5?-1:1);
56
+ return {x:sample.x+direction*.6,z:sample.z};
57
+ }
58
+
59
+ function companyPhases(sample) {
60
+ const target={x:sample.x<0?-.18:.18,z:Math.min(0,Math.max(-.35,sample.z+.65))};
61
+ const phases=[];
62
+ // Choose one nearby rest place, at most .8m away. A distant cat comes a
63
+ // little closer rather than instantly returning to the front every time.
64
+ const dx=target.x-sample.x,dz=target.z-sample.z,distance=Math.hypot(dx,dz);
65
+ if(distance>.18){const scale=Math.min(1,.8/distance);
66
+ phases.push(travel('walk',{kind:'go',x:sample.x+dx*scale,z:sample.z+dz*scale,gait:'walk'}));}
67
+ phases.push(hold('sit',1.2),...(clock>=eyeAfter?[hold('eye-greeting',3.4)]:[]),matHold('knead-enter',.8),
68
+ matHold('knead',4.8),matHold('knead-leave',.8),matHold('loaf-rest',range(25,45)),
69
+ matHold('loaf-leave',.9),matHold('sit',2));
70
+ companyAfter=clock+range(180,300);
71
+ return phases;
72
+ }
73
+
74
+ function descent(sample){
75
+ if(!(sample.supportY>0))return [];
76
+ const t=CAT_HABITAT.tower;
77
+ return [towerLoad(.4,1),travel('tower-down',{kind:'pounce',x:t.approachX,z:t.z,height:.055,landingY:0}),
78
+ hold('tower-land',.4),hold('sit',.7)];
79
+ }
80
+ function habitatPhases(kind){
81
+ habitatAfter=clock+range(160,260);
82
+ const t=CAT_HABITAT.tower,w=CAT_HABITAT.wheel;
83
+ if(kind==='wheel')return [travel('walk',{kind:'go',x:w.catX,z:w.z,gait:'walk'}),
84
+ hold('wheel-enter',.65),travel('wheel-run',{kind:'wheel',duration:10,speed:2*10*w.stride/10,radius:w.radius,stride:w.stride}),
85
+ hold('wheel-leave',.7),travel('walk',{kind:'go',x:w.catX-.3,z:w.z+.14,gait:'walk'}),hold('sit',3)];
86
+ return [travel('walk',{kind:'go',x:t.approachX,z:t.z,gait:'walk'}),towerLoad(.55,-1),
87
+ travel('tower-up',{kind:'pounce',x:t.lowX,z:t.z,height:.07,landingY:t.lowY}),hold('tower-land',.35),
88
+ towerLoad(.4,-1),travel('tower-up',{kind:'pounce',x:t.highX,z:t.z,height:.065,landingY:t.highY}),
89
+ hold('tower-land',.35),hold('sit',2),hold('eye-greeting',3.4),hold('loaf-enter',.9),
90
+ hold('loaf-rest',range(15,25)),hold('loaf-leave',.9),towerLoad(.4,1),
91
+ travel('tower-down',{kind:'pounce',x:t.lowX,z:t.z,height:.04,landingY:t.lowY}),hold('tower-land',.35),
92
+ ...descent({supportY:t.lowY})];
93
+ }
94
+
95
+ function beginInteraction(sample) {
96
+ const {kind, target} = pending;
97
+ const previousPhase=current(),previous = previousPhase.clip;
98
+ const fromSleep = ['sleep','loaf'].includes(previous);
99
+ const recovery = previous==='wheel-run'?[hold('wheel-leave',.7)]:
100
+ ['loaf-enter', 'loaf-rest'].includes(previous) ? [previousPhase.mat?matHold('loaf-leave',.9):hold('loaf-leave', .9)] :
101
+ ['knead-enter','knead'].includes(previous) ? [matHold('knead-leave',.8),matHold('loaf-leave',.9)] :
102
+ previous==='knead-leave' ? [matHold('loaf-leave',.9)] :
103
+ fromSleep ? [hold('wake', 1.8), ...(kind==='stretch'?[]:[hold('stretch-leave',1.15)])] :
104
+ ['stretch-reach','stretch-hold'].includes(previous) ? [hold('stretch-release', .7),hold('stretch-leave',1.15)] :
105
+ ['stretch-enter','stretch-release','wake'].includes(previous) ? [hold('stretch-leave',1.15)] :
106
+ previous.startsWith('feed-') && previous !== 'feed-leave' ? [hold('feed-leave', 1.35)] :
107
+ ['treat','stretch','greet','company'].includes(kind) && ['walk','creep','land'].includes(previous) ? [hold('sit', .55)] : [];
108
+ pending = null;
109
+ stopSent = false;
110
+ index = 0;
111
+ // Discard every old phase/target; direct play never resumes a stale route.
112
+ episode = {intent: kind, direct: true,
113
+ label: kind === 'toy' ? 'Stalking a toy' : kind === 'treat' ? 'Enjoying a nearby treat' :
114
+ kind==='stretch'?'A long supported stretch':kind==='tail'?'A little tail-tip attention':
115
+ kind==='company'?'Choosing a quiet place nearby':'A soft eye greeting',
116
+ phases: [...recovery,...descent(sample), ...(kind === 'toy' ? toyPhases(sample, target ?? localToy(sample)) :
117
+ kind === 'treat' ? [hold('feed-enter', 1.35), hold('feed-sniff', .45),
118
+ hold('feed-lick', 3.6), hold('feed-finish', .45), hold('feed-leave', 1.35), hold('sit', 1)] :
119
+ kind === 'stretch' ? [...stretchPhases().slice(fromSleep?1:0),hold('sit',2)] :
120
+ kind === 'tail' ? [hold('tail-swish',3.6),hold('sit',1)] :
121
+ kind === 'company' ? companyPhases(sample) :
122
+ ['tower','wheel'].includes(kind)?habitatPhases(kind):
123
+ clock>=eyeAfter?[hold('sit', .45), hold('eye-greeting', 3.4), hold('sit', 1)]:[hold('sit',1.2)])]};
124
+ // Quiet time follows a direct episode before autonomous travel/play returns.
125
+ roamAfter = Math.max(roamAfter, clock + 20);
126
+ playAfter = Math.max(playAfter, clock + 90);
127
+ }
128
+
129
+ function choose(sample) {
130
+ const choices = [['observe', 35], ['groom', 20], ['sleep', 20], ['stretch', 8],
131
+ ['roam', clock >= roamAfter ? 12 : 0], ['play', clock >= playAfter ? 5 : 0],
132
+ ['company',clock>=companyAfter?(clock<attentionUntil?12:6):0],
133
+ ['tower',clock>=habitatAfter?5:0],['wheel',clock>=habitatAfter?3:0]]
134
+ .filter(([kind, weight]) => kind !== lastChoice && weight > 0);
135
+ let ticket = random() * choices.reduce((sum, [, weight]) => sum + weight, 0);
136
+ const kind = choices.find(([, weight]) => (ticket -= weight) < 0)[0];
137
+ lastChoice = kind;
138
+ let phases;
139
+ let label;
140
+ switch (kind) {
141
+ case 'observe':
142
+ label = 'Watching quietly';
143
+ phases = random() < .7 ? [hold('sit', 2), hold('tail-swish',3.6),hold('loaf-enter', .9),
144
+ hold('loaf-rest', range(12, 24)), hold('loaf-leave', .9), hold('sit', 2)] :
145
+ [hold('sit', range(8, 16)), random()<.5?hold('eye-greeting',3.4):hold('blink',.8), hold('tail-tip',2.8),hold('idle', range(4, 8))];
146
+ break;
147
+ case 'groom':
148
+ label = 'A little grooming';
149
+ phases = [hold('sit', .6), hold('groom', range(5, 9)), hold('sit', 2)];
150
+ break;
151
+ case 'sleep':
152
+ label = 'A long quiet nap';
153
+ phases = [hold('loaf', 2), hold('sleep', range(60, 120)), hold('wake', 1.8),
154
+ ...stretchPhases().slice(1), hold('sit', 2)];
155
+ break;
156
+ case 'stretch':
157
+ label = 'Stretching out';
158
+ phases = [...stretchPhases(), hold('sit', range(5, 9))];
159
+ break;
160
+ case 'roam': {
161
+ label = 'A short wander';
162
+ const angle = range(0, Math.PI * 2);
163
+ const distance = range(.35, .8);
164
+ let x=Math.max(-.55,Math.min(.55,sample.x+Math.cos(angle)*distance));
165
+ let z=Math.max(-3.5,Math.min(0,sample.z+Math.sin(angle)*distance));
166
+ if(Math.hypot(x-sample.x,z-sample.z)<.15){x=sample.x>.0?-.35:.35;z=Math.max(-3,Math.min(0,sample.z-.3));}
167
+ phases = [hold('idle', .8), travel('walk', {kind: 'go',
168
+ x,z,gait:'walk'}),hold('sit',range(6,10))];
169
+ roamAfter = clock + 35;
170
+ break;
171
+ }
172
+ case 'play':
173
+ label = 'An imagined little hunt';
174
+ phases = toyPhases(sample, localToy(sample));
175
+ playAfter = clock + 120;
176
+ break;
177
+ case 'company':
178
+ label='Choosing a quiet place nearby';phases=companyPhases(sample);break;
179
+ case 'tower':case 'wheel':label=kind==='tower'?'A high quiet perch':'A short wheel run';phases=habitatPhases(kind);break;
180
+ }
181
+ episode = {intent: kind, label, direct: false, phases};
182
+ index = 0;
183
+ }
184
+
185
+ function advance(sample) {
186
+ const phase = current();
187
+ // An issued pounce always gets its landing recovery, even when interrupted.
188
+ if (pending && phase.command?.kind !== 'pounce') {
189
+ beginInteraction(sample);
190
+ } else if (++index === episode.phases.length) {
191
+ if(episode.direct&&['call','greet','treat'].includes(episode.intent))attentionUntil=clock+90;
192
+ choose(sample);
193
+ }
194
+ if(current().clip==='eye-greeting')eyeAfter=clock+20;
195
+ }
196
+
197
+ function step(dt, sample) {
198
+ if (!Number.isFinite(dt) || dt < 0 || dt > .1) {
199
+ throw new RangeError('dt must be finite and between 0 and .1 seconds');
200
+ }
201
+ if (!sample || !['x', 'y', 'z'].every(key => Number.isFinite(sample[key])) ||
202
+ sample.y < 0 || typeof sample.moving !== 'boolean') {
203
+ throw new TypeError('motionSample requires finite x/y/z, nonnegative y and boolean moving');
204
+ }
205
+ if (dt === 0) return null;
206
+ clock += dt;
207
+ let phase = current();
208
+ const committedPounce = phase.command?.kind === 'pounce' && phase.issued;
209
+ if (pending && !committedPounce) {
210
+ // Never cancel airborne motion, including externally driven ballistic arcs.
211
+ if (!grounded(sample)) return null;
212
+ if (sample.moving || (phase.command && phase.issued && !stopSent)) {
213
+ if (!stopSent) {
214
+ stopSent = true;
215
+ return {kind: 'stop'};
216
+ }
217
+ return null;
218
+ }
219
+ if (stopSent || (phase.command && !phase.issued)) {
220
+ beginInteraction(sample);
221
+ return null;
222
+ }
223
+ }
224
+ if (phase.done && settled(sample)) {
225
+ advance(sample);
226
+ return null; // Expose the next source clip's entry endpoint at progress 0.
227
+ }
228
+ if (phase.command) {
229
+ if (!phase.issued) {
230
+ if (!settled(sample)) return null;
231
+ phase.issued = true;
232
+ return {...phase.command};
233
+ }
234
+ if (sample.moving) phase.acknowledged = true;
235
+ if (phase.acknowledged && settled(sample)) {
236
+ phase.progress = 1;
237
+ phase.done = true;
238
+ }
239
+ return null;
240
+ }
241
+ if (!settled(sample)) return null;
242
+ // Complete source endpoints before changing pose. Quiet holds yield in .2s;
243
+ // landing retains its authored recovery duration. No per-behavior timer.
244
+ if (pending && !['land','tower-land'].includes(phase.clip)) {
245
+ phase.exitStart ??= phase.progress;
246
+ phase.exitElapsed = (phase.exitElapsed ?? 0) + dt;
247
+ phase.progress = phase.exitStart + (1 - phase.exitStart) * Math.min(1, phase.exitElapsed / .2);
248
+ } else {
249
+ phase.elapsed = Math.min(phase.duration, phase.elapsed + dt);
250
+ phase.progress = Math.min(1, phase.elapsed / phase.duration);
251
+ }
252
+ phase.done = phase.progress >= 1;
253
+ return null;
254
+ }
255
+
256
+ return {
257
+ step,
258
+ interact(kind, {target} = {}) {
259
+ if (!['treat', 'toy', 'call','stretch','tail','greet','company','tower','wheel'].includes(kind)) return false;
260
+ if (target !== undefined && (!target || !Number.isFinite(target.x) || !Number.isFinite(target.z))) return false;
261
+ // Repeated greetings do not restart a half-finished expression or turn
262
+ // quiet attention into an obedience loop. Other interactions still preempt.
263
+ if(['call','greet'].includes(kind)&&(current().clip==='eye-greeting'||
264
+ (episode.direct&&['call','greet'].includes(episode.intent)&&!pending)))return true;
265
+ if(kind==='company'&&episode.intent==='company'&&!pending)return true;
266
+ if(['tower','wheel'].includes(kind)&&episode.intent===kind&&!pending)return true;
267
+ pending = {kind, target: target === undefined ? undefined : {x: target.x, z: target.z}};
268
+ return true;
269
+ },
270
+ get report() {
271
+ const phase = current();
272
+ return {intent: episode.intent, label: episode.label, clip: phase.clip,
273
+ progress: phase.progress, busy: Boolean(pending || episode.direct || ['roam', 'play'].includes(episode.intent)),
274
+ food: phase.clip.startsWith('feed-') ? {remaining: phase.clip === 'feed-lick' ? 1-phase.progress :
275
+ ['feed-finish','feed-leave'].includes(phase.clip) ? 0 : 1} : null,
276
+ mat: Boolean(phase.mat),facing:phase.facing??null,resting:['sleep','loaf-rest'].includes(phase.clip)&&!pending,
277
+ sleeping: phase.clip === 'sleep'};
278
+ }
279
+ };
280
+ }
@@ -0,0 +1,130 @@
1
+ import {rgbaToBraille} from '../vendor/retrieviewer/braille.mjs';
2
+ import {createCatMotion,projectCat} from './world.mjs';
3
+ import {createCatLife} from './life.mjs';
4
+ import {createCatContacts,footSourcePoint} from './contact.mjs';
5
+ import {paintHabitat} from './habitat.mjs';
6
+ export function createCatFromAssets({seed=731}={},lease,kernel=null){
7
+ if(!Number.isInteger(seed)||seed<0||seed>0xffffffff)throw TypeError('Invalid cat seed');
8
+ let asset=lease.data;const release=lease.release,motion=createCatMotion(),life=createCatLife({seed}),contacts=createCatContacts(asset.meta.walk),sourcePoint={x:0,y:0};
9
+ let pixels=null,disposed=false,lastClip='sit',lastFrame=null,prep=null,view='front',mirror=false,settle=null,lastMoving=false,lastLifeClip='sit',lastPaintKey='',lastText='',rasterizations=0,heldHits=0;
10
+ const alive=()=>{if(disposed)throw Error('Cat is disposed');};
11
+ const direct=command=>{
12
+ if(command.kind==='go')motion.go(command.x,command.z,{gait:command.gait});
13
+ else if(command.kind==='pounce')motion.pounce(command.x,command.z,{height:command.height,landingY:command.landingY??0});
14
+ else if(command.kind==='wheel'){view='side';mirror=false;settle=null;motion.wheel(command);}
15
+ else if(command.kind==='stop'){prep=null;motion.stop();}
16
+ };
17
+ function issue(command){
18
+ if(command.kind==='go'&&view==='front'&&!motion.sample().moving){
19
+ const s=motion.sample(),dx=command.x-s.x,dz=command.z-s.z,length=Math.hypot(dx,dz);
20
+ if(length>.01){const targetView=Math.abs(dz/length)>.8?(dz>0?'front':'rear'):'side';prep={age:0,seconds:.4,command,targetView,clip:targetView==='front'?'rise-front':'rise'};mirror=dx<0;settle=null;return;}
21
+ }
22
+ if(command.kind!=='stop')settle=null;
23
+ direct(command);
24
+ }
25
+ return {
26
+ step(dt){
27
+ alive();if(!Number.isFinite(dt)||dt<0||dt>.25)throw RangeError('Invalid cat dt');
28
+ let left=dt;while(left>1e-10){const step=Math.min(1/60,left);left-=step;
29
+ if(prep){prep.age+=step;if(prep.age>=prep.seconds){const cmd=prep.command;view=prep.targetView;prep=null;direct(cmd);}}
30
+ motion.step(step);const s=motion.sample();
31
+ const command=life.step(step,s);if(command)issue(command);
32
+ const report=life.report,clip=report.clip;
33
+ if(s.moving&&!prep&&!s.exercising){const sx=Math.sin(s.heading),sz=Math.cos(s.heading);mirror=sx<0;view=s.airborne?'side':Math.abs(sz)>.8?(sz>0?'front':'rear'):'side';settle=null;}
34
+ if(clip!==lastLifeClip){
35
+ if(clip==='stretch-enter'){view='front';mirror=false;settle=null;}
36
+ if(clip==='knead-enter'){view='front';mirror=false;settle=null;}
37
+ if(['tower-load','tower-land','wheel-enter','wheel-leave'].includes(clip)){view='side';settle=null;if(clip.startsWith('wheel'))mirror=false;}
38
+ if(clip==='tower-load')mirror=report.facing<0;
39
+ if(clip==='stretch-reach'){view='side';mirror=false;settle=null;}
40
+ if(clip==='feed-enter'){view='side';mirror=false;settle=null;}
41
+ // These authored sequences already finish on the canonical front sit.
42
+ // Replaying stand-to-sit here made the cat rise again after paw play.
43
+ if(clip==='sit'&&['bat','stretch','stretch-leave','feed-leave','loaf-leave'].includes(lastLifeClip)){view='front';mirror=false;settle=null;}
44
+ lastLifeClip=clip;
45
+ }
46
+ const quiet=['idle','sit','blink','eye-greeting','groom','loaf','sleep','tail-swish','tail-tip'].includes(clip);
47
+ if(!s.moving&&!prep&&quiet){
48
+ if(!settle&&(lastMoving||view!=='front'))settle={age:0,seconds:.45,clip:view==='front'?'sit-front':'sit-down'};
49
+ if(settle){settle.age+=step;if(settle.age>=settle.seconds){view='front';mirror=false;settle=null;}}
50
+ }else settle=null;
51
+ lastMoving=s.moving;
52
+ if(s.moving&&!s.airborne&&!s.exercising&&s.supportY===0&&!prep&&view==='side')contacts.update(s,mirror);else contacts.reset();
53
+ }
54
+ },
55
+ frame({columns=160,rows=72}={}){
56
+ alive();if(!Number.isInteger(columns)||!Number.isInteger(rows)||columns<8||rows<4||columns*rows>125000)throw RangeError('Invalid cat viewport');
57
+ const width=columns*2,height=rows*4,n=width*height*4;
58
+ if(!pixels||pixels.length!==n){pixels=new Uint8ClampedArray(n);for(let i=3;i<n;i+=4)pixels[i]=255;}
59
+ const s=motion.sample(),r=life.report;let clip=r.clip??'sit',progress=r.progress??0;
60
+ if(s.moving&&!s.airborne&&!s.exercising){
61
+ clip=view==='side'?'walk':view==='front'?'walk-front':'walk-rear';progress=((s.phase%1)+1)%1;
62
+ }
63
+ if(s.airborne){clip=r.clip==='tower-up'?'tower-flight':r.clip==='tower-down'?'tower-fall':'pounce';progress=s.flightProgress;}
64
+ if(s.exercising||r.clip==='wheel-run'){clip='wheel-run';progress=((s.wheelPhase%1)+1)%1;}
65
+ if(prep){clip=prep.clip;progress=Math.min(1,prep.age/prep.seconds);}
66
+ if(settle){clip=settle.clip;progress=Math.min(1,settle.age/settle.seconds);}
67
+ // Nine authored licking cycles in 3.6s. Rendering only samples simulation
68
+ // progress; a hidden or sparse host cannot change eating cadence.
69
+ if(clip==='feed-lick')progress=progress>=1?0:(progress*9)%1;
70
+ if(clip==='knead')progress=progress>=1?0:(progress*4)%1;
71
+ const keys=asset.meta.clips[clip]??asset.meta.clips.sit;
72
+ const pins=clip==='walk'?contacts.pins(s):[];
73
+ // Every authored key needs a real hold, including the recovery/exit key.
74
+ const key=keys[Math.min(keys.length-1,Math.floor(Math.max(0,Math.min(1,progress))*keys.length))];
75
+ const food=r.food?Math.ceil(r.food.remaining*16)/16:null;
76
+ const wheelAngle=Math.round(s.wheelAngle*120)/120;
77
+ const p=projectCat(s.x,s.y,s.z,{width,height,canvasMeters:.6}),paintKey=[width,height,key,mirror,p.x,p.y,p.size,food,r.mat,wheelAngle].join('/');
78
+ const dish=p&&food!==null?{x:p.x+p.size*(269/320-.5),y:p.y+p.size*(288-290)/320,
79
+ radius:p.size*18/320,remaining:food}:null;
80
+ const mat=r.mat?{x:p.x,y:p.y+p.size*3/320,radiusX:p.size*72/320,radiusY:p.size*12/320}:null;
81
+ const result=text=>({text,pixels,columns,rows,color:asset.meta.color,state:{...s,clip,frame:key},contacts:pins,report:r,placement:p,dish,mat});
82
+ lastClip=clip;lastFrame=key;
83
+ if(paintKey===lastPaintKey){heldHits++;return result(lastText);}
84
+ if(kernel?.clear)kernel.clear(pixels);else for(let i=0;i<n;i+=4)pixels[i]=pixels[i+1]=pixels[i+2]=0;
85
+ const gray=asset.store.getFrame(key);
86
+ if(p){
87
+ const size=p.size,left=p.x-size*.5,top=p.y-size*(290/320),x0=Math.max(0,Math.floor(left)),y0=Math.max(0,Math.floor(top)),x1=Math.min(width,Math.ceil(left+size)),y1=Math.min(height,Math.ceil(top+size));
88
+ if(kernel?.catBlit)kernel.catBlit(pixels,gray,{width,height,x:p.x,y:p.y,size:p.size,mirror,pins});else for(let y=y0;y<y1;y++)for(let x=x0;x<x1;x++){
89
+ let u=(x+.5-left)*320/size-.5,v=(y+.5-top)*320/size-.5;if(mirror)u=319-u;
90
+ if(v>225&&pins.length){const source=footSourcePoint(u,v,pins,sourcePoint);u=source.x;v=source.y;}
91
+ if(u<0||v<0||u>319||v>319)continue;
92
+ const a=Math.floor(u),b=Math.floor(v),fu=u-a,fv=v-b,aa=Math.min(319,a+1),bb=Math.min(319,b+1);
93
+ const value=Math.round((gray[b*320+a]*(1-fu)+gray[b*320+aa]*fu)*(1-fv)+(gray[bb*320+a]*(1-fu)+gray[bb*320+aa]*fu)*fv),at=(y*width+x)*4;
94
+ pixels[at]=pixels[at+1]=pixels[at+2]=value;
95
+ }
96
+ // A small soft mat supplies the contact context. Only uncovered background
97
+ // pixels receive it; the original cat's fur and dark details stay intact.
98
+ if(mat){
99
+ for(let y=Math.max(0,Math.floor(mat.y-mat.radiusY));y<Math.min(height,Math.ceil(mat.y+mat.radiusY));y++)
100
+ for(let x=Math.max(0,Math.floor(mat.x-mat.radiusX));x<Math.min(width,Math.ceil(mat.x+mat.radiusX));x++){
101
+ const e=((x+.5-mat.x)/mat.radiusX)**4+((y+.5-mat.y)/mat.radiusY)**4;
102
+ if(e>=1)continue;const at=(y*width+x)*4;if(pixels[at])continue;
103
+ const value=e>.72?180:90;pixels[at]=pixels[at+1]=pixels[at+2]=value;
104
+ }
105
+ }
106
+ // A shallow plate and diminishing paste share the cat's pinhole transform.
107
+ // Paint directly into the borrowed grayscale output: no prop texture/cache.
108
+ if(dish){
109
+ const rx=dish.radius,ry=p.size*4/320;
110
+ for(let y=Math.max(0,Math.floor(dish.y-ry-3));y<Math.min(height,Math.ceil(dish.y+ry+2));y++)
111
+ for(let x=Math.max(0,Math.floor(dish.x-rx-1));x<Math.min(width,Math.ceil(dish.x+rx+2));x++){
112
+ const dx=(x+.5-dish.x)/rx,dy=(y+.5-dish.y)/ry,e=dx*dx+dy*dy;
113
+ let value=e<1&&e>.48?150:e<=.48?48:0;
114
+ const pasteRadius=Math.sqrt(food)*.72;
115
+ if(food>0&&pasteRadius>0&&dx*dx+(dy+.5)*(dy+.5)<pasteRadius*pasteRadius)value=220;
116
+ if(!value)continue;const at=(y*width+x)*4;
117
+ pixels[at]=pixels[at+1]=pixels[at+2]=Math.max(pixels[at],value);
118
+ }
119
+ }
120
+ }
121
+ paintHabitat(pixels,width,height,wheelAngle);
122
+ rasterizations++;lastPaintKey=paintKey;lastText=(kernel?.braille?kernel.braille(pixels,width,height,1.22,1.08):rgbaToBraille(pixels,width,height,{method:'stable',brightness:1.22,contrast:1.08})).text;
123
+ return result(lastText);
124
+ },
125
+ interact(kind,options){alive();if(!['treat','toy','call','stretch','tail','greet','company','tower','wheel'].includes(kind))throw TypeError('Invalid cat interaction');const accepted=life.interact(kind,options);if(accepted&&prep){prep=null;}return accepted;},
126
+ get report(){alive();return life.report;},
127
+ get stats(){alive();return {residentPixelBytes:asset.store.residentPixelBytes??asset.store.residentBytes,cachedFrames:asset.store.stats().cachedFrames,rgbaBytes:pixels?.byteLength??0,lastClip,lastFrame,leases:lease.users?.()??1,rasterizations,heldHits};},
128
+ dispose(){if(disposed)return;disposed=true;pixels=null;prep=null;contacts.reset();asset=null;lastText=lastPaintKey='';release();}
129
+ };
130
+ }
@@ -0,0 +1,37 @@
1
+ import {readPackedFrames} from '../vendor/retrieviewer/puppy-frame-store.mjs';
2
+ import {rgbaToBraille} from '../vendor/retrieviewer/braille.mjs';
3
+ import {createCatFromAssets} from './purrviewer-core.mjs';
4
+ export {createCatFromAssets} from './purrviewer-core.mjs';
5
+ let shared=null,pending=null,leases=0;
6
+ const root=new URL('../art/',import.meta.url);
7
+ async function read(url){
8
+ if(url.protocol==='file:'){const {readFile}=await import('node:fs/promises');return new Uint8Array(await readFile(url));}
9
+ const r=await fetch(url);if(!r.ok)throw Error('Cat asset HTTP '+r.status);return new Uint8Array(await r.arrayBuffer());
10
+ }
11
+ async function acquire(){
12
+ if(!pending&&!shared)pending=(async()=>{
13
+ const meta=JSON.parse(new TextDecoder().decode(await read(new URL('ginger.json',root))));
14
+ if(meta.version!==1||meta.width!==320||meta.height!==320||meta.frames<1||meta.frames>128)throw Error('Invalid cat art metadata');
15
+ let store;
16
+ if(root.protocol==='file:'){
17
+ const {openFrameFile}=await import('../vendor/retrieviewer/puppy-frame-file.mjs');
18
+ store=openFrameFile(new URL('ginger.rvz',root));
19
+ }else{
20
+ const gzip=await read(new URL('ginger.bin.gz',root));
21
+ const encoded=new Uint8Array(await new Response(new Blob([gzip]).stream().pipeThrough(new DecompressionStream('gzip'))).arrayBuffer());
22
+ store=readPackedFrames(encoded);
23
+ }
24
+ try{
25
+ if(store.frameCount!==meta.frames)throw Error('Cat art frame count mismatch');
26
+ for(const motion of Object.values(meta.clips))if(!Array.isArray(motion)||!motion.length||motion.some(i=>!Number.isInteger(i)||i<0||i>=meta.frames))throw Error('Invalid cat clip');
27
+ return {meta,store};
28
+ }catch(error){store.dispose?.();throw error;}
29
+ })().then(value=>{shared=value;pending=null;return value;},error=>{pending=null;throw error;});
30
+ const value=shared??await pending;leases++;return value;
31
+ }
32
+ function release(){if(--leases===0){shared?.store.dispose?.();shared=null;}}
33
+ export async function createCat({seed=731}={}){
34
+ if(!Number.isInteger(seed)||seed<0||seed>0xffffffff)throw TypeError('Invalid cat seed');
35
+ const data=await acquire();return createCatFromAssets({seed},{data,release,users:()=>leases});
36
+ }
37
+ // The native host owns loading and lifetime; behavior and rendering stay shared.
@@ -0,0 +1,3 @@
1
+ // Generated offline by tools/prepare-walk.py from observed hind-paw retreat.
2
+ // Authoring proxy, not a biological measurement.
3
+ export const WALK_STRIDE = 0.18141;