@homie-rocks/studio 0.45.1 → 0.45.3

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.
@@ -0,0 +1,93 @@
1
+ /** Solid triangulated height tiles. Each source triangle is a closed vertical
2
+ * prism; their union preserves noncoplanar samples without a convex-hull fill. */
3
+ import { charge } from './guard.ts';
4
+ import type { Vec3, MapHeightTile } from './rules.ts';
5
+ import type { Solid, Hit3 } from './collision.ts';
6
+ type Plane = { n: Vec3; c: number };
7
+ type Prism = { vertices: Vec3[]; faces: Vec3[][]; planes: Plane[]; edges: Vec3[] };
8
+ const cache = new WeakMap<MapHeightTile, Prism[]>();
9
+ const sub=(a:Vec3,b:Vec3):Vec3=>({x:a.x-b.x,y:a.y-b.y,z:a.z-b.z});
10
+ const add=(a:Vec3,b:Vec3,t=1):Vec3=>({x:a.x+b.x*t,y:a.y+b.y*t,z:a.z+b.z*t});
11
+ const dot=(a:Vec3,b:Vec3)=>a.x*b.x+a.y*b.y+a.z*b.z;
12
+ const cross=(a:Vec3,b:Vec3):Vec3=>({x:a.y*b.z-a.z*b.y,y:a.z*b.x-a.x*b.z,z:a.x*b.y-a.y*b.x});
13
+ const clamp=(x:number)=>Math.max(0,Math.min(1,x));
14
+ export function prepareTile(t:MapHeightTile):void {
15
+ if(t.base===undefined)return;
16
+ const top=t.heights.map((h,i)=>({x:t.at.x+(i%2)*t.size.x,y:t.at.y+(i>>1)*t.size.y,z:t.at.z+h}));
17
+ const tris=t.diagonal==='10-01'?[[0,1,2],[1,3,2]]:[[0,1,3],[0,3,2]];
18
+ const prisms:Prism[]=[];
19
+ for(const tri of tris){
20
+ const upper=tri.map(i=>top[i]),lower=upper.map(v=>({...v,z:t.at.z+t.base!}));
21
+ if(upper.every(v=>v.z===lower[0].z))continue;
22
+ const vertices=[...upper,...lower],faces=[upper,[...lower].reverse()];
23
+ for(let i=0;i<3;i++){const j=(i+1)%3;faces.push([upper[i],lower[i],lower[j]],[upper[i],lower[j],upper[j]]);}
24
+ const center=vertices.reduce((s,v)=>add(s,v,1/6),{x:0,y:0,z:0}),planes:Plane[]=[],valid:Vec3[][]=[],edges:Vec3[]=[];
25
+ for(const face of faces){let n=cross(sub(face[1],face[0]),sub(face[2],face[0]));const len=Math.sqrt(dot(n,n));if(len<1e-12)continue;n={x:n.x/len,y:n.y/len,z:n.z/len};let c=dot(n,face[0]);if(dot(n,center)>c){n={x:-n.x,y:-n.y,z:-n.z};c=-c;}planes.push({n,c});valid.push(face);for(let i=0;i<3;i++)edges.push(sub(face[(i+1)%3],face[i]));}
26
+ prisms.push({vertices,faces:valid,planes,edges});
27
+ }
28
+ cache.set(t,prisms);
29
+ }
30
+ // Closest point on a triangle, including vertex and edge Voronoi regions.
31
+ function triangle(p:Vec3,a:Vec3,b:Vec3,c:Vec3):Vec3 {
32
+ const ab=sub(b,a),ac=sub(c,a),ap=sub(p,a),d1=dot(ab,ap),d2=dot(ac,ap);
33
+ if(d1<=0&&d2<=0)return a;
34
+ const bp=sub(p,b),d3=dot(ab,bp),d4=dot(ac,bp);if(d3>=0&&d4<=d3)return b;
35
+ const vc=d1*d4-d3*d2;if(vc<=0&&d1>=0&&d3<=0)return add(a,ab,d1/(d1-d3));
36
+ const cp=sub(p,c),d5=dot(ab,cp),d6=dot(ac,cp);if(d6>=0&&d5<=d6)return c;
37
+ const vb=d5*d2-d1*d6;if(vb<=0&&d2>=0&&d6<=0)return add(a,ac,d2/(d2-d6));
38
+ const va=d3*d6-d5*d4;if(va<=0&&d4-d3>=0&&d5-d6>=0)return add(b,sub(c,b),(d4-d3)/(d4-d3+d5-d6));
39
+ const den=1/(va+vb+vc);return add(add(a,ab,vb*den),ac,vc*den);
40
+ }
41
+ function segmentPair(a:Vec3,b:Vec3,c:Vec3,d:Vec3):Vec3 {
42
+ const u=sub(b,a),v=sub(d,c),w=sub(a,c),aa=dot(u,u),bb=dot(u,v),cc=dot(v,v),dd=dot(u,w),ee=dot(v,w);
43
+ let s=aa?clamp((bb*ee-cc*dd)/(aa*cc-bb*bb)||0):0;
44
+ let t=cc?clamp((bb*s+ee)/cc):0;
45
+ s=aa?clamp((bb*t-dd)/aa):0;t=cc?clamp((bb*s+ee)/cc):0;
46
+ return sub(add(a,u,s),add(c,v,t));
47
+ }
48
+ function clip(prism:Prism,p:Vec3,d:Vec3):Hit3|null {
49
+ let near=0,far=1,n={x:0,y:0,z:0};
50
+ for(const plane of prism.planes){charge(8);const gap=plane.c-dot(plane.n,p),speed=dot(plane.n,d);if(Math.abs(speed)<1e-12){if(gap< -1e-9)return null;continue;}const t=gap/speed;if(speed<0){if(t>=near){near=t;n=plane.n;}}else far=Math.min(far,t);if(near>far+1e-9)return null;}
51
+ return near>=0&&near<=1&&far>=0?{t:near,nx:n.x,ny:n.y,nz:n.z}:null;
52
+ }
53
+ function distance(prism:Prism,a:Vec3,b:Vec3):Vec3 {
54
+ if(clip(prism,a,sub(b,a)))return {x:0,y:0,z:0};
55
+ let best={x:Infinity,y:Infinity,z:Infinity},sq=Infinity;
56
+ const take=(v:Vec3)=>{const n=dot(v,v);if(n<sq){sq=n;best=v;}};
57
+ for(const [x,y,z] of prism.faces){charge(36);take(sub(a,triangle(a,x,y,z)));take(sub(b,triangle(b,x,y,z)));take(segmentPair(a,b,x,y));take(segmentPair(a,b,y,z));take(segmentPair(a,b,z,x));}
58
+ return best;
59
+ }
60
+ function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
61
+ const axes:Vec3[]=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
62
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
63
+ let near=0,far=1,normal={x:0,y:0,z:0};
64
+ for(const axis of axes){charge(16);const len=Math.sqrt(dot(axis,axis));if(len<1e-10)continue;const n={x:axis.x/len,y:axis.y/len,z:axis.z/len};let lo=Infinity,hi=-Infinity;
65
+ for(const v of prism.vertices){const p=dot(n,v);lo=Math.min(lo,p);hi=Math.max(hi,p);}
66
+ let amin=0,amax=0;for(const k of ['x','y','z'] as const){amin+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);amax+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}
67
+ const speed=dot(n,d);if(Math.abs(speed)<1e-12){if(amax<=lo+1e-9||amin>=hi-1e-9)return null;continue;}
68
+ let enter=(lo-amax)/speed,exit=(hi-amin)/speed;if(enter>exit)[enter,exit]=[exit,enter];
69
+ if(enter>=near){near=enter;normal={x:n.x*(speed>0?-1:1),y:n.y*(speed>0?-1:1),z:n.z*(speed>0?-1:1)};}far=Math.min(far,exit);if(near>far)return null;
70
+ }
71
+ return near<=1&&dot(normal,d)<0?{t:near,nx:normal.x,ny:normal.y,nz:normal.z}:null;
72
+ }
73
+ export function castTerrain(tile:MapHeightTile,a:Solid,d:Vec3):Hit3|null {
74
+ charge(12);
75
+ let best:Hit3|null=null;
76
+ for(const prism of cache.get(tile)??[]){
77
+ let hit:Hit3|null=null;
78
+ if(a.r===0)hit=a.min.x===a.max.x&&a.min.y===a.max.y&&a.min.z===a.max.z?clip(prism,a.min,d):boxCast(prism,a,d);
79
+ else {let t=0;for(let i=0;i<32;i++){charge(24);const v=distance(prism,add(a.min,d,t),add(a.max,d,t)),len=Math.sqrt(dot(v,v)),gap=len-a.r;if(i===0&&gap< -1e-7)break;if(len===0)break;const n={x:v.x/len,y:v.y/len,z:v.z/len},closing=-dot(n,d);if(closing<=1e-9)break;if(gap<=1e-7){hit={t,nx:n.x,ny:n.y,nz:n.z};break;}t+=gap/closing;if(t>1)break;if(i===31)hit={t,nx:n.x,ny:n.y,nz:n.z};}}
80
+ if(hit&&(!best||hit.t<best.t))best=hit;
81
+ }return best;
82
+ }
83
+ export function overlapsTerrain(tile:MapHeightTile,a:Solid):boolean {
84
+ for(const prism of cache.get(tile)??[]){
85
+ if(a.r>0){const v=distance(prism,a.min,a.max);if(dot(v,v)<a.r*a.r-1e-9)return true;}
86
+ else {
87
+ // SAT overlap uses the same face, edge and box axes as continuous casts.
88
+ const axes=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
89
+ for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
90
+ let separated=false;for(const n of axes){charge(16);if(dot(n,n)<1e-18)continue;const ps=prism.vertices.map(v=>dot(n,v));let lo=0,hi=0;for(const k of ['x','y','z'] as const){lo+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);hi+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}if(hi<=Math.min(...ps)+1e-9||lo>=Math.max(...ps)-1e-9){separated=true;break;}}if(!separated)return true;
91
+ }
92
+ }return false;
93
+ }
package/rules/terrain.ts CHANGED
@@ -4,7 +4,7 @@ import { charge } from './guard.ts';
4
4
  import type { Vec3, MapHeightTile } from './rules.ts';
5
5
  import type { Solid, Hit3 } from './collision.ts';
6
6
  type Plane = { n: Vec3; c: number };
7
- type Prism = { vertices: Vec3[]; faces: Vec3[][]; planes: Plane[]; edges: Vec3[] };
7
+ type Prism = { vertices: Vec3[]; faces: Vec3[][]; bounds: {min:Vec3;max:Vec3}[]; planes: Plane[]; edges: Vec3[]; segments: [Vec3,Vec3][]; segmentIds: [number,number][]; boxAxes:Vec3[] };
8
8
  const cache = new WeakMap<MapHeightTile, Prism[]>();
9
9
  const sub=(a:Vec3,b:Vec3):Vec3=>({x:a.x-b.x,y:a.y-b.y,z:a.z-b.z});
10
10
  const add=(a:Vec3,b:Vec3,t=1):Vec3=>({x:a.x+b.x*t,y:a.y+b.y*t,z:a.z+b.z*t});
@@ -19,13 +19,25 @@ export function prepareTile(t:MapHeightTile):void {
19
19
  for(const tri of tris){
20
20
  const upper=tri.map(i=>top[i]),lower=upper.map(v=>({...v,z:t.at.z+t.base!}));
21
21
  if(upper.every(v=>v.z===lower[0].z))continue;
22
+ prisms.push(makePrism(upper,lower));
23
+ }
24
+ cache.set(t,prisms);
25
+ }
26
+ function makePrism(upper:Vec3[],lower:Vec3[]):Prism {
22
27
  const vertices=[...upper,...lower],faces=[upper,[...lower].reverse()];
23
28
  for(let i=0;i<3;i++){const j=(i+1)%3;faces.push([upper[i],lower[i],lower[j]],[upper[i],lower[j],upper[j]]);}
24
29
  const center=vertices.reduce((s,v)=>add(s,v,1/6),{x:0,y:0,z:0}),planes:Plane[]=[],valid:Vec3[][]=[],edges:Vec3[]=[];
25
30
  for(const face of faces){let n=cross(sub(face[1],face[0]),sub(face[2],face[0]));const len=Math.sqrt(dot(n,n));if(len<1e-12)continue;n={x:n.x/len,y:n.y/len,z:n.z/len};let c=dot(n,face[0]);if(dot(n,center)>c){n={x:-n.x,y:-n.y,z:-n.z};c=-c;}planes.push({n,c});valid.push(face);for(let i=0;i<3;i++)edges.push(sub(face[(i+1)%3],face[i]));}
26
- prisms.push({vertices,faces:valid,planes,edges});
27
- }
28
- cache.set(t,prisms);
31
+ const segments:[Vec3,Vec3][]=[],segmentIds:[number,number][]=[];
32
+ for(let i=0;i<vertices.length;i++)for(let j=i+1;j<vertices.length;j++){
33
+ const common=planes.filter(p=>Math.abs(dot(p.n,vertices[i])-p.c)<1e-9&&Math.abs(dot(p.n,vertices[j])-p.c)<1e-9);
34
+ if(common.some((p,k)=>common.slice(k+1).some(q=>Math.abs(dot(p.n,q.n))<1-1e-9))){segments.push([vertices[i],vertices[j]]);segmentIds.push([i,j]);}
35
+ }
36
+ const unique=planes.filter((p,i)=>!planes.slice(0,i).some(q=>dot(p.n,q.n)>1-1e-10&&Math.abs(p.c-q.c)<1e-9));
37
+ const boxAxes:Vec3[]=[];
38
+ const axis=(v:Vec3)=>{const len=Math.sqrt(dot(v,v));if(len<1e-10)return;const n={x:v.x/len,y:v.y/len,z:v.z/len};if(!boxAxes.some(a=>Math.abs(dot(a,n))>1-1e-10))boxAxes.push(n);};
39
+ const bases=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1}];for(const b of bases)axis(b);for(const p of unique)axis(p.n);for(const e of edges)for(const b of bases)axis(cross(e,b));
40
+ return {vertices,segments,segmentIds,boxAxes,faces:valid,bounds:valid.map(face=>({min:{x:Math.min(...face.map(v=>v.x)),y:Math.min(...face.map(v=>v.y)),z:Math.min(...face.map(v=>v.z))},max:{x:Math.max(...face.map(v=>v.x)),y:Math.max(...face.map(v=>v.y)),z:Math.max(...face.map(v=>v.z))}})),planes:unique,edges};
29
41
  }
30
42
  // Closest point on a triangle, including vertex and edge Voronoi regions.
31
43
  function triangle(p:Vec3,a:Vec3,b:Vec3,c:Vec3):Vec3 {
@@ -52,16 +64,20 @@ function clip(prism:Prism,p:Vec3,d:Vec3):Hit3|null {
52
64
  }
53
65
  function distance(prism:Prism,a:Vec3,b:Vec3):Vec3 {
54
66
  if(clip(prism,a,sub(b,a)))return {x:0,y:0,z:0};
67
+ // A separating plane is a distance lower bound. If its orthogonal projection
68
+ // lies on the prism, that bound is attained: no edge search is necessary.
69
+ let gap=0,normal:Vec3|null=null,endpoint=a;
70
+ for(const plane of prism.planes){charge(8);const ga=dot(plane.n,a)-plane.c,gb=dot(plane.n,b)-plane.c,g=Math.min(ga,gb);if(g>gap){gap=g;normal=plane.n;endpoint=ga<=gb?a:b;}}
71
+ if(normal){const projected=add(endpoint,normal,-gap);let inside=true;for(const plane of prism.planes){charge(8);if(dot(plane.n,projected)>plane.c+1e-10){inside=false;break;}}if(inside)return {x:normal.x*gap,y:normal.y*gap,z:normal.z*gap};}
55
72
  let best={x:Infinity,y:Infinity,z:Infinity},sq=Infinity;
56
73
  const take=(v:Vec3)=>{const n=dot(v,v);if(n<sq){sq=n;best=v;}};
57
- for(const [x,y,z] of prism.faces){charge(36);take(sub(a,triangle(a,x,y,z)));take(sub(b,triangle(b,x,y,z)));take(segmentPair(a,b,x,y));take(segmentPair(a,b,y,z));take(segmentPair(a,b,z,x));}
74
+ for(let i=0;i<prism.faces.length;i++){charge(12);const bounds=prism.bounds[i];let lower=0;for(const k of ['x','y','z'] as const){const gap=Math.max(0,bounds.min[k]-Math.max(a[k],b[k]),Math.min(a[k],b[k])-bounds.max[k]);lower+=gap*gap;}if(lower>sq)continue;const [x,y,z]=prism.faces[i];charge(36);take(sub(a,triangle(a,x,y,z)));take(sub(b,triangle(b,x,y,z)));take(segmentPair(a,b,x,y));take(segmentPair(a,b,y,z));take(segmentPair(a,b,z,x));}
58
75
  return best;
59
76
  }
60
77
  function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
61
- const axes:Vec3[]=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
62
- for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
78
+ const axes=prism.boxAxes;
63
79
  let near=0,far=1,normal={x:0,y:0,z:0};
64
- for(const axis of axes){charge(16);const len=Math.sqrt(dot(axis,axis));if(len<1e-10)continue;const n={x:axis.x/len,y:axis.y/len,z:axis.z/len};let lo=Infinity,hi=-Infinity;
80
+ for(const n of axes){charge(16);let lo=Infinity,hi=-Infinity;
65
81
  for(const v of prism.vertices){const p=dot(n,v);lo=Math.min(lo,p);hi=Math.max(hi,p);}
66
82
  let amin=0,amax=0;for(const k of ['x','y','z'] as const){amin+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);amax+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}
67
83
  const speed=dot(n,d);if(Math.abs(speed)<1e-12){if(amax<=lo+1e-9||amin>=hi-1e-9)return null;continue;}
@@ -70,13 +86,83 @@ function boxCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
70
86
  }
71
87
  return near<=1&&dot(normal,d)<0?{t:near,nx:normal.x,ny:normal.y,nz:normal.z}:null;
72
88
  }
89
+ const extended = new WeakMap<Prism, Map<number, Prism>>();
90
+ function capsulePrism(prism:Prism,length:number):Prism {
91
+ if(!length)return prism;
92
+ let variants=extended.get(prism);if(!variants){variants=new Map();extended.set(prism,variants);}
93
+ let result=variants.get(length);if(!result){const vertices=prism.vertices.map((p,i)=>i<3?p:{...p,z:p.z-length});
94
+ const translate=(p:Vec3)=>vertices[prism.vertices.indexOf(p)];
95
+ result={vertices,faces:prism.faces.map(face=>face.map(translate)),bounds:prism.bounds.map(b=>({min:{...b.min,z:b.min.z-length},max:b.max})),planes:prism.planes.map(p=>({n:p.n,c:p.c+Math.max(0,-p.n.z*length)})),edges:prism.edges,segments:prism.segmentIds.map(([i,j])=>[vertices[i],vertices[j]]),segmentIds:prism.segmentIds,boxAxes:prism.boxAxes};if(variants.size>=32)variants.clear();variants.set(length,result);}
96
+ return result;
97
+ }
98
+ /** Downward vertical support only meets the upper triangle or its rounded rim.
99
+ * The capsule's upper centre trails its lower centre, so no extrusion is needed. */
100
+ function downCast(prism:Prism,a:Solid,d:Vec3):Hit3|null {
101
+ charge(24);const p=a.min,r=a.r,top=prism.vertices.slice(0,3);
102
+ // Reject the rounded top bounds before doing any face/edge root solving.
103
+ if(p.x+r<Math.min(...top.map(v=>v.x))||p.x-r>Math.max(...top.map(v=>v.x))||p.y+r<Math.min(...top.map(v=>v.y))||p.y-r>Math.max(...top.map(v=>v.y))||p.z+d.z-r>Math.max(...top.map(v=>v.z)))return null;
104
+ const plane=prism.planes.find(q=>q.n.z>0);
105
+ if(!plane)return null;
106
+ let best:Hit3|null=null;
107
+ const take=(t:number,n:Vec3)=>{if(t<0||t>1||best&&t>=best.t||n.z<=1e-12)return;const len=Math.sqrt(dot(n,n));best={t,nx:n.x/len,ny:n.y/len,nz:n.z/len};};
108
+ const t=(plane.c+r-dot(plane.n,p))/(plane.n.z*d.z);
109
+ if(t>=0&&t<=1){const q=add(add(p,d,t),plane.n,-r),near=triangle(q,top[0],top[1],top[2]),delta=sub(q,near);if(dot(delta,delta)<1e-14)return {t,nx:plane.n.x,ny:plane.n.y,nz:plane.n.z};}
110
+ const speed2=d.z*d.z;
111
+ for(let i=0;i<3;i++){
112
+ const from=top[i],to=top[(i+1)%3],ba=sub(to,from),oa=sub(p,from);
113
+ charge(8);const xy=ba.x*ba.x+ba.y*ba.y,u=xy?clamp((oa.x*ba.x+oa.y*ba.y)/xy):0;
114
+ if((oa.x-ba.x*u)**2+(oa.y-ba.y*u)**2>r*r+1e-12)continue;
115
+ charge(40);const ba2=dot(ba,ba),bard=ba.z*d.z,baoa=dot(ba,oa);
116
+ const k2=ba2*speed2-bard*bard,k1=ba2*oa.z*d.z-baoa*bard,k0=ba2*dot(oa,oa)-baoa*baoa-r*r*ba2,h=k1*k1-k2*k0;
117
+ if(k2>1e-16&&h>=0){const t=(-k1-Math.sqrt(h))/k2,y=baoa+t*bard;if(y>=0&&y<=ba2)take(t,sub(add(p,d,t),add(from,ba,y/ba2)));}
118
+ const horizontal=oa.x*oa.x+oa.y*oa.y;if(horizontal<=r*r){const height=Math.sqrt(r*r-horizontal),t=(from.z+height-p.z)/d.z;take(t,{x:oa.x,y:oa.y,z:height});}
119
+ }
120
+ if(best&&dot(plane.n,p)-plane.c<r){const initial=distance(prism,a.min,a.max);if(dot(initial,initial)<r*r-1e-9)return null;}
121
+ return best;
122
+ }
123
+ /** Exact swept sphere against a convex prism's faces and rounded edges. A
124
+ * vertical capsule is a sphere against the prism extended down its centreline. */
125
+ function roundedCast(source:Prism,a:Solid,d:Vec3):Hit3|null {
126
+ charge(24);
127
+ const lo=source.vertices[3],hiZ=Math.max(source.vertices[0].z,source.vertices[1].z,source.vertices[2].z);
128
+ if(Math.min(a.min.z,a.min.z+d.z)-a.r>hiZ||Math.max(a.max.z,a.max.z+d.z)+a.r<lo.z)return null;
129
+ let near=0,far=1;
130
+ for(const k of ['x','y'] as const){const low=Math.min(source.vertices[0][k],source.vertices[1][k],source.vertices[2][k])-a.r,high=Math.max(source.vertices[0][k],source.vertices[1][k],source.vertices[2][k])+a.r;if(!d[k]){if(a.min[k]<low||a.min[k]>high)return null;}else{let t=(low-a.min[k])/d[k],u=(high-a.min[k])/d[k];if(t>u)[t,u]=[u,t];near=Math.max(near,t);far=Math.min(far,u);if(near>far)return null;}}
131
+ charge(64);const prism=capsulePrism(source,a.max.z-a.min.z),p=a.min,r=a.r;
132
+ let entry=0,exit=1,enterPlane:Plane|null=null;const approaching:{plane:Plane;t:number}[]=[];
133
+ for(const plane of prism.planes){charge(8);const gap=plane.c+r-dot(plane.n,p),speed=dot(plane.n,d);if(Math.abs(speed)<1e-12){if(gap< -1e-10)return null;}else if(speed>0)exit=Math.min(exit,gap/speed);else {const t=gap/speed;if(t>=-1e-9&&t<=1)approaching.push({plane,t});if(t>=entry){entry=t;enterPlane=plane;}}if(entry>exit+1e-10)return null;}
134
+ if(entry>1||exit<0)return null;
135
+ if(enterPlane){const projected=add(add(p,d,entry),enterPlane.n,-r);let inside=true;for(const plane of prism.planes){charge(8);if(dot(plane.n,projected)>plane.c+1e-10){inside=false;break;}}if(inside)return{t:Math.max(0,entry),nx:enterPlane.n.x,ny:enterPlane.n.y,nz:enterPlane.n.z};}
136
+
137
+ const speed2=dot(d,d);if(speed2<1e-20)return null;
138
+ let best:Hit3|null=null;
139
+ const take=(t:number,normal:Vec3)=>{if(t< -1e-9||t>1||best&&t>=best.t)return;const len=Math.sqrt(dot(normal,normal));if(len<1e-12||dot(normal,d)>=-1e-12)return;best={t:Math.max(0,t),nx:normal.x/len,ny:normal.y/len,nz:normal.z/len};};
140
+ const sphere=(v:Vec3)=>{const o=sub(p,v),b=dot(o,d),c=dot(o,o)-r*r,h=b*b-speed2*c;if(h<0)return;const t=(-b-Math.sqrt(h))/speed2;take(t,sub(add(p,d,t),v));};
141
+ // Reuse the plane times from clipping. Parallel and receding faces cannot
142
+ // be entry contacts; do not project or retest their already-known times.
143
+ for(const {plane,t} of approaching){
144
+ charge(8);if(best&&t>=best.t)continue;charge(16);const hit=add(add(p,d,t),plane.n,-r);let inside=true;
145
+ for(const q of prism.planes){charge(4);if(dot(q.n,hit)>q.c+1e-10){inside=false;break;}}
146
+ if(inside)take(t,plane.n);
147
+ }
148
+ const sweptMin={x:Math.min(p.x,p.x+d.x)-r,y:Math.min(p.y,p.y+d.y)-r,z:Math.min(p.z,p.z+d.z)-r},sweptMax={x:Math.max(p.x,p.x+d.x)+r,y:Math.max(p.y,p.y+d.y)+r,z:Math.max(p.z,p.z+d.z)+r};
149
+ for(const [from,to] of prism.segments){
150
+ charge(8);if(Math.max(from.x,to.x)<sweptMin.x||Math.min(from.x,to.x)>sweptMax.x||Math.max(from.y,to.y)<sweptMin.y||Math.min(from.y,to.y)>sweptMax.y||Math.max(from.z,to.z)<sweptMin.z||Math.min(from.z,to.z)>sweptMax.z)continue;
151
+ charge(48);const ba=sub(to,from),oa=sub(p,from),ba2=dot(ba,ba),bard=dot(ba,d),baoa=dot(ba,oa);
152
+ const k2=ba2*speed2-bard*bard,k1=ba2*dot(oa,d)-baoa*bard,k0=ba2*dot(oa,oa)-baoa*baoa-r*r*ba2,h=k1*k1-k2*k0;
153
+ if(k2>1e-16&&h>=0){const t=(-k1-Math.sqrt(h))/k2,y=baoa+t*bard;if(y>=0&&y<=ba2)take(t,sub(add(p,d,t),add(from,ba,y/ba2)));}
154
+ }
155
+ for(const v of prism.vertices){charge(4);if(v.x<sweptMin.x||v.x>sweptMax.x||v.y<sweptMin.y||v.y>sweptMax.y||v.z<sweptMin.z||v.z>sweptMax.z)continue;charge(24);sphere(v);}
156
+ if(best&&entry===0){const initial=distance(prism,p,p);if(dot(initial,initial)<r*r-1e-9)return null;}
157
+ return best;
158
+ }
73
159
  export function castTerrain(tile:MapHeightTile,a:Solid,d:Vec3):Hit3|null {
74
160
  charge(12);
75
161
  let best:Hit3|null=null;
76
162
  for(const prism of cache.get(tile)??[]){
77
163
  let hit:Hit3|null=null;
78
164
  if(a.r===0)hit=a.min.x===a.max.x&&a.min.y===a.max.y&&a.min.z===a.max.z?clip(prism,a.min,d):boxCast(prism,a,d);
79
- else {let t=0;for(let i=0;i<32;i++){charge(24);const v=distance(prism,add(a.min,d,t),add(a.max,d,t)),len=Math.sqrt(dot(v,v)),gap=len-a.r;if(i===0&&gap< -1e-7)break;if(len===0)break;const n={x:v.x/len,y:v.y/len,z:v.z/len},closing=-dot(n,d);if(closing<=1e-9)break;if(gap<=1e-7){hit={t,nx:n.x,ny:n.y,nz:n.z};break;}t+=gap/closing;if(t>1)break;if(i===31)hit={t,nx:n.x,ny:n.y,nz:n.z};}}
165
+ else hit=d.x===0&&d.y===0&&d.z<0?downCast(prism,a,d):roundedCast(prism,a,d);
80
166
  if(hit&&(!best||hit.t<best.t))best=hit;
81
167
  }return best;
82
168
  }
@@ -85,8 +171,7 @@ export function overlapsTerrain(tile:MapHeightTile,a:Solid):boolean {
85
171
  if(a.r>0){const v=distance(prism,a.min,a.max);if(dot(v,v)<a.r*a.r-1e-9)return true;}
86
172
  else {
87
173
  // SAT overlap uses the same face, edge and box axes as continuous casts.
88
- const axes=[{x:1,y:0,z:0},{x:0,y:1,z:0},{x:0,y:0,z:1},...prism.planes.map(p=>p.n)];
89
- for(const e of prism.edges)for(const k of axes.slice(0,3))axes.push(cross(e,k));
174
+ const axes=prism.boxAxes;
90
175
  let separated=false;for(const n of axes){charge(16);if(dot(n,n)<1e-18)continue;const ps=prism.vertices.map(v=>dot(n,v));let lo=0,hi=0;for(const k of ['x','y','z'] as const){lo+=n[k]*(n[k]>=0?a.min[k]:a.max[k]);hi+=n[k]*(n[k]>=0?a.max[k]:a.min[k]);}if(hi<=Math.min(...ps)+1e-9||lo>=Math.max(...ps)-1e-9){separated=true;break;}}if(!separated)return true;
91
176
  }
92
177
  }return false;
package/rules/types.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /** The build specialises these faces from a game's declarations; the runtime still checks every write. */
2
- import type { Vec3, BodyDef, Fields, ColliderDef } from './rules.ts';
2
+ import type { Vec3, BodyDef, Fields, ColliderDef, QueryDef } from './rules.ts';
3
3
  import type { math } from './math.ts';
4
4
 
5
5
  export type Point = { readonly x: number; readonly y: number; readonly z?: number };
6
6
  export type ReadonlyState<T> = T extends object ? { readonly [K in keyof T]: ReadonlyState<T[K]> } : T;
7
7
  export type WriteState<T> = T extends Vec3 ? keyof T extends keyof Vec3 ? Point : { [K in keyof T]: WriteState<T[K]> } : T extends object ? { [K in keyof T]: WriteState<T[K]> } : T;
8
8
  type Payload<T> = {} extends T ? [data?: WriteState<T>] : [data: WriteState<T>];
9
- type Query<T> = ReadonlyState<Omit<T, 'input'>>;
9
+ type Query<T> = T extends object ? ReadonlyState<Omit<T, 'input'>> : never;
10
10
  export interface Goal { readonly goal: string; readonly args: Readonly<Record<string, unknown>>; readonly from: 'brain' | 'floor'; readonly at: number; readonly asked: boolean; readonly state: 'active' | 'done' | 'failed' }
11
11
  export interface GuideDecision { goal?: string | null; args?: Record<string, unknown>; say?: string | null; sayArgs?: Record<string, unknown> }
12
12
  export interface GuideRequest { readonly k: string; readonly args: Readonly<Record<string, unknown>>; readonly from: number; readonly at: number }
@@ -16,7 +16,12 @@ export type Self<F, M, I, K extends string, P extends boolean, G = Goal> = F & {
16
16
  readonly id: string; readonly kind: K; readonly pos: Vec3; get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); readonly grounded: boolean;
17
17
  readonly motion: M; readonly input: ReadonlyState<I>;
18
18
  } & (P extends true ? Player<G> : {});
19
- export interface Hit { readonly entity?: string; readonly at: Point; readonly normal: Vec3; readonly dist?: number }
19
+ export interface Hit { readonly entity?: string; readonly part?: string; readonly at: Point; readonly normal: Vec3; readonly dist?: number }
20
+ export interface RayOptions {
21
+ terrain?: 'exact'; atTick?: number; profile?: string; radius?: number; shape?: 'sphere' | 'box' | 'capsule'; height?:number; geometryOnly?: boolean; entitiesOnly?: boolean; ignoreSelf?: boolean; ignore?: readonly string[]; kind?: string; tag?: string; layer?: string; where?: Readonly<Record<string, number | boolean | string | {gt?: number; gte?: number; lt?: number; lte?: number; eq?: number | boolean | string}>> }
22
+ export type RayHit = Hit & { readonly dist: number };
23
+ export interface RaySnapshot {sweep(body:{pos:Point;grounded?:boolean},delta:Point,options?:{ground?:boolean}):Hit|undefined;support(body:{pos:Point},distance?:number):(Hit&{dist:number})|undefined;ray(from:Point,direction:Point,max:number):RayHit|undefined;rayAll(from:Point,direction:Point,max:number):readonly RayHit[]}
24
+ export interface RayQueries { rays(options?:RayOptions):RaySnapshot; ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined; rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[] }
20
25
  export type Area = { sphere: { at: Point; r: number } } | { box: { min: Point; max: Point } } | { cone: { at: Point; dir: Point; r: number; angle: number } };
21
26
  export interface MapView { readonly name: string; spot(name: string): Vec3 | undefined; spots(name: string): readonly Vec3[] }
22
27
  export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
@@ -24,9 +29,11 @@ export interface Round { readonly n: number; readonly phase: 'live' | 'over'; re
24
29
  export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
25
30
  readonly tune: ReadonlyState<T>; readonly map: MapView; readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
26
31
  readonly shared: Room extends true ? S : ReadonlyState<S>;
27
- readonly round: Round & (Room extends true ? { end(): void } : {});
32
+ readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
28
33
  random(): number;
34
+ label(player:string):string;
29
35
  send<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): void;
36
+ dispatch<K extends keyof E>(target: string, event: K, ...data: Payload<E[K]>): boolean;
30
37
  sendRoom<K extends keyof E>(event: K, ...data: Payload<E[K]>): void;
31
38
  sendArea<K extends keyof E>(area: Area, event: K, ...data: Payload<E[K]>): void;
32
39
  after<K extends keyof E>(ticks: number, event: K, ...data: Payload<E[K]>): void;
@@ -36,7 +43,10 @@ export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
36
43
  near(at: Point, radius: number): readonly Query<V[keyof V]>[];
37
44
  inBox<K extends keyof V>(box: { min: Point; max: Point }, kind: K): readonly Query<V[K]>[];
38
45
  inBox(box: { min: Point; max: Point }): readonly Query<V[keyof V]>[];
39
- ray(from: Point, direction: Point, max: number): (Hit & { readonly dist: number }) | undefined;
46
+ route(graph: string | ReadonlyArray<readonly [number,number,number,readonly number[]]>, from: number, to: number, options?: {radius?:number;height?:number}): readonly number[];
47
+ ray(from: Point, direction: Point, max: number, options?: RayOptions): RayHit | undefined;
48
+ rays(options?:RayOptions):RaySnapshot;
49
+ rayAll(from: Point, direction: Point, max: number, options?: RayOptions): readonly RayHit[];
40
50
  ask<K extends keyof A>(name: K, state: WriteState<A[K]>): boolean;
41
51
  } & (Room extends true ? { announce<K extends keyof E>(event: K, ...data: Payload<E[K]>): void; finish(): void } : {
42
52
  goalDone(ok?: boolean): void;
@@ -51,7 +61,7 @@ export type RoomEvents<E = Record<string, never>> = RoundEvents & { seatJoined:
51
61
  export type RoundEvents = { roundStart: { n: number }; roundOver: { n: number; results: readonly { seat: number; id: string; driver: 'person' | 'bot' | 'ai'; score: number; place: number }[] } };
52
62
  type GuideWorld<W> = Pick<W, Extract<keyof W, 'tick' | 'dt' | 'ticks' | 'math' | 'map' | 'tune' | 'stage' | 'level' | 'levelMax' | 'guideLevel' | 'guideSeats' | 'kids' | 'levelSet' | 'round' | 'shared' | 'near' | 'inBox' | 'ray'>>;
53
63
  export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A = GuideRequest, D = GuideDecision> = {
54
- player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot' }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef;
64
+ player?: true | { away?: 'neutral' | 'think'; leave?: 'despawn' | 'bot'; control?: string; takeover?: string }; fields?: Fields; motion?: Fields; input?: Fields; body?: BodyDef; collider?: ColliderDef; query?: QueryDef;
55
65
  tick?: (world: W, self: S) => void;
56
66
  think?: (world: W, self: S) => Partial<I>;
57
67
  on?: { [K in keyof (Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })]?: (world: W, self: S, event: EventData<(Omit<E, keyof BuiltIns> & Omit<BuiltIns<E>, 'answer'> & { answer: Answer })[K]>) => void };
@@ -60,5 +70,5 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
60
70
  guide?: { view: (world: GuideWorld<W>, self: ReadonlyState<Omit<S, 'input'>>) => V; floor?: (world: W, self: S, view: GuideView<V, G, A>) => D };
61
71
  };
62
72
  export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
63
- export interface MoveCollision<M> { sweep(body: MoveBody<M>, delta: Point): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
73
+ export interface MoveCollision<M> extends RayQueries { sweep(body: MoveBody<M>, delta: Point, options?: {ground?: boolean}): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
64
74
  export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
package/rules/view.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
2
+ import { solidAt } from './collision.ts';
3
+ import type { RayQueries, RayOptions } from './types.ts';
1
4
  import { snapshotDecoder } from './interest.mjs';
2
- import { collisionMap, revisionAt, type CollisionRevision } from './live.ts';
5
+ import { collisionMap, collisionTargets, revisionAt, type CollisionRevision } from './live.ts';
3
6
  import { rulesOutput } from '../netplay/rules-output.mjs';
4
7
  import { rulesCaps, rulesRates } from '../worker/limits.mjs';
5
8
  /*
@@ -98,7 +101,8 @@ export interface OpenRoomOptions {
98
101
  now?: () => number;
99
102
  timers?: boolean;
100
103
  }
101
- export interface Room<R = unknown> {
104
+ export interface Room<R = unknown> extends RayQueries {
105
+ readonly viewTick: number;
102
106
  readonly status: RoomStatus;
103
107
  readonly seat: number | null;
104
108
  readonly me: Entity | null;
@@ -193,7 +197,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
193
197
  const hostAges: number[] = [];
194
198
  /** What the player holds, the presses since the last step, what the server was last told, and the entries of this send period. */
195
199
  let sample: Record<string, unknown> = {};
196
- let pressed = new Set<string>();
200
+ let pressed = new Map<string, number | boolean>();
197
201
  let lastSent = '';
198
202
  let lastSendAt = 0;
199
203
  let entries: { t: number; row: number[] }[] = [];
@@ -233,7 +237,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
233
237
  // A knock may travel faster than normal movement, and catch-up already accelerates it.
234
238
  // Fading the offset must not add another fraction of that accelerated speed.
235
239
  const allowance = length ? (myKind()?.maxSpeed ?? 0) * elapsed / 1000 / length : 1;
236
- const release = settled && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
240
+ const release = settled && !predict.idleHold && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
237
241
  const progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
238
242
  const nominal = Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
239
243
  o.fade = Math.max(nominal, o.fade - Math.max(release, progress));
@@ -258,7 +262,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
258
262
  if (revision !== cachedCollision) { geometry = collisionMap(map, revision?.[2] ?? []); cachedCollision = revision; }
259
263
  return geometry;
260
264
  };
261
- const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
265
+ const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, geometry: geometryAt, self: () => mine?.id, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, shape: () => ({ terrain: myKind()?.terrain, shape: myKind()?.shape ?? 'sphere', radius: myKind()?.radius ?? 0, height: myKind()?.height ?? 0 }), dims });
262
266
  function authorityRtt(): number {
263
267
  // The helper's ping ends at the relay. A browser host adds another network leg in both directions.
264
268
  // Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
@@ -290,7 +294,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
290
294
  leadAfter = clock() + 2 * rtt + period;
291
295
  base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
292
296
  stepped = Math.floor(base.tick) - 1;
293
- entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Set();
297
+ entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Map();
294
298
  speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; headingBlend = null; correction.rebases += 1;
295
299
  const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
296
300
  if (mine) {
@@ -306,7 +310,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
306
310
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
307
311
  function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
308
312
  const fn = moves[kindName];
309
- if (!fn) return body;
313
+ if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
310
314
  moveTick = t;
311
315
  // Like snapshot unpacking, prepare the bounded geometry outside the handler
312
316
  // quota. Authority debits projection once per tick, not once per mover.
@@ -327,9 +331,9 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
327
331
  const input: Record<string, unknown> = {};
328
332
  let press = false;
329
333
  for (const [name, fd] of kind.input) {
330
- if (fd.t === 'press') { const on = fresh && pressed.has(name); input[name] = on; values.push(on ? 1 : 0); if (on) press = true; } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
334
+ if (fd.t === 'press' || fd.t === 'pulse') { const on = fresh ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : (fd.t === 'press' ? false : 0); input[name] = on; values.push(fd.t === 'pulse' ? 0 : Number(on)); if (on) press = true; } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
331
335
  }
332
- if (fresh) pressed = new Set();
336
+ if (fresh) pressed = new Map();
333
337
  held = Object.freeze({ ...input });
334
338
  {
335
339
  const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
@@ -359,7 +363,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
359
363
  function keepalive(t: number): void {
360
364
  const kind = myKind();
361
365
  if (!mine || !kind) return;
362
- const values = kind.input.map(([name, fd]) => (fd.t === 'press' ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
366
+ const values = kind.input.map(([name, fd]) => ((fd.t === 'press' || fd.t === 'pulse') ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
363
367
  const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
364
368
  entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
365
369
  }
@@ -375,7 +379,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
375
379
  if (hidden) {
376
380
  // A hidden tab's timers are too slow to keep input alive, and a body must not run on without its player:
377
381
  // one neutral entry at once (every field at its init, no press), then no more steps until the tab shows again.
378
- if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Set(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
382
+ if (!hiddenSent) { hiddenSent = true; sample = {}; pressed = new Map(); const t = Math.floor(tickAt(now)) + 1; keepalive(t); lastSendAt = 0; flush(now, t); }
379
383
  base = null;
380
384
  return;
381
385
  }
@@ -430,21 +434,21 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
430
434
  adopt(me);
431
435
  history.set(f.k, copyMine(mine!));
432
436
  const ack = row?.[2] ?? 0;
433
- const late = new Set<string>();
437
+ const late = new Map<string, unknown>();
434
438
  for (const entry of pending) if (entry.t <= f.k) {
435
439
  replayHeld = entry.input;
436
440
  if (entry.t > ack && f.k + 1 - entry.t <= Math.ceil(tickHz / 4))
437
- for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) late.add(name);
441
+ for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) late.set(name, entry.input[name]);
438
442
  }
439
443
  let input = replayHeld;
440
444
  for (let t = f.k + 1; t <= stepped; t++) {
441
- const presses = new Set(t === f.k + 1 ? late : []);
445
+ const presses = new Map(t === f.k + 1 ? late : []);
442
446
  for (const entry of pending) if (entry.t === t) {
443
447
  input = entry.input;
444
- for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) presses.add(name);
448
+ for (const [name, fd] of myKind()!.input) if ((fd.t === 'press' || fd.t === 'pulse') && entry.input[name]) presses.set(name, entry.input[name]);
445
449
  }
446
450
  const stepInput = { ...input };
447
- for (const [name, fd] of myKind()!.input) if (fd.t === 'press') stepInput[name] = presses.has(name);
451
+ for (const [name, fd] of myKind()!.input) if (fd.t === 'press' || fd.t === 'pulse') stepInput[name] = presses.get(name) ?? (fd.t === 'press' ? false : 0);
448
452
  Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
449
453
  history.set(t, copyMine(mine!));
450
454
  }
@@ -581,6 +585,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
581
585
  const server = latest.ents.get(mine.id);
582
586
  if (!server) return null;
583
587
  const kind = kindOf.get(mine.kind);
588
+ if (kind?.control && !server.motion[kind.control]) return entityOf(server, true);
584
589
  let pos = mine.pos;
585
590
  let heading = mine.heading;
586
591
  let velocity = mine.vel, grounded = mine.grounded, motion = mine.motion;
@@ -589,7 +594,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
589
594
  if (kind && base) {
590
595
  const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
591
596
  const now: Record<string, unknown> = {};
592
- for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
597
+ for (const [name, fd] of kind.input) now[name] = (fd.t === 'press' || fd.t === 'pulse') ? (pressed.get(name) ?? (fd.t === 'press' ? false : 0)) : coerce(fd, sample[name] ?? fd.init, dims);
593
598
  const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
594
599
  pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
595
600
  heading = blendHeading(mine.heading, next.heading, a, dims);
@@ -710,11 +715,25 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
710
715
  const onShow = (): void => { if (typeof document !== 'undefined' && !document.hidden) { base = null; if (latest) rebase(latest.k); } };
711
716
  if (typeof document !== 'undefined') document.addEventListener('visibilitychange', onShow);
712
717
 
718
+ const viewTargets = () => {
719
+ pump();
720
+ const targets: QueryTarget[] = collisionTargets(collisionMap(map, collisionHistory.at(-1)?.[2] ?? []));
721
+ for(const k of schema.kinds) {
722
+ if(k.collider || !k.radius)continue;
723
+ each(k.name,e=>targets.push({id:e.id,kind:k.name,query:k.query,fields:e as Record<string,unknown>,geometry:false,solid:solidAt(e.pos,{shape:k.shape??'circle',radius:k.radius,height:k.height??0}),at:e.pos}));
724
+ }
725
+ return targets;
726
+ };
727
+ const viewRay = (from: unknown, direction: unknown, max: number, options?: RayOptions, all=false) => rayQuery(map,viewTargets(),dims,from,direction,max,options,mine?.id,all);
713
728
  return {
729
+ get viewTick() { if(solo)return solo.viewTick;const s=net.sample();const a=s?frameOf(s.a):latest,b=s?frameOf(s.b):latest;return a&&b?a.k+(b.k-a.k)*(s?.alpha??0):latest?.k??0; },
714
730
  get status() { return solo ? 'offline' : status; },
715
731
  get seat() { return solo ? solo.seat : net.seat; },
716
732
  get me() { pump(); return meNow(); },
717
733
  each, get,
734
+ ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
735
+ rays: options => solo ? solo.rays(options) : raySnapshot(map,viewTargets(),dims,options,mine?.id),
736
+ rayAll: (from,direction,max,options) => solo ? solo.rayAll(from,direction,max,options) : viewRay(from,direction,max,options,true),
718
737
  on(name, fn) { let set = listeners.get(name); if (!set) { set = new Set(); listeners.set(name, set); } set.add(fn); forwardSolo(name); return () => { set?.delete(fn); }; },
719
738
  get shared() { if (solo) return solo.shared; return Object.freeze(unpackFields(schema.shared, net.stateOf('shared'), dims)); },
720
739
  input(s) {
@@ -723,7 +742,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
723
742
  const next: Record<string, unknown> = {};
724
743
  let changed = false;
725
744
  for (const [name, fd] of kind?.input ?? []) {
726
- if (fd.t === 'press') { if ((s[name] === true || s[name] === 1) && !pressed.has(name)) changed = true; }
745
+ if (fd.t === 'press' || fd.t === 'pulse') { if (coerce(fd,s[name],dims) && !pressed.has(name)) changed = true; }
727
746
  else { if (s[name] !== undefined) next[name] = s[name]; if ((next[name] ?? fd.init) !== (sample[name] ?? fd.init)) changed = true; }
728
747
  }
729
748
  // A fresh control can replace a preview partway through a tick (especially
@@ -731,7 +750,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
731
750
  // may advance the new path, rather than drawing elapsed time with new input.
732
751
  if (changed) pump();
733
752
  const shown = changed ? meNow()?.pos : null;
734
- for (const [name, fd] of kind?.input ?? []) if (fd.t === 'press' && (s[name] === true || s[name] === 1)) pressed.add(name);
753
+ for (const [name, fd] of kind?.input ?? []) if ((fd.t === 'press' || fd.t === 'pulse') && coerce(fd,s[name],dims)) { const value=coerce(fd,s[name],dims) as number | boolean; if(fd.t==='pulse'&&!pressed.has(name))net.send('pulse',[epoch,name,value]); pressed.set(name,value); }
735
754
  sample = next;
736
755
  if (shown) {
737
756
  const now = clock(), nextShown = meNow()?.pos;
package/worker/index.mjs CHANGED
@@ -1030,6 +1030,11 @@ async function route(request, env, ctx) {
1030
1030
  return serversPage(cat, meta, { origin: url.origin, servers: await serverLive(env, meta, servers, url.origin, lobby), hidden: true, pick: true });
1031
1031
  }
1032
1032
  if (!d.ok) return doorPage(cat, meta, srv, { why: d.why, days: d.days, origin: url.origin, next: `${url.pathname}${url.search}`, code: url.searchParams.get('invite') ?? '' });
1033
+ let recordsGuest=null;
1034
+ if(meta.room?.records&&!embed&&!screen&&!d.acct&&env.DB){
1035
+ const made=await playerAccounts.recordsGuest(request,env,url);if(made.error)return made.error;
1036
+ recordsGuest=made.session;d.acct='p-'+recordsGuest.player.id;d.holder=joinHolders(d.holder,d.acct);
1037
+ }
1033
1038
  if (d.acct?.startsWith('p-') && srv.id !== 'public') ctx?.waitUntil?.(noteMember(env, game, srv.id, d.acct.slice(2)));
1034
1039
  const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
1035
1040
  const pol = policyFor(srv, named);
@@ -1054,7 +1059,8 @@ async function route(request, env, ctx) {
1054
1059
  }
1055
1060
  await countVisit(request, env, ctx, game, 'play');
1056
1061
  shareDaily(cat, url, ctx);
1057
- return playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
1062
+ const page=playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
1063
+ for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
1058
1064
  }
1059
1065
  if (sub === 'watch') {
1060
1066
  // The same door as Play: a game that is private or an invite-only beta is watched only by whoever may play it.
@@ -1422,6 +1428,7 @@ export class Table {
1422
1428
  let ticks = 0;
1423
1429
  this.hostRt = startHost(this.game, {
1424
1430
  ...(saved ? { restore: new TextEncoder().encode((saved.hostText ?? JSON.stringify(saved.host))), restoreEpoch: epoch, startDelayMs: boot.count === 1 ? restoreDelay(`${this.game}/${this.code}`) : 0 } : {}),
1431
+ record: (owner,key,value) => this.queueRoundRecord(owner,key,value),
1425
1432
  store: { save: (bytes) => this.saveRoom(bytes) },
1426
1433
  onTick: () => {
1427
1434
  if (!saved) return;
@@ -1683,12 +1690,32 @@ export class Table {
1683
1690
  * a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
1684
1691
  */
1685
1692
  say(line) {
1693
+ if(line?.ev==='hello'&&line.seat!==null&&this.ctx.storage.sql){const c=this.room?.clients.get(line.id),owner='p'+this.room?.seats.get(line.seat)?.occ;if(c?.player){this.ctx.storage.sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');this.ctx.storage.sql.exec('INSERT OR REPLACE INTO round_players VALUES(?,?)',owner,c.player);}}
1686
1694
  if(line?.ev==='hello'&&!line.resumed&&line.seat!==null) this.queueFunctionEvent('player.joined',{game:this.game,room:line.room,player:this.room?.clients.get(line.id)?.conn?.player??null,seat:line.seat});
1687
1695
  const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
1688
1696
  if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
1689
1697
  try { console[bad ? 'error' : 'log'](JSON.stringify({ at: new Date().toISOString(), ...line, game: this.game ?? null, room: line?.room ?? this.code ?? null })); } catch { /* no console */ }
1690
1698
  }
1691
1699
 
1700
+ queueRoundRecord(owner,key,value) {
1701
+ const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
1702
+ sql.exec('CREATE TABLE IF NOT EXISTS round_players(owner TEXT PRIMARY KEY,player TEXT)');
1703
+ const client=[...this.room.clients.values()].find(c=>c.seat!==null&&'p'+this.room.seats.get(c.seat)?.occ===owner);
1704
+ const player=client?.player??sql.exec('SELECT player FROM round_players WHERE owner=?',owner).toArray()[0]?.player;
1705
+ if(!player)return;
1706
+ sql.exec('CREATE TABLE IF NOT EXISTS round_outbox(id TEXT PRIMARY KEY,player TEXT,key TEXT,value TEXT)');
1707
+ sql.exec('INSERT OR IGNORE INTO round_outbox VALUES(?,?,?,?)',player+':'+key+':'+value.id,player,key,JSON.stringify(value));
1708
+ this.ctx.waitUntil(this.flushRoundRecords());
1709
+ }
1710
+ async flushRoundRecords(){
1711
+ const sql=this.ctx.storage.sql;if(!sql||!this.env.DB)return;
1712
+ if(!sql.exec("SELECT name FROM sqlite_master WHERE name='round_outbox'").toArray().length)return;
1713
+ const {appendServerRecord}=await import('./saves.mjs');
1714
+ for(const row of sql.exec('SELECT * FROM round_outbox LIMIT 100').toArray()){
1715
+ try{await appendServerRecord(this.env,row.player,this.game,row.key,JSON.parse(row.value));sql.exec('DELETE FROM round_outbox WHERE id=?',row.id);}
1716
+ catch(error){this.say({ev:'record-retry',error:String(error)});this.armRoom(Date.now()+60000);return;}
1717
+ }
1718
+ }
1692
1719
  queueFunctionEvent(type,data) {
1693
1720
  if(!this.env.DB || this.env.HOMIE_PREVIEW==='1' || !this.ctx.storage.sql) return;
1694
1721
  this.ctx.waitUntil((async()=>{
@@ -1928,6 +1955,7 @@ export class Table {
1928
1955
 
1929
1956
  /** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
1930
1957
  async alarm() {
1958
+ await this.flushRoundRecords();
1931
1959
  await this.flushFunctionEvents();
1932
1960
  // A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
1933
1961
  try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
@@ -388,6 +388,7 @@ export const players = Object.freeze({
388
388
  if(identity?.id && !identity.id.startsWith('office-')) { const p=await env.DB.prepare('SELECT id,name,guest,owner FROM players WHERE id=?1').bind(identity.id).first();return p?{...p,guest:Boolean(p.guest),owner:Boolean(p.owner)}:null; }
389
389
  try { const s = await sessionOf(request, env); return s ? { id: s.player.id, name: s.player.name, guest: s.player.guest, owner: s.player.owner } : null; } catch { return null; }
390
390
  },
391
+ async recordsGuest(request,env,url){return newGuest(request,env,url);},
391
392
  /** Shop guest creation uses the studio's new-guest address rate and the daily player limit. */
392
393
  async shopGuest(request, env, url, addressLimit) { return newGuest(request, env, url, addressLimit, 'shop-new'); },
393
394
  /** True when the request carries the session of an account its owner marked (`homie-studio players owner`). */
@@ -548,7 +549,8 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
548
549
  if (head === 'stats') return withCookies(json({ ok: true, game: game.id, stats: s ? await readPlayerStats(env, s.player.id, game.id) : {} }), s?.cookies);
549
550
  if (third && parts.length === 3) {
550
551
  if (!s) return json({ ok: true, key: third, value: null, version: 0, updatedAt: null });
551
- const r = await getSave(env, s.player.id, game.id, third);
552
+ let key;try{key=decodeURIComponent(third);}catch{return fail(400,'key','invalid save key');}
553
+ const r = await getSave(env, s.player.id, game.id, key);
552
554
  return withCookies(json(r, r.ok ? 200 : 400), s.cookies);
553
555
  }
554
556
  if (!s) return json({ ok: true, game: game.id, player: null, keys: [], used: { keys: 0, bytes: 0, blobBytes: 0 }, limits: SAVE_LIMITS });