@netforge/engine 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/GAME-DESIGN.md +195 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +30 -0
  4. package/README.md +100 -2
  5. package/dist/api/appearance.d.ts +37 -0
  6. package/dist/api/appearance.js +41 -0
  7. package/dist/api/cards.d.ts +105 -0
  8. package/dist/api/cards.js +133 -0
  9. package/dist/api/collector.d.ts +114 -0
  10. package/dist/api/collector.js +204 -0
  11. package/dist/api/dungeon.d.ts +58 -0
  12. package/dist/api/dungeon.js +216 -0
  13. package/dist/api/harness.d.ts +149 -0
  14. package/dist/api/harness.js +549 -0
  15. package/dist/api/idle.d.ts +71 -0
  16. package/dist/api/idle.js +104 -0
  17. package/dist/api/race-harness.d.ts +3 -0
  18. package/dist/api/race-harness.js +148 -0
  19. package/dist/api/registry.d.ts +241 -0
  20. package/dist/api/registry.js +394 -0
  21. package/dist/api/scheduler.d.ts +56 -0
  22. package/dist/api/scheduler.js +160 -0
  23. package/dist/api/schemas.d.ts +958 -0
  24. package/dist/api/schemas.js +859 -0
  25. package/dist/api/tower.d.ts +127 -0
  26. package/dist/api/tower.js +183 -0
  27. package/dist/game/adapter.d.ts +69 -0
  28. package/dist/game/adapter.js +341 -0
  29. package/dist/game/arena-bot.d.ts +66 -0
  30. package/dist/game/arena-bot.js +188 -0
  31. package/dist/game/bot.d.ts +95 -0
  32. package/dist/game/bot.js +285 -0
  33. package/dist/game/compile.d.ts +97 -0
  34. package/dist/game/compile.js +476 -0
  35. package/dist/game/director.d.ts +42 -0
  36. package/dist/game/director.js +179 -0
  37. package/dist/game/earn-source.d.ts +122 -0
  38. package/dist/game/earn-source.js +386 -0
  39. package/dist/game/earn.d.ts +49 -0
  40. package/dist/game/earn.js +55 -0
  41. package/dist/game/index.d.ts +37 -0
  42. package/dist/game/index.js +25 -0
  43. package/dist/game/platformer.d.ts +156 -0
  44. package/dist/game/platformer.js +619 -0
  45. package/dist/game/profile.d.ts +97 -0
  46. package/dist/game/profile.js +140 -0
  47. package/dist/game/race.d.ts +177 -0
  48. package/dist/game/race.js +386 -0
  49. package/dist/game/racer.d.ts +98 -0
  50. package/dist/game/racer.js +379 -0
  51. package/dist/game/runner.d.ts +286 -0
  52. package/dist/game/runner.js +824 -0
  53. package/dist/game/threats.d.ts +23 -0
  54. package/dist/game/threats.js +49 -0
  55. package/dist/game/view.d.ts +47 -0
  56. package/dist/game/view.js +123 -0
  57. package/dist/index.d.ts +33 -0
  58. package/dist/index.js +24 -0
  59. package/dist/net/bits.d.ts +33 -0
  60. package/dist/net/bits.js +135 -0
  61. package/dist/net/client.d.ts +75 -0
  62. package/dist/net/client.js +151 -0
  63. package/dist/net/index.d.ts +23 -0
  64. package/dist/net/index.js +23 -0
  65. package/dist/net/input.d.ts +49 -0
  66. package/dist/net/input.js +112 -0
  67. package/dist/net/interpolation.d.ts +37 -0
  68. package/dist/net/interpolation.js +97 -0
  69. package/dist/net/netsim.d.ts +22 -0
  70. package/dist/net/netsim.js +52 -0
  71. package/dist/net/prediction.d.ts +31 -0
  72. package/dist/net/prediction.js +101 -0
  73. package/dist/net/profile-client.d.ts +75 -0
  74. package/dist/net/profile-client.js +173 -0
  75. package/dist/net/protocol.d.ts +215 -0
  76. package/dist/net/protocol.js +10 -0
  77. package/dist/net/room-client.d.ts +65 -0
  78. package/dist/net/room-client.js +156 -0
  79. package/dist/net/schema.d.ts +78 -0
  80. package/dist/net/schema.js +121 -0
  81. package/dist/net/sim-adapter.d.ts +134 -0
  82. package/dist/net/sim-adapter.js +22 -0
  83. package/dist/net/snapshot.d.ts +61 -0
  84. package/dist/net/snapshot.js +301 -0
  85. package/dist/rules/adapter.d.ts +53 -0
  86. package/dist/rules/adapter.js +261 -0
  87. package/dist/rules/cards.d.ts +115 -0
  88. package/dist/rules/cards.js +547 -0
  89. package/dist/rules/collector.d.ts +161 -0
  90. package/dist/rules/collector.js +732 -0
  91. package/dist/rules/genres.d.ts +13 -0
  92. package/dist/rules/genres.js +38 -0
  93. package/dist/rules/harness.d.ts +16 -0
  94. package/dist/rules/harness.js +127 -0
  95. package/dist/rules/idle.d.ts +115 -0
  96. package/dist/rules/idle.js +370 -0
  97. package/dist/rules/index.d.ts +16 -0
  98. package/dist/rules/index.js +14 -0
  99. package/dist/rules/kit.d.ts +171 -0
  100. package/dist/rules/kit.js +88 -0
  101. package/dist/rules/tower.d.ts +214 -0
  102. package/dist/rules/tower.js +655 -0
  103. package/dist/runtime/arena.d.ts +86 -0
  104. package/dist/runtime/arena.js +269 -0
  105. package/dist/runtime/assets.d.ts +26 -0
  106. package/dist/runtime/assets.js +35 -0
  107. package/dist/runtime/camera.d.ts +113 -0
  108. package/dist/runtime/camera.js +247 -0
  109. package/dist/runtime/crowd.d.ts +18 -0
  110. package/dist/runtime/crowd.js +62 -0
  111. package/dist/runtime/feel/audio.d.ts +150 -0
  112. package/dist/runtime/feel/audio.js +327 -0
  113. package/dist/runtime/feel/clock.d.ts +52 -0
  114. package/dist/runtime/feel/clock.js +80 -0
  115. package/dist/runtime/feel/flash.d.ts +28 -0
  116. package/dist/runtime/feel/flash.js +60 -0
  117. package/dist/runtime/feel/index.d.ts +27 -0
  118. package/dist/runtime/feel/index.js +16 -0
  119. package/dist/runtime/feel/kit.d.ts +159 -0
  120. package/dist/runtime/feel/kit.js +226 -0
  121. package/dist/runtime/feel/knockback.d.ts +41 -0
  122. package/dist/runtime/feel/knockback.js +70 -0
  123. package/dist/runtime/feel/noise.d.ts +6 -0
  124. package/dist/runtime/feel/noise.js +30 -0
  125. package/dist/runtime/feel/numbers.d.ts +91 -0
  126. package/dist/runtime/feel/numbers.js +172 -0
  127. package/dist/runtime/feel/rumble.d.ts +40 -0
  128. package/dist/runtime/feel/rumble.js +58 -0
  129. package/dist/runtime/feel/sound.d.ts +96 -0
  130. package/dist/runtime/feel/sound.js +176 -0
  131. package/dist/runtime/feel/spring.d.ts +42 -0
  132. package/dist/runtime/feel/spring.js +95 -0
  133. package/dist/runtime/feel/trauma.d.ts +48 -0
  134. package/dist/runtime/feel/trauma.js +76 -0
  135. package/dist/runtime/feel/tween.d.ts +66 -0
  136. package/dist/runtime/feel/tween.js +180 -0
  137. package/dist/runtime/feel/types.d.ts +30 -0
  138. package/dist/runtime/feel/types.js +5 -0
  139. package/dist/runtime/index.d.ts +28 -0
  140. package/dist/runtime/index.js +26 -0
  141. package/dist/runtime/input.d.ts +38 -0
  142. package/dist/runtime/input.js +159 -0
  143. package/dist/runtime/interpolate.d.ts +6 -0
  144. package/dist/runtime/interpolate.js +13 -0
  145. package/dist/runtime/render/animation.d.ts +63 -0
  146. package/dist/runtime/render/animation.js +128 -0
  147. package/dist/runtime/render/atmosphere.d.ts +32 -0
  148. package/dist/runtime/render/atmosphere.js +59 -0
  149. package/dist/runtime/render/crowd-motion.d.ts +68 -0
  150. package/dist/runtime/render/crowd-motion.js +163 -0
  151. package/dist/runtime/render/decals.d.ts +64 -0
  152. package/dist/runtime/render/decals.js +222 -0
  153. package/dist/runtime/render/highlight.d.ts +29 -0
  154. package/dist/runtime/render/highlight.js +110 -0
  155. package/dist/runtime/render/index.d.ts +35 -0
  156. package/dist/runtime/render/index.js +22 -0
  157. package/dist/runtime/render/lighting.d.ts +50 -0
  158. package/dist/runtime/render/lighting.js +142 -0
  159. package/dist/runtime/render/particles.d.ts +117 -0
  160. package/dist/runtime/render/particles.js +289 -0
  161. package/dist/runtime/render/post.d.ts +92 -0
  162. package/dist/runtime/render/post.js +191 -0
  163. package/dist/runtime/render/presets.d.ts +57 -0
  164. package/dist/runtime/render/presets.js +116 -0
  165. package/dist/runtime/render/quality.d.ts +113 -0
  166. package/dist/runtime/render/quality.js +180 -0
  167. package/dist/runtime/render/rig.d.ts +29 -0
  168. package/dist/runtime/render/rig.js +107 -0
  169. package/dist/runtime/render/sprite.d.ts +34 -0
  170. package/dist/runtime/render/sprite.js +58 -0
  171. package/dist/runtime/render/stack.d.ts +74 -0
  172. package/dist/runtime/render/stack.js +132 -0
  173. package/dist/runtime/render/vat.d.ts +78 -0
  174. package/dist/runtime/render/vat.js +219 -0
  175. package/dist/runtime/renderer.d.ts +19 -0
  176. package/dist/runtime/renderer.js +18 -0
  177. package/dist/runtime/stats.d.ts +11 -0
  178. package/dist/runtime/stats.js +29 -0
  179. package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
  180. package/dist/runtime/styles/handdrawn/ground.js +282 -0
  181. package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
  182. package/dist/runtime/styles/handdrawn/index.js +94 -0
  183. package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
  184. package/dist/runtime/styles/handdrawn/ink.js +68 -0
  185. package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
  186. package/dist/runtime/styles/handdrawn/looks.js +42 -0
  187. package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
  188. package/dist/runtime/styles/handdrawn/paint.js +129 -0
  189. package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
  190. package/dist/runtime/styles/handdrawn/palette.js +70 -0
  191. package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
  192. package/dist/runtime/styles/handdrawn/textures.js +150 -0
  193. package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
  194. package/dist/runtime/styles/handdrawn/vfx.js +73 -0
  195. package/dist/runtime/styles/index.d.ts +9 -0
  196. package/dist/runtime/styles/index.js +32 -0
  197. package/dist/runtime/styles/kit.d.ts +193 -0
  198. package/dist/runtime/styles/kit.js +1 -0
  199. package/dist/runtime/styles/pixel/index.d.ts +46 -0
  200. package/dist/runtime/styles/pixel/index.js +167 -0
  201. package/dist/runtime/styles/ps1/index.d.ts +29 -0
  202. package/dist/runtime/styles/ps1/index.js +41 -0
  203. package/dist/runtime/styles/ps1/lights.d.ts +47 -0
  204. package/dist/runtime/styles/ps1/lights.js +146 -0
  205. package/dist/runtime/styles/ps1/materials.d.ts +62 -0
  206. package/dist/runtime/styles/ps1/materials.js +285 -0
  207. package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
  208. package/dist/runtime/styles/ps1/pipeline.js +111 -0
  209. package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
  210. package/dist/runtime/styles/ps1/surfaces.js +228 -0
  211. package/dist/runtime/styles/toon/index.d.ts +5 -0
  212. package/dist/runtime/styles/toon/index.js +178 -0
  213. package/dist/runtime/styles/toon/ink.d.ts +39 -0
  214. package/dist/runtime/styles/toon/ink.js +73 -0
  215. package/dist/runtime/styles/toon/look.d.ts +10 -0
  216. package/dist/runtime/styles/toon/look.js +112 -0
  217. package/dist/runtime/styles/toon/material.d.ts +74 -0
  218. package/dist/runtime/styles/toon/material.js +208 -0
  219. package/dist/runtime/styles/toon/normals.d.ts +26 -0
  220. package/dist/runtime/styles/toon/normals.js +127 -0
  221. package/dist/runtime/styles/toon/shadows.d.ts +24 -0
  222. package/dist/runtime/styles/toon/shadows.js +42 -0
  223. package/dist/runtime/styles/toon/vfx.d.ts +15 -0
  224. package/dist/runtime/styles/toon/vfx.js +47 -0
  225. package/dist/runtime/styles/view.d.ts +35 -0
  226. package/dist/runtime/styles/view.js +34 -0
  227. package/dist/runtime/styles/voxel/core.d.ts +114 -0
  228. package/dist/runtime/styles/voxel/core.js +577 -0
  229. package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
  230. package/dist/runtime/styles/voxel/geometry.js +40 -0
  231. package/dist/runtime/styles/voxel/index.d.ts +21 -0
  232. package/dist/runtime/styles/voxel/index.js +23 -0
  233. package/dist/runtime/styles/voxel/kit.d.ts +6 -0
  234. package/dist/runtime/styles/voxel/kit.js +11 -0
  235. package/dist/runtime/styles/voxel/look.d.ts +10 -0
  236. package/dist/runtime/styles/voxel/look.js +16 -0
  237. package/dist/runtime/styles/voxel/props.d.ts +9 -0
  238. package/dist/runtime/styles/voxel/props.js +52 -0
  239. package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
  240. package/dist/runtime/styles/voxel/shapes.js +83 -0
  241. package/dist/runtime/styles/voxel/world.d.ts +49 -0
  242. package/dist/runtime/styles/voxel/world.js +422 -0
  243. package/llms-full.txt +460 -0
  244. package/llms.txt +183 -0
  245. package/package.json +104 -3
@@ -0,0 +1,86 @@
1
+ import type { SimConfig } from "@netforge/engine-sim";
2
+ import type { Backend } from "./renderer.js";
3
+ export interface ArenaOptions {
4
+ readonly sim?: SimConfig;
5
+ readonly forceWebGL?: boolean;
6
+ /** Called after every rendered frame with the latest numbers. */
7
+ readonly onStats?: (s: ArenaStats) => void;
8
+ /** Keep frame time near this budget by scaling resolution (ms). Default 16.7. */
9
+ readonly frameBudgetMs?: number;
10
+ /** Feed scripted inputs instead of the keyboard (headless measurement). */
11
+ readonly autopilot?: boolean;
12
+ }
13
+ export interface ArenaStats {
14
+ readonly backend: Backend;
15
+ readonly fps: number;
16
+ /** Interval between frames (what the player feels). */
17
+ readonly frameMs: {
18
+ readonly mean: number;
19
+ readonly p50: number;
20
+ readonly p95: number;
21
+ readonly p99: number;
22
+ };
23
+ /** Main-thread time spent inside the frame callback (sim + render submission). */
24
+ readonly cpuMs: {
25
+ readonly mean: number;
26
+ readonly p95: number;
27
+ };
28
+ readonly simMsPerTick: number;
29
+ readonly drawCalls: number;
30
+ readonly triangles: number;
31
+ readonly entities: number;
32
+ readonly enemies: number;
33
+ readonly frames: number;
34
+ readonly pixelRatio: number;
35
+ readonly tick: number;
36
+ readonly health: number;
37
+ readonly kills: number;
38
+ }
39
+ /**
40
+ * The week-2 single-player arena: engine-sim on a fixed tick, rendered by
41
+ * three.js with interpolation, one instanced draw for every enemy, a
42
+ * follow camera, keyboard/mouse/touch input. Placeholder primitives only.
43
+ */
44
+ export declare class ArenaClient {
45
+ private readonly host;
46
+ private readonly handle;
47
+ private readonly world;
48
+ private readonly opts;
49
+ private constructor();
50
+ private scene;
51
+ private rig;
52
+ private input;
53
+ private crowd;
54
+ private playerMesh;
55
+ private pulse;
56
+ private pulseT;
57
+ private stepper;
58
+ private readonly frame;
59
+ private readonly cpu;
60
+ private readonly sim;
61
+ private last;
62
+ private frames;
63
+ private kills;
64
+ private flash;
65
+ private pixelRatio;
66
+ private resLastChange;
67
+ private readonly target;
68
+ private autoYaw;
69
+ private autoSeq;
70
+ private disposed;
71
+ private latest;
72
+ static create(host: HTMLElement, opts?: ArenaOptions): Promise<ArenaClient>;
73
+ private build;
74
+ private resize;
75
+ start(): void;
76
+ /** Latest numbers (also passed to `onStats`). */
77
+ stats(): ArenaStats | null;
78
+ private autopilotInput;
79
+ private loop;
80
+ private drawPlayer;
81
+ private onEvents;
82
+ private animateFx;
83
+ /** Dynamic resolution: hold the frame budget by trading pixels (spec §5 quality tiers). */
84
+ private adaptResolution;
85
+ dispose(): void;
86
+ }
@@ -0,0 +1,269 @@
1
+ import { AmbientLight, BoxGeometry, CapsuleGeometry, Color, ConeGeometry, CylinderGeometry, DirectionalLight, Fog, GridHelper, HemisphereLight, Mesh, MeshBasicMaterial, MeshStandardMaterial, PlaneGeometry, RingGeometry, Scene, Vector3, } from "three/webgpu";
2
+ import { EntityFlag, EntityKind, FixedStepper, addPlayer, createWorld, destroyWorld, initSim, renderView, step, } from "@netforge/engine-sim";
3
+ import { CameraRig } from "./camera.js";
4
+ import { InstancedCrowd } from "./crowd.js";
5
+ import { InputController } from "./input.js";
6
+ import { lerp } from "./interpolate.js";
7
+ import { createRenderer } from "./renderer.js";
8
+ import { FrameStats } from "./stats.js";
9
+ const PLAYER_ID = 1;
10
+ /**
11
+ * The week-2 single-player arena: engine-sim on a fixed tick, rendered by
12
+ * three.js with interpolation, one instanced draw for every enemy, a
13
+ * follow camera, keyboard/mouse/touch input. Placeholder primitives only.
14
+ */
15
+ export class ArenaClient {
16
+ host;
17
+ handle;
18
+ world;
19
+ opts;
20
+ constructor(host, handle, world, opts) {
21
+ this.host = host;
22
+ this.handle = handle;
23
+ this.world = world;
24
+ this.opts = opts;
25
+ }
26
+ scene = new Scene();
27
+ rig;
28
+ input;
29
+ crowd;
30
+ playerMesh;
31
+ pulse;
32
+ pulseT = 1;
33
+ stepper;
34
+ frame = new FrameStats();
35
+ cpu = new FrameStats();
36
+ sim = new FrameStats(120);
37
+ last = 0;
38
+ frames = 0;
39
+ kills = 0;
40
+ flash = 0;
41
+ pixelRatio = 1;
42
+ resLastChange = 0;
43
+ target = new Vector3();
44
+ autoYaw = 0;
45
+ autoSeq = 0;
46
+ disposed = false;
47
+ latest = null;
48
+ static async create(host, opts = {}) {
49
+ await initSim();
50
+ const canvas = document.createElement("canvas");
51
+ canvas.style.cssText = "display:block;width:100%;height:100%;touch-action:none";
52
+ host.append(canvas);
53
+ const handle = await createRenderer(canvas, opts.forceWebGL === undefined ? {} : { forceWebGL: opts.forceWebGL });
54
+ const world = createWorld(opts.sim);
55
+ addPlayer(world, PLAYER_ID);
56
+ const c = new ArenaClient(host, handle, world, opts);
57
+ c.build();
58
+ return c;
59
+ }
60
+ build() {
61
+ const { renderer } = this.handle;
62
+ const cfg = this.world.config;
63
+ const half = cfg.arena.halfExtent;
64
+ this.pixelRatio = renderer.getPixelRatio();
65
+ const bg = new Color(0x0b0d12);
66
+ this.scene.background = bg;
67
+ this.scene.fog = new Fog(bg, 40, 140);
68
+ this.scene.add(new HemisphereLight(0xbfd4ff, 0x1a1408, 1.1));
69
+ this.scene.add(new AmbientLight(0xffffff, 0.15));
70
+ const sun = new DirectionalLight(0xffffff, 1.6);
71
+ sun.position.set(20, 40, 10);
72
+ this.scene.add(sun);
73
+ const floor = new Mesh(new PlaneGeometry(half * 2, half * 2), new MeshStandardMaterial({ color: 0x1b2230, roughness: 0.95 }));
74
+ floor.rotation.x = -Math.PI / 2;
75
+ this.scene.add(floor);
76
+ const grid = new GridHelper(half * 2, Math.round(half), 0x2f3a50, 0x232b3b);
77
+ grid.position.y = 0.01;
78
+ this.scene.add(grid);
79
+ const wallMat = new MeshStandardMaterial({ color: 0x3a4660, roughness: 0.8 });
80
+ const h = cfg.arena.wallHeight;
81
+ for (const [x, z, w, d] of [
82
+ [0, half + 0.5, half * 2 + 2, 1],
83
+ [0, -half - 0.5, half * 2 + 2, 1],
84
+ [half + 0.5, 0, 1, half * 2 + 2],
85
+ [-half - 0.5, 0, 1, half * 2 + 2],
86
+ ]) {
87
+ const m = new Mesh(new BoxGeometry(w, h, d), wallMat);
88
+ m.position.set(x, h / 2, z);
89
+ this.scene.add(m);
90
+ }
91
+ const pillarMat = new MeshStandardMaterial({ color: 0x55627d, roughness: 0.7 });
92
+ for (const p of cfg.arena.pillars) {
93
+ const m = new Mesh(new CylinderGeometry(p.radius, p.radius, p.height, 24), pillarMat);
94
+ m.position.set(p.x, p.height / 2, p.z);
95
+ this.scene.add(m);
96
+ }
97
+ const pr = cfg.player;
98
+ this.playerMesh = new Mesh(new CapsuleGeometry(pr.radius, pr.halfHeight * 2, 4, 12), new MeshStandardMaterial({ color: 0x4fd1c5, roughness: 0.4, emissive: new Color(0x0b3b38) }));
99
+ const nose = new Mesh(new BoxGeometry(0.18, 0.18, 0.4), new MeshStandardMaterial({ color: 0xffffff }));
100
+ nose.position.set(0, 0.35, -pr.radius);
101
+ this.playerMesh.add(nose);
102
+ this.scene.add(this.playerMesh);
103
+ // Enemies: a 6-sided cone pointing along -z (its facing), one draw call.
104
+ const er = cfg.enemies.radius;
105
+ const cone = new ConeGeometry(er, er * 2.4, 6);
106
+ cone.rotateX(-Math.PI / 2);
107
+ const cap = Math.max(16, cfg.enemies.target + cfg.enemies.spawnPerTick);
108
+ this.crowd = new InstancedCrowd(cone, new MeshStandardMaterial({ color: 0xf06449, roughness: 0.6 }), cap, EntityKind.Enemy);
109
+ this.scene.add(this.crowd.mesh);
110
+ this.pulse = new Mesh(new RingGeometry(0.85, 1, 48), new MeshBasicMaterial({ color: 0x9ae6ff, transparent: true, opacity: 0, depthWrite: false }));
111
+ this.pulse.rotation.x = -Math.PI / 2;
112
+ this.scene.add(this.pulse);
113
+ this.rig = new CameraRig(1);
114
+ this.input = new InputController(this.host, this.rig);
115
+ this.stepper = new FixedStepper(this.world.tickRate, 4);
116
+ this.resize();
117
+ const ro = new ResizeObserver(() => this.resize());
118
+ ro.observe(this.host);
119
+ // Nothing runs while the tab is hidden; on return, drop the backlog.
120
+ document.addEventListener("visibilitychange", () => {
121
+ if (!document.hidden) {
122
+ this.stepper.reset();
123
+ this.last = 0;
124
+ }
125
+ });
126
+ }
127
+ resize() {
128
+ const w = Math.max(1, this.host.clientWidth);
129
+ const h = Math.max(1, this.host.clientHeight);
130
+ this.handle.renderer.setSize(w, h, false);
131
+ this.rig.resize(w / h);
132
+ }
133
+ start() {
134
+ this.handle.renderer.setAnimationLoop((t) => this.loop(t));
135
+ }
136
+ /** Latest numbers (also passed to `onStats`). */
137
+ stats() {
138
+ return this.latest;
139
+ }
140
+ autopilotInput(tick) {
141
+ // Circle-strafe and pulse: exercises movement, the crowd and combat.
142
+ this.autoYaw += 0.01;
143
+ return { seq: ++this.autoSeq, tick, moveX: 0.6, moveZ: 0.8, yaw: this.autoYaw, buttons: tick % 20 < 2 ? 2 : 0 };
144
+ }
145
+ loop(now) {
146
+ if (this.disposed || document.hidden)
147
+ return;
148
+ const t0 = performance.now();
149
+ const dtMs = this.last === 0 ? 1000 / 60 : now - this.last;
150
+ if (this.last !== 0)
151
+ this.frame.push(dtMs);
152
+ this.last = now;
153
+ const adv = this.stepper.advance(dtMs);
154
+ const events = [];
155
+ for (let i = 0; i < adv.ticks; i++) {
156
+ const cmd = this.opts.autopilot ? this.autopilotInput(this.world.tick) : this.input.sample(this.world.tick);
157
+ if (this.opts.autopilot)
158
+ this.rig.yaw = cmd.yaw;
159
+ const s0 = performance.now();
160
+ const r = step(this.world, new Map([[PLAYER_ID, cmd]]));
161
+ this.sim.push(performance.now() - s0);
162
+ for (const e of r.events)
163
+ events.push(e);
164
+ }
165
+ this.onEvents(events);
166
+ const view = renderView(this.world);
167
+ const enemies = this.crowd.update(view, adv.alpha);
168
+ const me = this.drawPlayer(view, adv.alpha);
169
+ const dt = dtMs / 1000;
170
+ this.rig.update(this.target, dt);
171
+ this.animateFx(dt);
172
+ this.handle.renderer.render(this.scene, this.rig.camera);
173
+ this.cpu.push(performance.now() - t0);
174
+ this.frames++;
175
+ this.adaptResolution(now);
176
+ const info = this.handle.renderer.info.render;
177
+ const mean = this.frame.mean();
178
+ this.latest = {
179
+ backend: this.handle.backend,
180
+ fps: mean > 0 ? 1000 / mean : 0,
181
+ frameMs: { mean, p50: this.frame.percentile(0.5), p95: this.frame.percentile(0.95), p99: this.frame.percentile(0.99) },
182
+ cpuMs: { mean: this.cpu.mean(), p95: this.cpu.percentile(0.95) },
183
+ simMsPerTick: this.sim.mean(),
184
+ drawCalls: info.drawCalls,
185
+ triangles: info.triangles,
186
+ entities: view.count,
187
+ enemies,
188
+ frames: this.frames,
189
+ pixelRatio: this.pixelRatio,
190
+ tick: this.world.tick,
191
+ health: me?.health ?? 0,
192
+ kills: this.kills,
193
+ };
194
+ this.opts.onStats?.(this.latest);
195
+ }
196
+ drawPlayer(view, alpha) {
197
+ for (let i = 0; i < view.count; i++) {
198
+ if (view.kind[i] !== EntityKind.Player || view.owner[i] !== PLAYER_ID)
199
+ continue;
200
+ const x = lerp(view.prevX[i] ?? 0, view.x[i] ?? 0, alpha);
201
+ const y = lerp(view.prevY[i] ?? 0, view.y[i] ?? 0, alpha);
202
+ const z = lerp(view.prevZ[i] ?? 0, view.z[i] ?? 0, alpha);
203
+ this.playerMesh.position.set(x, y, z);
204
+ this.playerMesh.rotation.y = view.yaw[i] ?? 0;
205
+ this.playerMesh.visible = ((view.flags[i] ?? 0) & EntityFlag.Dead) === 0;
206
+ this.target.set(x, y, z);
207
+ return { health: view.health[i] ?? 0 };
208
+ }
209
+ return null;
210
+ }
211
+ onEvents(events) {
212
+ for (const e of events) {
213
+ if (e.type === "enemy.killed")
214
+ this.kills++;
215
+ else if (e.type === "player.pulse") {
216
+ this.pulseT = 0;
217
+ this.rig.shake(0.15, 0.12);
218
+ }
219
+ else if (e.type === "player.hit") {
220
+ this.flash = 1;
221
+ this.rig.shake(0.25, 0.15);
222
+ }
223
+ }
224
+ }
225
+ animateFx(dt) {
226
+ const mat = this.pulse.material;
227
+ if (this.pulseT < 1) {
228
+ this.pulseT = Math.min(1, this.pulseT + dt * 4);
229
+ const r = this.world.config.player.pulseRadius * (0.3 + 0.7 * this.pulseT);
230
+ this.pulse.scale.setScalar(r);
231
+ this.pulse.position.set(this.target.x, 0.05, this.target.z);
232
+ mat.opacity = 0.8 * (1 - this.pulseT);
233
+ }
234
+ else {
235
+ mat.opacity = 0;
236
+ }
237
+ if (this.flash > 0) {
238
+ this.flash = Math.max(0, this.flash - dt * 5);
239
+ this.playerMesh.material.emissive.setRGB(0.05 + this.flash, 0.23 * (1 - this.flash), 0.22 * (1 - this.flash));
240
+ }
241
+ }
242
+ /** Dynamic resolution: hold the frame budget by trading pixels (spec §5 quality tiers). */
243
+ adaptResolution(now) {
244
+ if (this.frame.count < 120 || now - this.resLastChange < 2000)
245
+ return;
246
+ const budget = this.opts.frameBudgetMs ?? 1000 / 60;
247
+ const p95 = this.frame.percentile(0.95);
248
+ const cap = Math.min(globalThis.devicePixelRatio || 1, 2);
249
+ let next = this.pixelRatio;
250
+ if (p95 > budget * 1.25 && this.pixelRatio > 0.75)
251
+ next = Math.max(0.75, this.pixelRatio - 0.25);
252
+ else if (p95 < budget * 1.05 && this.pixelRatio < cap)
253
+ next = Math.min(cap, this.pixelRatio + 0.25);
254
+ if (next !== this.pixelRatio) {
255
+ this.pixelRatio = next;
256
+ this.handle.renderer.setPixelRatio(next);
257
+ this.resize();
258
+ this.resLastChange = now;
259
+ }
260
+ }
261
+ dispose() {
262
+ this.disposed = true;
263
+ this.handle.renderer.setAnimationLoop(null);
264
+ this.input.dispose();
265
+ this.crowd.dispose();
266
+ this.handle.renderer.dispose();
267
+ destroyWorld(this.world);
268
+ }
269
+ }
@@ -0,0 +1,26 @@
1
+ import type { WebGPURenderer } from "three/webgpu";
2
+ import type { GLTF } from "three/addons/loaders/GLTFLoader.js";
3
+ export interface AssetLoaderOptions {
4
+ /**
5
+ * Maps a library id ("style.lowpoly.mech_01") to a URL on the asset CDN.
6
+ * Games never see URLs (spec §10); only this resolver does.
7
+ */
8
+ readonly resolve: (id: string) => string;
9
+ /** Where basis_transcoder.js/.wasm are served (copied from three/examples/jsm/libs/basis/). */
10
+ readonly transcoderPath: string;
11
+ }
12
+ /**
13
+ * glTF loading with the runtime's compression stack: meshopt geometry
14
+ * (EXT_meshopt_compression) and KTX2/Basis textures (KHR_texture_basisu).
15
+ * Loads are cached by id, so a prefab used 2,000 times is fetched once.
16
+ */
17
+ export declare class AssetLoader {
18
+ private readonly opts;
19
+ private readonly gltf;
20
+ private readonly ktx2;
21
+ private readonly cache;
22
+ private ready;
23
+ constructor(renderer: WebGPURenderer, opts: AssetLoaderOptions);
24
+ load(id: string): Promise<GLTF>;
25
+ dispose(): void;
26
+ }
@@ -0,0 +1,35 @@
1
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
2
+ import { KTX2Loader } from "three/addons/loaders/KTX2Loader.js";
3
+ import { MeshoptDecoder } from "three/addons/libs/meshopt_decoder.module.js";
4
+ /**
5
+ * glTF loading with the runtime's compression stack: meshopt geometry
6
+ * (EXT_meshopt_compression) and KTX2/Basis textures (KHR_texture_basisu).
7
+ * Loads are cached by id, so a prefab used 2,000 times is fetched once.
8
+ */
9
+ export class AssetLoader {
10
+ opts;
11
+ gltf;
12
+ ktx2;
13
+ cache = new Map();
14
+ ready;
15
+ constructor(renderer, opts) {
16
+ this.opts = opts;
17
+ this.ktx2 = new KTX2Loader().setTranscoderPath(opts.transcoderPath);
18
+ this.ready = this.ktx2.detectSupportAsync(renderer).then(() => undefined);
19
+ this.gltf = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder).setKTX2Loader(this.ktx2);
20
+ }
21
+ load(id) {
22
+ let p = this.cache.get(id);
23
+ if (!p) {
24
+ const url = this.opts.resolve(id);
25
+ p = this.ready.then(() => this.gltf.loadAsync(url));
26
+ p.catch(() => this.cache.delete(id));
27
+ this.cache.set(id, p);
28
+ }
29
+ return p;
30
+ }
31
+ dispose() {
32
+ this.ktx2.dispose();
33
+ this.cache.clear();
34
+ }
35
+ }
@@ -0,0 +1,113 @@
1
+ import { PerspectiveCamera, Vector3 } from "three/webgpu";
2
+ import type { Camera } from "three/webgpu";
3
+ import { Trauma } from "./feel/trauma.js";
4
+ import type { TraumaOptions } from "./feel/trauma.js";
5
+ export interface CameraRigOptions {
6
+ readonly distance?: number;
7
+ readonly height?: number;
8
+ readonly minPitch?: number;
9
+ readonly maxPitch?: number;
10
+ /** Follow stiffness, 1/s. Higher is tighter (a critically damped spring, no overshoot). */
11
+ readonly follow?: number;
12
+ /** Seconds of target velocity the camera looks ahead by (camera lead, §13). */
13
+ readonly lead?: number;
14
+ /** Largest look-ahead, m. Default 6 (a teleport never flings the camera). */
15
+ readonly leadMax?: number;
16
+ /** How fast the look-ahead itself settles, 1/s (0 = instant). Default 10. */
17
+ readonly leadSmoothing?: number;
18
+ /** The target may move this far (m, ground plane) before the camera follows. Default 0. */
19
+ readonly deadZone?: number;
20
+ readonly fov?: number;
21
+ /** Trauma shake tuning (maxOffset in m, maxRoll in rad, decay per second). */
22
+ readonly shake?: TraumaOptions;
23
+ /** FOV punch spring: frequency Hz and damping ratio. Default 4 Hz, 0.55. */
24
+ readonly punchFrequency?: number;
25
+ readonly punchDamping?: number;
26
+ /**
27
+ * Keep the visible ground inside these bounds (world x/z, m): near a wall
28
+ * the camera stops instead of showing the void past it. Pass the play
29
+ * field plus however much dressed outside ground should show.
30
+ */
31
+ readonly bounds?: GroundBounds | null;
32
+ /** Height of the ground plane the bounds apply to. Default 0. */
33
+ readonly groundY?: number;
34
+ }
35
+ /** A rectangle on the ground plane. */
36
+ export interface GroundBounds {
37
+ readonly minX: number;
38
+ readonly maxX: number;
39
+ readonly minZ: number;
40
+ readonly maxZ: number;
41
+ }
42
+ /** Square bounds around the origin: a field of half size `half` plus `margin` of outside ground. */
43
+ export declare function fieldBounds(half: number, margin?: number): GroundBounds;
44
+ /**
45
+ * The ground a camera sees: the box around where its four corner rays meet
46
+ * the plane y = `groundY`. A ray at or above the horizon counts as reaching
47
+ * `reach` m out along the ground. The camera's matrices must be current.
48
+ */
49
+ export declare function groundFootprint(camera: Camera, groundY?: number, reach?: number): GroundBounds;
50
+ /**
51
+ * How far to move a view so `seen` stays inside `bounds`, per axis: zero
52
+ * when it already fits, the overshoot when it pokes out of one side, and
53
+ * centring when it is wider than the bounds.
54
+ */
55
+ export declare function boundsShift(seen: GroundBounds, bounds: GroundBounds): {
56
+ x: number;
57
+ z: number;
58
+ };
59
+ /**
60
+ * Third-person orbit-follow camera with the camera parts of the feel kit:
61
+ * critically damped follow, smoothed and clamped lead, a dead zone, trauma
62
+ * shake and FOV punches. `yaw` matches the sim's convention (0 looks down
63
+ * -z), so the input layer can send the camera's yaw as the character's
64
+ * facing. Shake is applied as a per-frame `offset` and `roll` on top of the
65
+ * computed pose, so it never accumulates drift.
66
+ */
67
+ export declare class CameraRig {
68
+ readonly camera: PerspectiveCamera;
69
+ yaw: number;
70
+ pitch: number;
71
+ /** Trauma shake (add with `addTrauma`). */
72
+ readonly trauma: Trauma;
73
+ /** This frame's shake offset (world, m) and roll (rad). */
74
+ readonly offset: Vector3;
75
+ roll: number;
76
+ private readonly focus;
77
+ private readonly focusVel;
78
+ private readonly lastTarget;
79
+ private readonly vel;
80
+ private readonly leadVec;
81
+ private readonly fovSpring;
82
+ private initialised;
83
+ private readonly o;
84
+ private bounds;
85
+ /** This frame's clamp shift (m, ground plane): non-zero while the bounds hold the camera back. */
86
+ readonly clampShift: {
87
+ x: number;
88
+ z: number;
89
+ };
90
+ constructor(aspect: number, opts?: CameraRigOptions);
91
+ /** Camera distance from the focus, m (e.g. pull back on a portrait phone). */
92
+ setDistance(metres: number): void;
93
+ /** Keep the visible ground inside `bounds` (null lets the camera follow anywhere). */
94
+ setBounds(bounds: GroundBounds | null): void;
95
+ /** The point the camera looks at (after follow and lead). */
96
+ get target(): Readonly<Vector3>;
97
+ /** Apply look input (radians). */
98
+ look(dYaw: number, dPitch: number): void;
99
+ /** Add trauma 0..1 (game feel, §13): the shake is trauma squared, so small hits barely move it. */
100
+ addTrauma(amount: number): void;
101
+ /** The classic shake: about `amplitude` m of shake held for `seconds`, then fading. */
102
+ shake(amplitude: number, seconds: number): void;
103
+ /** Kick the field of view by `degrees` (negative zooms in) and spring back. */
104
+ punch(degrees: number): void;
105
+ /** Current FOV offset from the punch spring, degrees. */
106
+ get fovKick(): number;
107
+ /**
108
+ * Follow `target`. `dt` is the (hit-stop scaled) frame delta; pass
109
+ * `realDt` so shake and punches keep moving during a hit-stop.
110
+ */
111
+ update(target: Vector3, dt: number, realDt?: number): void;
112
+ resize(aspect: number): void;
113
+ }