GODVERSE grass system + test-ride grinds
js/grass.js: reusable single-file grass module for all our games — GPU-instanced blades (33k+ in one draw call), vertex-shader wind hashed per blade, root->tip fake-AO gradient x per-instance tint, alpha-test pixel style (nearest-filter 16x24 canvas cards, stepped sway), deterministic seed, player-push uniform. docs/GRASS.md records the technique ladder (clump maps, shell texturing, billboard LOD, MB atlas gen) for future tiers. Auto-grows on parkland + islands, PARK panel style/density, included in exported levels via buildParkScene. Test ride: grind physics — falling onto any flattened steel (rails, ledge caps, pool coping) locks a slide along the segment, pop off with Space or ride off the end; grass parts around the rider. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a962ac41d9
commit
a5c8765776
@ -46,8 +46,14 @@ persistence (`parks/`, `assets/uploads/`).
|
|||||||
(hunyuan3d_mlx image→GLB). Everything free and local on the m3ultra queue.
|
(hunyuan3d_mlx image→GLB). Everything free and local on the m3ultra queue.
|
||||||
- **PARK CHECK** — live design linter encoding `docs/DESIGN_PRINCIPLES.md`: pumping
|
- **PARK CHECK** — live design linter encoding `docs/DESIGN_PRINCIPLES.md`: pumping
|
||||||
geometry, run-out clearance, crossing lines, skill tiering, buried/floating steel.
|
geometry, run-out clearance, crossing lines, skill tiering, buried/floating steel.
|
||||||
|
- **GRASS** — reusable GODVERSE grass system (`js/grass.js` + `docs/GRASS.md`): GPU-instanced
|
||||||
|
blades, vertex-shader wind, root→tip fake-AO gradient, player-push. Styles: lush, dry,
|
||||||
|
**pixel** (nearest-filter PS1 cards). Auto-grows on the parkland ring + islands;
|
||||||
|
style/density in the PARK panel. Lift the single file into any game.
|
||||||
- **▶ TEST RIDE** (`Enter`) — instant Create-A-Park-style build↔skate toggle with
|
- **▶ TEST RIDE** (`Enter`) — instant Create-A-Park-style build↔skate toggle with
|
||||||
bookquoy's physics constants over the live data. W push, A/D steer, Space ollie.
|
bookquoy's physics constants over the live data. W push, A/D steer, Space ollie,
|
||||||
|
**land on any steel to grind** (rails, ledges, pool coping — pop off with Space),
|
||||||
|
and the grass parts around you.
|
||||||
|
|
||||||
## Park format
|
## Park format
|
||||||
|
|
||||||
|
|||||||
59
docs/GRASS.md
Normal file
59
docs/GRASS.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# GODVERSE grass — the module and the tricks
|
||||||
|
|
||||||
|
`js/grass.js` is the canonical grass system for all our games. It's a **system, not a
|
||||||
|
mesh** — that's why it lives as a code module with style presets, not as GLBs on the
|
||||||
|
farm. Lift the single file into any three.js project:
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { makeGrass } from './grass.js';
|
||||||
|
const grass = makeGrass({ heightAt, areas, style: 'lush', density: 8 });
|
||||||
|
scene.add(grass); // self-ticking wind; add and forget
|
||||||
|
grass.userData.setPlayer(x, z); // optional: grass parts around the player
|
||||||
|
```
|
||||||
|
|
||||||
|
Styles: `lush` (tapered instanced blades), `dry` (same, straw palette),
|
||||||
|
`pixel` (crossed alpha-test cards, 16×24 nearest-filter canvas texture, banded
|
||||||
|
palette, stepped sway — PS1/N64 vibe).
|
||||||
|
|
||||||
|
## The tricks it uses (why it's fast)
|
||||||
|
|
||||||
|
1. **GPU instancing** — one `InstancedMesh` = one draw call for up to 40k blades.
|
||||||
|
Per-blade cost after spawn is zero on the CPU.
|
||||||
|
2. **Vertex-shader wind** — `sin(time + hash(blade origin))`, bend scaled by
|
||||||
|
`uv.y²` so roots stay planted, tips fly. Two frequencies layered so it doesn't
|
||||||
|
look like a metronome. Pixel style floors the sway to 3 steps.
|
||||||
|
3. **Root→tip gradient with darkened base** — geometry vertex colors (0.35 at the
|
||||||
|
root → 1.15 at the tip) multiply with a per-instance tip tint. Reads as ambient
|
||||||
|
occlusion without any lighting cost. *The single biggest visual win.*
|
||||||
|
4. **Per-instance jitter** — scale, rotation, lean, hue offset. Uniformity is what
|
||||||
|
makes cheap grass look cheap.
|
||||||
|
5. **alphaTest, never alpha-blend** — no depth sorting, no overdraw melt.
|
||||||
|
6. **No per-blade shadows** — the base darkening does that job.
|
||||||
|
7. **Deterministic seed** (mulberry32) — same park, same lawn, every load.
|
||||||
|
8. **Player-push uniform** — blades within 1.2m bend away from the rider. Sells
|
||||||
|
physicality for one uniform update per frame.
|
||||||
|
|
||||||
|
## The next tiers (when a game needs bigger fields)
|
||||||
|
|
||||||
|
- **Chunked frustum culling** — split into 16m tiles, cull per-tile. Needed beyond
|
||||||
|
~100m fields; our parks don't need it yet.
|
||||||
|
- **Far-field billboard cards** — crossed quads with a grass-clump texture past
|
||||||
|
~40m, instanced blades near. Classic LOD split.
|
||||||
|
- **Clump maps** (Ghost of Tsushima GDC 2021, "Procedural Grass in Ghost of
|
||||||
|
Tsushima") — sample a Voronoi clump texture: blades in a clump share lean/height,
|
||||||
|
clump centers get taller blades. Breaks the "even lawn" look at scale.
|
||||||
|
- **Shell texturing** (Acerola's "How Do Games Render So Much Grass?") — N stacked
|
||||||
|
transparent shells; dot patterns at increasing height thresholds. Ultra cheap
|
||||||
|
retro turf/fur; a natural fourth style for the pixel games.
|
||||||
|
- **Bezier blades + tessellation** for hero close-ups (Sucker Punch use 3-4 verts
|
||||||
|
per blade too — verts are not where the money is; shading is).
|
||||||
|
- **MODELBEAST atlas textures** — flux_local can generate blade/clump atlases for
|
||||||
|
the billboard tiers (`grass blade atlas, alpha background, hand-painted style`),
|
||||||
|
then `bg_remove_local` for clean alpha.
|
||||||
|
|
||||||
|
## In SKATEMAKER PRO
|
||||||
|
|
||||||
|
Grass auto-grows on the parkland ring outside the slab and on ISLAND pads, from
|
||||||
|
`park.grass` (`{style, density}` — PARK panel when nothing is selected). Exported
|
||||||
|
levels include it via `buildParkScene`; the editor rebuilds it on committed ground
|
||||||
|
changes only (never mid-drag).
|
||||||
17
js/editor.js
17
js/editor.js
@ -7,6 +7,7 @@ import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark,
|
|||||||
normalizeRail } from './parkmath.js';
|
normalizeRail } from './parkmath.js';
|
||||||
import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture,
|
import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture,
|
||||||
GRID_PAD } from './parkbuild.js';
|
GRID_PAD } from './parkbuild.js';
|
||||||
|
import { makeGrass, parkGrassAreas } from './grass.js';
|
||||||
|
|
||||||
const AUTOSAVE_KEY = 'smp_autosave_v1';
|
const AUTOSAVE_KEY = 'smp_autosave_v1';
|
||||||
|
|
||||||
@ -52,7 +53,7 @@ export function initEditor(container) {
|
|||||||
|
|
||||||
let ground = null, railsGrp = null, propsGrp = null, letterMeshes = [],
|
let ground = null, railsGrp = null, propsGrp = null, letterMeshes = [],
|
||||||
markersGrp = new THREE.Group(), selGrp = new THREE.Group(),
|
markersGrp = new THREE.Group(), selGrp = new THREE.Group(),
|
||||||
underlayMesh = null;
|
underlayMesh = null, grassGrp = null;
|
||||||
scene.add(markersGrp, selGrp);
|
scene.add(markersGrp, selGrp);
|
||||||
|
|
||||||
// ---------------------------------------------------------------- rebuilds
|
// ---------------------------------------------------------------- rebuilds
|
||||||
@ -72,6 +73,16 @@ export function initEditor(container) {
|
|||||||
B.x0 - GRID_PAD, B.x1 + GRID_PAD, B.z0 - GRID_PAD, B.z1 + GRID_PAD);
|
B.x0 - GRID_PAD, B.x1 + GRID_PAD, B.z0 - GRID_PAD, B.z1 + GRID_PAD);
|
||||||
ground.material.needsUpdate = true;
|
ground.material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
|
function rebuildGrass() {
|
||||||
|
if (grassGrp) { scene.remove(grassGrp); grassGrp.userData.dispose?.(); grassGrp = null; }
|
||||||
|
const g = ed.park.grass || (ed.park.grass = { style: 'lush', density: 8 });
|
||||||
|
if (g.style === 'off') return;
|
||||||
|
grassGrp = makeGrass({ heightAt: ed.heightAt, areas: parkGrassAreas(ed.park, ed.heightAt),
|
||||||
|
style: g.style, density: g.density ?? 8, seed: 7 });
|
||||||
|
scene.add(grassGrp);
|
||||||
|
}
|
||||||
|
ed.rebuildGrass = rebuildGrass;
|
||||||
|
ed.grassPlayer = (x, z, on) => grassGrp?.userData.setPlayer(x, z, on);
|
||||||
function rebuildRails() {
|
function rebuildRails() {
|
||||||
if (railsGrp) scene.remove(railsGrp);
|
if (railsGrp) scene.remove(railsGrp);
|
||||||
railsGrp = buildRails(ed.park); scene.add(railsGrp);
|
railsGrp = buildRails(ed.park); scene.add(railsGrp);
|
||||||
@ -185,7 +196,7 @@ export function initEditor(container) {
|
|||||||
function rebuildAll() {
|
function rebuildAll() {
|
||||||
ed.heightAt = makeHeightAt(ed.park);
|
ed.heightAt = makeHeightAt(ed.park);
|
||||||
rebuildGround(); rebuildRails(); rebuildProps(); rebuildMarkers();
|
rebuildGround(); rebuildRails(); rebuildProps(); rebuildMarkers();
|
||||||
rebuildUnderlay(); rebuildSelection();
|
rebuildUnderlay(); rebuildGrass(); rebuildSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- undo / persistence
|
// ---------------------------------------------------------------- undo / persistence
|
||||||
@ -201,7 +212,7 @@ export function initEditor(container) {
|
|||||||
// parts: {ground, rails, props, markers, tex, underlay} — default everything cheap
|
// parts: {ground, rails, props, markers, tex, underlay} — default everything cheap
|
||||||
ed.commit = (parts = {}) => {
|
ed.commit = (parts = {}) => {
|
||||||
if (parts.snapshot !== false) snapshot();
|
if (parts.snapshot !== false) snapshot();
|
||||||
if (parts.ground) rebuildGround();
|
if (parts.ground) { rebuildGround(); rebuildGrass(); }
|
||||||
if (parts.tex) rebakeTexture();
|
if (parts.tex) rebakeTexture();
|
||||||
if (parts.rails) rebuildRails();
|
if (parts.rails) rebuildRails();
|
||||||
if (parts.props) rebuildProps();
|
if (parts.props) rebuildProps();
|
||||||
|
|||||||
208
js/grass.js
Normal file
208
js/grass.js
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
// SKATEMAKER PRO — reusable grass system. The GODVERSE grass module: lift this file
|
||||||
|
// into any game. Styles share one engine: GPU-instanced geometry, vertex-shader wind
|
||||||
|
// (CPU never touches a blade after spawn), root->tip gradient with darkened base
|
||||||
|
// (fake AO — the single biggest visual win), per-instance hue/height jitter, and a
|
||||||
|
// player-push uniform so riders part the grass.
|
||||||
|
//
|
||||||
|
// makeGrass({ heightAt, areas, style, density, seed }) -> THREE.Group
|
||||||
|
// .userData.setPlayer(x, z) — grass bends away from here (test ride / gameplay)
|
||||||
|
// self-ticks its wind clock; add to scene and forget.
|
||||||
|
//
|
||||||
|
// styles: 'lush' (realistic tapered blades) | 'pixel' (chunky nearest-filter cards,
|
||||||
|
// stepped sway — PS1 vibes) | 'dry' (lush geometry, straw palette)
|
||||||
|
// areas: [{x0,x1,z0,z1, keep(x,z)}] — rectangles + a predicate, so callers decide
|
||||||
|
// what counts as lawn (outside the slab, grass zones, islands...).
|
||||||
|
//
|
||||||
|
// Perf notes (the tricks, so future-us remembers):
|
||||||
|
// - InstancedMesh = one draw call per style. 30k blades is nothing on M-series.
|
||||||
|
// - Wind is sin(time + hash(instance origin)) evaluated in the vertex shader; bend
|
||||||
|
// scales with (height-along-blade)^2 so roots stay planted.
|
||||||
|
// - alphaTest, never alpha blend — no sorting, no overdraw melt.
|
||||||
|
// - No per-blade shadows; the root-darkening gradient reads as occlusion.
|
||||||
|
// - Next tier when we need it: chunked frustum culling, far-field billboard cards,
|
||||||
|
// clump maps (Ghost of Tsushima GDC talk), shell texturing for retro fur/turf.
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
const mulberry = seed => () => {
|
||||||
|
seed |= 0; seed = seed + 0x6D2B79F5 | 0;
|
||||||
|
let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
|
||||||
|
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
||||||
|
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PALETTES = {
|
||||||
|
lush: { base: 0x2e5b25, tipA: 0x6fae4a, tipB: 0x8fc65e, sat: 0.12 },
|
||||||
|
dry: { base: 0x6b6234, tipA: 0xb3a55e, tipB: 0xc9bd7a, sat: 0.10 },
|
||||||
|
pixel: { levels: ['#1e4a1c', '#2f6b28', '#4a9436', '#71b848'] },
|
||||||
|
};
|
||||||
|
|
||||||
|
// tapered blade: 5 verts, 3 tris, bends in-shader. h encoded in uv.y.
|
||||||
|
function bladeGeometry() {
|
||||||
|
const g = new THREE.BufferGeometry();
|
||||||
|
const w = 0.045;
|
||||||
|
g.setAttribute('position', new THREE.Float32BufferAttribute([
|
||||||
|
-w, 0, 0, w, 0, 0, -w * 0.7, 0.5, 0, w * 0.7, 0.5, 0, 0, 1, 0,
|
||||||
|
], 3));
|
||||||
|
g.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 0, 0.5, 1, 0.5, 0.5, 1], 2));
|
||||||
|
g.setIndex([0, 1, 2, 2, 1, 3, 2, 3, 4]);
|
||||||
|
g.computeVertexNormals();
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunky pixel card: crossed quads, nearest-filter canvas texture, banded palette
|
||||||
|
function pixelTexture(rand) {
|
||||||
|
const cv = document.createElement('canvas'); cv.width = 16; cv.height = 24;
|
||||||
|
const g = cv.getContext('2d');
|
||||||
|
const cols = PALETTES.pixel.levels;
|
||||||
|
for (let i = 0; i < 10; i++) { // a few fat pixel blades
|
||||||
|
const bx = 1 + Math.floor(rand() * 14);
|
||||||
|
const h = 10 + Math.floor(rand() * 13);
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
g.fillStyle = cols[Math.min(cols.length - 1, Math.floor(y / h * cols.length))];
|
||||||
|
const wob = Math.floor(Math.sin(y * 0.6 + i) * (y / h) * 2);
|
||||||
|
g.fillRect(bx + wob, 23 - y, y > h * 0.7 ? 1 : 2, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tex = new THREE.CanvasTexture(cv);
|
||||||
|
tex.magFilter = tex.minFilter = THREE.NearestFilter;
|
||||||
|
tex.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
return tex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function windify(material, opts) {
|
||||||
|
material.onBeforeCompile = sh => {
|
||||||
|
sh.uniforms.uTime = opts.uTime;
|
||||||
|
sh.uniforms.uPlayer = opts.uPlayer;
|
||||||
|
sh.vertexShader = `
|
||||||
|
uniform float uTime; uniform vec3 uPlayer;
|
||||||
|
` + sh.vertexShader.replace('#include <begin_vertex>', `
|
||||||
|
#include <begin_vertex>
|
||||||
|
{
|
||||||
|
vec4 org = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||||
|
float phase = org.x * 1.7 + org.z * 2.3;
|
||||||
|
float t = uv.y * uv.y; // roots planted, tips fly
|
||||||
|
float sway = sin(uTime * ${opts.pixel ? '1.6' : '2.2'} + phase)
|
||||||
|
+ 0.5 * sin(uTime * 4.7 + phase * 1.3);
|
||||||
|
${opts.pixel ? 'sway = floor(sway * 3.0) / 3.0;' : ''}
|
||||||
|
transformed.x += t * sway * ${opts.pixel ? '0.06' : '0.09'};
|
||||||
|
transformed.z += t * 0.04 * sin(uTime * 1.4 + phase);
|
||||||
|
vec2 away = org.xz - uPlayer.xz;
|
||||||
|
float d = length(away);
|
||||||
|
if (d < 1.2 && uPlayer.y > 0.5) transformed.xz += normalize(away) * t * (1.2 - d) * 0.5;
|
||||||
|
}`);
|
||||||
|
};
|
||||||
|
return material;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeGrass({ heightAt, areas, style = 'lush', density = 8, seed = 7 }) {
|
||||||
|
const grp = new THREE.Group(); grp.name = 'grass';
|
||||||
|
if (style === 'off' || !areas.length) { grp.userData.setPlayer = () => {}; return grp; }
|
||||||
|
const rand = mulberry(seed);
|
||||||
|
const uTime = { value: 0 }, uPlayer = { value: new THREE.Vector3(0, -10, 0) };
|
||||||
|
const pixel = style === 'pixel';
|
||||||
|
|
||||||
|
// scatter
|
||||||
|
const spots = [];
|
||||||
|
for (const a of areas) {
|
||||||
|
const n = Math.min(20000, Math.round((a.x1 - a.x0) * (a.z1 - a.z0) * density));
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = a.x0 + rand() * (a.x1 - a.x0), z = a.z0 + rand() * (a.z1 - a.z0);
|
||||||
|
if (a.keep && !a.keep(x, z)) continue;
|
||||||
|
spots.push([x, heightAt(x, z), z]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const count = Math.min(spots.length, 40000);
|
||||||
|
|
||||||
|
let mesh;
|
||||||
|
if (pixel) {
|
||||||
|
const geo = new THREE.PlaneGeometry(0.5, 0.5);
|
||||||
|
geo.translate(0, 0.25, 0);
|
||||||
|
const mat = windify(new THREE.MeshLambertMaterial({
|
||||||
|
map: pixelTexture(rand), alphaTest: 0.5, side: THREE.DoubleSide,
|
||||||
|
}), { uTime, uPlayer, pixel: true });
|
||||||
|
mesh = new THREE.InstancedMesh(geo, mat, count * 2); // crossed pairs
|
||||||
|
} else {
|
||||||
|
const mat = windify(new THREE.MeshLambertMaterial({ vertexColors: false }), { uTime, uPlayer });
|
||||||
|
mesh = new THREE.InstancedMesh(bladeGeometry(), mat, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pal = PALETTES[style] || PALETTES.lush;
|
||||||
|
const dummy = new THREE.Object3D();
|
||||||
|
const cA = new THREE.Color(pal.tipA ?? 0x6fae4a), cB = new THREE.Color(pal.tipB ?? 0x8fc65e);
|
||||||
|
const tint = new THREE.Color();
|
||||||
|
let k = 0;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const [x, y, z] = spots[i];
|
||||||
|
const s = 0.7 + rand() * 0.7;
|
||||||
|
if (pixel) {
|
||||||
|
for (const ry of [0, Math.PI / 2]) {
|
||||||
|
dummy.position.set(x, y, z);
|
||||||
|
dummy.rotation.set(0, ry + rand() * 0.6, 0);
|
||||||
|
dummy.scale.setScalar(s);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(k++, dummy.matrix);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dummy.position.set(x, y, z);
|
||||||
|
dummy.rotation.set((rand() - 0.5) * 0.15, rand() * Math.PI * 2, (rand() - 0.5) * 0.2);
|
||||||
|
dummy.scale.set(s, s * (0.8 + rand() * 0.8), s);
|
||||||
|
dummy.updateMatrix();
|
||||||
|
mesh.setMatrixAt(i, dummy.matrix);
|
||||||
|
tint.lerpColors(cA, cB, rand()).offsetHSL((rand() - 0.5) * pal.sat, 0, 0);
|
||||||
|
mesh.setColorAt(i, tint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!pixel) {
|
||||||
|
// root->tip gradient: geometry vertex colors (darkened base) multiply with the
|
||||||
|
// per-instance tip tint natively (USE_COLOR × USE_INSTANCING_COLOR)
|
||||||
|
const g = mesh.geometry;
|
||||||
|
const shades = [0.35, 0.35, 0.7, 0.7, 1.15]; // per-vertex root->tip multiplier
|
||||||
|
g.setAttribute('color', new THREE.Float32BufferAttribute(
|
||||||
|
shades.flatMap(v => [v, v, v]), 3));
|
||||||
|
mesh.material.vertexColors = true;
|
||||||
|
}
|
||||||
|
mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
grp.add(mesh);
|
||||||
|
|
||||||
|
// self-ticking wind clock — drop into any scene, no contract changes needed
|
||||||
|
let alive = true;
|
||||||
|
const t0 = performance.now();
|
||||||
|
(function tick() {
|
||||||
|
if (!alive) return;
|
||||||
|
uTime.value = (performance.now() - t0) / 1000;
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
})();
|
||||||
|
grp.userData.setPlayer = (x, z, active = true) => {
|
||||||
|
uPlayer.value.set(x, active ? 1 : -10, z);
|
||||||
|
};
|
||||||
|
grp.userData.dispose = () => { alive = false; mesh.geometry.dispose(); mesh.material.dispose(); };
|
||||||
|
return grp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// standard park areas: the parkland ring outside the slab + island pads + grassy zones
|
||||||
|
export function parkGrassAreas(park, heightAt) {
|
||||||
|
const B = park.bounds, PAD = 13;
|
||||||
|
const areas = [];
|
||||||
|
const outside = (x, z) => (x < B.x0 || x > B.x1 || z < B.z0 || z > B.z1) &&
|
||||||
|
heightAt(x, z) < 2.2;
|
||||||
|
areas.push({ x0: B.x0 - PAD, x1: B.x1 + PAD, z0: B.z0 - PAD, z1: B.z0, keep: outside });
|
||||||
|
areas.push({ x0: B.x0 - PAD, x1: B.x1 + PAD, z0: B.z1, z1: B.z1 + PAD, keep: outside });
|
||||||
|
areas.push({ x0: B.x0 - PAD, x1: B.x0, z0: B.z0, z1: B.z1, keep: outside });
|
||||||
|
areas.push({ x0: B.x1, x1: B.x1 + PAD, z0: B.z0, z1: B.z1, keep: outside });
|
||||||
|
for (const e of park.elements || []) {
|
||||||
|
if (e.kind !== 'dome') continue;
|
||||||
|
const c = Math.cos(e.rot || 0), s = Math.sin(e.rot || 0);
|
||||||
|
areas.push({
|
||||||
|
x0: e.x - Math.max(e.rx, e.rz), x1: e.x + Math.max(e.rx, e.rz),
|
||||||
|
z0: e.z - Math.max(e.rx, e.rz), z1: e.z + Math.max(e.rx, e.rz),
|
||||||
|
keep: (x, z) => {
|
||||||
|
const dx = x - e.x, dz = z - e.z;
|
||||||
|
const u = (dx * c + dz * s) / e.rx, v = (-dx * s + dz * c) / e.rz;
|
||||||
|
return u * u + v * v < 0.92;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return areas;
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import * as THREE from 'three';
|
|||||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||||
import { makeHeightAt, normalizeRail, bowlRimWorld } from './parkmath.js';
|
import { makeHeightAt, normalizeRail, bowlRimWorld } from './parkmath.js';
|
||||||
import { makeBuiltinProp } from './props3d.js';
|
import { makeBuiltinProp } from './props3d.js';
|
||||||
|
import { makeGrass, parkGrassAreas } from './grass.js';
|
||||||
|
|
||||||
export const GRID_RES = 0.5, GRID_PAD = 14;
|
export const GRID_RES = 0.5, GRID_PAD = 14;
|
||||||
|
|
||||||
@ -218,6 +219,13 @@ export function buildParkScene(scene, park, opts = {}) {
|
|||||||
const heightAt = makeHeightAt(park);
|
const heightAt = makeHeightAt(park);
|
||||||
const ground = buildGround(park);
|
const ground = buildGround(park);
|
||||||
scene.add(ground);
|
scene.add(ground);
|
||||||
|
let grass = null;
|
||||||
|
const gcfg = park.grass || { style: 'lush', density: 8 };
|
||||||
|
if (gcfg.style && gcfg.style !== 'off' && !opts.skipGrass) {
|
||||||
|
grass = makeGrass({ heightAt, areas: parkGrassAreas(park, heightAt),
|
||||||
|
style: gcfg.style, density: gcfg.density ?? 8 });
|
||||||
|
scene.add(grass);
|
||||||
|
}
|
||||||
const rails = buildRails(park);
|
const rails = buildRails(park);
|
||||||
scene.add(rails);
|
scene.add(rails);
|
||||||
const props = buildProps(park, heightAt, opts.onPropLoaded);
|
const props = buildProps(park, heightAt, opts.onPropLoaded);
|
||||||
@ -227,5 +235,5 @@ export function buildParkScene(scene, park, opts = {}) {
|
|||||||
letterMeshes = buildLetters(park, heightAt);
|
letterMeshes = buildLetters(park, heightAt);
|
||||||
for (const m of letterMeshes) scene.add(m);
|
for (const m of letterMeshes) scene.add(m);
|
||||||
}
|
}
|
||||||
return { ground, rails, props, letterMeshes };
|
return { ground, rails, props, letterMeshes, grass };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,14 +2,15 @@
|
|||||||
// toggle is what makes an editor sing. Simplified bookquoy physics over the live
|
// toggle is what makes an editor sing. Simplified bookquoy physics over the live
|
||||||
// park data — same heightAt, same constants, so speed and pop feel like the game.
|
// park data — same heightAt, same constants, so speed and pop feel like the game.
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { makeHeightAt, makeNormalAt } from './parkmath.js';
|
import { makeHeightAt, makeNormalAt, flattenRails } from './parkmath.js';
|
||||||
|
|
||||||
const GRAV = 18, MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, STEER = 2.6;
|
const GRAV = 18, MAX_SPEED = 10.5, PUSH_ACC = 6.5, FRICTION = 0.28, STEER = 2.6;
|
||||||
|
|
||||||
export function attachTestRide(ed) {
|
export function attachTestRide(ed) {
|
||||||
const keys = new Set();
|
const keys = new Set();
|
||||||
let rider = null, heightAt = null, normalAt = null;
|
let rider = null, heightAt = null, normalAt = null, rails = [];
|
||||||
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
|
let pos, heading = 0, speed = 0, vy = 0, state = 'roll';
|
||||||
|
let grind = null; // {seg, t, dir, len}
|
||||||
const N = new THREE.Vector3();
|
const N = new THREE.Vector3();
|
||||||
const camTarget = new THREE.Vector3();
|
const camTarget = new THREE.Vector3();
|
||||||
let savedCam = null;
|
let savedCam = null;
|
||||||
@ -36,6 +37,7 @@ export function attachTestRide(ed) {
|
|||||||
function start() {
|
function start() {
|
||||||
heightAt = makeHeightAt(ed.park);
|
heightAt = makeHeightAt(ed.park);
|
||||||
normalAt = makeNormalAt(heightAt);
|
normalAt = makeNormalAt(heightAt);
|
||||||
|
rails = flattenRails(ed.park);
|
||||||
const s = ed.park.spawn;
|
const s = ed.park.spawn;
|
||||||
pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z);
|
pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z);
|
||||||
heading = s.heading; speed = 0; vy = 0; state = 'roll';
|
heading = s.heading; speed = 0; vy = 0; state = 'roll';
|
||||||
@ -45,9 +47,11 @@ export function attachTestRide(ed) {
|
|||||||
ed.controls.enabled = false;
|
ed.controls.enabled = false;
|
||||||
ed.testing = true;
|
ed.testing = true;
|
||||||
ed.emit('test', true);
|
ed.emit('test', true);
|
||||||
ed.emit('status', 'TEST RIDE — W push · A/D steer · S brake · SPACE ollie · R respawn · ENTER back to editing');
|
ed.emit('status', 'TEST RIDE — W push · A/D steer · S brake · SPACE ollie · land on steel to grind · R respawn · ENTER back to editing');
|
||||||
}
|
}
|
||||||
function stop() {
|
function stop() {
|
||||||
|
ed.grassPlayer?.(0, 0, false);
|
||||||
|
grind = null;
|
||||||
if (rider) ed.scene.remove(rider);
|
if (rider) ed.scene.remove(rider);
|
||||||
rider = null;
|
rider = null;
|
||||||
ed.testing = false;
|
ed.testing = false;
|
||||||
@ -78,11 +82,43 @@ export function attachTestRide(ed) {
|
|||||||
pos.y = h;
|
pos.y = h;
|
||||||
if (keys.has('Space')) { state = 'air'; vy = 4.6 + speed * 0.12; }
|
if (keys.has('Space')) { state = 'air'; vy = 4.6 + speed * 0.12; }
|
||||||
}
|
}
|
||||||
|
} else if (state === 'grind') {
|
||||||
|
grind.t += grind.dir * speed * dt / grind.len;
|
||||||
|
const g = grind.seg;
|
||||||
|
if (!keys.has('Space')) grind.latch = false; // must re-press to pop off
|
||||||
|
if (grind.t <= 0 || grind.t >= 1 || (keys.has('Space') && !grind.latch)) {
|
||||||
|
state = 'air'; vy = 2.6; grind = null;
|
||||||
|
} else {
|
||||||
|
pos.x = g.a[0] + (g.b[0] - g.a[0]) * grind.t;
|
||||||
|
pos.z = g.a[1] + (g.b[1] - g.a[1]) * grind.t;
|
||||||
|
pos.y = g.ya + (g.yb - g.ya) * grind.t + 0.06;
|
||||||
|
heading = Math.atan2((g.b[0] - g.a[0]) * grind.dir, (g.b[1] - g.a[1]) * grind.dir);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
vy -= GRAV * dt;
|
vy -= GRAV * dt;
|
||||||
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; pos.y += vy * dt;
|
pos.x += fwd.x * speed * dt; pos.z += fwd.z * speed * dt; pos.y += vy * dt;
|
||||||
const h = heightAt(pos.x, pos.z);
|
if (vy < 0) { // falling: try to lock a grind
|
||||||
if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; }
|
for (const g of rails) {
|
||||||
|
const dx = g.b[0] - g.a[0], dz = g.b[1] - g.a[1];
|
||||||
|
const len = Math.hypot(dx, dz) || 0.01;
|
||||||
|
const t = ((pos.x - g.a[0]) * dx + (pos.z - g.a[1]) * dz) / (len * len);
|
||||||
|
if (t < 0.02 || t > 0.98) continue;
|
||||||
|
const px = g.a[0] + dx * t, pz = g.a[1] + dz * t;
|
||||||
|
const railY = g.ya + (g.yb - g.ya) * t;
|
||||||
|
if (Math.hypot(pos.x - px, pos.z - pz) < 0.4 &&
|
||||||
|
pos.y > railY - 0.05 && pos.y < railY + 0.4) {
|
||||||
|
const dir = (Math.sin(heading) * dx + Math.cos(heading) * dz) >= 0 ? 1 : -1;
|
||||||
|
grind = { seg: g, t, dir, len, latch: keys.has('Space') };
|
||||||
|
state = 'grind'; vy = 0;
|
||||||
|
speed = Math.max(speed, 3.5);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state === 'air') {
|
||||||
|
const h = heightAt(pos.x, pos.z);
|
||||||
|
if (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (keys.has('KeyR')) {
|
if (keys.has('KeyR')) {
|
||||||
const s = ed.park.spawn;
|
const s = ed.park.spawn;
|
||||||
@ -90,6 +126,7 @@ export function attachTestRide(ed) {
|
|||||||
}
|
}
|
||||||
rider.position.copy(pos);
|
rider.position.copy(pos);
|
||||||
rider.rotation.y = heading;
|
rider.rotation.y = heading;
|
||||||
|
ed.grassPlayer?.(pos.x, pos.z, true);
|
||||||
if (state === 'roll') {
|
if (state === 'roll') {
|
||||||
normalAt(pos.x, pos.z, N);
|
normalAt(pos.x, pos.z, N);
|
||||||
const tilt = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), N);
|
const tilt = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), N);
|
||||||
|
|||||||
10
js/ui.js
10
js/ui.js
@ -360,6 +360,16 @@ export function initUI(ed) {
|
|||||||
|
|
||||||
function buildParkInspector() {
|
function buildParkInspector() {
|
||||||
inspector.append(el('div', 'ititle', 'PARK'));
|
inspector.append(el('div', 'ititle', 'PARK'));
|
||||||
|
const g = ed.park.grass || (ed.park.grass = { style: 'lush', density: 8 });
|
||||||
|
const gsel = el('select');
|
||||||
|
gsel.innerHTML = '<option value="lush">lush grass</option><option value="dry">dry grass</option>' +
|
||||||
|
'<option value="pixel">pixel grass</option><option value="off">no grass</option>';
|
||||||
|
gsel.value = g.style;
|
||||||
|
gsel.onchange = () => { g.style = gsel.value; ed.rebuildGrass(); ed.commit({}); };
|
||||||
|
inspector.append(row('grass', gsel));
|
||||||
|
inspector.append(numRow(g, 'density', 'blades/m²', 1, 20, 1, (v, done) => {
|
||||||
|
g.density = v; if (done) { ed.rebuildGrass(); ed.commit({}); }
|
||||||
|
}));
|
||||||
const cols = [['slabColor', 'slab'], ['grassColor', 'grass']];
|
const cols = [['slabColor', 'slab'], ['grassColor', 'grass']];
|
||||||
for (const [key, label] of cols) {
|
for (const [key, label] of cols) {
|
||||||
const c = el('input'); c.type = 'color'; c.value = ed.park[key];
|
const c = el('input'); c.type = 'color'; c.value = ed.park[key];
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user