Hero fixes (user reports): (1) 'appears to be last' was structural — the grid start filled stations AHEAD of the hero's station-0 spawn; the hero now draws a MID-PACK grid slot from the designed distribution (uniform over the 20th-80th percentile, label rng grid/hero — seed- varied, never first, never last) and peers fill the remaining slots. (2) 'glitchy, rendering on top of everything' — at pack scale the hero's transmission shell sampled a buffer holding only opaques, so the hundreds of transparent-shelled peers behind it vanished through the glass; the hero drops to plain alpha glass when peers are instanced (transmission stays for the <=32 kit-mesh world). Instanced fresnel rims softened (blown-white halos -> subtle tinted rims). Camera: follow mode gains wheel zoom (x0.35-x3) and drag orbit (yaw/pitch around the marble), easing back to the standard follow shot after 3 quiet seconds (exp return). Orbit-mode OrbitControls untouched. Verified: 201-marble grid, hero spawns 36th percentile and races mid-field; camera adjusts and returns; 0 console errors; 116 green.
- commit
- 6a17c3d4c4ed35e0a4fa9a99b1acf6032706fdc9
- parent
- ec5616b8a4e61633baf91d51f14207061e8766d6
- author
- Dami <dami.etoile@gmail.com>
| M | src/ | 73 | ++++++++++++++++++++++++ |
| M | src/ | 93 | ++++++++++++++++++++++++------ |
| M | src/ | 4 | +- |
@@ -40,6 +40,12 @@ const TRAIL = 4;
4040 const HEIGHT = 1.6;4141 const LERP = 0.08;4242 const LOOK_UP = 0.5;43+/** User camera adjust (follow mode): wheel zoom + drag orbit, easing44+ * back to the standard follow shot after this many quiet seconds. */45+const ADJUST_RETURN_AFTER = 3;46+const ADJUST_RETURN_RATE = 2.2; // exp decay per second47+const ZOOM_MIN = 0.35;48+const ZOOM_MAX = 3;4349 4450 export interface CameraOptions {4551 /** Follow-cam trail distance/height — endless mode rides higher and@@ -79,6 +85,48 @@ export function createCameras(
7985 8086 const desired = new THREE.Vector3();8187 const lookTarget = new THREE.Vector3();88+ // Follow-mode user adjust state (wheel zoom, drag orbit).89+ let zoomUser = 1;90+ let yawUser = 0;91+ let pitchUser = 0;92+ let lastAdjustT = -1e9;93+ const rel = new THREE.Vector3();94+ const sph = new THREE.Spherical();95+ let dragging = false;96+ let dragX = 0;97+ let dragY = 0;98+ const dom = renderer.domElement;99+ dom.addEventListener(100+ "wheel",101+ (e: WheelEvent) => {102+ if (mode !== "follow") return;103+ e.preventDefault();104+ zoomUser = Math.min(105+ ZOOM_MAX,106+ Math.max(ZOOM_MIN, zoomUser * Math.exp(e.deltaY * 0.0012)),107+ );108+ lastAdjustT = performance.now();109+ },110+ { passive: false },111+ );112+ dom.addEventListener("pointerdown", (e: PointerEvent) => {113+ if (mode !== "follow") return;114+ dragging = true;115+ dragX = e.clientX;116+ dragY = e.clientY;117+ });118+ addEventListener("pointermove", (e: PointerEvent) => {119+ if (!dragging || mode !== "follow") return;120+ yawUser -= (e.clientX - dragX) * 0.006;121+ pitchUser += (e.clientY - dragY) * 0.004;122+ pitchUser = Math.min(1.1, Math.max(-1.1, pitchUser));123+ dragX = e.clientX;124+ dragY = e.clientY;125+ lastAdjustT = performance.now();126+ });127+ addEventListener("pointerup", () => {128+ dragging = false;129+ });82130 const lookNow = new THREE.Vector3();83131 const raycaster = new THREE.Raycaster();84132 const rayDir = new THREE.Vector3();@@ -171,6 +219,31 @@ export function createCameras(
171219 lookTarget.copy(activeMarble.obj.position);172220 lookTarget.y += LOOK_UP;173221 222+ // User adjust (zoom / orbit around the marble) with a debounced223+ // return: after a few quiet seconds the shot eases back to the224+ // standard follow framing.225+ if (226+ performance.now() - lastAdjustT > ADJUST_RETURN_AFTER * 1000 &&227+ (zoomUser !== 1 || yawUser !== 0 || pitchUser !== 0)228+ ) {229+ const k = Math.exp(-ADJUST_RETURN_RATE * Math.max(dt, 1e-4));230+ zoomUser = 1 + (zoomUser - 1) * k;231+ yawUser *= k;232+ pitchUser *= k;233+ if (Math.abs(zoomUser - 1) < 1e-3) zoomUser = 1;234+ if (Math.abs(yawUser) < 1e-3) yawUser = 0;235+ if (Math.abs(pitchUser) < 1e-3) pitchUser = 0;236+ }237+ if (zoomUser !== 1 || yawUser !== 0 || pitchUser !== 0) {238+ rel.copy(desired).sub(lookTarget);239+ sph.setFromVector3(rel);240+ sph.radius = Math.max(0.6, sph.radius * zoomUser);241+ sph.theta += yawUser;242+ sph.phi = Math.min(2.6, Math.max(0.25, sph.phi - pitchUser));243+ rel.setFromSpherical(sph);244+ desired.copy(lookTarget).add(rel);245+ }246+174247 // Occlusion: never let island mass sit between marble and camera —175248 // pull the desired position in front of the first hit (small176249 // margin), so the cam slides over shoulders instead of clipping@@ -52,6 +52,7 @@ import {
5252 wantJump,5353 } from "./physics/autopilot.ts";5454 import { racerIdentity } from "./peersCore.ts";55+import { rngFamily } from "../gen/rng.ts";5556 import {5657 applyTunnelVariants,5758 fresnelShellMaterial,@@ -364,6 +365,8 @@ class Session {
364365 private racers: Racer[] = [];365366 /** Real racer-peer count (0 outside physics-autopilot mode). */366367 private realPeerCount = 0;368+ /** Decorative-ring size when real racers are off. */369+ private ringPeerCount: number | undefined = undefined;367370 /** Leader-cam (M15): OFF by default — the camera follows the hero (who368371 * carries the trail); SPACE elects chasing the race leader instead. */369372 private leaderCam = false;@@ -457,6 +460,18 @@ class Session {
457460 ctx.scene.add(this.root);458461 this.atmosphere = new Atmosphere(ctx, stars, drawZone(seed, 0));459462 463+ // Session peer sizing first — the hero's shell material depends on it464+ // (transmission glass only below pack scale; see cloneOne).465+ {466+ const peersParam = new URLSearchParams(location.search).get("peers");467+ const peerCount = peersParam === null468+ ? undefined469+ : Math.max(0, Math.min(9999, Number.parseInt(peersParam, 10) || 0));470+ const realRace = this.engine !== null && this.useAutopilot;471+ this.realPeerCount = realRace ? Math.min(9999, peerCount ?? 5) : 0;472+ this.ringPeerCount = realRace ? 0 : peerCount;473+ }474+460475 // Marble (same grounded-mesh-in-pivot construction as the loop mode).461476 this.marbleObj = new THREE.Group();462477 this.marbleObj.name = "marble";@@ -475,9 +490,28 @@ class Session {
475490 if (!m.isMesh) return;476491 const cloneOne = (mat: THREE.Material): THREE.Material => {477492 // Hero shell: clear real glass (neutral phenotype); stable478- // transparent order (see buildRacerVisual).493+ // transparent order (see buildRacerVisual). At PACK scale the494+ // transmission buffer (opaques only) erases the hundreds of495+ // transparent-shelled peers behind the hero — glitch city — so496+ // the hero drops to plain alpha glass there.479497 if (mat.name === "glass" && physics) {480498 m.renderOrder = 10;499+ if (this.realPeerCount > INSTANCED_AT) {500+ const g = new THREE.MeshPhysicalMaterial({501+ color: 0xe8f2fc,502+ transparent: true,503+ opacity: 0.3,504+ roughness: 0.05,505+ metalness: 0,506+ clearcoat: 1,507+ clearcoatRoughness: 0.08,508+ envMap: marbleEnv(ctx.renderer),509+ envMapIntensity: 1.1,510+ depthWrite: false,511+ });512+ heroMaterials.push(g);513+ return g;514+ }481515 const g = glassShellMaterial(ctx.renderer, {482516 meshId: MARBLE_PIECE,483517 tint: null,@@ -507,22 +541,9 @@ class Session {
507541 this.followedPivot = this.pivot;508542 ctx.scene.add(this.marbleObj);509543 ctx.scene.add(this.trail.obj);510- // Ring size is session config, not seed material: ?peers=N (0 turns511- // the ring off, capped at 24; default DEFAULT_PEER_COUNT). In physics512- // autopilot mode the decorative ring is OFF — peers are REAL racer513- // bodies instead (M15), same param, capped by the shim's 16 bodies.514- const peersParam = new URLSearchParams(location.search).get("peers");515- const peerCount = peersParam === null516- ? undefined517- : Math.max(0, Math.min(9999, Number.parseInt(peersParam, 10) || 0));518- const realRace = this.engine !== null && this.useAutopilot;519- this.realPeerCount = realRace ? Math.min(9999, peerCount ?? 5) : 0;520- this.peerRing = new PeerRing(521- seed,522- assets,523- ctx.scene,524- realRace ? 0 : peerCount,525- );544+ // Decorative ring only outside physics-autopilot mode (real racers545+ // replace it there) — sizing computed above, before the hero mesh.546+ this.peerRing = new PeerRing(seed, assets, ctx.scene, this.ringPeerCount);526547 527548 this.ensure();528549 this.initialBuild = false;@@ -590,10 +611,42 @@ class Session {
590611 }591612 const span = Math.max(2, Math.min(rail.pts.length - 2, 120));592613 const LANES = [-0.26, 0, 0.26];614+ // Grid slots for hero + peers: the hero draws a MID-PACK slot from615+ // the designed distribution (uniform over the 20th–80th percentile,616+ // label rng — user rule: not first, not last, seed-varied); peers617+ // fill the remaining slots in order.618+ const totalSlots = this.realPeerCount + 1;619+ const heroSlot = Math.min(620+ totalSlots - 1,621+ Math.floor(622+ (0.2 + rngFamily(seed, "grid/hero").float() * 0.6) * totalSlots,623+ ),624+ );625+ const slotPos = (626+ k: number,627+ ): { at: number; lane: number; tier: number } => ({628+ at: 1 + (k % span),629+ lane: LANES[k % LANES.length],630+ tier: Math.floor(k / (span * LANES.length)),631+ });632+ {633+ const hs = slotPos(heroSlot);634+ const tt = rail.tans[hs.at];635+ const nl = Math.hypot(tt[2], tt[0]) || 1;636+ this.engine.spawnMarble(0, [637+ rail.pts[hs.at][0] + (tt[2] / nl) * hs.lane,638+ rail.pts[hs.at][1] + 0.4 + hs.tier * 0.35,639+ rail.pts[hs.at][2] + (-tt[0] / nl) * hs.lane,640+ ], [tt[0] * 2.2, 0, tt[2] * 2.2]);641+ const hero = this.racers[0];642+ hero.nearest = { seg: 0, idx: hs.at };643+ hero.s = rail.s[hs.at];644+ hero.lastProgressS = hero.s;645+ this.marbleS = hero.s;646+ }593647 for (let i = 1; i <= this.realPeerCount; i++) {594- const at = 1 + ((i - 1) % span);595- const lane = LANES[i % LANES.length];596- const tier = Math.floor((i - 1) / (span * LANES.length));648+ const k = i - 1 < heroSlot ? i - 1 : i;649+ const { at, lane, tier } = slotPos(k);597650 const tt = rail.tans[at];598651 const nl = Math.hypot(tt[2], tt[0]) || 1;599652 this.engine.spawnMarble(i, [@@ -100,8 +100,8 @@ export function fresnelShellMaterial(): THREE.ShaderMaterial {
100100 varying vec3 vTint;101101 void main() {102102 vec3 V = normalize(cameraPosition - vW);103- float f = pow(1.0 - abs(dot(normalize(vN), V)), 3.0);104- vec3 c = vTint * f * 1.35 + vec3(0.55) * f * f;103+ float f = pow(1.0 - abs(dot(normalize(vN), V)), 3.5);104+ vec3 c = vTint * f * 0.85 + vec3(0.22) * f * f;105105 gl_FragColor = vec4(c, 1.0);106106 }107107 `,