From a5c8765776bbb1b0f5941b9c3f47ebde534dba28 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 3 Aug 2026 20:39:45 +1000 Subject: [PATCH] GODVERSE grass system + test-ride grinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 8 +- docs/GRASS.md | 59 ++++++++++++++ js/editor.js | 17 +++- js/grass.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++++ js/parkbuild.js | 10 ++- js/testride.js | 47 +++++++++-- js/ui.js | 10 +++ 7 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 docs/GRASS.md create mode 100644 js/grass.js diff --git a/README.md b/README.md index 18d0911..a232c18 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,14 @@ persistence (`parks/`, `assets/uploads/`). (hunyuan3d_mlx image→GLB). Everything free and local on the m3ultra queue. - **PARK CHECK** — live design linter encoding `docs/DESIGN_PRINCIPLES.md`: pumping 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 - 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 diff --git a/docs/GRASS.md b/docs/GRASS.md new file mode 100644 index 0000000..1a6ab07 --- /dev/null +++ b/docs/GRASS.md @@ -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). diff --git a/js/editor.js b/js/editor.js index 6c37482..cb72149 100644 --- a/js/editor.js +++ b/js/editor.js @@ -7,6 +7,7 @@ import { makeHeightAt, newElement, newId, elementHit, elementOutline, emptyPark, normalizeRail } from './parkmath.js'; import { buildGround, buildRails, buildProps, buildLetters, bakeGroundTexture, GRID_PAD } from './parkbuild.js'; +import { makeGrass, parkGrassAreas } from './grass.js'; const AUTOSAVE_KEY = 'smp_autosave_v1'; @@ -52,7 +53,7 @@ export function initEditor(container) { let ground = null, railsGrp = null, propsGrp = null, letterMeshes = [], markersGrp = new THREE.Group(), selGrp = new THREE.Group(), - underlayMesh = null; + underlayMesh = null, grassGrp = null; scene.add(markersGrp, selGrp); // ---------------------------------------------------------------- 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); 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() { if (railsGrp) scene.remove(railsGrp); railsGrp = buildRails(ed.park); scene.add(railsGrp); @@ -185,7 +196,7 @@ export function initEditor(container) { function rebuildAll() { ed.heightAt = makeHeightAt(ed.park); rebuildGround(); rebuildRails(); rebuildProps(); rebuildMarkers(); - rebuildUnderlay(); rebuildSelection(); + rebuildUnderlay(); rebuildGrass(); rebuildSelection(); } // ---------------------------------------------------------------- undo / persistence @@ -201,7 +212,7 @@ export function initEditor(container) { // parts: {ground, rails, props, markers, tex, underlay} — default everything cheap ed.commit = (parts = {}) => { if (parts.snapshot !== false) snapshot(); - if (parts.ground) rebuildGround(); + if (parts.ground) { rebuildGround(); rebuildGrass(); } if (parts.tex) rebakeTexture(); if (parts.rails) rebuildRails(); if (parts.props) rebuildProps(); diff --git a/js/grass.js b/js/grass.js new file mode 100644 index 0000000..a01d4f2 --- /dev/null +++ b/js/grass.js @@ -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 ', ` +#include +{ + 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; +} diff --git a/js/parkbuild.js b/js/parkbuild.js index ad8cd44..c5dc473 100644 --- a/js/parkbuild.js +++ b/js/parkbuild.js @@ -4,6 +4,7 @@ import * as THREE from 'three'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { makeHeightAt, normalizeRail, bowlRimWorld } from './parkmath.js'; import { makeBuiltinProp } from './props3d.js'; +import { makeGrass, parkGrassAreas } from './grass.js'; export const GRID_RES = 0.5, GRID_PAD = 14; @@ -218,6 +219,13 @@ export function buildParkScene(scene, park, opts = {}) { const heightAt = makeHeightAt(park); const ground = buildGround(park); 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); scene.add(rails); const props = buildProps(park, heightAt, opts.onPropLoaded); @@ -227,5 +235,5 @@ export function buildParkScene(scene, park, opts = {}) { letterMeshes = buildLetters(park, heightAt); for (const m of letterMeshes) scene.add(m); } - return { ground, rails, props, letterMeshes }; + return { ground, rails, props, letterMeshes, grass }; } diff --git a/js/testride.js b/js/testride.js index f9268fd..77eb2ac 100644 --- a/js/testride.js +++ b/js/testride.js @@ -2,14 +2,15 @@ // 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. 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; export function attachTestRide(ed) { 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 grind = null; // {seg, t, dir, len} const N = new THREE.Vector3(); const camTarget = new THREE.Vector3(); let savedCam = null; @@ -36,6 +37,7 @@ export function attachTestRide(ed) { function start() { heightAt = makeHeightAt(ed.park); normalAt = makeNormalAt(heightAt); + rails = flattenRails(ed.park); const s = ed.park.spawn; pos = new THREE.Vector3(s.x, heightAt(s.x, s.z), s.z); heading = s.heading; speed = 0; vy = 0; state = 'roll'; @@ -45,9 +47,11 @@ export function attachTestRide(ed) { ed.controls.enabled = false; ed.testing = 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() { + ed.grassPlayer?.(0, 0, false); + grind = null; if (rider) ed.scene.remove(rider); rider = null; ed.testing = false; @@ -78,11 +82,43 @@ export function attachTestRide(ed) { pos.y = h; 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 { vy -= GRAV * 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 (pos.y <= h) { pos.y = h; state = 'roll'; vy = 0; } + if (vy < 0) { // falling: try to lock a grind + 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')) { const s = ed.park.spawn; @@ -90,6 +126,7 @@ export function attachTestRide(ed) { } rider.position.copy(pos); rider.rotation.y = heading; + ed.grassPlayer?.(pos.x, pos.z, true); if (state === 'roll') { normalAt(pos.x, pos.z, N); const tilt = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), N); diff --git a/js/ui.js b/js/ui.js index 575ce08..36cacc8 100644 --- a/js/ui.js +++ b/js/ui.js @@ -360,6 +360,16 @@ export function initUI(ed) { function buildParkInspector() { inspector.append(el('div', 'ititle', 'PARK')); + const g = ed.park.grass || (ed.park.grass = { style: 'lush', density: 8 }); + const gsel = el('select'); + gsel.innerHTML = '' + + ''; + 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']]; for (const [key, label] of cols) { const c = el('input'); c.type = 'color'; c.value = ed.park[key];