@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.
- package/CHANGELOG.md +53 -0
- package/guides/game/RULES.md +222 -2
- package/lib/build.mjs +1 -1
- package/lib/rules-check-worker.mjs +6 -5
- package/lib/rules-check.mjs +2 -2
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/netplay.ts +1 -0
- package/package.json +1 -1
- package/rules/collision.ts +51 -11
- package/rules/core.ts +105 -21
- package/rules/guard.ts +1 -1
- package/rules/host.ts +40 -9
- package/rules/interest.mjs +3 -3
- package/rules/live.ts +19 -7
- package/rules/map-index.ts +7 -1
- package/rules/math.ts +20 -8
- package/rules/navigation.ts +69 -0
- package/rules/pack.ts +2 -2
- package/rules/query.ts +103 -0
- package/rules/rules.ts +89 -23
- package/rules/terrain-legacy.ts +93 -0
- package/rules/terrain.ts +96 -11
- package/rules/types.ts +17 -7
- package/rules/view.ts +38 -19
- package/worker/index.mjs +29 -1
- package/worker/players.mjs +3 -1
- package/worker/room.mjs +12 -3
- package/worker/saves-shell.mjs +5 -1
- package/worker/saves.mjs +19 -1
- package/worker/version.mjs +1 -1
|
@@ -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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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 */ } }
|
package/worker/players.mjs
CHANGED
|
@@ -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
|
-
|
|
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 });
|