From 1daffe607a7acda399999521513c416d5794c8a5 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 8 May 2026 19:29:05 -0400 Subject: [PATCH] arena: walls, pillars, strafe-jumping, solid player meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the open todo at the top of disks/arena.mjs. 🧱 Static obstacles. New shared module `lib/arena-world.mjs` exports ARENA_OBSTACLES (4 corner pillars, a center totem, two staggered cover walls) β€” imported by both disks/arena.mjs (client + render) and session-server/arena-manager.mjs (authority), so what you see is what blocks you. lib/pmove.mjs gains `resolveObstacles` (cylinder push-out + AABB push-out, Y-bounded so high jumps clear them); the client runs the same routine inside lib/cam-doll.mjs after horizontal integration so prediction stays converged with the server without depending on the reconciler to push you out of walls. πŸƒ Strafe-jumping / bunny-hop. lib/pmove.mjs takes opt-in `airAccel`, `groundAccel`, `airCapSpeed`, `groundFriction` cfg keys. When set, the legacy hVelDecay lerp is replaced by Q3 ground-friction + ground/air-accelerate. Air mode caps wishspeed (Q3's air-control window) so perpendicular wishdir adds velocity without subtracting forward speed β€” strafe-jumping accumulates speed past runSpeed. Jump edge-trigger moved before friction so a held jump preserves landing speed (PMF_TIME_LAND trick). lib/cam-doll.mjs mirrors the same physics on the client (owns the dolly's horizontal velocity when Q3 mode is on; dolly.dec set to 1 so dolly.sim acts as pure integration). Tunables live in ARENA_PHYSICS in arena-world.mjs. 🧍 Solid player meshes. buildRemoteBody in arena.mjs replaces the line stick figure with a boxy humanoid (head, torso, arms, legs) emitted as type:"triangle" with shaded face colors. The +Z face of the head gets a darker visor so facing direction reads at a glance. Watchers stay translucent; named handles render solid. Walls + pillars get matching solid meshes too β€” cylinder side+top fans for pillars, full 6-face boxes for walls, plus golden top-rim line outlines so silhouettes survive fog. Drawn after the ground in paint(). Knockback note: applyImpulse rescales for the Q3 dolly convention (per-second world velocity Γ· simHz), so existing EXPLOSION_KICK_HOR values may now feel weak β€” tune in arena.mjs if needed. Co-Authored-By: Claude Opus 4.7 --- session-server/arena-manager.mjs | 9 + .../public/aesthetic.computer/disks/arena.mjs | 278 ++++++++++++++++-- .../aesthetic.computer/lib/arena-world.mjs | 62 ++++ .../aesthetic.computer/lib/cam-doll.mjs | 135 ++++++++- .../public/aesthetic.computer/lib/pmove.mjs | 146 ++++++++- 5 files changed, 589 insertions(+), 41 deletions(-) create mode 100644 system/public/aesthetic.computer/lib/arena-world.mjs diff --git a/session-server/arena-manager.mjs b/session-server/arena-manager.mjs index 1f0cf1bf86..6977249158 100644 --- a/session-server/arena-manager.mjs +++ b/session-server/arena-manager.mjs @@ -12,6 +12,7 @@ // them on is additive. import { newState, pmove, unpackCmd, DEFAULT_CFG, BTN } from "../system/public/aesthetic.computer/lib/pmove.mjs"; +import { ARENA_OBSTACLES, ARENA_PHYSICS } from "../system/public/aesthetic.computer/lib/arena-world.mjs"; const PLAYER_FIELDS = ["h","x","y","z","vx","vy","vz","yaw","pitch","c","g","a"]; @@ -35,6 +36,14 @@ export const ARENA_CFG = Object.freeze({ groundBounds: { xMin: -14, xMax: 14, zMin: -14, zMax: 14 }, deathFloorY: -30, simHz: TICK_RATE, + // πŸƒ Quake-style strafe-jumping + bunny-hop. + airAccel: ARENA_PHYSICS.airAccel, + groundAccel: ARENA_PHYSICS.groundAccel, + airCapSpeed: ARENA_PHYSICS.airCapSpeed, + groundFriction: ARENA_PHYSICS.groundFriction, + // 🧱 Static walls + pillars (shared with the client). + obstacles: ARENA_OBSTACLES, + playerRadius: ARENA_PHYSICS.playerRadius, }); // Spawn ring β€” spread players around the arena. diff --git a/system/public/aesthetic.computer/disks/arena.mjs b/system/public/aesthetic.computer/disks/arena.mjs index 77bd653c8c..ae05d70a46 100644 --- a/system/public/aesthetic.computer/disks/arena.mjs +++ b/system/public/aesthetic.computer/disks/arena.mjs @@ -2,14 +2,15 @@ // Quake-style arena with large tessellated ground, player shadow, and speedometer. /* #region 🏁 TODO - - [] Add more arena geometry (walls, pillars) - - [] Strafe-jumping / bunny-hop acceleration + Done - [x] Fork from fps.mjs - [x] Large pre-tessellated ground plane - [x] Speed meter HUD + FPS counter - [x] Gravity + space-to-jump + shift-to-crouch (Quake-style) - [x] Multiuser: WS+UDP wiring to session-server/arena-manager.mjs + - [x] Walls + pillars (solid meshes, shared client/server collision) + - [x] Strafe-jumping / bunny-hop acceleration (Q3 friction + air-accel) + - [x] Solid humanoid meshes for remote players #endregion */ // --------------------------------------------------------------------------- @@ -18,6 +19,7 @@ // --------------------------------------------------------------------------- import { BTN, packCmd, pmove } from "../lib/pmove.mjs"; +import { ARENA_OBSTACLES, ARENA_OBSTACLE_COLORS, ARENA_PHYSICS } from "../lib/arena-world.mjs"; let myHandle = "guest"; let netServer = null; // WebSocket (reliable) @@ -60,6 +62,14 @@ const ARENA_CFG = Object.freeze({ groundBounds: { xMin: -14, xMax: 14, zMin: -14, zMax: 14 }, deathFloorY: -30, deathFloorClearance: 0.3, simHz: 60, hVelDecay: 0.9, + // πŸƒ Q3 air-physics (mirrors server) β€” enables strafe-jump / bunny-hop. + airAccel: ARENA_PHYSICS.airAccel, + groundAccel: ARENA_PHYSICS.groundAccel, + airCapSpeed: ARENA_PHYSICS.airCapSpeed, + groundFriction: ARENA_PHYSICS.groundFriction, + // 🧱 Static walls + pillars (shared geometry). + obstacles: ARENA_OBSTACLES, + playerRadius: ARENA_PHYSICS.playerRadius, }); // Remote players (everyone except me) @@ -485,31 +495,96 @@ function isGuestHandle(h) { return typeof h === "string" && (h.startsWith("guest_") || h.startsWith("swarm_")); } -// Build a small humanoid stick-figure as a single line Form. Cheap to clone -// per remote (handful of verts) and matches the arena's visual language. +// Build a solid boxy humanoid mesh (head, torso, arms, legs) as a single +// triangle Form so remote players read as bodies, not line drawings. The +// origin sits at eye height so paintRemotes can place body.position at the +// remote's eye coords without offset gymnastics. Face-side (+Z in body-local) +// gets a darker visor tone so you can tell which way the player is looking. function buildRemoteBody(Form, colorRGB, watcher = false) { - const [r, g, b] = colorRGB; - const a = watcher ? 0.35 : 0.9; - const aDim = watcher ? 0.18 : 0.5; - const col = [r / 255, g / 255, b / 255, a]; - const colDim = [r / 255, g / 255, b / 255, aDim]; - const head = 0.3, shoulder = -0.4, hip = -1.1, foot = -2.0; - // coords are (x, y, z, w); y is "up" in the form's local space. - const pts = [ - // spine - [0, head, 0, 1], [0, hip, 0, 1], - // shoulders - [-0.35, shoulder, 0, 1], [0.35, shoulder, 0, 1], - // arms (slightly forward) - [-0.35, shoulder, 0, 1], [-0.45, -0.9, 0.25, 1], - [ 0.35, shoulder, 0, 1], [ 0.45, -0.9, 0.25, 1], - // hips β†’ feet - [-0.18, hip, 0, 1], [-0.18, foot, 0, 1], - [ 0.18, hip, 0, 1], [ 0.18, foot, 0, 1], + const [R, G, B] = colorRGB; + const a = watcher ? 0.55 : 1.0; + const r = R / 255, g = G / 255, b = B / 255; + // Tone tiers from the deterministic per-handle hue. + const main = [r, g, b, a]; + const dark = [r * 0.55, g * 0.55, b * 0.55, a]; + const light = [Math.min(1, r * 1.18), Math.min(1, g * 1.18), Math.min(1, b * 1.18), a]; + const skinHead = [ + Math.min(1, r * 0.4 + 0.5), + Math.min(1, g * 0.4 + 0.45), + Math.min(1, b * 0.4 + 0.4), + a, ]; - const cols = [col, col, col, col, col, colDim, col, colDim, col, col, col, col]; - const f = new Form({ type: "line", positions: pts, colors: cols }, - { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }); + const visor = watcher + ? [0.55, 0.6, 0.7, a] + : [0.08, 0.08, 0.12, a]; + + const positions = []; + const colors = []; + // Push one axis-aligned box (in body-local coords). `faces` can override + // each face individually; missing faces fall back to `main`. + const pushBox = (xMin, yMin, zMin, xMax, yMax, zMax, faces) => { + const top = faces.top ?? faces.main; + const bot = faces.bot ?? faces.main; + const north = faces.north ?? faces.side ?? faces.main; // -Z + const south = faces.south ?? faces.side ?? faces.main; // +Z (face/forward) + const east = faces.east ?? faces.side ?? faces.main; // +X + const west = faces.west ?? faces.side ?? faces.main; // -X + const v = (x, y, z) => [x, y, z, 1]; + const quad = (A, B, C, D, c) => { + positions.push(A, B, C, A, C, D); + for (let i = 0; i < 6; i++) colors.push(c); + }; + quad(v(xMin,yMax,zMin), v(xMin,yMax,zMax), v(xMax,yMax,zMax), v(xMax,yMax,zMin), top); + quad(v(xMin,yMin,zMin), v(xMax,yMin,zMin), v(xMax,yMin,zMax), v(xMin,yMin,zMax), bot); + quad(v(xMin,yMin,zMin), v(xMin,yMax,zMin), v(xMax,yMax,zMin), v(xMax,yMin,zMin), north); + quad(v(xMax,yMin,zMax), v(xMax,yMax,zMax), v(xMin,yMax,zMax), v(xMin,yMin,zMax), south); + quad(v(xMax,yMin,zMin), v(xMax,yMax,zMin), v(xMax,yMax,zMax), v(xMax,yMin,zMax), east); + quad(v(xMin,yMin,zMax), v(xMin,yMax,zMax), v(xMin,yMax,zMin), v(xMin,yMin,zMin), west); + }; + + // Body coordinate convention (matches the prior stick figure): + // y=+0.35 head top, y=0 shoulder/eye, y=-1.1 hip, y=-2.0 feet. + // +Z is the player's forward direction (they face +Z at yaw=0). + + // Torso β€” primary handle color. + pushBox(-0.30, -1.10, -0.18, 0.30, -0.40, 0.18, { + main, top: light, bot: dark, north: dark, south: main, + }); + + // Head β€” neutral skin tone with a darker visor on the front (+Z) so the + // facing direction reads at a glance. + pushBox(-0.22, -0.05, -0.20, 0.22, 0.35, 0.20, { + main: skinHead, + top: [ + Math.min(1, skinHead[0] * 1.05), + Math.min(1, skinHead[1] * 1.05), + Math.min(1, skinHead[2] * 1.05), + a, + ], + bot: dark, + south: visor, // +Z = face + }); + + // Arms β€” thin boxes on either side of the torso. + pushBox(-0.50, -1.05, -0.12, -0.32, -0.40, 0.12, { + main: dark, top: main, bot: dark, + }); + pushBox( 0.32, -1.05, -0.12, 0.50, -0.40, 0.12, { + main: dark, top: main, bot: dark, + }); + + // Legs β€” squarer boxes from hip to feet. + pushBox(-0.22, -2.00, -0.14, -0.04, -1.10, 0.14, { + main: dark, top: main, bot: [0, 0, 0, a], + }); + pushBox( 0.04, -2.00, -0.14, 0.22, -1.10, 0.14, { + main: dark, top: main, bot: [0, 0, 0, a], + }); + + const f = new Form( + { type: "triangle", positions, colors }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); f.noFade = true; return f; } @@ -581,6 +656,8 @@ let plumbLine; // vertical line from ground to the player's feet let bodyFeet; // two foot wireframes, anchored to ground + yaw let bodyArms; // two arm wireframes, anchored to eye + yaw let platformEdge; // bright outline at the ground's perimeter +let obstacleForms = []; // solid pillar + wall meshes (one Form per shape) +let obstacleEdges = []; // bright top-rim line forms paired with obstacleForms let FormRef; // captured at boot so sim/paint can build transient forms let penLocked = false; @@ -1102,6 +1179,18 @@ export const fpsOpts = { }, // Dead players stop on the lava instead of falling forever. deathFloorY: DEATH_FLOOR_Y, + // πŸƒ Quake-style local prediction: cam-doll runs the same friction + + // air-accel model as pmove on the server, so strafe-jump speed survives + // reconciliation. + airAccel: ARENA_PHYSICS.airAccel, + groundAccel: ARENA_PHYSICS.groundAccel, + airCapSpeed: ARENA_PHYSICS.airCapSpeed, + groundFriction: ARENA_PHYSICS.groundFriction, + // 🧱 Solid obstacle list resolved client-side too (matches server) so + // local motion stays in sync with pmove without depending on the + // reconciler to push the player back out of walls. + obstacles: ARENA_OBSTACLES, + playerRadius: ARENA_PHYSICS.playerRadius, }; const BG = [45 / 255, 48 / 255, 55 / 255]; @@ -1855,6 +1944,141 @@ function boot({ Form, penLock, system, screen, ui, api, painting, net, handle, s ); platformEdge.noFade = true; + // 🧱 Pillars + walls β€” solid triangle meshes built from ARENA_OBSTACLES. + // The same list drives client/server collision (see lib/arena-world.mjs), + // so what you see is what blocks you. + obstacleForms = []; + obstacleEdges = []; + for (let i = 0; i < ARENA_OBSTACLES.length; i++) { + const o = ARENA_OBSTACLES[i]; + const baseColor = ARENA_OBSTACLE_COLORS[i] ?? [0.4, 0.4, 0.45, 1.0]; + if (o.type === "cylinder") { + const segs = 14; + const positions = []; + const colors = []; + const yLo = o.yMin, yHi = o.yMax; + // Lighter top, darker bottom for depth. + const top = baseColor; + const bot = [baseColor[0] * 0.55, baseColor[1] * 0.55, baseColor[2] * 0.55, 1.0]; + for (let s = 0; s < segs; s++) { + const a0 = (s / segs) * Math.PI * 2; + const a1 = ((s + 1) / segs) * Math.PI * 2; + const x0 = o.x + Math.cos(a0) * o.r, z0 = o.z + Math.sin(a0) * o.r; + const x1 = o.x + Math.cos(a1) * o.r, z1 = o.z + Math.sin(a1) * o.r; + // Side quad as two triangles. Slight per-segment shade variation + // so the curvature reads instead of looking like a flat hexagon. + const shade = 0.85 + 0.15 * Math.cos(a0 * 1.5); + const sCol = [top[0] * shade, top[1] * shade, top[2] * shade, 1.0]; + const sBot = [bot[0] * shade, bot[1] * shade, bot[2] * shade, 1.0]; + positions.push( + [x0, yLo, z0, 1], [x1, yLo, z1, 1], [x1, yHi, z1, 1], + [x0, yLo, z0, 1], [x1, yHi, z1, 1], [x0, yHi, z0, 1], + ); + colors.push(sBot, sBot, sCol, sBot, sCol, sCol); + // Top cap fan triangle. + positions.push([o.x, yHi, o.z, 1], [x0, yHi, z0, 1], [x1, yHi, z1, 1]); + colors.push(top, sCol, sCol); + } + const f = new Form( + { type: "triangle", positions, colors }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + f.noFade = true; + obstacleForms.push(f); + + // Top rim outline so the pillar reads in depth even in fog. + const rimPos = []; + const rimCol = []; + const rim = [1, 0.85, 0.5, 0.85]; + for (let s = 0; s < segs; s++) { + const a0 = (s / segs) * Math.PI * 2; + const a1 = ((s + 1) / segs) * Math.PI * 2; + rimPos.push( + [o.x + Math.cos(a0) * o.r, yHi + 0.01, o.z + Math.sin(a0) * o.r, 1], + [o.x + Math.cos(a1) * o.r, yHi + 0.01, o.z + Math.sin(a1) * o.r, 1], + ); + rimCol.push(rim, rim); + } + const fe = new Form( + { type: "line", positions: rimPos, colors: rimCol }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + fe.noFade = true; + obstacleEdges.push(fe); + } else if (o.type === "box") { + const yLo = o.yMin, yHi = o.yMax; + const x0 = o.xMin, x1 = o.xMax; + const z0 = o.zMin, z1 = o.zMax; + const top = baseColor; + const sideN = [baseColor[0] * 0.78, baseColor[1] * 0.78, baseColor[2] * 0.78, 1.0]; + const sideE = [baseColor[0] * 0.92, baseColor[1] * 0.92, baseColor[2] * 0.92, 1.0]; + const sideS = sideN; + const sideW = sideE; + const bot = [baseColor[0] * 0.5, baseColor[1] * 0.5, baseColor[2] * 0.5, 1.0]; + const positions = []; + const colors = []; + const quad = (a, b, c, d, col) => { + positions.push(a, b, c, a, c, d); + for (let k = 0; k < 6; k++) colors.push(col); + }; + // Top + quad( + [x0, yHi, z0, 1], [x0, yHi, z1, 1], [x1, yHi, z1, 1], [x1, yHi, z0, 1], + top, + ); + // Bottom (visible if you peek under, also shields lava bleed) + quad( + [x0, yLo, z0, 1], [x1, yLo, z0, 1], [x1, yLo, z1, 1], [x0, yLo, z1, 1], + bot, + ); + // North (-Z) + quad( + [x0, yLo, z0, 1], [x0, yHi, z0, 1], [x1, yHi, z0, 1], [x1, yLo, z0, 1], + sideN, + ); + // South (+Z) + quad( + [x1, yLo, z1, 1], [x1, yHi, z1, 1], [x0, yHi, z1, 1], [x0, yLo, z1, 1], + sideS, + ); + // East (+X) + quad( + [x1, yLo, z0, 1], [x1, yHi, z0, 1], [x1, yHi, z1, 1], [x1, yLo, z1, 1], + sideE, + ); + // West (-X) + quad( + [x0, yLo, z1, 1], [x0, yHi, z1, 1], [x0, yHi, z0, 1], [x0, yLo, z0, 1], + sideW, + ); + const f = new Form( + { type: "triangle", positions, colors }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + f.noFade = true; + obstacleForms.push(f); + + // Top edge outline β€” four lines around the wall's roof. + const rim = [1, 0.85, 0.5, 0.85]; + const ey = yHi + 0.01; + const fe = new Form( + { + type: "line", + positions: [ + [x0, ey, z0, 1], [x1, ey, z0, 1], + [x1, ey, z0, 1], [x1, ey, z1, 1], + [x1, ey, z1, 1], [x0, ey, z1, 1], + [x0, ey, z1, 1], [x0, ey, z0, 1], + ], + colors: [rim, rim, rim, rim, rim, rim, rim, rim], + }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + fe.noFade = true; + obstacleEdges.push(fe); + } + } + // πŸ«₯ Feet reference β€” three shadow symbols swapped based on physics state, // plus a vertical plumb line from the ring up to the eye. const ringR = 0.35; @@ -2354,6 +2578,10 @@ function paint({ wipe, ink, screen, write, box, system, pen, canvas, api, painti if (platformBlock) ink(255).form(platformBlock); // bottom and side faces if (groundSkirt) ink(255).form(groundSkirt); ink(255).form(groundPlane); + // 🧱 Walls + pillars β€” solid bodies first, glowing rims on top so the + // silhouette reads even when fog dims the body color. + for (const f of obstacleForms) ink(255).form(f); + for (const f of obstacleEdges) ink(255).form(f); if (hiPos.length > 0 && FormRef) { const hi = new FormRef( { type: "triangle", positions: hiPos, colors: hiCol }, diff --git a/system/public/aesthetic.computer/lib/arena-world.mjs b/system/public/aesthetic.computer/lib/arena-world.mjs new file mode 100644 index 0000000000..e3556410dd --- /dev/null +++ b/system/public/aesthetic.computer/lib/arena-world.mjs @@ -0,0 +1,62 @@ +// arena-world.mjs β€” shared static arena geometry. +// Imported by both disks/arena.mjs (client render + prediction) and +// session-server/arena-manager.mjs (authoritative collision). Keep this +// dep-free so it works in Node and the browser. +// +// Coordinates are world-space. Y is up, ground sits at -1.5. Obstacles are +// flat-top extrusions: pillars are cylinders, walls are axis-aligned boxes. +// Heights are tuned so a stand-jump can clear the platform edge but not the +// obstacle tops β€” strafe-jumping skill earns elevation. + +const GROUND_Y = -1.5; + +// Horizontal extents of obstacle layout. Players can run between them. +// Pillars are placed in the four "shoulders" of the arena, walls form a +// central S-curve so cover lines aren't symmetric. +export const ARENA_OBSTACLES = Object.freeze([ + // Four corner pillars (cylinders). r=0.8, h=4. + { type: "cylinder", x: 8, z: 8, r: 0.8, yMin: GROUND_Y, yMax: GROUND_Y + 4 }, + { type: "cylinder", x: -8, z: 8, r: 0.8, yMin: GROUND_Y, yMax: GROUND_Y + 4 }, + { type: "cylinder", x: 8, z: -8, r: 0.8, yMin: GROUND_Y, yMax: GROUND_Y + 4 }, + { type: "cylinder", x: -8, z: -8, r: 0.8, yMin: GROUND_Y, yMax: GROUND_Y + 4 }, + + // Center totem: tall thin pillar at origin. + { type: "cylinder", x: 0, z: 0, r: 0.5, yMin: GROUND_Y, yMax: GROUND_Y + 5 }, + + // Two staggered cover walls forming a soft S-curve. Thickness 0.6, height + // 2.4 β€” tall enough to break sightlines, short enough that strafe-jumps + // can peek over the top. + { + type: "box", + xMin: -5.5, xMax: -0.5, + zMin: -3.3, zMax: -2.7, + yMin: GROUND_Y, yMax: GROUND_Y + 2.4, + }, + { + type: "box", + xMin: 0.5, xMax: 5.5, + zMin: 2.7, zMax: 3.3, + yMin: GROUND_Y, yMax: GROUND_Y + 2.4, + }, +]); + +// Per-obstacle visual color palette (paint helpers; not used by physics). +// Indices match ARENA_OBSTACLES order so render code can pick per-shape tints. +export const ARENA_OBSTACLE_COLORS = Object.freeze([ + [0.55, 0.42, 0.30, 1.0], // NE pillar β€” warm sand + [0.42, 0.50, 0.55, 1.0], // NW pillar β€” cool slate + [0.48, 0.40, 0.45, 1.0], // SE pillar β€” dusty plum + [0.40, 0.46, 0.36, 1.0], // SW pillar β€” moss + [0.62, 0.55, 0.32, 1.0], // center totem β€” brassy ochre + [0.38, 0.34, 0.42, 1.0], // wall A β€” deep indigo-stone + [0.44, 0.36, 0.32, 1.0], // wall B β€” terracotta +]); + +// πŸƒ Quake-style movement tuning for the arena (shared client/server). +export const ARENA_PHYSICS = Object.freeze({ + airAccel: 70, // u/sΒ² along wishdir while airborne + groundAccel: 80, // u/sΒ² along wishdir on the ground + airCapSpeed: 1.5, // Q3 air-control window + groundFriction: 6, + playerRadius: 0.4, +}); diff --git a/system/public/aesthetic.computer/lib/cam-doll.mjs b/system/public/aesthetic.computer/lib/cam-doll.mjs index 7594f3210d..9c423b203d 100644 --- a/system/public/aesthetic.computer/lib/cam-doll.mjs +++ b/system/public/aesthetic.computer/lib/cam-doll.mjs @@ -6,6 +6,7 @@ // unwanted resets of state / ✨ allow disabling of state... ✨ - 24.07.03.02.50 import { GAMEPAD_MAPPINGS } from "./gamepad-mappings.mjs"; +import { resolveObstacles } from "./pmove.mjs"; const { abs } = Math; @@ -98,6 +99,19 @@ export class CamDoll { // πŸ’€ Death floor clamp β€” when set, a frozen (dead) player lands on this // world Y instead of falling forever. #deathFloorY = null; + + // πŸƒ Quake-style air physics. Set to a number to enable strafe-jumping / + // bunny-hop: in air the dolly's horizontal decay is replaced by an + // air-accelerate primitive that adds velocity along wishdir without + // touching perpendicular speed. On the ground a friction+accelerate model + // takes over. Must mirror lib/pmove.mjs so the server arrives at the same + // state. + #airAccel = null; + #groundAccel = 80; + #airCapSpeed = 1.5; + #groundFriction = 6; + #obstacles = null; + #playerRadius = 0.4; #deathFloorEyeClearance = 0.3; // πŸ–– Disable built-in touch controls for pieces that implement custom mobile UI @@ -129,6 +143,16 @@ export class CamDoll { this.#moveDamp = this.#runSpeed / this.#simHz / 10; if (opts.groundBounds) this.#groundBounds = opts.groundBounds; if (typeof opts.deathFloorY === "number") this.#deathFloorY = opts.deathFloorY; + // πŸƒ Quake-style physics β€” opt-in. When enabled the dolly stops decaying + // (we own velocity) and friction/air-accel run inline below. + if (typeof opts.airAccel === "number") { + this.#airAccel = opts.airAccel; + if (typeof opts.groundAccel === "number") this.#groundAccel = opts.groundAccel; + if (typeof opts.airCapSpeed === "number") this.#airCapSpeed = opts.airCapSpeed; + if (typeof opts.groundFriction === "number") this.#groundFriction = opts.groundFriction; + } + if (Array.isArray(opts.obstacles)) this.#obstacles = opts.obstacles; + if (typeof opts.playerRadius === "number") this.#playerRadius = opts.playerRadius; // Snap camera onto the floor at boot. this.cam.y = -(this.#groundY + this.#eyeHeight); } @@ -191,8 +215,11 @@ export class CamDoll { applyImpulse({ x = 0, y = 0, z = 0 } = {}) { // cam.x = -worldX, cam.z = -worldZ; dolly applies xVel directly to cam.x, // so to push +worldX we feed -x into the dolly. - if (x) this.#dolly.xVel += -x; - if (z) this.#dolly.zVel += -z; + // In Q3 mode our dolly.xVel is per-frame (= worldVel/simHz), so scale + // accordingly. Legacy mode keeps the old per-tick convention untouched. + const scale = this.#airAccel !== null ? 1 / this.#simHz : 1; + if (x) this.#dolly.xVel += -x * scale; + if (z) this.#dolly.zVel += -z * scale; if (y) { this.#worldYVel += y; if (y > 0) this.#onGround = false; @@ -257,7 +284,9 @@ export class CamDoll { if (this.#A) strafe = push; if (this.#D) strafe = -push; // SPACE and SHIFT no longer push Y β€” handled in the physics pass below. - if (this.#W || this.#S || this.#A || this.#D) { + // In Q3 mode we own horizontal velocity directly (qStep below); skip + // dolly.push so we don't double-apply input. + if ((this.#W || this.#S || this.#A || this.#D) && this.#airAccel === null) { this.#dolly.push({ x: strafe, y: 0, z: forward }); } } else { @@ -280,7 +309,10 @@ export class CamDoll { } } - if (abs(this.#ANALOG.move.z) > 0 || abs(this.#ANALOG.move.x) > 0) { + if ( + (abs(this.#ANALOG.move.z) > 0 || abs(this.#ANALOG.move.x) > 0) && + this.#airAccel === null + ) { // console.log("Over zero:", this.#ANALOG.move.z); this.#dolly.push({ x: this.#ANALOG.move.x, @@ -337,8 +369,103 @@ export class CamDoll { if (this.cam.rotX > maxPitch) this.cam.rotX = maxPitch; if (this.cam.rotX < -maxPitch) this.cam.rotX = -maxPitch; + // πŸƒ Q3 step: when airAccel is enabled we own the dolly's horizontal + // velocity. Read previous-frame world velocity from the dolly, run + // friction (ground) + accelerate (ground/air), and write back. The + // dolly's decay is set to 1 so dolly.sim() acts as pure integration. + if (this.#physicsEnabled && this.#airAccel !== null) { + this.#dolly.dec = 1; + const dt = 1 / this.#simHz; + + // Per-frame dolly velocities are camera-coords (cam.x = -worldX), + // so worldVel = -dollyVel * simHz. Convert in & out. + let wvx = -this.#dolly.xVel * this.#simHz; + let wvz = -this.#dolly.zVel * this.#simHz; + + // Wishvel from W/S/A/D + analog (camera-local β†’ world by yaw). + let ix = 0, iz = 0; + if (this.#D) ix += 1; + if (this.#A) ix -= 1; + if (this.#W) iz += 1; + if (this.#S) iz -= 1; + if (this.#moveDamp > 0) { + const ax = this.#ANALOG.move.x / this.#moveDamp; + const az = this.#ANALOG.move.z / this.#moveDamp; + // Analog convention: dolly.push x = camera-local +x (Aβ†’strafe-left), + // z = -forward (W β†’ -push). Our ix is "right=+1", iz is "fwd=+1", so + // negate to map. + ix += -ax > 1 ? 1 : -ax < -1 ? -1 : -ax; + iz += -az > 1 ? 1 : -az < -1 ? -1 : -az; + } + const ilen = Math.hypot(ix, iz); + if (ilen > 1) { ix /= ilen; iz /= ilen; } + + const speed = this.#SHIFT ? this.#walkSpeed : this.#runSpeed; + const yr = this.cam.rotY * Math.PI / 180; + const sy = Math.sin(yr), cy = Math.cos(yr); + const wx = (ix * cy + iz * sy) * speed; + const wz = (-ix * sy + iz * cy) * speed; + const wishLen = Math.hypot(wx, wz); + const wdx = wishLen > 1e-6 ? wx / wishLen : 0; + const wdz = wishLen > 1e-6 ? wz / wishLen : 0; + + // Edge-trigger jump BEFORE friction so a bhop landing keeps speed. + if (!this.#frozen && this.#SPACE && this.#onGround) { + this.#worldYVel = this.#jumpVelocity; + this.#onGround = false; + } + + // Friction (ground only). + if (this.#onGround && !this.#frozen) { + const sp = Math.hypot(wvx, wvz); + if (sp < 1e-4) { wvx = 0; wvz = 0; } + else { + const stop = Math.max(sp, this.#runSpeed * 0.5); + const drop = stop * this.#groundFriction * dt; + const k = Math.max(0, sp - drop) / sp; + wvx *= k; wvz *= k; + } + } + + // Accelerate. + if (wishLen > 1e-6 && !this.#frozen) { + const accel = this.#onGround ? this.#groundAccel : this.#airAccel; + const cap = this.#onGround ? wishLen : Math.min(wishLen, this.#airCapSpeed); + const cur = wvx * wdx + wvz * wdz; + const addspeed = cap - cur; + if (addspeed > 0) { + let mag = accel * dt * cap; + if (mag > addspeed) mag = addspeed; + wvx += wdx * mag; wvz += wdz * mag; + } + } + + // Write back as per-frame cam-coords for dolly.sim() to integrate. + this.#dolly.xVel = -wvx / this.#simHz; + this.#dolly.zVel = -wvz / this.#simHz; + } + this.#dolly.sim(); + // Static obstacle push-out (after horizontal integration). + if (this.#airAccel !== null && this.#obstacles) { + const ws = { + x: -this.cam.x, z: -this.cam.z, + y: -this.cam.y, + vx: -this.#dolly.xVel * this.#simHz, + vz: -this.#dolly.zVel * this.#simHz, + }; + resolveObstacles(ws, { + obstacles: this.#obstacles, + playerRadius: this.#playerRadius, + eyeHeight: this.#eyeHeight, + }); + this.cam.x = -ws.x; + this.cam.z = -ws.z; + this.#dolly.xVel = -ws.vx / this.#simHz; + this.#dolly.zVel = -ws.vz / this.#simHz; + } + // 🌍 Grounded physics pass β€” only when opted-in via opts.gravity. if (this.#physicsEnabled) { const dt = 1 / this.#simHz; diff --git a/system/public/aesthetic.computer/lib/pmove.mjs b/system/public/aesthetic.computer/lib/pmove.mjs index 3e0e5a1726..3b4746e003 100644 --- a/system/public/aesthetic.computer/lib/pmove.mjs +++ b/system/public/aesthetic.computer/lib/pmove.mjs @@ -30,6 +30,24 @@ export const DEFAULT_CFG = Object.freeze({ // here so the server doesn't need the Dolly object: hVelDecay per tick // and a push matching the same steady state. hVelDecay: 0.9, + // πŸƒ Quake-style physics (opt-in). When `airAccel` is set, the legacy + // hVelDecay lerp is replaced by Q3 friction+accelerate on the ground and + // air-accelerate (with a perpendicular wishspeed cap) in the air. This + // is what makes strafe-jumping / bunny-hopping work: in air, velocity + // perpendicular to the existing motion vector is added without subtracting + // current speed, so turning slightly off-axis lets the player accumulate + // velocity above runSpeed. + airAccel: null, // u/sΒ² along wishdir while airborne + groundAccel: null, // u/sΒ² along wishdir while on ground + airCapSpeed: 1.5, // max wishspeed used during air-accel (Q3 air-control window) + groundFriction: 6, // Q3-style friction coefficient + // Static obstacles: array of { type:"box", xMin, xMax, yMin, yMax, zMin, zMax } + // or { type:"cylinder", x, z, r, yMin, yMax }. Resolved as horizontal + // push-out after integration so prediction (client) and authority (server) + // converge. yMin/yMax bound the vertical span β€” only collide when the + // player's feetβ†’eye column overlaps the obstacle's Y range. + obstacles: null, + playerRadius: 0.4, // horizontal radius for obstacle push-out }); // Buttons bitmask (matches usercmd wire format). @@ -101,16 +119,50 @@ export function pmove(state, cmd, cfg = DEFAULT_CFG) { const wx = (ix * cy + iz * sy) * speed; const wz = (-ix * sy + iz * cy) * speed; - // cam-doll uses Dolly's decay + push which converges to `speed` at full - // stick. We approximate the same feel with a per-tick lerp toward the - // input velocity. - const decay = Math.pow(cfg.hVelDecay, dt * cfg.simHz); - s.vx = s.vx * decay + wx * (1 - decay); - s.vz = s.vz * decay + wz * (1 - decay); + // --- Jump: edge-trigger BEFORE friction so a bunny-hop landing keeps + // its horizontal speed (Q3 PMF_TIME_LAND trick). The flag is checked + // off-frame so holding jump auto-rebounds the next time we touch ground. + if (!s.frozen && jumping && s.onGround) { + s.vy = cfg.jumpVelocity; + s.onGround = false; + } + + if (typeof cfg.airAccel === "number") { + // πŸƒ Quake-style: ground friction + ground/air accelerate. + const wishLen = Math.hypot(wx, wz); + const wishSpeed = wishLen; // already speed-scaled above + const wdx = wishLen > 1e-6 ? wx / wishLen : 0; + const wdz = wishLen > 1e-6 ? wz / wishLen : 0; + + // Friction is applied only on the ground. + if (s.onGround && !s.frozen) qFriction(s, cfg, dt); + + // Accelerate along wishdir. + if (wishLen > 1e-6 && !s.frozen) { + if (s.onGround) { + const accel = cfg.groundAccel ?? 80; + qAccel(s, wdx, wdz, wishSpeed, accel, dt); + } else { + // Air: cap wishspeed but keep accel β€” perpendicular input adds + // velocity without subtracting from forward speed (strafe-jump). + const cap = cfg.airCapSpeed ?? 1.5; + const airWish = Math.min(wishSpeed, cap); + qAccel(s, wdx, wdz, airWish, cfg.airAccel, dt); + } + } + } else { + // Legacy: lerp toward target velocity. Hard-caps at `speed`. + const decay = Math.pow(cfg.hVelDecay, dt * cfg.simHz); + s.vx = s.vx * decay + wx * (1 - decay); + s.vz = s.vz * decay + wz * (1 - decay); + } s.x += s.vx * dt; s.z += s.vz * dt; + // Static obstacle resolution (horizontal push-out + velocity clip). + if (cfg.obstacles) resolveObstacles(s, cfg); + // --- Crouch lerp. --- const crouchTarget = (!s.frozen && crouching) ? 1 : 0; s.crouchT += (crouchTarget - s.crouchT) * cfg.crouchLerp; @@ -118,12 +170,6 @@ export function pmove(state, cmd, cfg = DEFAULT_CFG) { if (s.crouchT > 0.9995 && crouchTarget === 1) s.crouchT = 1; const effEye = cfg.eyeHeight + (cfg.crouchEyeHeight - cfg.eyeHeight) * s.crouchT; - // --- Jump: initiate on edge, only when grounded and not frozen. --- - if (!s.frozen && jumping && s.onGround) { - s.vy = cfg.jumpVelocity; - s.onGround = false; - } - // --- Vertical integration: always run gravity; frozen players still fall. --- if (!s.onGround || s.frozen) { s.vy -= cfg.gravity * dt; @@ -192,3 +238,79 @@ export function unpackCmd(w) { function clamp(v, lo, hi) { return v < lo ? lo : v > hi ? hi : v; } function round2(n) { return Math.round(n * 100) / 100; } + +// --- Quake physics helpers (shared with cam-doll for client prediction). --- + +function qFriction(s, cfg, dt) { + const speed = Math.hypot(s.vx, s.vz); + if (speed < 1e-4) { s.vx = 0; s.vz = 0; return; } + const stop = Math.max(speed, cfg.runSpeed * 0.5); // Q3-style pm_stopspeed + const drop = stop * cfg.groundFriction * dt; + const k = Math.max(0, speed - drop) / speed; + s.vx *= k; + s.vz *= k; +} + +function qAccel(s, wdx, wdz, wishSpeed, accel, dt) { + const cur = s.vx * wdx + s.vz * wdz; + const addspeed = wishSpeed - cur; + if (addspeed <= 0) return; + let mag = accel * dt * wishSpeed; + if (mag > addspeed) mag = addspeed; + s.vx += wdx * mag; + s.vz += wdz * mag; +} + +/** + * Resolve horizontal collisions against a flat list of static obstacles. + * Mutates `s.x`, `s.z` and the velocity components along contact normals. + * Box and cylinder obstacles are checked against the player's vertical + * column [feetY, eyeY]: if their Y span doesn't overlap, they're skipped + * (so the player can leap above a low wall, fall through a missing roof, + * etc.). Pure horizontal β€” no top/bottom collision. + */ +export function resolveObstacles(s, cfg) { + if (!cfg?.obstacles?.length) return; + const r = cfg.playerRadius ?? 0.4; + const eyeY = s.y; + const feetY = s.y - cfg.eyeHeight; + for (const o of cfg.obstacles) { + if (typeof o.yMax === "number" && feetY > o.yMax) continue; + if (typeof o.yMin === "number" && eyeY < o.yMin) continue; + if (o.type === "cylinder") { + const dx = s.x - o.x; + const dz = s.z - o.z; + const d = Math.hypot(dx, dz); + const minD = (o.r ?? 1) + r; + if (d >= minD) continue; + if (d < 1e-4) { + // Degenerate centre-overlap: punt along +X. + s.x += minD; continue; + } + const nx = dx / d, nz = dz / d; + const push = minD - d; + s.x += nx * push; + s.z += nz * push; + const vd = s.vx * nx + s.vz * nz; + if (vd < 0) { s.vx -= vd * nx; s.vz -= vd * nz; } + } else if (o.type === "box") { + const cx = (o.xMin + o.xMax) * 0.5; + const cz = (o.zMin + o.zMax) * 0.5; + const hx = (o.xMax - o.xMin) * 0.5 + r; + const hz = (o.zMax - o.zMin) * 0.5 + r; + const dx = s.x - cx; + const dz = s.z - cz; + const ox = hx - Math.abs(dx); + const oz = hz - Math.abs(dz); + if (ox <= 0 || oz <= 0) continue; + // Push along the axis of smallest penetration. + if (ox < oz) { + s.x += dx >= 0 ? ox : -ox; + if ((dx >= 0 && s.vx < 0) || (dx < 0 && s.vx > 0)) s.vx = 0; + } else { + s.z += dz >= 0 ? oz : -oz; + if ((dz >= 0 && s.vz < 0) || (dz < 0 && s.vz > 0)) s.vz = 0; + } + } + } +} -- 2.51.2