@netforge/engine 0.0.0-stage → 0.1.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.
- package/GAME-DESIGN.md +195 -0
- package/LICENSE +21 -0
- package/NOTICE +30 -0
- package/README.md +147 -2
- package/dist/api/appearance.d.ts +37 -0
- package/dist/api/appearance.js +41 -0
- package/dist/api/cards.d.ts +105 -0
- package/dist/api/cards.js +133 -0
- package/dist/api/collector.d.ts +114 -0
- package/dist/api/collector.js +204 -0
- package/dist/api/dungeon.d.ts +58 -0
- package/dist/api/dungeon.js +216 -0
- package/dist/api/harness.d.ts +149 -0
- package/dist/api/harness.js +549 -0
- package/dist/api/idle.d.ts +71 -0
- package/dist/api/idle.js +104 -0
- package/dist/api/race-harness.d.ts +3 -0
- package/dist/api/race-harness.js +148 -0
- package/dist/api/registry.d.ts +241 -0
- package/dist/api/registry.js +394 -0
- package/dist/api/scheduler.d.ts +56 -0
- package/dist/api/scheduler.js +160 -0
- package/dist/api/schemas.d.ts +958 -0
- package/dist/api/schemas.js +859 -0
- package/dist/api/tower.d.ts +127 -0
- package/dist/api/tower.js +183 -0
- package/dist/game/adapter.d.ts +69 -0
- package/dist/game/adapter.js +341 -0
- package/dist/game/arena-bot.d.ts +66 -0
- package/dist/game/arena-bot.js +188 -0
- package/dist/game/bot.d.ts +95 -0
- package/dist/game/bot.js +285 -0
- package/dist/game/compile.d.ts +97 -0
- package/dist/game/compile.js +476 -0
- package/dist/game/director.d.ts +42 -0
- package/dist/game/director.js +179 -0
- package/dist/game/earn-source.d.ts +122 -0
- package/dist/game/earn-source.js +386 -0
- package/dist/game/earn.d.ts +49 -0
- package/dist/game/earn.js +55 -0
- package/dist/game/index.d.ts +37 -0
- package/dist/game/index.js +25 -0
- package/dist/game/platformer.d.ts +156 -0
- package/dist/game/platformer.js +619 -0
- package/dist/game/profile.d.ts +97 -0
- package/dist/game/profile.js +140 -0
- package/dist/game/race.d.ts +177 -0
- package/dist/game/race.js +386 -0
- package/dist/game/racer.d.ts +98 -0
- package/dist/game/racer.js +379 -0
- package/dist/game/runner.d.ts +286 -0
- package/dist/game/runner.js +824 -0
- package/dist/game/threats.d.ts +23 -0
- package/dist/game/threats.js +49 -0
- package/dist/game/view.d.ts +47 -0
- package/dist/game/view.js +123 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +24 -0
- package/dist/net/bits.d.ts +33 -0
- package/dist/net/bits.js +135 -0
- package/dist/net/client.d.ts +75 -0
- package/dist/net/client.js +151 -0
- package/dist/net/index.d.ts +23 -0
- package/dist/net/index.js +23 -0
- package/dist/net/input.d.ts +49 -0
- package/dist/net/input.js +112 -0
- package/dist/net/interpolation.d.ts +37 -0
- package/dist/net/interpolation.js +97 -0
- package/dist/net/netsim.d.ts +22 -0
- package/dist/net/netsim.js +52 -0
- package/dist/net/prediction.d.ts +31 -0
- package/dist/net/prediction.js +101 -0
- package/dist/net/profile-client.d.ts +75 -0
- package/dist/net/profile-client.js +173 -0
- package/dist/net/protocol.d.ts +208 -0
- package/dist/net/protocol.js +8 -0
- package/dist/net/room-client.d.ts +57 -0
- package/dist/net/room-client.js +144 -0
- package/dist/net/schema.d.ts +78 -0
- package/dist/net/schema.js +121 -0
- package/dist/net/sim-adapter.d.ts +134 -0
- package/dist/net/sim-adapter.js +22 -0
- package/dist/net/snapshot.d.ts +61 -0
- package/dist/net/snapshot.js +301 -0
- package/dist/rules/adapter.d.ts +53 -0
- package/dist/rules/adapter.js +261 -0
- package/dist/rules/cards.d.ts +115 -0
- package/dist/rules/cards.js +547 -0
- package/dist/rules/collector.d.ts +161 -0
- package/dist/rules/collector.js +732 -0
- package/dist/rules/genres.d.ts +13 -0
- package/dist/rules/genres.js +38 -0
- package/dist/rules/harness.d.ts +16 -0
- package/dist/rules/harness.js +127 -0
- package/dist/rules/idle.d.ts +115 -0
- package/dist/rules/idle.js +370 -0
- package/dist/rules/index.d.ts +16 -0
- package/dist/rules/index.js +14 -0
- package/dist/rules/kit.d.ts +171 -0
- package/dist/rules/kit.js +88 -0
- package/dist/rules/tower.d.ts +214 -0
- package/dist/rules/tower.js +655 -0
- package/dist/runtime/arena.d.ts +86 -0
- package/dist/runtime/arena.js +269 -0
- package/dist/runtime/assets.d.ts +26 -0
- package/dist/runtime/assets.js +35 -0
- package/dist/runtime/camera.d.ts +113 -0
- package/dist/runtime/camera.js +247 -0
- package/dist/runtime/crowd.d.ts +18 -0
- package/dist/runtime/crowd.js +62 -0
- package/dist/runtime/feel/audio.d.ts +150 -0
- package/dist/runtime/feel/audio.js +327 -0
- package/dist/runtime/feel/clock.d.ts +52 -0
- package/dist/runtime/feel/clock.js +80 -0
- package/dist/runtime/feel/flash.d.ts +28 -0
- package/dist/runtime/feel/flash.js +60 -0
- package/dist/runtime/feel/index.d.ts +27 -0
- package/dist/runtime/feel/index.js +16 -0
- package/dist/runtime/feel/kit.d.ts +159 -0
- package/dist/runtime/feel/kit.js +226 -0
- package/dist/runtime/feel/knockback.d.ts +41 -0
- package/dist/runtime/feel/knockback.js +70 -0
- package/dist/runtime/feel/noise.d.ts +6 -0
- package/dist/runtime/feel/noise.js +30 -0
- package/dist/runtime/feel/numbers.d.ts +91 -0
- package/dist/runtime/feel/numbers.js +172 -0
- package/dist/runtime/feel/rumble.d.ts +40 -0
- package/dist/runtime/feel/rumble.js +58 -0
- package/dist/runtime/feel/sound.d.ts +96 -0
- package/dist/runtime/feel/sound.js +176 -0
- package/dist/runtime/feel/spring.d.ts +42 -0
- package/dist/runtime/feel/spring.js +95 -0
- package/dist/runtime/feel/trauma.d.ts +48 -0
- package/dist/runtime/feel/trauma.js +76 -0
- package/dist/runtime/feel/tween.d.ts +66 -0
- package/dist/runtime/feel/tween.js +180 -0
- package/dist/runtime/feel/types.d.ts +30 -0
- package/dist/runtime/feel/types.js +5 -0
- package/dist/runtime/index.d.ts +28 -0
- package/dist/runtime/index.js +26 -0
- package/dist/runtime/input.d.ts +38 -0
- package/dist/runtime/input.js +159 -0
- package/dist/runtime/interpolate.d.ts +6 -0
- package/dist/runtime/interpolate.js +13 -0
- package/dist/runtime/render/animation.d.ts +63 -0
- package/dist/runtime/render/animation.js +128 -0
- package/dist/runtime/render/atmosphere.d.ts +32 -0
- package/dist/runtime/render/atmosphere.js +59 -0
- package/dist/runtime/render/crowd-motion.d.ts +68 -0
- package/dist/runtime/render/crowd-motion.js +163 -0
- package/dist/runtime/render/decals.d.ts +64 -0
- package/dist/runtime/render/decals.js +222 -0
- package/dist/runtime/render/highlight.d.ts +29 -0
- package/dist/runtime/render/highlight.js +110 -0
- package/dist/runtime/render/index.d.ts +35 -0
- package/dist/runtime/render/index.js +22 -0
- package/dist/runtime/render/lighting.d.ts +50 -0
- package/dist/runtime/render/lighting.js +142 -0
- package/dist/runtime/render/particles.d.ts +117 -0
- package/dist/runtime/render/particles.js +289 -0
- package/dist/runtime/render/post.d.ts +92 -0
- package/dist/runtime/render/post.js +191 -0
- package/dist/runtime/render/presets.d.ts +57 -0
- package/dist/runtime/render/presets.js +116 -0
- package/dist/runtime/render/quality.d.ts +113 -0
- package/dist/runtime/render/quality.js +180 -0
- package/dist/runtime/render/rig.d.ts +29 -0
- package/dist/runtime/render/rig.js +107 -0
- package/dist/runtime/render/sprite.d.ts +34 -0
- package/dist/runtime/render/sprite.js +58 -0
- package/dist/runtime/render/stack.d.ts +74 -0
- package/dist/runtime/render/stack.js +132 -0
- package/dist/runtime/render/vat.d.ts +78 -0
- package/dist/runtime/render/vat.js +219 -0
- package/dist/runtime/renderer.d.ts +19 -0
- package/dist/runtime/renderer.js +18 -0
- package/dist/runtime/stats.d.ts +11 -0
- package/dist/runtime/stats.js +29 -0
- package/dist/runtime/styles/handdrawn/ground.d.ts +11 -0
- package/dist/runtime/styles/handdrawn/ground.js +282 -0
- package/dist/runtime/styles/handdrawn/index.d.ts +4 -0
- package/dist/runtime/styles/handdrawn/index.js +94 -0
- package/dist/runtime/styles/handdrawn/ink.d.ts +31 -0
- package/dist/runtime/styles/handdrawn/ink.js +68 -0
- package/dist/runtime/styles/handdrawn/looks.d.ts +18 -0
- package/dist/runtime/styles/handdrawn/looks.js +42 -0
- package/dist/runtime/styles/handdrawn/paint.d.ts +59 -0
- package/dist/runtime/styles/handdrawn/paint.js +129 -0
- package/dist/runtime/styles/handdrawn/palette.d.ts +49 -0
- package/dist/runtime/styles/handdrawn/palette.js +70 -0
- package/dist/runtime/styles/handdrawn/textures.d.ts +27 -0
- package/dist/runtime/styles/handdrawn/textures.js +150 -0
- package/dist/runtime/styles/handdrawn/vfx.d.ts +14 -0
- package/dist/runtime/styles/handdrawn/vfx.js +73 -0
- package/dist/runtime/styles/index.d.ts +9 -0
- package/dist/runtime/styles/index.js +32 -0
- package/dist/runtime/styles/kit.d.ts +193 -0
- package/dist/runtime/styles/kit.js +1 -0
- package/dist/runtime/styles/pixel/index.d.ts +46 -0
- package/dist/runtime/styles/pixel/index.js +167 -0
- package/dist/runtime/styles/ps1/index.d.ts +29 -0
- package/dist/runtime/styles/ps1/index.js +41 -0
- package/dist/runtime/styles/ps1/lights.d.ts +47 -0
- package/dist/runtime/styles/ps1/lights.js +146 -0
- package/dist/runtime/styles/ps1/materials.d.ts +62 -0
- package/dist/runtime/styles/ps1/materials.js +285 -0
- package/dist/runtime/styles/ps1/pipeline.d.ts +52 -0
- package/dist/runtime/styles/ps1/pipeline.js +111 -0
- package/dist/runtime/styles/ps1/surfaces.d.ts +44 -0
- package/dist/runtime/styles/ps1/surfaces.js +228 -0
- package/dist/runtime/styles/toon/index.d.ts +5 -0
- package/dist/runtime/styles/toon/index.js +178 -0
- package/dist/runtime/styles/toon/ink.d.ts +39 -0
- package/dist/runtime/styles/toon/ink.js +73 -0
- package/dist/runtime/styles/toon/look.d.ts +10 -0
- package/dist/runtime/styles/toon/look.js +112 -0
- package/dist/runtime/styles/toon/material.d.ts +74 -0
- package/dist/runtime/styles/toon/material.js +208 -0
- package/dist/runtime/styles/toon/normals.d.ts +26 -0
- package/dist/runtime/styles/toon/normals.js +127 -0
- package/dist/runtime/styles/toon/shadows.d.ts +24 -0
- package/dist/runtime/styles/toon/shadows.js +42 -0
- package/dist/runtime/styles/toon/vfx.d.ts +15 -0
- package/dist/runtime/styles/toon/vfx.js +47 -0
- package/dist/runtime/styles/view.d.ts +35 -0
- package/dist/runtime/styles/view.js +34 -0
- package/dist/runtime/styles/voxel/core.d.ts +114 -0
- package/dist/runtime/styles/voxel/core.js +577 -0
- package/dist/runtime/styles/voxel/geometry.d.ts +27 -0
- package/dist/runtime/styles/voxel/geometry.js +40 -0
- package/dist/runtime/styles/voxel/index.d.ts +21 -0
- package/dist/runtime/styles/voxel/index.js +23 -0
- package/dist/runtime/styles/voxel/kit.d.ts +6 -0
- package/dist/runtime/styles/voxel/kit.js +11 -0
- package/dist/runtime/styles/voxel/look.d.ts +10 -0
- package/dist/runtime/styles/voxel/look.js +16 -0
- package/dist/runtime/styles/voxel/props.d.ts +9 -0
- package/dist/runtime/styles/voxel/props.js +52 -0
- package/dist/runtime/styles/voxel/shapes.d.ts +41 -0
- package/dist/runtime/styles/voxel/shapes.js +83 -0
- package/dist/runtime/styles/voxel/world.d.ts +49 -0
- package/dist/runtime/styles/voxel/world.js +422 -0
- package/llms.txt +183 -0
- package/package.json +82 -3
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voxel worlds: the play field's floor, terraced ground past the wall, a
|
|
3
|
+
* stone (or steel) wall, pillars with a voxel flame, and ground clutter.
|
|
4
|
+
*
|
|
5
|
+
* Static voxel scenery is greedy-meshed with occlusion baked into vertex
|
|
6
|
+
* colours, and the material adds a per-voxel shade from the voxel's world
|
|
7
|
+
* cell (a hash), so large merged faces still read as stacked cubes without
|
|
8
|
+
* costing a quad per voxel. The floor is one plane painted per cell the same
|
|
9
|
+
* way, and every placement is seeded, so every client sees the same world.
|
|
10
|
+
*/
|
|
11
|
+
import { Color, InstancedMesh, Matrix4, Mesh, MeshStandardNodeMaterial, PlaneGeometry, Quaternion, Vector3 } from "three/webgpu";
|
|
12
|
+
import { abs, dot, float, floor, fract, max, mix, normalWorld, positionWorld, sin, smoothstep, step, vec3, vertexColor } from "three/tsl";
|
|
13
|
+
import { fillWhere, greedyMesh, hash3, jitter, shadeRgb, VoxelGrid } from "./core.js";
|
|
14
|
+
import { voxelGeometry, voxelMaterial } from "./geometry.js";
|
|
15
|
+
import { flameGrid } from "./shapes.js";
|
|
16
|
+
/** Floor cell (and terrain voxel), m. */
|
|
17
|
+
export const WORLD_CELL = 0.5;
|
|
18
|
+
// A moonlit meadow: deep greens, cool stone and earth that stay brown under the blue night light.
|
|
19
|
+
const MEADOW = {
|
|
20
|
+
grass: [0x36603a, 0x3b6740, 0x325736, 0x447a45],
|
|
21
|
+
dirt: [0x5a4631, 0x634d35, 0x4d3c2b],
|
|
22
|
+
stone: [0x8c8679, 0x7c776b, 0x9a9486, 0x6c675d],
|
|
23
|
+
moss: 0x46703a,
|
|
24
|
+
path: [0x5f4f39, 0x67563e, 0x574833],
|
|
25
|
+
flowers: [0xf2d64b, 0xe8584a, 0xf4f1e6, 0xa77be0, 0xff9a3c],
|
|
26
|
+
};
|
|
27
|
+
const YARD = {
|
|
28
|
+
grass: [0x3d4552, 0x434c5a, 0x394150, 0x48515f],
|
|
29
|
+
dirt: [0x2e333c, 0x353a44, 0x2a2e36],
|
|
30
|
+
stone: [0x4a5260, 0x535c6b, 0x3f4653, 0x5c6573],
|
|
31
|
+
moss: 0xd9a12a,
|
|
32
|
+
path: [0x5a6272, 0x626b7b, 0x535a68],
|
|
33
|
+
flowers: [0xffb020, 0x16e0ff, 0xff4fd8],
|
|
34
|
+
};
|
|
35
|
+
export function paletteFor(theme) {
|
|
36
|
+
return theme === "yard" ? YARD : MEADOW;
|
|
37
|
+
}
|
|
38
|
+
const pick = (list, h) => list[Math.min(list.length - 1, Math.floor(h * list.length))] ?? 0x808080;
|
|
39
|
+
/** A per-cell 0..1 hash in TSL (sin-hash; stable to a few hundred metres from the origin). */
|
|
40
|
+
function cellHash(c) {
|
|
41
|
+
return fract(sin(dot(c, vec3(12.9898, 78.233, 37.719))).mul(43758.5453));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The material for greedy-meshed static voxels: vertex colours (occlusion
|
|
45
|
+
* baked) times a per-voxel shade from the voxel's world cell, and a faint
|
|
46
|
+
* darkening along each voxel's edges so merged faces still show their cubes.
|
|
47
|
+
*/
|
|
48
|
+
export function voxelWorldMaterial(opts = { cell: WORLD_CELL }) {
|
|
49
|
+
const m = voxelMaterial({ roughness: opts.roughness ?? 0.9 });
|
|
50
|
+
const cell = float(opts.cell);
|
|
51
|
+
// The voxel a face belongs to: half a cell in from the face, along its normal.
|
|
52
|
+
const inside = positionWorld.sub(normalWorld.mul(cell.mul(0.5))).div(cell);
|
|
53
|
+
const id = floor(inside);
|
|
54
|
+
const shade = float(1).add(cellHash(id).sub(0.5).mul(2 * (opts.jitter ?? 0.07)));
|
|
55
|
+
// Distance to the nearest voxel edge across the face (in cells): 0 at an edge, 0.5 in the middle.
|
|
56
|
+
const f = fract(positionWorld.div(cell));
|
|
57
|
+
const g = abs(f.sub(0.5));
|
|
58
|
+
const n = abs(normalWorld);
|
|
59
|
+
// Use the two axes in the face's plane.
|
|
60
|
+
const a = mix(g.x, g.z, step(0.5, n.x));
|
|
61
|
+
const b = mix(g.y, g.z, step(0.5, n.y));
|
|
62
|
+
const edge = smoothstep(0.5, 0.44, max(a, b));
|
|
63
|
+
const lines = float(1).sub(float(1).sub(edge).mul(opts.edge ?? 0.12));
|
|
64
|
+
m.colorNode = vertexColor().mul(shade).mul(lines);
|
|
65
|
+
// colorNode already carries the vertex colours; with the flag on as well, NodeMaterial multiplies them in twice (dark, muddy stone).
|
|
66
|
+
m.vertexColors = false;
|
|
67
|
+
return m;
|
|
68
|
+
}
|
|
69
|
+
/** The floor plane's material: grass cells with dirt and path patches, one colour per cell. */
|
|
70
|
+
function floorMaterial(p, half) {
|
|
71
|
+
const m = new MeshStandardNodeMaterial();
|
|
72
|
+
m.roughness = 0.95;
|
|
73
|
+
m.metalness = 0;
|
|
74
|
+
const cell = float(WORLD_CELL);
|
|
75
|
+
const q = positionWorld.xz.div(cell);
|
|
76
|
+
const c = floor(q);
|
|
77
|
+
const h = cellHash(vec3(c.x, 0, c.y));
|
|
78
|
+
const h2 = cellHash(vec3(c.y, 3, c.x));
|
|
79
|
+
// Blocky patches: a coarse hash per 4x4 cells, blended by the cell's own hash so patch edges stay ragged.
|
|
80
|
+
const big = floor(c.div(4));
|
|
81
|
+
const patch = cellHash(vec3(big.x, 7, big.y));
|
|
82
|
+
const col = (hex) => {
|
|
83
|
+
const r = ((hex >> 16) & 255) / 255;
|
|
84
|
+
const g = ((hex >> 8) & 255) / 255;
|
|
85
|
+
const b = (hex & 255) / 255;
|
|
86
|
+
// sRGB to linear (square approximation is enough for a palette).
|
|
87
|
+
return vec3(r * r, g * g, b * b);
|
|
88
|
+
};
|
|
89
|
+
const grassA = col(p.grass[0] ?? 0x4c7a3b);
|
|
90
|
+
const grassB = col(p.grass[1] ?? 0x568a41);
|
|
91
|
+
const grassC = col(p.grass[2] ?? 0x44703a);
|
|
92
|
+
const dirt = col(p.dirt[1] ?? 0x7c5c40);
|
|
93
|
+
const path = col(p.path[0] ?? 0x8a6d4a);
|
|
94
|
+
// Soft patches of two greens (one per 4 x 4 cells) and a sprinkle of a third, so the floor reads as blocks
|
|
95
|
+
// without a checkerboard competing with the crowd.
|
|
96
|
+
let base = mix(grassA, grassB, smoothstep(0.3, 0.7, patch.add(h.sub(0.5).mul(0.35))));
|
|
97
|
+
base = mix(base, grassC, step(0.9, h2));
|
|
98
|
+
// Dirt patches (about 1 in 9 blocks of 4x4), ragged at the edges.
|
|
99
|
+
const isDirt = step(0.89, patch.add(h.sub(0.5).mul(0.12)));
|
|
100
|
+
base = mix(base, dirt, isDirt);
|
|
101
|
+
// A worn ring path around the centre reads as a place people walk.
|
|
102
|
+
const r = q.mul(WORLD_CELL).length();
|
|
103
|
+
const ring = step(abs(r.sub(half * 0.42)), float(1.6).add(h.mul(0.8)));
|
|
104
|
+
base = mix(base, path, ring.mul(step(0.25, h2)));
|
|
105
|
+
const shade = float(1).add(h.sub(0.5).mul(0.08));
|
|
106
|
+
const f = fract(q);
|
|
107
|
+
const edge = smoothstep(0.0, 0.06, f.x).mul(smoothstep(1.0, 0.94, f.x)).mul(smoothstep(0.0, 0.06, f.y)).mul(smoothstep(1.0, 0.94, f.y));
|
|
108
|
+
m.colorNode = base.mul(shade).mul(float(0.86).add(edge.mul(0.14)));
|
|
109
|
+
return m;
|
|
110
|
+
}
|
|
111
|
+
/** Deterministic value noise in 2D, 0..1. */
|
|
112
|
+
function noise2(x, z, seed) {
|
|
113
|
+
const x0 = Math.floor(x);
|
|
114
|
+
const z0 = Math.floor(z);
|
|
115
|
+
const fx = x - x0;
|
|
116
|
+
const fz = z - z0;
|
|
117
|
+
const sx = fx * fx * (3 - 2 * fx);
|
|
118
|
+
const sz = fz * fz * (3 - 2 * fz);
|
|
119
|
+
const a = hash3(x0, 0, z0, seed);
|
|
120
|
+
const b = hash3(x0 + 1, 0, z0, seed);
|
|
121
|
+
const c = hash3(x0, 0, z0 + 1, seed);
|
|
122
|
+
const d = hash3(x0 + 1, 0, z0 + 1, seed);
|
|
123
|
+
return a + (b - a) * sx + (c - a) * sz + (a - b - c + d) * sx * sz;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Height of the voxel ground at (x, z), m: flat inside the field and for a
|
|
127
|
+
* strip past the wall, then terraces that climb away from it in whole
|
|
128
|
+
* voxels. Scenery standing outside the wall uses it to stand on the ground.
|
|
129
|
+
*/
|
|
130
|
+
export function terrainHeight(spec, x, z) {
|
|
131
|
+
const d = Math.max(Math.abs(x), Math.abs(z)) - spec.half - 1;
|
|
132
|
+
if (d < 3)
|
|
133
|
+
return 0;
|
|
134
|
+
const n = noise2(x * 0.07, z * 0.07, 11) * 0.65 + noise2(x * 0.19, z * 0.19, 12) * 0.35;
|
|
135
|
+
const climb = spec.theme === "yard" ? 0.06 : 0.16;
|
|
136
|
+
const raw = (d - 3) * climb + (n - 0.35) * (spec.theme === "yard" ? 1.2 : 3.2);
|
|
137
|
+
const steps = Math.max(0, Math.round(raw / WORLD_CELL));
|
|
138
|
+
return Math.min(12, steps) * WORLD_CELL;
|
|
139
|
+
}
|
|
140
|
+
/** Terraced ground in a rectangle of cells (one greedy-meshed chunk). */
|
|
141
|
+
function terrainChunk(spec, p, x0, z0, nx, nz) {
|
|
142
|
+
const heights = [];
|
|
143
|
+
let top = 0;
|
|
144
|
+
for (let k = 0; k < nz; k++)
|
|
145
|
+
for (let i = 0; i < nx; i++) {
|
|
146
|
+
const cx = x0 + (i + 0.5) * WORLD_CELL;
|
|
147
|
+
const cz = z0 + (k + 0.5) * WORLD_CELL;
|
|
148
|
+
const h = Math.round(terrainHeight(spec, cx, cz) / WORLD_CELL);
|
|
149
|
+
heights.push(h);
|
|
150
|
+
top = Math.max(top, h);
|
|
151
|
+
}
|
|
152
|
+
if (top === 0)
|
|
153
|
+
return null;
|
|
154
|
+
const grid = new VoxelGrid(nx, top, nz, WORLD_CELL, [x0, 0, z0]);
|
|
155
|
+
for (let k = 0; k < nz; k++)
|
|
156
|
+
for (let i = 0; i < nx; i++) {
|
|
157
|
+
const h = heights[i + k * nx] ?? 0;
|
|
158
|
+
for (let y = 0; y < h; y++) {
|
|
159
|
+
const wx = Math.floor(x0 / WORLD_CELL) + i;
|
|
160
|
+
const wz = Math.floor(z0 / WORLD_CELL) + k;
|
|
161
|
+
const hh = hash3(wx, y, wz, 21);
|
|
162
|
+
// Grass on top, dirt under it, stone deep down; the colour is per material, the shader adds per-voxel shade.
|
|
163
|
+
const c = y === h - 1 ? (hh < 0.85 ? (p.grass[1] ?? 0) : p.moss) : y >= h - 3 ? (p.dirt[0] ?? 0) : hh < 0.5 ? (p.stone[1] ?? 0) : (p.dirt[2] ?? 0);
|
|
164
|
+
grid.set(i, y, k, c);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Neighbours outside the chunk count as solid where the terrain is, so chunk seams have no faces or occlusion gaps.
|
|
168
|
+
const solidOutside = (x, y, z) => {
|
|
169
|
+
if (y < 0)
|
|
170
|
+
return true;
|
|
171
|
+
const wx = x0 + (x + 0.5) * WORLD_CELL;
|
|
172
|
+
const wz = z0 + (z + 0.5) * WORLD_CELL;
|
|
173
|
+
return y < Math.round(terrainHeight(spec, wx, wz) / WORLD_CELL);
|
|
174
|
+
};
|
|
175
|
+
return voxelGeometry(greedyMesh(grid, { splitParts: false, skip: ["-y"], solidOutside }));
|
|
176
|
+
}
|
|
177
|
+
/** A seeded PRNG. */
|
|
178
|
+
function rng(seed) {
|
|
179
|
+
let s = seed >>> 0;
|
|
180
|
+
return () => {
|
|
181
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
182
|
+
let t = s;
|
|
183
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
184
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
185
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** The wall around the field: dry stone blocks (meadow) or steel barrier blocks (yard), an uneven top. */
|
|
189
|
+
function wallGeometry(spec, p) {
|
|
190
|
+
const out = [];
|
|
191
|
+
const cell = WORLD_CELL;
|
|
192
|
+
const rows = Math.max(2, Math.round(Math.min(spec.wallHeight, spec.theme === "yard" ? 1.5 : 2) / cell));
|
|
193
|
+
const len = Math.ceil((spec.half * 2 + 2) / cell);
|
|
194
|
+
const thick = 2;
|
|
195
|
+
// Two long sides (north, south) and two short (east, west) fitted between them.
|
|
196
|
+
const sides = [
|
|
197
|
+
[-spec.half - 1, spec.half, len, thick],
|
|
198
|
+
[-spec.half - 1, -spec.half - 1, len, thick],
|
|
199
|
+
[spec.half, -spec.half, thick, len - 4],
|
|
200
|
+
[-spec.half - 1, -spec.half, thick, len - 4],
|
|
201
|
+
];
|
|
202
|
+
sides.forEach(([x0, z0, nx, nz], s) => {
|
|
203
|
+
const grid = new VoxelGrid(nx, rows + 1, nz, cell, [x0, 0, z0]);
|
|
204
|
+
fillWhere(grid, (x, y, z) => {
|
|
205
|
+
const i = Math.floor(x), k = Math.floor(z);
|
|
206
|
+
if (y < rows)
|
|
207
|
+
return true;
|
|
208
|
+
// An uneven top: a few raised blocks.
|
|
209
|
+
return hash3(i, 99, k, s) < 0.18;
|
|
210
|
+
}, (x, y, z) => {
|
|
211
|
+
const wx = Math.floor(x0 / cell) + x;
|
|
212
|
+
const wz = Math.floor(z0 / cell) + z;
|
|
213
|
+
const h = hash3(wx, y, wz, 31);
|
|
214
|
+
if (spec.theme === "yard")
|
|
215
|
+
return y === rows - 1 && (Math.floor((wx + wz) / 2) & 1) === 0 ? p.moss : pick(p.stone, h);
|
|
216
|
+
if (y >= rows - 1 && h < 0.45)
|
|
217
|
+
return p.moss;
|
|
218
|
+
return pick(p.stone, h);
|
|
219
|
+
});
|
|
220
|
+
out.push(voxelGeometry(greedyMesh(grid, { splitParts: false, skip: ["-y"] })));
|
|
221
|
+
});
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/** Ground clutter models: a grass tuft, a flower, a pebble (tiny voxel models at 0.1 m). */
|
|
225
|
+
function clutterModels(p, theme) {
|
|
226
|
+
const s = 0.1;
|
|
227
|
+
const tuft = new VoxelGrid(3, 4, 3, s, [-0.15, 0, -0.15]);
|
|
228
|
+
const g0 = p.grass[3] ?? 0x5d9447;
|
|
229
|
+
const g1 = p.grass[0] ?? 0x4c7a3b;
|
|
230
|
+
tuft.set(1, 0, 1, g1);
|
|
231
|
+
tuft.set(1, 1, 1, g0);
|
|
232
|
+
tuft.set(1, 2, 1, g0);
|
|
233
|
+
tuft.set(0, 0, 1, g1);
|
|
234
|
+
tuft.set(0, 1, 1, g0);
|
|
235
|
+
tuft.set(2, 0, 0, g1);
|
|
236
|
+
tuft.set(2, 1, 0, g0);
|
|
237
|
+
tuft.set(1, 0, 2, g1);
|
|
238
|
+
tuft.set(1, 3, 1, shadeRgb(g0, 1.15));
|
|
239
|
+
const flower = new VoxelGrid(3, 4, 3, s, [-0.15, 0, -0.15]);
|
|
240
|
+
flower.set(1, 0, 1, g1);
|
|
241
|
+
flower.set(1, 1, 1, g0);
|
|
242
|
+
flower.set(1, 2, 1, g0);
|
|
243
|
+
const petal = 0xffffff;
|
|
244
|
+
for (const [x, z] of [[0, 1], [2, 1], [1, 0], [1, 2]])
|
|
245
|
+
flower.set(x, 3, z, petal);
|
|
246
|
+
flower.set(1, 3, 1, 0xf2d64b);
|
|
247
|
+
const pebble = new VoxelGrid(3, 2, 2, s, [-0.15, 0, -0.1]);
|
|
248
|
+
for (let x = 0; x < 3; x++)
|
|
249
|
+
for (let z = 0; z < 2; z++)
|
|
250
|
+
pebble.set(x, 0, z, pick(p.stone, hash3(x, 0, z, 5)));
|
|
251
|
+
pebble.set(1, 1, 0, pick(p.stone, 0.2));
|
|
252
|
+
if (theme === "yard") {
|
|
253
|
+
// The lot: bolts, cable stubs and hazard studs instead of grass.
|
|
254
|
+
return [
|
|
255
|
+
{ geometry: voxelGeometry(greedyMesh(pebble, { splitParts: false, skip: ["-y"] })), count: 900, seed: 3 },
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
return [
|
|
259
|
+
{ geometry: voxelGeometry(greedyMesh(tuft, { splitParts: false, skip: ["-y"] })), count: 2600, seed: 1 },
|
|
260
|
+
{ geometry: voxelGeometry(greedyMesh(flower, { splitParts: false, skip: ["-y"] })), count: 420, seed: 2 },
|
|
261
|
+
{ geometry: voxelGeometry(greedyMesh(pebble, { splitParts: false, skip: ["-y"] })), count: 500, seed: 3 },
|
|
262
|
+
];
|
|
263
|
+
}
|
|
264
|
+
/** Every flower gets one of the palette's colours: the petals are white in the model and tinted per instance. */
|
|
265
|
+
function scatterClutter(spec, p, avoid) {
|
|
266
|
+
const out = [];
|
|
267
|
+
const reach = spec.half + spec.outside * 0.6;
|
|
268
|
+
const m = new Matrix4();
|
|
269
|
+
const q = new Quaternion();
|
|
270
|
+
const axis = new Vector3(0, 1, 0);
|
|
271
|
+
const pos = new Vector3();
|
|
272
|
+
const scl = new Vector3();
|
|
273
|
+
for (const c of clutterModels(p, spec.theme)) {
|
|
274
|
+
const mat = voxelMaterial({ roughness: 0.95 });
|
|
275
|
+
const mesh = new InstancedMesh(c.geometry, mat, c.count);
|
|
276
|
+
const r = rng(0x9e37 + c.seed * 7919);
|
|
277
|
+
let n = 0;
|
|
278
|
+
for (let i = 0; i < c.count * 3 && n < c.count; i++) {
|
|
279
|
+
// Clumped: pick a clump centre, then scatter around it.
|
|
280
|
+
const x = (r() * 2 - 1) * reach;
|
|
281
|
+
const z = (r() * 2 - 1) * reach;
|
|
282
|
+
const inField = Math.abs(x) < spec.half - 0.6 && Math.abs(z) < spec.half - 0.6;
|
|
283
|
+
const outside = Math.max(Math.abs(x), Math.abs(z)) > spec.half + 1.5;
|
|
284
|
+
if (!inField && !outside)
|
|
285
|
+
continue;
|
|
286
|
+
if (avoid.some((pl) => (pl.x - x) ** 2 + (pl.z - z) ** 2 < (pl.radius + 0.6) ** 2))
|
|
287
|
+
continue;
|
|
288
|
+
// Thinner in the middle of the field, where the fights are.
|
|
289
|
+
if (inField && r() < 0.35)
|
|
290
|
+
continue;
|
|
291
|
+
const y = outside ? terrainHeight(spec, x, z) : 0;
|
|
292
|
+
// Snap to the floor's cells so clutter sits on blocks.
|
|
293
|
+
const sx = Math.round(x / 0.1) * 0.1;
|
|
294
|
+
const sz = Math.round(z / 0.1) * 0.1;
|
|
295
|
+
q.setFromAxisAngle(axis, Math.floor(r() * 4) * (Math.PI / 2));
|
|
296
|
+
const k = 0.8 + r() * 0.6;
|
|
297
|
+
pos.set(sx, y, sz);
|
|
298
|
+
scl.set(k, k * (0.8 + r() * 0.5), k);
|
|
299
|
+
mesh.setMatrixAt(n, m.compose(pos, q, scl));
|
|
300
|
+
if (c.seed === 2)
|
|
301
|
+
mesh.setColorAt(n, new Color(pick(p.flowers, r())));
|
|
302
|
+
n++;
|
|
303
|
+
}
|
|
304
|
+
mesh.count = n;
|
|
305
|
+
mesh.instanceMatrix.needsUpdate = true;
|
|
306
|
+
if (mesh.instanceColor)
|
|
307
|
+
mesh.instanceColor.needsUpdate = true;
|
|
308
|
+
mesh.receiveShadow = true;
|
|
309
|
+
mesh.castShadow = false;
|
|
310
|
+
mesh.computeBoundingSphere();
|
|
311
|
+
mesh.name = "VoxelClutter";
|
|
312
|
+
out.push(mesh);
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The whole field: the painted floor, terraced ground past the wall, the
|
|
318
|
+
* wall, and ground clutter. Returns meshes to add to the scene.
|
|
319
|
+
*/
|
|
320
|
+
export function voxelField(spec, pillars = []) {
|
|
321
|
+
const p = paletteFor(spec.theme);
|
|
322
|
+
const out = [];
|
|
323
|
+
const size = (spec.half + spec.outside) * 2;
|
|
324
|
+
const floor = new Mesh(new PlaneGeometry(size, size), floorMaterial(p, spec.half));
|
|
325
|
+
floor.rotation.x = -Math.PI / 2;
|
|
326
|
+
floor.receiveShadow = true;
|
|
327
|
+
floor.name = "VoxelFloor";
|
|
328
|
+
out.push(floor);
|
|
329
|
+
const worldMat = voxelWorldMaterial({ cell: WORLD_CELL, jitter: 0.08, edge: 0.14 });
|
|
330
|
+
// Terraces in chunks of 32 x 32 cells around the field.
|
|
331
|
+
const edge = spec.half + Math.min(spec.outside, 28);
|
|
332
|
+
const chunk = 32 * WORLD_CELL;
|
|
333
|
+
const start = -Math.ceil(edge / chunk) * chunk;
|
|
334
|
+
for (let z0 = start; z0 < edge; z0 += chunk)
|
|
335
|
+
for (let x0 = start; x0 < edge; x0 += chunk) {
|
|
336
|
+
// Skip chunks entirely inside the flat strip.
|
|
337
|
+
const far = Math.max(Math.abs(x0), Math.abs(x0 + chunk), Math.abs(z0), Math.abs(z0 + chunk));
|
|
338
|
+
if (far < spec.half + 4)
|
|
339
|
+
continue;
|
|
340
|
+
const g = terrainChunk(spec, p, x0, z0, 32, 32);
|
|
341
|
+
if (!g)
|
|
342
|
+
continue;
|
|
343
|
+
const mesh = new Mesh(g, worldMat);
|
|
344
|
+
mesh.receiveShadow = true;
|
|
345
|
+
mesh.castShadow = true;
|
|
346
|
+
mesh.name = "VoxelTerrain";
|
|
347
|
+
out.push(mesh);
|
|
348
|
+
}
|
|
349
|
+
if (spec.wall !== false) {
|
|
350
|
+
for (const g of wallGeometry(spec, p)) {
|
|
351
|
+
const mesh = new Mesh(g, worldMat);
|
|
352
|
+
mesh.castShadow = true;
|
|
353
|
+
mesh.receiveShadow = true;
|
|
354
|
+
mesh.name = "VoxelWall";
|
|
355
|
+
out.push(mesh);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
out.push(...scatterClutter(spec, p, pillars));
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
/** A stone column of rough courses with a capital, an iron brazier and a voxel flame (meadow); a steel post with a lamp (yard). */
|
|
362
|
+
export function voxelPillar(spec, theme, index) {
|
|
363
|
+
const p = paletteFor(theme);
|
|
364
|
+
const cell = 0.25;
|
|
365
|
+
const r = spec.radius;
|
|
366
|
+
const n = Math.ceil((r * 2.3) / cell) + 1;
|
|
367
|
+
const rows = Math.max(4, Math.round(spec.height / cell));
|
|
368
|
+
const grid = new VoxelGrid(n, rows + 2, n, cell, [spec.x - (n * cell) / 2, 0, spec.z - (n * cell) / 2]);
|
|
369
|
+
const c = n / 2;
|
|
370
|
+
const yard = theme === "yard";
|
|
371
|
+
fillWhere(grid, (x, y, z) => {
|
|
372
|
+
const d = Math.hypot(x - c, z - c) * cell;
|
|
373
|
+
const course = Math.floor(y / 2);
|
|
374
|
+
// Rough courses: each ring of stones juts a little in or out.
|
|
375
|
+
const wobble = (hash3(Math.floor(Math.atan2(z - c, x - c) * 3), course, index, 41) - 0.5) * cell * 1.2;
|
|
376
|
+
if (y < 2)
|
|
377
|
+
return d < r * 1.08 + (yard ? 0 : wobble * 0.5);
|
|
378
|
+
if (y >= rows - 1)
|
|
379
|
+
return d < r * 1.02;
|
|
380
|
+
return d < r * 0.86 + (yard ? 0 : wobble);
|
|
381
|
+
}, (x, y, z) => {
|
|
382
|
+
const course = Math.floor(y / 2);
|
|
383
|
+
const stone = pick(p.stone, hash3(Math.floor(Math.atan2(z - c, x - c) * 2.5), course, index, 43));
|
|
384
|
+
if (yard)
|
|
385
|
+
return y >= rows - 1 || y < 2 ? (p.stone[3] ?? 0) : (y & 3) === 0 ? p.moss : (p.stone[0] ?? 0);
|
|
386
|
+
if (y < 2 && hash3(x, y, z, 45) < 0.4)
|
|
387
|
+
return p.moss;
|
|
388
|
+
// The capital a top-down camera mostly sees: darker, weathered stone with moss, so a pillar is not a bright disc.
|
|
389
|
+
if (y >= rows - 1)
|
|
390
|
+
return hash3(x, y, z, 49) < 0.3 ? shadeRgb(p.moss, 0.8) : shadeRgb(stone, 0.62);
|
|
391
|
+
// Alternate courses a step apart read as stacked blocks.
|
|
392
|
+
return shadeRgb(stone, (course & 1) === 0 ? 0.86 : 0.76);
|
|
393
|
+
});
|
|
394
|
+
const body = new Mesh(voxelGeometry(greedyMesh(grid, { splitParts: false, skip: ["-y"] })), voxelWorldMaterial({ cell, jitter: 0.06, edge: 0.1 }));
|
|
395
|
+
body.castShadow = true;
|
|
396
|
+
body.receiveShadow = true;
|
|
397
|
+
body.name = "VoxelPillar";
|
|
398
|
+
const top = [];
|
|
399
|
+
const topY = (rows + 1) * cell;
|
|
400
|
+
// The brazier: a dark iron bowl on the capital.
|
|
401
|
+
const bowlN = Math.max(4, Math.round((r * 1.1) / 0.125));
|
|
402
|
+
const bowl = new VoxelGrid(bowlN, 3, bowlN, 0.125, [spec.x - (bowlN * 0.125) / 2, topY, spec.z - (bowlN * 0.125) / 2]);
|
|
403
|
+
const bc = bowlN / 2;
|
|
404
|
+
fillWhere(bowl, (x, y, z) => {
|
|
405
|
+
const d = Math.hypot(x - bc, z - bc);
|
|
406
|
+
return y < 1 ? d < bc * 0.7 : d < bc && d > bc - 1.3;
|
|
407
|
+
}, (x, y, z) => jitter(yard ? 0x2a2f38 : 0x3a3330, x, y, z, 0.1, 47));
|
|
408
|
+
const bowlMesh = new Mesh(voxelGeometry(greedyMesh(bowl, { splitParts: false })), voxelMaterial({ roughness: 0.6 }));
|
|
409
|
+
bowlMesh.castShadow = true;
|
|
410
|
+
top.push(bowlMesh);
|
|
411
|
+
let flame = null;
|
|
412
|
+
if (!yard) {
|
|
413
|
+
const fg = flameGrid(index + 1);
|
|
414
|
+
const flameMesh = new Mesh(voxelGeometry(greedyMesh(fg, { splitParts: false, ao: false })), voxelMaterial({ emissive: 0xff7a1c, emissiveIntensity: 2.4, roughness: 1 }));
|
|
415
|
+
flameMesh.position.set(spec.x, topY + 0.1, spec.z);
|
|
416
|
+
flameMesh.scale.setScalar(Math.max(0.8, r * 0.45));
|
|
417
|
+
flameMesh.name = "VoxelFlame";
|
|
418
|
+
top.push(flameMesh);
|
|
419
|
+
flame = [spec.x, topY + 0.5, spec.z];
|
|
420
|
+
}
|
|
421
|
+
return { body, fade: [body.material], top, flame };
|
|
422
|
+
}
|
package/llms.txt
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# NetNet game engine: physics, game feel and rendering (llms.txt)
|
|
2
|
+
|
|
3
|
+
> The engine every Net Forge game runs on. Two
|
|
4
|
+
> layers matter here:
|
|
5
|
+
> 1. Game code (src/game.ts) only DECLARES how things look and feel, with
|
|
6
|
+
> `look` on game() and `feel` on prefab() and weapon(). Templates draw it.
|
|
7
|
+
> 2. Template code (client/ and the sim) uses the runtime APIs below:
|
|
8
|
+
> the physics world in @netforge/engine-sim, and the feel kit and
|
|
9
|
+
> render stack in @netforge/engine/runtime.
|
|
10
|
+
> Generated game code never imports the runtime. It cannot reach the
|
|
11
|
+
> renderer, netcode or physics, and it does not need to.
|
|
12
|
+
|
|
13
|
+
## 1. Game code: look and feel declarations
|
|
14
|
+
|
|
15
|
+
Everything here is presentation. It never changes the simulation, so it
|
|
16
|
+
is always safe to tune. Unknown values fail the build with a message.
|
|
17
|
+
|
|
18
|
+
### The look of the whole game (game({ look }))
|
|
19
|
+
|
|
20
|
+
game({
|
|
21
|
+
template: "survivor", style: "lowpoly", places: { ... }, players: { ... },
|
|
22
|
+
look: { preset: "dusk", bloom: 1.3, fog: 0.4, vignette: 0.4, tint: "#ffd9a0" },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
- preset: "night" | "dusk" | "day" | "studio". Sets the sun, sky, fog and
|
|
26
|
+
colour grade together. Default "night".
|
|
27
|
+
- bloom: glow on bright and emissive things, 0 (off) to 3. Default 1.
|
|
28
|
+
- shadows: sun shadows on or off (on by default above the low quality tier).
|
|
29
|
+
- outline: a toon outline around shapes (true or false).
|
|
30
|
+
- fog: 0 (clear) to 1 (thick).
|
|
31
|
+
- exposure (0.2 to 3), saturation (0 to 2), contrast (0.5 to 1.5),
|
|
32
|
+
vignette (0 to 1), tint ("#rrggbb", a grading colour over the frame).
|
|
33
|
+
|
|
34
|
+
Slow devices drop shadows, post and resolution by themselves (quality
|
|
35
|
+
tiers and dynamic resolution), so turning things on never breaks 60 fps.
|
|
36
|
+
|
|
37
|
+
### How a prefab dies and takes hits (prefab({ feel }))
|
|
38
|
+
|
|
39
|
+
prefab("brute", { model: "style.lowpoly.brute", health: 160, speed: 2.2, radius: 0.8, mass: 6,
|
|
40
|
+
feel: { particles: "burst", decal: "scorch", sound: "boom", shake: 0.5, hitStop: 0.06, color: "#ff4d5e" } });
|
|
41
|
+
|
|
42
|
+
### How a weapon's impacts look (weapon({ feel }))
|
|
43
|
+
|
|
44
|
+
weapon("frost_ring", { kind: "nova", damage: 6, cooldown: 0.8, range: 5, knockback: 8,
|
|
45
|
+
feel: { particles: "magic", decal: "ring", color: "#7ad7ff", shake: 0.1 } });
|
|
46
|
+
|
|
47
|
+
feel fields, all optional:
|
|
48
|
+
- shake: screen shake added, 0 to 1 (0.2 a bump, 0.6 a big hit).
|
|
49
|
+
- hitStop: seconds the picture freezes on impact (0.03 to 0.08 feels good, 0.2 max).
|
|
50
|
+
- particles: "sparks" | "burst" | "embers" | "smoke" | "hitSpark" | "dust" | "magic".
|
|
51
|
+
- decal: "none" | "scorch" | "splat" | "ring" (left on the ground, fades).
|
|
52
|
+
- sound: "none" | "hit" | "crunch" | "boom" | "zap" | "chime" | "thud".
|
|
53
|
+
- color: "#rrggbb" for particles, decals and flashes.
|
|
54
|
+
- flash: false turns off the white hit flash.
|
|
55
|
+
|
|
56
|
+
### Knockback is physics
|
|
57
|
+
|
|
58
|
+
weapon knockback is metres per second pushed into each enemy, divided by
|
|
59
|
+
the enemy's mass. Enemies knocked hard stagger, slide, bounce off pillars
|
|
60
|
+
and shove the enemies behind them. mass 1 is light, 6 is a brute, 20 a
|
|
61
|
+
boss (light hits barely move it).
|
|
62
|
+
|
|
63
|
+
### Dungeon levels (game({ field }) with walls and floors)
|
|
64
|
+
|
|
65
|
+
const CRYPT = dungeon({ seed: 2026, rooms: 10, cell: 24, size: [12, 18], corridor: 6, loops: 2 });
|
|
66
|
+
game({ template: "dungeon", ..., field: CRYPT.field });
|
|
67
|
+
|
|
68
|
+
dungeon(...) grows a floor plan from a seed: rooms on a grid, joined by
|
|
69
|
+
corridors, the start room centred on [0, 0] and the deepest room as
|
|
70
|
+
CRYPT.boss. field.walls are boxes [x, z, halfX, halfZ] that stop players,
|
|
71
|
+
enemies and bolts; field.floors are the only ground (spawns land on them,
|
|
72
|
+
and enemies behind a wall path round it through the doors on a flow
|
|
73
|
+
field). Systems get onScreen(x, z) to bring enemies in where nobody is
|
|
74
|
+
looking. A system's room logic reads CRYPT.rooms and CRYPT.roomAt(x, z).
|
|
75
|
+
|
|
76
|
+
## 2. Template code: the physics world (@netforge/engine-sim)
|
|
77
|
+
|
|
78
|
+
Deterministic Rapier on client and server. No rendering dependency.
|
|
79
|
+
|
|
80
|
+
import { initSim, createPhysics, restorePhysics, Layer } from "@netforge/engine-sim";
|
|
81
|
+
await initSim();
|
|
82
|
+
const p = createPhysics({ gravity: [0, -20, 0], tickRate: 30 });
|
|
83
|
+
p.addStaticBox([30, 0.5, 30], [0, -0.5, 0], { layer: Layer.Level });
|
|
84
|
+
const crate = p.addBody({ type: "dynamic", shape: { box: [0.4, 0.4, 0.4] }, position: [0, 2, 0],
|
|
85
|
+
mass: 20, friction: 0.7, restitution: 0.2, layer: Layer.Prop });
|
|
86
|
+
const bolt = p.addBody({ type: "dynamic", shape: { sphere: 0.1 }, ccd: true, gravityScale: 0,
|
|
87
|
+
linearVelocity: [80, 0, 0], layer: Layer.Projectile, mask: Layer.Level | Layer.Enemy });
|
|
88
|
+
const zone = p.addTrigger({ box: [2, 2, 2] }, [5, 1, 0]);
|
|
89
|
+
const hero = p.addCharacter({ position: [0, 1, 0], maxSpeed: 7, jumpVelocity: 8, coyoteTicks: 5,
|
|
90
|
+
jumpBufferTicks: 5, airAccel: 18, dashTicks: 6, dashSpeed: 22, dashCooldownTicks: 30 });
|
|
91
|
+
const { events, characters } = p.step(new Map([[hero, { moveX: 1, moveZ: 0, jump: true, dash: false }]]));
|
|
92
|
+
|
|
93
|
+
- Bodies: dynamic, kinematic (setKinematicTarget) or static; box, sphere,
|
|
94
|
+
capsule, cylinder; mass or density, friction, restitution, damping,
|
|
95
|
+
gravityScale, lockRotations, sensor, ccd, userData (a NetId).
|
|
96
|
+
- Layers: Layer.Level, Player, Enemy, Projectile, Pickup, Prop, Trigger,
|
|
97
|
+
Debris, User1..8. A pair collides only when each mask holds the other.
|
|
98
|
+
- Events from step(): contact.start, contact.end, trigger.enter,
|
|
99
|
+
trigger.exit, sorted, with a < b.
|
|
100
|
+
- Forces: applyImpulse, applyForce (one step), applyKnockback(id, from,
|
|
101
|
+
strength, { lift, byMass }). Knockback works on characters too.
|
|
102
|
+
- Queries: raycast(origin, dir, maxDist, { mask, exclude, includeSensors })
|
|
103
|
+
for line of sight and hitscan; raycastAll; shapeCast; overlap.
|
|
104
|
+
- Joints: addJoint({ kind: "fixed" | "ball" | "hinge" | "slider", a, b }).
|
|
105
|
+
- Character motor: slopes (climb and slide), steps, ground snapping, coyote
|
|
106
|
+
time, jump buffering, variable jump height, ground and air acceleration,
|
|
107
|
+
terminal velocity, dash with buffer, cooldown and invulnerability,
|
|
108
|
+
pushing dynamic bodies. Per-step reports: grounded, landed, jumped,
|
|
109
|
+
dashed, hitCeiling, groundNormal. characterState / setCharacterState for
|
|
110
|
+
reconcile.
|
|
111
|
+
- Netcode: snapshot() and restorePhysics(bytes) restore bit-exact;
|
|
112
|
+
stateHash() hashes every pose, velocity and motor state.
|
|
113
|
+
- Rapier 0.21 already stops small fast spheres on thin walls; use round
|
|
114
|
+
projectiles (thin spinning planks can still tunnel).
|
|
115
|
+
|
|
116
|
+
## 3. Template code: game feel (@netforge/engine/runtime)
|
|
117
|
+
|
|
118
|
+
import { FeelKit } from "@netforge/engine/runtime";
|
|
119
|
+
const feel = new FeelKit({ preset: "default" }); // "subtle" | "default" | "heavy"
|
|
120
|
+
feel.camera = rig; feel.project = (x, y, z) => scene.project(x, y, z); feel.particles = stack.particles;
|
|
121
|
+
// per frame
|
|
122
|
+
const { now, dt, realDt } = feel.tick(performance.now()); // hit-stop aware render clock
|
|
123
|
+
feel.hit({ id, x, y, z, amount, big, dirX, dirZ }); // flash, knockback, number, sound, shake
|
|
124
|
+
feel.kill({ x, y, z, big, color }); // particles, shake, hit-stop when big
|
|
125
|
+
feel.hurt(damage, maxHealth); // shake, stop, rumble, hurtPulse
|
|
126
|
+
feel.levelUp(); feel.dash(); feel.land(0.6); feel.footstep(speed);
|
|
127
|
+
feel.update(dt, realDt);
|
|
128
|
+
|
|
129
|
+
Parts, each usable alone: Trauma (seeded trauma shake), FeelClock (hit-stop,
|
|
130
|
+
slow motion, smooth catch-up), HitFlash, KnockbackOffsets (spring offsets
|
|
131
|
+
and squash), Spring and Spring3, DamageNumbers (pooled, merges rapid hits),
|
|
132
|
+
Tweens and Ease with pop / shakeEl / countUp for UI, FeelAudio (procedural
|
|
133
|
+
cues, throttles, voice limits, pan, distance) and Footsteps, Rumble
|
|
134
|
+
(Gamepad dual-rumble, navigator.vibrate fallback). CameraRig adds
|
|
135
|
+
addTrauma, punch (FOV kick), a smoothed and clamped lead and a dead zone.
|
|
136
|
+
|
|
137
|
+
Real sound (SoundPlayer): CC0 samples for the cues and one music loop from
|
|
138
|
+
the asset library's audio section. Samples replace the synth recipes under
|
|
139
|
+
the same cue names (the synth plays until a sample loads, or if it fails).
|
|
140
|
+
|
|
141
|
+
import { soundBank } from "@netforge/asset-library";
|
|
142
|
+
const sound = new SoundPlayer(feel.audio, { key: "mygame:sound", musicVolume: 0.35 });
|
|
143
|
+
sound.load(soundBank(libraryBase)); // cues: hit impact kill bigKill hurt levelUp gem coin dash step land boss ui pick downed revive shoot
|
|
144
|
+
sound.toggle(); // the mute (effects and music), remembered; returns true when muted
|
|
145
|
+
feel.audio.play("boss"); // any cue by name
|
|
146
|
+
|
|
147
|
+
Camera bounds: never show the void past the field.
|
|
148
|
+
rig.setBounds(fieldBounds(half, 16)); // the field plus 16 m of dressed outside ground
|
|
149
|
+
The clamp moves the camera by the overshoot of what it sees (groundFootprint,
|
|
150
|
+
boundsShift); dress that outside strip (ground, trees, fog).
|
|
151
|
+
|
|
152
|
+
Hero highlight: an outline and a rim on one character (the local hero), as
|
|
153
|
+
extra draws that share its geometry and skeleton; its materials stay as
|
|
154
|
+
they are.
|
|
155
|
+
const hl = new HeroHighlight(character.root, { rim: 0xffd27a, outline: 0x140a02, outlineWidth: 0.05 });
|
|
156
|
+
hl.strength = 0.4; hl.dispose();
|
|
157
|
+
|
|
158
|
+
## 4. Template code: rendering (@netforge/engine/runtime)
|
|
159
|
+
|
|
160
|
+
const stack = createRenderStack(handle, scene, rig.camera, { preset: "night", quality: "auto", bloom: true });
|
|
161
|
+
stack.frame(dt, focus); // instead of renderer.render(scene, camera)
|
|
162
|
+
stack.particles.burst("bigKill", x, y, z, { color: 0xffc070 });
|
|
163
|
+
stack.decals.add("scorch", x, z, { size: 3 });
|
|
164
|
+
stack.rings.fire(x, z, 6, { color: 0xffb347 });
|
|
165
|
+
stack.post.hurt.value = feel.hurtPulse;
|
|
166
|
+
|
|
167
|
+
- LightingRig: hemisphere, sun shadow following the focus with texel
|
|
168
|
+
snapping, presets.
|
|
169
|
+
- PostFX: bloom, grade (contrast, saturation, split tone, vignette, hurt
|
|
170
|
+
pulse), tone mapping once, FXAA, optional depth outline.
|
|
171
|
+
- Atmosphere: fog (linear or exp2, height fog) and sky gradient. A high
|
|
172
|
+
top-down camera should set heightFog 0.
|
|
173
|
+
- QualityManager / DynamicResolution: tiers low, medium and high
|
|
174
|
+
(auto-detected), pixel ratio held to the frame budget.
|
|
175
|
+
- GpuParticles: one draw for every preset; motion runs on the GPU.
|
|
176
|
+
- GroundDecals and GroundRings: pooled, one draw each.
|
|
177
|
+
- AnimatedCrowd: procedural vertex animation for instanced crowds (bob,
|
|
178
|
+
waddle, lean, hit squash, flash). VatCrowd with bakeVertexAnimation:
|
|
179
|
+
skinned clips baked to a texture, thousands of instances in one draw.
|
|
180
|
+
AnimatedCharacter: skinned glTF with locomotion blending.
|
|
181
|
+
- WebGPU pipelines build on first use. Draw one of everything out of sight
|
|
182
|
+
at load (EMBERWATCH's SurvivorScene.warmup) so the first kill does not
|
|
183
|
+
hitch.
|