sindicate 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/README.md +20 -0
- package/docs/contracts/README.md +22 -0
- package/package.json +38 -0
- package/src/core/decalField.js +151 -0
- package/src/core/quality.js +109 -0
- package/src/core/utils.js +48 -0
- package/src/index.js +31 -0
- package/src/systems/shatter.js +94 -0
- package/src/vendor/FBXLoader.js +4586 -0
- package/src/world/bvhWorker.js +85 -0
- package/src/world/collision.js +174 -0
- package/src/world/grass.js +347 -0
- package/src/world/water.js +227 -0
- package/src/world/waterNoise.js +47 -0
- package/src/world/weatherUniforms.js +24 -0
- package/src/world/wind.js +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SINDICATE
|
|
2
|
+
|
|
3
|
+
**Open-world game engine for three.js WebGPU.** Streaming worlds, characters, mounts & vehicles, missions, ambient crowds — extracted from a shipped browser western (Dustwater) and consumed as an npm package by every game built on it.
|
|
4
|
+
|
|
5
|
+
- **Design & migration plan:** [`ENGINE_DESIGN.md`](./ENGINE_DESIGN.md) — the adversarially-reviewed blueprint. Read it first.
|
|
6
|
+
- **Contracts:** [`docs/contracts/`](./docs/contracts/) — the written interface each subsystem exposes and each game implements.
|
|
7
|
+
- **Consumption:** games depend on `"sindicate": "github:<owner>/sindicate#vX.Y.Z"` (stable, lockfile-pinned) or `"file:../Sindicate"` (live engine hacking). Source ESM, no build step; `three` and `three-mesh-bvh` are peer dependencies.
|
|
8
|
+
- **The rule:** engine code changes **only in this repo** — never copy engine files into a game to hack them.
|
|
9
|
+
|
|
10
|
+
## Status
|
|
11
|
+
|
|
12
|
+
Migration step 1 (of the plan in ENGINE_DESIGN.md §9): package scaffold + the verified-clean tier moved verbatim from western — core utils/quality/decals, collision + BVH worker, water/grass/wind/weather-uniform world modules, pooled shatter, and the patched FBXLoader (vendored; merges all FBX animation layers — pinned against three ^0.184).
|
|
13
|
+
|
|
14
|
+
## Games on Sindicate
|
|
15
|
+
|
|
16
|
+
| Game | Status |
|
|
17
|
+
|---|---|
|
|
18
|
+
| western (Dustwater) | migrating — strangler-fig branch `sindicate-port` |
|
|
19
|
+
| fable (Aldenvale) | queued — ports after western reaches parity |
|
|
20
|
+
| GTA-style (name TBD) | future — first born-on-the-engine game |
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Sindicate contracts
|
|
2
|
+
|
|
3
|
+
One page per facet/interface. A contract states: what the facet exposes, who owns each piece of state (single writer), what a game must register, and what events flow. Integration knowledge lives **here**, never as "see main.js:748" prose.
|
|
4
|
+
|
|
5
|
+
These documents are the real deliverable of design principle P1 ("areas are tweakable in isolation" — ENGINE_DESIGN.md §2). A system that needs something not in its facet has found a design bug, not a workaround.
|
|
6
|
+
|
|
7
|
+
## Planned contracts (filled in as each migration step lands — §9)
|
|
8
|
+
|
|
9
|
+
| Contract | Covers | Lands with |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `world.md` | heightAt/riverInfo/groundAt/navFloorAt/segmentBlocked/moveCapsule oracles, WorldDescription, cook fingerprints | step 4 |
|
|
12
|
+
| `entities.md` | every named collection (player, npcs, enemies, horses, vehicles, children, interactables…), single-writer ownership, registration API | step 5 |
|
|
13
|
+
| `input.md` | down/hit/consume, bindings tables, UI-modal suppression + edge priming | step 2 |
|
|
14
|
+
| `events.md` | event vocabulary (entity:killed, crime:report, …) — reactions only; sync queries belong to services | step 5 |
|
|
15
|
+
| `time-feel.md` | dt chain, game-time timers, timeScale contributions (hitstop/slow-mo), shake write API | step 3 |
|
|
16
|
+
| `render.md` | scene attach/detach, camera (read), frustum (read), world-to-screen, quality-apply write path | step 3 |
|
|
17
|
+
| `services.md` | synchronous query surfaces: economy, flags, nav, platforms, spawn factory, hit-interceptors | step 5 |
|
|
18
|
+
| `assets.md` | AssetPolicy, RigDescriptors, atlas rules, clip manifests, warm registry (PERF LAW) | step 2 |
|
|
19
|
+
| `ui.md` | modality (anyPanelOpen, modal key routing, close-consumes-key), theme tokens, panel nav | step 6 |
|
|
20
|
+
| `audio.md` | sfx/music/ambience catalogues, venue ducking, volume persistence | step 6 |
|
|
21
|
+
| `persistence.md` | slot management, game-registered serializers, storage namespacing | step 5 |
|
|
22
|
+
| `quality.md` | presets, AutoTuner, per-game storage key | step 2 |
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sindicate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sindicate — open-world game engine for three.js WebGPU. Streaming worlds, characters, mounts & vehicles, missions, crowds.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "https://github.com/nickaccent/Sindicate-Engine"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"three",
|
|
16
|
+
"threejs",
|
|
17
|
+
"webgpu",
|
|
18
|
+
"game-engine",
|
|
19
|
+
"open-world",
|
|
20
|
+
"streaming",
|
|
21
|
+
"engine"
|
|
22
|
+
],
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./src/index.js",
|
|
25
|
+
"./*": "./src/*"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"src",
|
|
29
|
+
"tools",
|
|
30
|
+
"docs",
|
|
31
|
+
"README.md"
|
|
32
|
+
],
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"three": "^0.184.0",
|
|
35
|
+
"three-mesh-bvh": "^0.9.10"
|
|
36
|
+
},
|
|
37
|
+
"license": "UNLICENSED"
|
|
38
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// DecalField — portable ground/surface decals for the unity-vfx-webgpu format.
|
|
2
|
+
// Lays flat textured quads (blood splats, scorch marks, magic sigils, footprints)
|
|
3
|
+
// on the surface they are projected onto, oriented to the surface normal, with
|
|
4
|
+
// fade-in/out over life and a ring-buffer budget. NO game-specific code.
|
|
5
|
+
//
|
|
6
|
+
// These are simple laid-flat quads, NOT true projected decals (no mesh-conforming
|
|
7
|
+
// UV projection) — cheap, WebGPU-safe, and enough for a flat-ish ground surface.
|
|
8
|
+
import * as THREE from 'three/webgpu';
|
|
9
|
+
|
|
10
|
+
const _FWD = new THREE.Vector3(0, 0, 1); // PlaneGeometry faces +Z by default
|
|
11
|
+
const _n = new THREE.Vector3();
|
|
12
|
+
const _q = new THREE.Quaternion();
|
|
13
|
+
const _spin = new THREE.Quaternion();
|
|
14
|
+
|
|
15
|
+
const rand = (r) => r[0] + Math.random() * (r[1] - r[0]);
|
|
16
|
+
|
|
17
|
+
export class DecalField {
|
|
18
|
+
// scene: a THREE.Scene (or any Object3D container).
|
|
19
|
+
// loadTexture(nameOrUrl) -> Promise<Texture>. textureBase resolves bare basenames
|
|
20
|
+
// to `${base}/${name}.png` (norm maps too); absent = basename passed through.
|
|
21
|
+
// max: ring-buffer budget — the oldest decal is evicted when the (max+1)th is added.
|
|
22
|
+
constructor(scene, { loadTexture, textureBase = '', max = 128 } = {}) {
|
|
23
|
+
this.scene = scene;
|
|
24
|
+
this._loadTexture = loadTexture;
|
|
25
|
+
this.textureBase = textureBase;
|
|
26
|
+
this.max = max;
|
|
27
|
+
this.recipes = {};
|
|
28
|
+
this.textures = {}; // basename -> THREE.Texture (albedo + optional normal)
|
|
29
|
+
this.items = []; // active decals, oldest first (ring buffer)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_src(name) {
|
|
33
|
+
if (!name) return null;
|
|
34
|
+
return this.textureBase
|
|
35
|
+
? (this.textureBase.endsWith('/') ? this.textureBase + name : this.textureBase + '/' + name) + '.png'
|
|
36
|
+
: name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// pre-resolve every decal texture (and normal map) referenced by the recipes
|
|
40
|
+
async load(decalRecipes) {
|
|
41
|
+
this.recipes = decalRecipes || {};
|
|
42
|
+
const names = new Set();
|
|
43
|
+
for (const d of Object.values(this.recipes)) {
|
|
44
|
+
if (d.tex) names.add(d.tex);
|
|
45
|
+
if (d.norm) names.add(d.norm);
|
|
46
|
+
}
|
|
47
|
+
for (const n of names) {
|
|
48
|
+
if (this.textures[n]) continue;
|
|
49
|
+
try { this.textures[n] = await this._loadTexture(this._src(n)); }
|
|
50
|
+
catch { this.textures[n] = null; console.warn('[decal] missing tex', n); }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Lay a decal named `name` at world `pos`, oriented so its face points along
|
|
55
|
+
// `normal`. scale multiplies the authored size; rot overrides the recipe's
|
|
56
|
+
// rotation ('rand' | degrees); color multiplies the authored tint.
|
|
57
|
+
add(name, { pos, normal = [0, 1, 0], scale = 1, rot, color } = {}) {
|
|
58
|
+
const def = this.recipes[name];
|
|
59
|
+
if (!def) return null;
|
|
60
|
+
|
|
61
|
+
// ---- geometry: a quad sized in metres (square unless aspect = w/h) ----
|
|
62
|
+
const base = rand(def.sz || [1, 1]) * scale;
|
|
63
|
+
const aspect = def.aspect || 1;
|
|
64
|
+
const geo = new THREE.PlaneGeometry(base * aspect, base);
|
|
65
|
+
|
|
66
|
+
// ---- tint: recipe tint × caller colour ----
|
|
67
|
+
let cr = 1, cg = 1, cb = 1, ca = 1;
|
|
68
|
+
const tn = def.tint || [1, 1, 1, 1];
|
|
69
|
+
cr = tn[0]; cg = tn[1]; cb = tn[2]; ca = tn[3];
|
|
70
|
+
if (color != null) {
|
|
71
|
+
if (Array.isArray(color)) { cr *= color[0]; cg *= color[1]; cb *= color[2]; if (color[3] != null) ca *= color[3]; }
|
|
72
|
+
else { const c = color.isColor ? color : new THREE.Color(color); cr *= c.r; cg *= c.g; cb *= c.b; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// polygonOffset beats z-fighting against the surface it lies on; depthWrite off
|
|
76
|
+
// + transparent so overlapping splats blend instead of clipping each other.
|
|
77
|
+
const blending = def.blend === 'a' ? THREE.AdditiveBlending : THREE.NormalBlending;
|
|
78
|
+
const mat = new THREE.MeshBasicMaterial({
|
|
79
|
+
map: def.tex ? this.textures[def.tex] ?? null : null,
|
|
80
|
+
transparent: true, depthWrite: false, blending,
|
|
81
|
+
polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -4,
|
|
82
|
+
side: THREE.DoubleSide, fog: false,
|
|
83
|
+
});
|
|
84
|
+
mat.color.setRGB(cr, cg, cb);
|
|
85
|
+
|
|
86
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
87
|
+
|
|
88
|
+
// ---- orient the quad to the surface normal, then spin about it ----
|
|
89
|
+
_n.set(normal[0], normal[1], normal[2]);
|
|
90
|
+
if (_n.lengthSq() < 1e-8) _n.set(0, 1, 0);
|
|
91
|
+
_n.normalize();
|
|
92
|
+
_q.setFromUnitVectors(_FWD, _n);
|
|
93
|
+
let ang = 0;
|
|
94
|
+
const spec = rot != null ? rot : def.rot;
|
|
95
|
+
if (spec === 'rand') ang = Math.random() * Math.PI * 2;
|
|
96
|
+
else if (typeof spec === 'number') ang = spec * Math.PI / 180;
|
|
97
|
+
if (ang) { _spin.setFromAxisAngle(_n, ang); _q.premultiply(_spin); }
|
|
98
|
+
mesh.quaternion.copy(_q);
|
|
99
|
+
|
|
100
|
+
// lift along the normal to avoid z-fighting (default 0.02m)
|
|
101
|
+
const yOff = def.yOffset != null ? def.yOffset : 0.02;
|
|
102
|
+
mesh.position.set(pos.x ?? pos[0], pos.y ?? pos[1], pos.z ?? pos[2]).addScaledVector(_n, yOff);
|
|
103
|
+
|
|
104
|
+
// ---- fade bookkeeping: life 0/null = persistent (fades in, never out) ----
|
|
105
|
+
const life = def.life || 0;
|
|
106
|
+
const fadeIn = def.fade ? def.fade[0] : 0;
|
|
107
|
+
const fadeOut = def.fade ? def.fade[1] : 0;
|
|
108
|
+
const d = { mesh, age: 0, life, fadeIn, fadeOut, baseAlpha: ca };
|
|
109
|
+
// spawn at the correct first-frame alpha (0 if fading in) so it never pops
|
|
110
|
+
mat.opacity = fadeIn > 0 ? 0 : ca;
|
|
111
|
+
|
|
112
|
+
this.scene.add(mesh);
|
|
113
|
+
this.items.push(d);
|
|
114
|
+
// ring buffer: evict the oldest beyond budget
|
|
115
|
+
while (this.items.length > this.max) this._dispose(this.items.shift());
|
|
116
|
+
|
|
117
|
+
return { decal: d, remove: () => this._remove(d) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_dispose(d) {
|
|
121
|
+
if (!d) return;
|
|
122
|
+
this.scene.remove(d.mesh);
|
|
123
|
+
d.mesh.geometry.dispose();
|
|
124
|
+
d.mesh.material.dispose();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_remove(d) {
|
|
128
|
+
const i = this.items.indexOf(d);
|
|
129
|
+
if (i >= 0) { this.items.splice(i, 1); this._dispose(d); }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
update(dt) {
|
|
133
|
+
for (let i = this.items.length - 1; i >= 0; i--) {
|
|
134
|
+
const d = this.items[i];
|
|
135
|
+
d.age += dt;
|
|
136
|
+
let a = d.baseAlpha;
|
|
137
|
+
if (d.fadeIn > 0 && d.age < d.fadeIn) a *= d.age / d.fadeIn;
|
|
138
|
+
if (d.life > 0) {
|
|
139
|
+
if (d.age >= d.life) { this.items.splice(i, 1); this._dispose(d); continue; }
|
|
140
|
+
const remain = d.life - d.age;
|
|
141
|
+
if (d.fadeOut > 0 && remain < d.fadeOut) a *= Math.max(0, remain / d.fadeOut);
|
|
142
|
+
}
|
|
143
|
+
d.mesh.material.opacity = a;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
clear() {
|
|
148
|
+
for (const d of this.items) this._dispose(d);
|
|
149
|
+
this.items.length = 0;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Graphics quality presets + auto-downgrade for weaker machines.
|
|
2
|
+
// pixelRatio and culling apply live; antialias applies on next reload.
|
|
3
|
+
const KEY = 'aldenvale_quality';
|
|
4
|
+
|
|
5
|
+
export const PRESETS = {
|
|
6
|
+
low: {
|
|
7
|
+
label: 'Low',
|
|
8
|
+
pixelRatio: 1,
|
|
9
|
+
antialias: false,
|
|
10
|
+
shadows: false,
|
|
11
|
+
shadowSize: 512,
|
|
12
|
+
entityCull: 45, // hide+freeze characters beyond this (m)
|
|
13
|
+
animHalfRate: 22, // beyond this, animate every other frame
|
|
14
|
+
animMidRate: 33, // beyond this, every 3rd frame (every 4th near the cull edge)
|
|
15
|
+
maxLamps: 2,
|
|
16
|
+
fogScale: 0.45, // pulls fog in = less overdraw
|
|
17
|
+
cameraFar: 200,
|
|
18
|
+
},
|
|
19
|
+
medium: {
|
|
20
|
+
label: 'Medium',
|
|
21
|
+
pixelRatio: 1.5,
|
|
22
|
+
antialias: true,
|
|
23
|
+
shadows: false, // shadow mapping retired (again — re-landed solo): the pass is a SECOND
|
|
24
|
+
shadowSize: 1024, // full scene render EVERY frame (~7.5ms; r184 ignores the renderer-level
|
|
25
|
+
// throttle flags). Characters get blob discs (world/blobShadows.js).
|
|
26
|
+
entityCull: 70,
|
|
27
|
+
animHalfRate: 32,
|
|
28
|
+
animMidRate: 48,
|
|
29
|
+
maxLamps: 6,
|
|
30
|
+
fogScale: 0.8,
|
|
31
|
+
cameraFar: 420,
|
|
32
|
+
},
|
|
33
|
+
high: {
|
|
34
|
+
label: 'High',
|
|
35
|
+
pixelRatio: 1.5, // was 2 — full retina cost ~1.5ms/frame for imperceptible gain at TRAA
|
|
36
|
+
antialias: true,
|
|
37
|
+
shadows: false, // retired — see medium
|
|
38
|
+
shadowSize: 2048,
|
|
39
|
+
entityCull: 110,
|
|
40
|
+
animHalfRate: 55,
|
|
41
|
+
animMidRate: 80,
|
|
42
|
+
maxLamps: 15,
|
|
43
|
+
fogScale: 1,
|
|
44
|
+
cameraFar: 800,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function getQualityName() {
|
|
49
|
+
const q = localStorage.getItem(KEY);
|
|
50
|
+
return PRESETS[q] ? q : 'medium'; // safe default; auto-tuner adjusts
|
|
51
|
+
}
|
|
52
|
+
export function getQuality() { return PRESETS[getQualityName()]; }
|
|
53
|
+
export function setQualityName(name) { if (PRESETS[name]) localStorage.setItem(KEY, name); }
|
|
54
|
+
|
|
55
|
+
// Apply everything that can change live.
|
|
56
|
+
export function applyQuality(game, name) {
|
|
57
|
+
setQualityName(name);
|
|
58
|
+
const q = PRESETS[name];
|
|
59
|
+
const r = game.renderer;
|
|
60
|
+
r.setPixelRatio(Math.min(window.devicePixelRatio, q.pixelRatio));
|
|
61
|
+
r.setSize(window.innerWidth, window.innerHeight);
|
|
62
|
+
// NOTE: shadows are configured once at startup (renderer.js / sky.js).
|
|
63
|
+
// Mutating shadow maps live corrupts the WebGPU backend's shadow state
|
|
64
|
+
// and the sun renders everything as shadowed (world goes dark at noon).
|
|
65
|
+
game.camera.far = q.cameraFar;
|
|
66
|
+
game.camera.updateProjectionMatrix();
|
|
67
|
+
game.quality = q;
|
|
68
|
+
game.qualityName = name;
|
|
69
|
+
game.__traa = true; // live A/B override only — TRAA now exists solely when opted in via ?traa (renderer.wantsTRAA); default is the plain render path on every tier
|
|
70
|
+
if (game.world) {
|
|
71
|
+
game.world.quality = q;
|
|
72
|
+
if (game.world.sky) game.world.sky.fogScale = q.fogScale;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Rolling fps monitor: steps quality down when the machine can't keep up.
|
|
77
|
+
export class AutoTuner {
|
|
78
|
+
constructor(game) {
|
|
79
|
+
this.game = game;
|
|
80
|
+
this.acc = 0;
|
|
81
|
+
this.frames = 0;
|
|
82
|
+
this.grace = 6; // ignore the first seconds (loading hitches)
|
|
83
|
+
this.done = false;
|
|
84
|
+
}
|
|
85
|
+
update(dt) {
|
|
86
|
+
if (this.done) return;
|
|
87
|
+
if (this.grace > 0) { this.grace -= dt; return; }
|
|
88
|
+
this.acc += dt;
|
|
89
|
+
this.frames++;
|
|
90
|
+
if (this.acc < 5) return;
|
|
91
|
+
const fps = this.frames / this.acc;
|
|
92
|
+
this.acc = 0; this.frames = 0;
|
|
93
|
+
const order = ['high', 'medium', 'low'];
|
|
94
|
+
const cur = order.indexOf(this.game.qualityName);
|
|
95
|
+
if (fps < 24 && cur < order.length - 1) {
|
|
96
|
+
// two consecutive bad windows before acting — applyQuality's setPixelRatio+setSize
|
|
97
|
+
// is itself a big hitch, so a single loading burst must not trigger it mid-play
|
|
98
|
+
this.badRun = (this.badRun ?? 0) + 1;
|
|
99
|
+
if (this.badRun >= 2) {
|
|
100
|
+
this.badRun = 0;
|
|
101
|
+
const next = order[cur + 1];
|
|
102
|
+
applyQuality(this.game, next);
|
|
103
|
+
this.game.ui?.toast(`Performance: graphics set to ${PRESETS[next].label} (change in Esc menu)`, 4200);
|
|
104
|
+
}
|
|
105
|
+
} else if (fps >= 24) {
|
|
106
|
+
this.done = true; // stable — stop fiddling
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Small shared helpers.
|
|
2
|
+
import * as THREE from 'three/webgpu';
|
|
3
|
+
import { ImprovedNoise } from 'three/addons/math/ImprovedNoise.js';
|
|
4
|
+
|
|
5
|
+
export const noise = new ImprovedNoise();
|
|
6
|
+
|
|
7
|
+
// Deterministic pseudo-random for reproducible world generation.
|
|
8
|
+
export function mulberry32(seed) {
|
|
9
|
+
let a = seed >>> 0;
|
|
10
|
+
return function () {
|
|
11
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
12
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
13
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
14
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function fbm(x, z, octaves = 4, lacunarity = 2, gain = 0.5, scale = 0.01) {
|
|
19
|
+
let amp = 1, freq = scale, sum = 0, norm = 0;
|
|
20
|
+
for (let i = 0; i < octaves; i++) {
|
|
21
|
+
sum += amp * noise.noise(x * freq, z * freq, 7.31 + i * 13.7);
|
|
22
|
+
norm += amp;
|
|
23
|
+
amp *= gain;
|
|
24
|
+
freq *= lacunarity;
|
|
25
|
+
}
|
|
26
|
+
return sum / norm;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function clamp(v, a, b) { return Math.min(b, Math.max(a, v)); }
|
|
30
|
+
export function lerp(a, b, t) { return a + (b - a) * t; }
|
|
31
|
+
export function smoothstep(a, b, t) {
|
|
32
|
+
const x = clamp((t - a) / (b - a), 0, 1);
|
|
33
|
+
return x * x * (3 - 2 * x);
|
|
34
|
+
}
|
|
35
|
+
export function dist2D(ax, az, bx, bz) { const dx = ax - bx, dz = az - bz; return Math.sqrt(dx * dx + dz * dz); } // hypot is 5-10x slower in V8 — this is THE hot distance helper
|
|
36
|
+
|
|
37
|
+
// Shortest-arc angle lerp for smooth turning.
|
|
38
|
+
export function angleLerp(a, b, t) {
|
|
39
|
+
let d = (b - a) % (Math.PI * 2);
|
|
40
|
+
if (d > Math.PI) d -= Math.PI * 2;
|
|
41
|
+
if (d < -Math.PI) d += Math.PI * 2;
|
|
42
|
+
return a + d * t;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const tmpV1 = new THREE.Vector3();
|
|
46
|
+
export const tmpV2 = new THREE.Vector3();
|
|
47
|
+
export const tmpV3 = new THREE.Vector3();
|
|
48
|
+
export const tmpQ1 = new THREE.Quaternion();
|
package/src/index.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Sindicate — open-world game engine for three.js WebGPU.
|
|
2
|
+
//
|
|
3
|
+
// Games import subsystems directly ('sindicate/core/utils.js') or via this
|
|
4
|
+
// barrel. Source ESM, no build step: the game's bundler compiles engine
|
|
5
|
+
// sources, so you can step into engine code while debugging a game.
|
|
6
|
+
//
|
|
7
|
+
// Migration status (see ENGINE_DESIGN.md §9): step 1 — the verified-clean
|
|
8
|
+
// tier, moved verbatim from the western game. Refactors (config injection,
|
|
9
|
+
// facet contracts) land in later steps; step 1 files are byte-identical to
|
|
10
|
+
// their proven originals on purpose.
|
|
11
|
+
|
|
12
|
+
// core — math/utils, quality presets + auto-tuner, decals
|
|
13
|
+
export * from './core/utils.js';
|
|
14
|
+
export { PRESETS, getQuality, getQualityName, setQualityName, applyQuality, AutoTuner } from './core/quality.js';
|
|
15
|
+
export { DecalField } from './core/decalField.js';
|
|
16
|
+
|
|
17
|
+
// world — collision (sync + worker BVH), water materials, grass, wind, shared weather uniforms
|
|
18
|
+
export * from './world/collision.js';
|
|
19
|
+
export * from './world/water.js';
|
|
20
|
+
export * from './world/grass.js';
|
|
21
|
+
export * from './world/wind.js';
|
|
22
|
+
export * from './world/weatherUniforms.js';
|
|
23
|
+
export * from './world/waterNoise.js';
|
|
24
|
+
|
|
25
|
+
// systems — pooled debris shatter
|
|
26
|
+
export * from './systems/shatter.js';
|
|
27
|
+
|
|
28
|
+
// vendor — patched FBXLoader (merges ALL FBX animation layers per stack;
|
|
29
|
+
// stock three drops all but the first and multi-layer Synty packs T-pose).
|
|
30
|
+
// Pinned against three ^0.184 — re-verify on every three upgrade.
|
|
31
|
+
export { FBXLoader } from './vendor/FBXLoader.js';
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Physics-y debris shatter: when a pot/urn is smashed we clone the pack's real vase-shard meshes
|
|
2
|
+
// (SM_Prop_Vase_Shard_01..04), fling them out with a little velocity + spin, let gravity drop them,
|
|
3
|
+
// bounce + settle on the floor, then fade them away. Pure CPU integration on a tiny pool — no engine,
|
|
4
|
+
// no per-shard lights, nothing resident (the meshes are added on smash and removed when faded).
|
|
5
|
+
import * as THREE from 'three/webgpu';
|
|
6
|
+
|
|
7
|
+
const G = 17; // gravity (m/s²-ish, tuned for snappy low-poly debris)
|
|
8
|
+
const REST = 0.34; // floor bounce restitution
|
|
9
|
+
const FRICTION = 0.62; // horizontal speed kept per bounce
|
|
10
|
+
const SETTLE_HOLD = 1.4; // seconds a settled shard lingers before fading
|
|
11
|
+
const FADE = 1.1; // fade-out duration
|
|
12
|
+
|
|
13
|
+
export class Shatter {
|
|
14
|
+
constructor(scene) { this.scene = scene; this.pieces = []; }
|
|
15
|
+
|
|
16
|
+
// pos: world Vector3 (base of the pot). frags: array of template Object3D to clone (the shards).
|
|
17
|
+
// floorY: Y the shards rest on. opts.count = how many shards to throw.
|
|
18
|
+
// Templates carrying userData.pivot (pre-fractured smashables) spawn at their true
|
|
19
|
+
// position INSIDE the prop and fly outward from its centre, so the prop visibly
|
|
20
|
+
// breaks apart along its fracture planes instead of debris materialising at a point.
|
|
21
|
+
// opts.dir (unit Vector3) carries the blow through; opts.ry = the prop's yaw.
|
|
22
|
+
burst(pos, frags, { count = 8, floorY = pos.y, spread = 2.4, up = 3.2, scale = 1, dir = null, ry = 0 } = {}) {
|
|
23
|
+
if (!frags || !frags.length) return;
|
|
24
|
+
const cy = Math.cos(ry), sy = Math.sin(ry);
|
|
25
|
+
for (let i = 0; i < count; i++) {
|
|
26
|
+
const tmpl = frags[i % frags.length];
|
|
27
|
+
const m = tmpl.clone(true);
|
|
28
|
+
// independent material instances so each shard fades on its own
|
|
29
|
+
m.traverse((o) => { if (o.material) o.material = Array.isArray(o.material) ? o.material.map((x) => x.clone()) : o.material.clone(); });
|
|
30
|
+
m.scale.multiplyScalar(scale);
|
|
31
|
+
const pivot = tmpl.userData?.pivot;
|
|
32
|
+
let vel;
|
|
33
|
+
if (pivot) {
|
|
34
|
+
const px = (pivot.x * cy + pivot.z * sy) * scale, pz = (pivot.z * cy - pivot.x * sy) * scale;
|
|
35
|
+
m.position.set(pos.x + px, pos.y + pivot.y * scale, pos.z + pz);
|
|
36
|
+
m.rotation.set(0, ry, 0); // shards start in the prop's rest orientation
|
|
37
|
+
const ol = Math.hypot(px, pz) || 1;
|
|
38
|
+
vel = new THREE.Vector3((px / ol) * spread * (0.4 + Math.random() * 0.6), up * (0.35 + Math.random() * 0.55), (pz / ol) * spread * (0.4 + Math.random() * 0.6));
|
|
39
|
+
m.userData.spinScale = 0.55; // chunky prop shards tumble slower than pot slivers
|
|
40
|
+
} else {
|
|
41
|
+
m.position.copy(pos).add(new THREE.Vector3((Math.random() - 0.5) * 0.35, 0.25 + Math.random() * 0.45, (Math.random() - 0.5) * 0.35));
|
|
42
|
+
m.rotation.set(Math.random() * 6.28, Math.random() * 6.28, Math.random() * 6.28);
|
|
43
|
+
vel = new THREE.Vector3((Math.random() - 0.5) * spread, up * (0.55 + Math.random() * 0.7), (Math.random() - 0.5) * spread);
|
|
44
|
+
}
|
|
45
|
+
if (dir) vel.addScaledVector(dir, spread * (0.5 + Math.random() * 0.4)); // the blow carries through
|
|
46
|
+
this.scene.add(m);
|
|
47
|
+
const ss = m.userData.spinScale ?? 1;
|
|
48
|
+
this.pieces.push({
|
|
49
|
+
m, floorY: floorY + 0.04 + Math.random() * 0.04, // settle just above the floor (slight stack)
|
|
50
|
+
vel,
|
|
51
|
+
spin: new THREE.Vector3((Math.random() - 0.5) * 9 * ss, (Math.random() - 0.5) * 9 * ss, (Math.random() - 0.5) * 9 * ss),
|
|
52
|
+
rest: false, settleT: 0,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
update(dt) {
|
|
58
|
+
if (!this.pieces.length) return;
|
|
59
|
+
dt = Math.min(dt, 0.04);
|
|
60
|
+
for (let i = this.pieces.length - 1; i >= 0; i--) {
|
|
61
|
+
const p = this.pieces[i];
|
|
62
|
+
if (!p.rest) {
|
|
63
|
+
p.vel.y -= G * dt;
|
|
64
|
+
p.m.position.addScaledVector(p.vel, dt);
|
|
65
|
+
p.m.rotation.x += p.spin.x * dt; p.m.rotation.y += p.spin.y * dt; p.m.rotation.z += p.spin.z * dt;
|
|
66
|
+
if (p.m.position.y <= p.floorY) {
|
|
67
|
+
p.m.position.y = p.floorY;
|
|
68
|
+
p.vel.y = -p.vel.y * REST;
|
|
69
|
+
p.vel.x *= FRICTION; p.vel.z *= FRICTION;
|
|
70
|
+
p.spin.multiplyScalar(0.45);
|
|
71
|
+
if (Math.abs(p.vel.y) < 0.7 && p.vel.x * p.vel.x + p.vel.z * p.vel.z < 0.4) { p.rest = true; p.spin.set(0, 0, 0); }
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
p.settleT += dt;
|
|
75
|
+
if (p.settleT > SETTLE_HOLD) {
|
|
76
|
+
const a = 1 - (p.settleT - SETTLE_HOLD) / FADE;
|
|
77
|
+
p.m.traverse((o) => { if (o.material) { o.material.transparent = true; o.material.depthWrite = false; o.material.opacity = a < 0 ? 0 : a; } });
|
|
78
|
+
if (a <= 0) { this.scene.remove(p.m); disposeMats(p.m); this.pieces.length && this.pieces.splice(i, 1); }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
clear() { for (const p of this.pieces) { this.scene.remove(p.m); disposeMats(p.m); } this.pieces.length = 0; }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// shards clone their materials per piece (independent fades) — free them on removal
|
|
88
|
+
// (geometry is SHARED with the templates and must never be disposed here)
|
|
89
|
+
function disposeMats(root) {
|
|
90
|
+
root.traverse((o) => {
|
|
91
|
+
const mats = Array.isArray(o.material) ? o.material : (o.material ? [o.material] : []);
|
|
92
|
+
for (const m of mats) m.dispose?.();
|
|
93
|
+
});
|
|
94
|
+
}
|