diff --git a/system/public/aesthetic.computer/disks/arena.mjs b/system/public/aesthetic.computer/disks/arena.mjs index be97bb2b44..8b4b0e812f 100644 --- a/system/public/aesthetic.computer/disks/arena.mjs +++ b/system/public/aesthetic.computer/disks/arena.mjs @@ -8,11 +8,54 @@ - [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) #endregion */ let groundPlane; +let shadowGround; // ring + cross β€” standing on the ground +let shadowAir; // diagonal X β€” airborne +let shadowCrouch; // dense inner dot + outer ring β€” crouched +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 FormRef; // captured at boot so sim/paint can build transient forms let penLocked = false; +// Walk-cycle phase (advanced in sim while moving) for gentle arm/foot bob. +let walkPhase = 0; + +// πŸ’€ Death / respawn state +let playerAlive = true; +let deathTickAge = 0; // how long we've been dead (sim ticks, for UI fade-in) + +// 🟨 Per-tile highlight state. +// hoverTile: { row, col } of tile under the crosshair, or null. +// walkedTiles: Map. A tile just stepped onto starts at +// WALK_AGE_TICKS and decays each sim tick; drawn with alpha = age/TICKS. +const WALK_AGE_TICKS = 90; // β‰ˆ 0.75 s at 120 Hz +let hoverTile = null; +let prevPlayerTile = null; +const walkedTiles = new Map(); + +function tileKey(row, col) { return row * GRID + col; } +function tileFromKey(k) { return { row: Math.floor(k / GRID), col: k % GRID }; } + +// World XZ β†’ tile (row, col) or null if outside the GRID bounds. +function tileAt(worldX, worldZ) { + const step = (GROUND_SIZE * 2) / GRID; + const col = Math.floor((worldX + GROUND_SIZE) / step); + const row = Math.floor((worldZ + GROUND_SIZE) / step); + if (col < 0 || col >= GRID || row < 0 || row >= GRID) return null; + return { row, col }; +} + +// AC sim runs at a fixed 120 Hz (see lib/loop.mjs updateFps). All physics and +// state updates happen in sim(), so the game runs at constant speed regardless +// of paint FPS β€” the CPU rasterizer dropping to 30 fps while looking at the +// ground will slow the *visual* update but not the simulation. +const SIM_HZ = 120; + // Speed tracking let prevX = 0, prevY = 0, prevZ = 0; let speedSmoothed = 0; @@ -22,31 +65,145 @@ const SPEED_SMOOTH = 0.15; let frameTimes = []; let lastFrameTime = 0; -// Ground config +// Ground config. GRID is odd so there's a centre tile under the player at the +// origin (even grids put the player on a 4-way corner junction, which makes +// tile highlights look offset by half a tile). const GROUND_SIZE = 14; -const GRID = 14; +const GRID = 15; const GROUND_Y = -1.5; const FOG_START_SQ = 7 * 7; const FOG_END_SQ = 14 * 14; +// πŸƒ Quake-style movement tuning (AC-scaled β‰ˆ Quake Γ· 32). +// Canonical Quake: FOV 90Β°, run 320 u/s, jump 270 u/s, gravity 800 u/sΒ². +// Here AC units ~ 32 Quake units, so numbers shrink proportionally. +const FOV = 90; +const RUN_SPEED = 10; // u/s (β‰ˆ 320 Quake u/s) +const WALK_SPEED = 5; // u/s crouched +const JUMP_VELOCITY = 8; // u/s initial β†’ peak jump = jvΒ²/(2g) AC units +const GRAVITY = 50; // u/sΒ² (heavier / less lofty than Quake's 800-scaled β‰ˆ25) +const EYE_HEIGHT = 2.0; // AC units above ground (matches prior default cam.y=-0.5) +const CROUCH_EYE = 1.2; + +// πŸ’€ Death pit β€” declared before fpsOpts so it can be passed as deathFloorY. +const DEATH_FLOOR_Y = -30; +const DEATH_PAD = 60; // half-size of the lava floor (generous) +const DEATH_TILES = 14; // (unused since donut build; kept for reference) +const DEATH_STRIP_FREQ = 0.22; +const DEATH_FLOW_SPEED = 1.8; + +export const fpsOpts = { + fov: FOV, + y: 0, + z: 0, + sensitivity: 0.002, + runSpeed: RUN_SPEED, + walkSpeed: WALK_SPEED, + jumpVelocity: JUMP_VELOCITY, + gravity: GRAVITY, + groundY: GROUND_Y, + eyeHeight: EYE_HEIGHT, + crouchEyeHeight: CROUCH_EYE, + // Outside this XZ rectangle the floor clamp is disabled so the player + // falls into the pit below. Matches the ground plane's half-size. + groundBounds: { + xMin: -GROUND_SIZE, + xMax: GROUND_SIZE, + zMin: -GROUND_SIZE, + zMax: GROUND_SIZE, + }, + // Dead players stop on the lava instead of falling forever. + deathFloorY: DEATH_FLOOR_Y, +}; + const BG = [45 / 255, 48 / 255, 55 / 255]; const COLOR_A = [0.38, 0.35, 0.30, 1.0]; const COLOR_B = [0.22, 0.20, 0.19, 1.0]; +// Build a fresh lava floor Form with time-animated stripe colors. The lava +// is a DONUT β€” no geometry sits under the main ground plane, so tile-edge +// gaps in the ground rasterization can't expose lava. Only rendered outside +// the arena bounds where it's actually visible from the pit. +function buildLavaFloor(t) { + if (!FormRef) return null; + const positions = []; + const colors = []; + const deathY = DEATH_FLOOR_Y; + + const lavaTile = (x0, z0, x1, z1) => { + const cx = (x0 + x1) / 2; + const cz = (z0 + z1) / 2; + const w1 = Math.sin(cx * DEATH_STRIP_FREQ - t * DEATH_FLOW_SPEED + cz * 0.08); + const w2 = Math.sin(cx * 0.09 + cz * 0.27 - t * 0.9); + const glow = (w1 + w2 * 0.6) * 0.5; + const hot = 0.35 + Math.max(0, glow) * 0.65; + const r = 0.45 + hot * 0.55; + const g = hot * hot * 0.45; + const b = hot * hot * hot * 0.08; + const c = [r, g, b, 1.0]; + positions.push( + [x0, deathY, z0, 1], [x0, deathY, z1, 1], [x1, deathY, z1, 1], + [x0, deathY, z0, 1], [x1, deathY, z1, 1], [x1, deathY, z0, 1], + ); + for (let i = 0; i < 6; i++) colors.push(c); + }; + + // 4 strips forming a frame around the main arena. Each strip gets its own + // local tessellation density so the stripes still look organic. + const STEP = 4; // lava tile size (AC units) + const gs = GROUND_SIZE; + // North strip: z in [gs, DEATH_PAD], x in [-DEATH_PAD, DEATH_PAD] + for (let z = gs; z < DEATH_PAD; z += STEP) { + for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { + lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, DEATH_PAD)); + } + } + // South strip + for (let z = -DEATH_PAD; z < -gs; z += STEP) { + for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { + lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, -gs)); + } + } + // West strip (within arena's Z range) + for (let x = -DEATH_PAD; x < -gs; x += STEP) { + for (let z = -gs; z < gs; z += STEP) { + lavaTile(x, z, Math.min(x + STEP, -gs), Math.min(z + STEP, gs)); + } + } + // East strip + for (let x = gs; x < DEATH_PAD; x += STEP) { + for (let z = -gs; z < gs; z += STEP) { + lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, gs)); + } + } + + const f = new FormRef( + { type: "triangle", positions, colors }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + f.noFade = true; + return f; +} + +// Ground fog: RGB lerps toward BG with distance so the arena "dissolves" into +// the sky, but alpha stays a full 1.0. Dropping alpha lets the red death floor +// show through the ground (no depth buffer β†’ painter's order only), and the +// RGB lerp already gives the visual fade. function fogColor(base, distSq) { if (distSq <= FOG_START_SQ) return base; - if (distSq >= FOG_END_SQ) return [BG[0], BG[1], BG[2], 0.0]; + if (distSq >= FOG_END_SQ) return [BG[0], BG[1], BG[2], 1.0]; const t = (distSq - FOG_START_SQ) / (FOG_END_SQ - FOG_START_SQ); return [ base[0] + (BG[0] - base[0]) * t, base[1] + (BG[1] - base[1]) * t, base[2] + (BG[2] - base[2]) * t, - base[3] * (1 - t), + 1.0, ]; } function boot({ Form, penLock, system }) { penLock(); + FormRef = Form; const cam = system?.fps?.doll?.cam; if (cam) { prevX = cam.x; prevY = cam.y; prevZ = cam.z; } @@ -84,21 +241,298 @@ function boot({ Form, penLock, system }) { { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, ); groundPlane.noFade = true; + + // 🧱 Platform edge β€” a glowing rim so you can see where the floor ends. + // Four line segments slightly above the ground, plus short drops at each + // corner so the edge reads in depth even when approaching at a shallow + // angle. + const edgeY = GROUND_Y + 0.04; + const edgeDrop = GROUND_Y - 0.2; + const gs = GROUND_SIZE; + const rim = [1, 0.9, 0.45, 0.95]; + const drop = [1, 0.35, 0.15, 0.85]; + platformEdge = new Form( + { + type: "line", + positions: [ + // Perimeter + [-gs, edgeY, -gs, 1], [ gs, edgeY, -gs, 1], + [ gs, edgeY, -gs, 1], [ gs, edgeY, gs, 1], + [ gs, edgeY, gs, 1], [-gs, edgeY, gs, 1], + [-gs, edgeY, gs, 1], [-gs, edgeY, -gs, 1], + // Corner drops (short lines falling into the pit) + [-gs, edgeY, -gs, 1], [-gs, edgeDrop, -gs, 1], + [ gs, edgeY, -gs, 1], [ gs, edgeDrop, -gs, 1], + [ gs, edgeY, gs, 1], [ gs, edgeDrop, gs, 1], + [-gs, edgeY, gs, 1], [-gs, edgeDrop, gs, 1], + ], + colors: [ + rim, rim, rim, rim, rim, rim, rim, rim, + rim, drop, rim, drop, rim, drop, rim, drop, + ], + }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + platformEdge.noFade = true; + + // πŸ«₯ 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; + const ringSegs = 16; + + const mkRing = (r, segs, color) => { + const pos = [], col = []; + for (let i = 0; i < segs; i++) { + const a0 = (i / segs) * Math.PI * 2; + const a1 = ((i + 1) / segs) * Math.PI * 2; + pos.push( + [Math.cos(a0) * r, 0, Math.sin(a0) * r, 1], + [Math.cos(a1) * r, 0, Math.sin(a1) * r, 1], + ); + col.push(color, color); + } + return { pos, col }; + }; + + // Ground: ring + small cross inside. + const g = mkRing(ringR, ringSegs, [1, 1, 1, 0.75]); + const crossArm = ringR * 0.6; + g.pos.push( + [-crossArm, 0, 0, 1], [crossArm, 0, 0, 1], + [0, 0, -crossArm, 1], [0, 0, crossArm, 1], + ); + const gx = [1, 1, 1, 0.6]; + g.col.push(gx, gx, gx, gx); + shadowGround = new FormRef( + { type: "line", positions: g.pos, colors: g.col }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + shadowGround.noFade = true; + + // Air: diagonals only (bright X at feet so you can track your fall point). + const xArm = ringR * 0.9; + const airColor = [1, 0.9, 0.4, 0.85]; + shadowAir = new FormRef( + { + type: "line", + positions: [ + [-xArm, 0, -xArm, 1], [xArm, 0, xArm, 1], + [-xArm, 0, xArm, 1], [xArm, 0, -xArm, 1], + ], + colors: [airColor, airColor, airColor, airColor], + }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + shadowAir.noFade = true; + + // Crouch: smaller dense ring with a centre dot β€” reads as "compressed". + const c = mkRing(ringR * 0.75, ringSegs, [1, 0.7, 0.3, 0.85]); + const dotR = 0.06; + const dot = [1, 0.85, 0.5, 0.95]; + c.pos.push( + [-dotR, 0, 0, 1], [dotR, 0, 0, 1], + [0, 0, -dotR, 1], [0, 0, dotR, 1], + ); + c.col.push(dot, dot, dot, dot); + shadowCrouch = new FormRef( + { type: "line", positions: c.pos, colors: c.col }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + shadowCrouch.noFade = true; + + // Plumb line: unit-length along +Y at local origin. Scale.y each frame to + // the current eye-above-ground distance. Fade alpha bottomβ†’top so the line + // doesn't poke into the pupil as a stark white segment. + plumbLine = new Form( + { + type: "line", + positions: [[0, 0, 0, 1], [0, 1, 0, 1]], + colors: [[1, 1, 1, 0.45], [1, 1, 1, 0.0]], + }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: [1, 1, 1] }, + ); + plumbLine.noFade = true; + + // (Death floor is rebuilt each paint β€” see buildLavaFloor.) + + // 🦢 Feet β€” two stubby boxes (top + side edges as lines) anchored under the + // player. Yaw rotates the whole form each frame so they face where you look. + const footBox = (dx, ySpan, color) => { + // Rectangle outline on top of the foot (local y = ySpan.max). + const xL = dx - 0.08, xR = dx + 0.08; + const zT = 0.35, zB = 0.05; // toe + heel in local forward (+Z) + const yT = ySpan; // boot height + const pts = [ + // top rectangle + [xL, yT, zB, 1], [xR, yT, zB, 1], + [xR, yT, zB, 1], [xR, yT, zT, 1], + [xR, yT, zT, 1], [xL, yT, zT, 1], + [xL, yT, zT, 1], [xL, yT, zB, 1], + // four corners dropping to ground + [xL, 0, zB, 1], [xL, yT, zB, 1], + [xR, 0, zB, 1], [xR, yT, zB, 1], + [xR, 0, zT, 1], [xR, yT, zT, 1], + [xL, 0, zT, 1], [xL, yT, zT, 1], + ]; + const cols = pts.map(() => color); + return { pts, cols }; + }; + const footColor = [0.9, 0.9, 1.0, 0.85]; + const leftFoot = footBox(-0.18, 0.15, footColor); + const rightFoot = footBox(0.18, 0.15, footColor); + bodyFeet = new Form( + { + type: "line", + positions: [...leftFoot.pts, ...rightFoot.pts], + colors: [...leftFoot.cols, ...rightFoot.cols], + }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + bodyFeet.noFade = true; + + // 🀲 Arms β€” two angled line segments from shoulder to wrist, extending + // forward and down from the eye. Color fades darker at the wrists so they + // don't scream at the reader. + const armLocal = (side) => { + const sx = 0.28 * side; + const shoulder = [sx, -0.35, 0.05, 1]; + const elbow = [sx * 0.9, -0.55, 0.45, 1]; + const wrist = [sx * 0.75, -0.65, 0.75, 1]; + const shoulderCol = [1, 1, 1, 0.7]; + const wristCol = [1, 0.9, 0.7, 0.9]; + return { + pts: [shoulder, elbow, elbow, wrist], + cols: [shoulderCol, wristCol, wristCol, wristCol], + }; + }; + const leftArm = armLocal(-1); + const rightArm = armLocal(+1); + bodyArms = new Form( + { + type: "line", + positions: [...leftArm.pts, ...rightArm.pts], + colors: [...leftArm.cols, ...rightArm.cols], + }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + bodyArms.noFade = true; } function sim({ system }) { - const cam = system?.fps?.doll?.cam; + const doll = system?.fps?.doll; + const cam = doll?.cam; if (!cam) return; - const dx = cam.x - prevX; - const dy = cam.y - prevY; - const dz = cam.z - prevZ; - const speed = Math.sqrt(dx * dx + dy * dy + dz * dz); + // Always use the *logical* player position (not the render camera) for + // gameplay state. In 3P mode cam.x/z is offset behind the player. + const phys = doll?.physics; + const playerCamX = phys?.playerCamX ?? cam.x; + const playerCamY = phys?.playerCamY ?? cam.y; + const playerCamZ = phys?.playerCamZ ?? cam.z; + + // Horizontal speed only (ignore vertical so jump bursts don't spike the bar). + const dx = playerCamX - prevX; + const dz = playerCamZ - prevZ; + const speed = Math.sqrt(dx * dx + dz * dz); speedSmoothed += (speed - speedSmoothed) * SPEED_SMOOTH; - prevX = cam.x; prevY = cam.y; prevZ = cam.z; + prevX = playerCamX; prevY = playerCamY; prevZ = playerCamZ; + + // Walk cycle phase advances with horizontal speed; fallback to slow drift. + walkPhase += Math.max(0.002, speedSmoothed) * 8; + + // Player world position β€” cam stores negated world coords (see Camera + // #transform). cam.y is the one exception and uses opposite inversion. + const pWorldX = -playerCamX; + const pWorldZ = -playerCamZ; + const pWorldY = -playerCamY; + + // --- πŸ’€ Death detection: hitting the red floor kills the player. --- + if (playerAlive && pWorldY <= DEATH_FLOOR_Y + EYE_HEIGHT + 0.05) { + playerAlive = false; + deathTickAge = 0; + doll.setFrozen?.(true); + doll.clearHeldKeys?.(); + } + if (!playerAlive) deathTickAge += 1; + + // --- Walked-tile trail: when the player's current tile changes, stamp it. --- + const curTile = tileAt(pWorldX, pWorldZ); + if (curTile) { + const key = tileKey(curTile.row, curTile.col); + if (!prevPlayerTile || prevPlayerTile !== key) { + walkedTiles.set(key, WALK_AGE_TICKS); + prevPlayerTile = key; + } else { + // Also refresh the age while standing still so the glow lingers under you. + walkedTiles.set(key, WALK_AGE_TICKS); + } + } + // Age + prune. + for (const [k, age] of walkedTiles) { + const next = age - 1; + if (next <= 0) walkedTiles.delete(k); + else walkedTiles.set(k, next); + } + + // --- Hover tile: raycast camera-forward onto the ground plane. --- + // Forward in world (rotY=0 β†’ +Z). rotX positive = look up, so forward.y + // = sin(rotX). If forward.y >= 0 we aren't looking at the floor. + const rx = cam.rotX * Math.PI / 180; + const ry = cam.rotY * Math.PI / 180; + const cosPitch = Math.cos(rx); + const fx = Math.sin(ry) * cosPitch; + const fy = Math.sin(rx); + const fz = Math.cos(ry) * cosPitch; + hoverTile = null; + if (fy < -0.05) { + const camWorldY = -cam.y; + const t = (GROUND_Y - camWorldY) / fy; + if (t > 0 && t < 200) { + const hitX = pWorldX + t * fx; + const hitZ = pWorldZ + t * fz; + hoverTile = tileAt(hitX, hitZ); + } + } + + // --- Anchor shadow / plumb / body at the logical player position (see top + // of sim). Reusing playerCam* captured above. --- + const playerWorldY = pWorldY; + const feetY = GROUND_Y + 0.01; + + const shadows = [shadowGround, shadowAir, shadowCrouch]; + for (const s of shadows) { + if (!s) continue; + s.position[0] = playerCamX; + s.position[1] = feetY; + s.position[2] = playerCamZ; + } + if (plumbLine) { + plumbLine.position[0] = playerCamX; + plumbLine.position[1] = feetY; + plumbLine.position[2] = playerCamZ; + plumbLine.scale[1] = Math.max(0, playerWorldY - GROUND_Y - 0.02); + } + + if (bodyFeet) { + const footBaseY = playerAlive + ? GROUND_Y + : playerWorldY - EYE_HEIGHT; + bodyFeet.position[0] = playerCamX; + bodyFeet.position[1] = footBaseY; + bodyFeet.position[2] = playerCamZ; + bodyFeet.rotation[1] = cam.rotY; + } + if (bodyArms) { + const crouchDrop = (phys?.crouch ?? 0) * 0.2; + const bob = Math.sin(walkPhase) * 0.03 * Math.min(1, speedSmoothed * 40); + bodyArms.position[0] = playerCamX; + bodyArms.position[1] = playerWorldY - crouchDrop + bob; + bodyArms.position[2] = playerCamZ; + bodyArms.rotation[1] = cam.rotY; + } } -function paint({ wipe, ink, screen, write, box }) { +function paint({ wipe, ink, screen, write, box, system }) { // FPS calc const now = performance.now(); const dt = now - lastFrameTime; @@ -108,26 +542,113 @@ function paint({ wipe, ink, screen, write, box }) { const avgDt = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length; const fps = Math.round(1000 / avgDt); - // Render scene - wipe(45, 48, 55).form(groundPlane); + // --- Tile highlights: build a single transient Form containing one quad + // per visible highlight (hover + walked trail). Drawn *after* the ground, + // just above floor-Y, with additive-ish semi-transparent tint. + const step = (GROUND_SIZE * 2) / GRID; + const hiPos = []; + const hiCol = []; + const pushQuad = (row, col, color) => { + const x0 = -GROUND_SIZE + col * step; + const z0 = -GROUND_SIZE + row * step; + const x1 = x0 + step; + const z1 = z0 + step; + const y = GROUND_Y + 0.015; + hiPos.push( + [x0, y, z0, 1], [x0, y, z1, 1], [x1, y, z1, 1], + [x0, y, z0, 1], [x1, y, z1, 1], [x1, y, z0, 1], + ); + for (let i = 0; i < 6; i++) hiCol.push(color); + }; + // Walked trail (bright yellow, fades with age β€” kept highly visible). + // Skip the tile under the player's feet so the active standing tile reads as + // "present" rather than already part of the trail. + for (const [k, age] of walkedTiles) { + if (k === prevPlayerTile) continue; + const { row, col } = tileFromKey(k); + const alpha = (age / WALK_AGE_TICKS) * 0.85; + pushQuad(row, col, [1.0, 0.95, 0.3, alpha]); + } + // Hover (cyan, steady, steady alpha). + if (hoverTile) { + pushQuad(hoverTile.row, hoverTile.col, [0.4, 0.95, 1.0, 0.55]); + } + + // Render scene β€” lava donut first (never under the main ground), then the + // ground, its glowing edge, tile highlights, then the feet shadow + body. + wipe(45, 48, 55); + const lava = buildLavaFloor(now / 1000); + if (lava) ink(255).form(lava); + ink(255).form(groundPlane); + if (platformEdge) ink(255).form(platformEdge); + if (hiPos.length > 0 && FormRef) { + const hi = new FormRef( + { type: "triangle", positions: hiPos, colors: hiCol }, + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, + ); + hi.noFade = true; + ink(255, 255, 255).form(hi); + } + + // Pick the correct shadow symbol for the current physics state. + const phys = system?.fps?.doll?.physics; + let activeShadow = shadowGround; + if (phys) { + if (!phys.onGround) activeShadow = shadowAir; + else if (phys.crouch > 0.5) activeShadow = shadowCrouch; + } + // Only draw ground-anchored shadow/plumb while on solid ground. + const onSolidGround = phys?.onGround; + if (activeShadow && onSolidGround) ink(255, 255, 255).form(activeShadow); + if (plumbLine && onSolidGround && plumbLine.scale[1] > 0.05) { + ink(255, 255, 255).form(plumbLine); + } + // Feet + arms render regardless of ground state (they fall with you). + if (bodyFeet) ink(255).form(bodyFeet); + if (bodyArms) ink(255).form(bodyArms); // --- HUD (top-right) --- const font = "MatrixChunky8"; const margin = 4; const lineH = 10; const rX = screen.width - margin; // right edge + const rightLabel = (txt, y) => { + // MatrixChunky8 glyphs are ~4px wide; right-align by char count. + write(txt, { x: rX - txt.length * 4, y }, undefined, undefined, false, font); + }; // FPS ink(fps >= 30 ? "lime" : fps >= 15 ? "yellow" : "red"); - write(`${fps} FPS`, { x: rX - 7 * 4, y: margin }, undefined, undefined, false, font); + rightLabel(`${fps} FPS`, margin); // Frame time ink(180, 180, 180); - write(`${avgDt.toFixed(1)}ms`, { x: rX - 7 * 4, y: margin + lineH }, undefined, undefined, false, font); + rightLabel(`${avgDt.toFixed(1)}ms`, margin + lineH); + + // FOV + run speed (Quake-style spec) + ink(150, 200, 255); + rightLabel(`FOV ${FOV}`, margin + lineH * 2); + rightLabel(`RUN ${RUN_SPEED.toFixed(1)}u/s`, margin + lineH * 3); - // Speed meter (bottom-center) - const ups = speedSmoothed * 60; - const barMaxUPS = 15; + // Grounded / airborne indicator (reuses `phys` captured above). + if (phys) { + const airborne = !phys.onGround; + const crouching = phys.crouch > 0.5; + ink(airborne ? "yellow" : crouching ? "orange" : "lime"); + rightLabel( + airborne ? "AIR" : crouching ? "CROUCH" : "GROUND", + margin + lineH * 4, + ); + + // POV indicator β€” 1P or 3P (middle-mouse toggles). + ink(phys.thirdPerson ? "magenta" : "cyan"); + rightLabel(phys.thirdPerson ? "3P" : "1P", margin + lineH * 5); + } + + // Speed meter (bottom-center). speedSmoothed is per-sim-tick position delta; + // sim runs at SIM_HZ, so ups = perTickDelta * SIM_HZ. + const ups = speedSmoothed * SIM_HZ; + const barMaxUPS = RUN_SPEED * 1.2; // headroom for strafe-jump bonuses later const barFill = Math.min(1, ups / barMaxUPS); const barW = Math.min(160, Math.floor(screen.width * 0.4)); @@ -145,12 +666,50 @@ function paint({ wipe, ink, screen, write, box }) { ink("white"); write(`${ups.toFixed(1)} u/s`, { x: barX + barW + 4, y: barY - 1 }, undefined, undefined, false, font); + + // --- πŸ’€ Death screen overlay (fade-in) --- + if (!playerAlive) { + const fade = Math.min(1, deathTickAge / 24); // ~0.2 s ramp + ink(60, 0, 0, Math.floor(fade * 160)); + box(0, 0, screen.width, screen.height); + + const cx = screen.width / 2; + const cy = screen.height / 2; + ink(255, 80, 80, Math.floor(fade * 255)); + const died = "YOU DIED"; + // MatrixChunky8 β‰ˆ 4px/char; centre roughly. + write(died, { x: Math.floor(cx - died.length * 4), y: Math.floor(cy - 12) }, undefined, undefined, false, font); + if (deathTickAge > 30) { + ink(230, 230, 230, 220); + const prompt = "TAP TO RESPAWN"; + write(prompt, { x: Math.floor(cx - prompt.length * 4), y: Math.floor(cy + 6) }, undefined, undefined, false, font); + } + } } -function act({ event: e, penLock }) { +function act({ event: e, penLock, system }) { if (e.is("pen:locked")) penLocked = true; if (e.is("pen:unlocked")) penLocked = false; - if (!penLocked && e.is("touch")) penLock(); + + // πŸŽ₯ Middle-mouse toggles third-person (press once to enter, press again + // to exit). Only trigger on touch so the release doesn't also flip. + if (e.device === "mouse" && e.button === 1 && e.is("touch")) { + system?.fps?.doll?.toggleThirdPerson?.(); + } + + // While dead, any touch respawns; otherwise the first touch re-locks the pen. + if (e.is("touch")) { + if (!playerAlive && deathTickAge > 30) { + playerAlive = true; + deathTickAge = 0; + walkedTiles.clear(); + prevPlayerTile = null; + system?.fps?.doll?.respawn?.(0, 0); + return; + } + // Don't re-lock on middle-click β€” user is using it for 3P toggle. + if (!penLocked && e.button !== 1) penLock(); + } } export const system = "fps"; diff --git a/system/public/aesthetic.computer/lib/cam-doll.mjs b/system/public/aesthetic.computer/lib/cam-doll.mjs index c0d0ca65e9..4ff0efc4bf 100644 --- a/system/public/aesthetic.computer/lib/cam-doll.mjs +++ b/system/public/aesthetic.computer/lib/cam-doll.mjs @@ -56,6 +56,50 @@ export class CamDoll { #penLocked = false; + // πŸ§— Optional Quake-style grounded physics (enabled when opts.gravity is set). + // When enabled, SPACE jumps only while grounded, SHIFT crouches (clamped to + // ground, cannot dip below), WASD moves at runSpeed (walkSpeed while crouched), + // and vertical motion is integrated with gravity + a ground clamp. + // Conventions: speeds in world-units/second, gravity in u/sΒ². cam.y is inverted + // relative to world Y (positive cam.y = below origin), hence world-up = -cam.y. + #physicsEnabled = false; + #runSpeed = 0; + #walkSpeed = 0; + #jumpVelocity = 0; + #gravity = 0; + #groundY = 0; + #eyeHeight = 1.5; + #crouchEyeHeight = 0.8; + #simHz = 120; + #worldYVel = 0; // world-space vertical velocity, + is up + #onGround = true; + #crouchT = 0; // 0 standing β†’ 1 crouched + #moveDamp = 0.002; + // Optional XZ rectangle bounding the solid floor. Outside these bounds the + // floor clamp is disabled so the player falls off the edge. Format: + // { xMin, xMax, zMin, zMax } in world units. + #groundBounds = null; + // When true, all physics input is ignored and the camera just free-falls. + // External callers (e.g. a piece with a death state) set this via setFrozen(). + #frozen = false; + + // πŸŽ₯ Third-person mode. When enabled, the render camera lazily follows a + // target point (player βˆ’ forwardΒ·distance + upΒ·height). The current render + // position is stored in #tpCurrent and lerped toward the target each tick. + // Physics runs on the *logical* player position; the render offset is + // applied at the end of sim and undone at the start of the next. + #thirdPerson = false; + #thirdPersonDistance = 4.5; + #thirdPersonHeight = 1.5; + #thirdPersonFollow = 0.08; // per-tick lerp factor (β‰ˆ100 ms to catch up) + #appliedOffset = [0, 0, 0]; + #tpCurrent = null; // current render offset (cam-space), init lazy + + // πŸ’€ Death floor clamp β€” when set, a frozen (dead) player lands on this + // world Y instead of falling forever. + #deathFloorY = null; + #deathFloorEyeClearance = 0.3; + constructor(Camera, Dolly, opts) { this.cam = new Camera(opts.fov || 80, { z: opts.z || 0, @@ -64,30 +108,147 @@ export class CamDoll { }); this.sensitivity = opts.sensitivity || 0.00025; this.#dolly = new Dolly(this.cam); // moves the camera + + if (opts.gravity !== undefined) { + this.#physicsEnabled = true; + this.#gravity = opts.gravity; + this.#runSpeed = opts.runSpeed ?? 10; + this.#walkSpeed = opts.walkSpeed ?? this.#runSpeed * 0.5; + this.#jumpVelocity = opts.jumpVelocity ?? 8; + this.#groundY = opts.groundY ?? 0; + this.#eyeHeight = opts.eyeHeight ?? 1.5; + this.#crouchEyeHeight = opts.crouchEyeHeight ?? this.#eyeHeight * 0.55; + this.#simHz = opts.simHz ?? 120; + // Dolly steady-state speed = push / (1 - decay) = 10 * push (decay 0.9). + // So analog gamepad moveDamp scales to hit runSpeed at full stick deflection. + this.#moveDamp = this.#runSpeed / this.#simHz / 10; + if (opts.groundBounds) this.#groundBounds = opts.groundBounds; + if (typeof opts.deathFloorY === "number") this.#deathFloorY = opts.deathFloorY; + // Snap camera onto the floor at boot. + this.cam.y = -(this.#groundY + this.#eyeHeight); + } + } + + /** Clear every held input state. Called on window defocus / visibility loss + * so keys don't "stick" if the user alt-tabs mid-movement. */ + clearHeldKeys() { + this.#W = this.#S = this.#A = this.#D = false; + this.#SPACE = this.#SHIFT = false; + this.#UP = this.#DOWN = this.#LEFT = this.#RIGHT = false; + this.#BTN_LOOK_UP = this.#BTN_LOOK_DOWN = false; + this.#BTN_LOOK_LEFT = this.#BTN_LOOK_RIGHT = false; + this.#ANALOG.move.x = this.#ANALOG.move.y = this.#ANALOG.move.z = 0; + this.#ANALOG.look.x = this.#ANALOG.look.y = this.#ANALOG.look.z = 0; + } + + /** Teleport the camera back to standing on the floor at the configured + * ground position, clearing velocity. Used for respawn. */ + respawn(worldX = 0, worldZ = 0) { + this.cam.x = -worldX; + this.cam.z = -worldZ; + this.cam.y = -(this.#groundY + this.#eyeHeight); + this.#worldYVel = 0; + this.#onGround = true; + this.#frozen = false; + // Kill any residual momentum in the dolly so we don't slide on spawn. + this.#dolly.xVel = this.#dolly.yVel = this.#dolly.zVel = 0; + this.clearHeldKeys(); + } + + /** When frozen the physics pass is skipped entirely (e.g. during a death + * animation). Player input is already zeroed via clearHeldKeys. */ + setFrozen(v) { + this.#frozen = !!v; + if (v) this.clearHeldKeys(); + } + + /** Enable / disable third-person view. On entry the render offset is + * re-initialised so the camera snaps into position; on exit it instantly + * returns to first person. */ + setThirdPerson(v, distance, height) { + this.#thirdPerson = !!v; + if (typeof distance === "number") this.#thirdPersonDistance = distance; + if (typeof height === "number") this.#thirdPersonHeight = height; + if (!this.#thirdPerson) this.#tpCurrent = null; + } + + /** Flip between 1P and 3P. Use for single-tap / middle-mouse bindings. */ + toggleThirdPerson() { + this.setThirdPerson(!this.#thirdPerson); + } + + /** True if third-person mode is currently active. */ + get thirdPerson() { return this.#thirdPerson; } + + /** Live telemetry for HUDs / debug panels. */ + get physics() { + if (!this.#physicsEnabled) return null; + return { + fov: this.cam.fov, + runSpeed: this.#runSpeed, + walkSpeed: this.#walkSpeed, + jumpVelocity: this.#jumpVelocity, + gravity: this.#gravity, + onGround: this.#onGround, + crouch: this.#crouchT, + worldYVel: this.#worldYVel, + thirdPerson: this.#thirdPerson, + // Logical player position in the camera's negated-world coordinate + // system (useful for anchoring body parts at the player, not at the + // offset render camera). + playerCamX: this.cam.x - this.#appliedOffset[0], + playerCamY: this.cam.y - this.#appliedOffset[1], + playerCamZ: this.cam.z - this.#appliedOffset[2], + }; } sim() { + // πŸŽ₯ Undo last frame's third-person offset so physics runs on the logical + // player position. We'll reapply a fresh offset at the end of this tick. + if (this.#appliedOffset[0] !== 0 || this.#appliedOffset[1] !== 0 || this.#appliedOffset[2] !== 0) { + this.cam.x -= this.#appliedOffset[0]; + this.cam.y -= this.#appliedOffset[1]; + this.cam.z -= this.#appliedOffset[2]; + this.#appliedOffset[0] = this.#appliedOffset[1] = this.#appliedOffset[2] = 0; + } + // πŸ”« FPS style camera movement. let forward = 0, updown = 0, strafe = 0; - if (this.#W) forward = -this.sensitivity; - if (this.#S) forward = this.sensitivity; - if (this.#A) strafe = this.sensitivity; // A moves left (positive in rotated space) - if (this.#D) strafe = -this.sensitivity; // D moves right (negative in rotated space) - if (this.#SPACE) updown = -this.sensitivity; - if (this.#SHIFT) updown = this.sensitivity; - - if ( - this.#W || - this.#S || - this.#A || - this.#D || - this.#SPACE || - this.#SHIFT - ) { - this.#dolly.push({ x: strafe, y: updown, z: forward }); + if (this.#physicsEnabled) { + // Quake-style: scale push so Dolly's 0.9-decay momentum settles at the + // requested run/walk speed. Steady-state vel/frame = push/(1-decay), + // and we want vel/frame = speed/simHz, so push = speed/(simHz*10). + const speed = this.#SHIFT ? this.#walkSpeed : this.#runSpeed; + const push = speed / this.#simHz / 10; + if (this.#W) forward = -push; + if (this.#S) forward = push; + 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) { + this.#dolly.push({ x: strafe, y: 0, z: forward }); + } + } else { + if (this.#W) forward = -this.sensitivity; + if (this.#S) forward = this.sensitivity; + if (this.#A) strafe = this.sensitivity; // A moves left (positive in rotated space) + if (this.#D) strafe = -this.sensitivity; // D moves right (negative in rotated space) + if (this.#SPACE) updown = -this.sensitivity; + if (this.#SHIFT) updown = this.sensitivity; + + if ( + this.#W || + this.#S || + this.#A || + this.#D || + this.#SPACE || + this.#SHIFT + ) { + this.#dolly.push({ x: strafe, y: updown, z: forward }); + } } if (abs(this.#ANALOG.move.z) > 0 || abs(this.#ANALOG.move.x) > 0) { @@ -148,10 +309,120 @@ export class CamDoll { if (this.cam.rotX < -maxPitch) this.cam.rotX = -maxPitch; this.#dolly.sim(); + + // 🌍 Grounded physics pass β€” only when opted-in via opts.gravity. + if (this.#physicsEnabled) { + const dt = 1 / this.#simHz; + + // Jump: SPACE while grounded. Holding SPACE re-jumps on landing. + if (!this.#frozen && this.#SPACE && this.#onGround) { + this.#worldYVel = this.#jumpVelocity; + this.#onGround = false; + } + + // Gravity + vertical integration. Always runs, even when frozen, so a + // dead player still falls into the pit at a constant rate. + if (!this.#onGround || this.#frozen) { + this.#worldYVel -= this.#gravity * dt; + // cam.y is inverted from world Y (see constructor): world-up is -cam.y, + // so a positive worldYVel (up) decreases cam.y. + this.cam.y -= this.#worldYVel * dt; + } + + // Crouch lerp (shift toggles between stand and crouch eye height). + const crouchTarget = (!this.#frozen && this.#SHIFT) ? 1 : 0; + this.#crouchT += (crouchTarget - this.#crouchT) * 0.25; + if (this.#crouchT < 0.0005 && crouchTarget === 0) this.#crouchT = 0; + if (this.#crouchT > 0.9995 && crouchTarget === 1) this.#crouchT = 1; + const effEyeHeight = + this.#eyeHeight + + (this.#crouchEyeHeight - this.#eyeHeight) * this.#crouchT; + + // Only floor-clamp when the player is within the solid ground bounds. + // Outside the rectangle, gravity continues uninterrupted β†’ pit fall. + let onSolidGround = true; + if (this.#groundBounds) { + const px = -this.cam.x; + const pz = -this.cam.z; + const b = this.#groundBounds; + onSolidGround = + px >= b.xMin && px <= b.xMax && pz >= b.zMin && pz <= b.zMax; + } + + const floorCamY = -(this.#groundY + effEyeHeight); + if (onSolidGround && !this.#frozen) { + if (this.cam.y >= floorCamY) { + this.cam.y = floorCamY; + if (this.#worldYVel < 0) this.#worldYVel = 0; + this.#onGround = true; + } else if (this.#onGround) { + // If we're above the floor but onGround was set (e.g., eye height + // just shrank because of crouch release), keep the eye glued to the + // ground by snapping cam.y β€” no falling from a crouch-release. + this.cam.y = floorCamY; + } + } else { + // Over the edge (or frozen/dead): never grounded, just keep falling. + this.#onGround = false; + } + + // πŸ’€ Death floor clamp β€” once frozen and the eye reaches the lava, + // stop falling so the player lies on top of the pit instead of + // descending forever. + if (this.#frozen && this.#deathFloorY !== null) { + const lavaCamY = -(this.#deathFloorY + this.#deathFloorEyeClearance); + if (this.cam.y >= lavaCamY) { + this.cam.y = lavaCamY; + this.#worldYVel = 0; + } + } + } + + // πŸŽ₯ Third-person lazy-follow. Compute the target offset (behind + above + // the player along the current look direction) and lerp the applied + // offset toward it. The offset is added to cam.x/y/z for rendering only + // and is undone at the start of the next tick. + if (this.#thirdPerson) { + const rx = this.cam.rotX * Math.PI / 180; + const ry = this.cam.rotY * Math.PI / 180; + const cp = Math.cos(rx); + const fx = Math.sin(ry) * cp; + const fy = Math.sin(rx); + const fz = Math.cos(ry) * cp; + const d = this.#thirdPersonDistance; + const h = this.#thirdPersonHeight; + // Target offset in cam-space coords. + const targetX = d * fx; + const targetZ = d * fz; + const targetY = d * fy - h; + + // Lazy lerp: on the first frame after enabling 3P, snap; otherwise ease. + if (!this.#tpCurrent) { + this.#tpCurrent = [targetX, targetY, targetZ]; + } else { + const k = this.#thirdPersonFollow; + this.#tpCurrent[0] += (targetX - this.#tpCurrent[0]) * k; + this.#tpCurrent[1] += (targetY - this.#tpCurrent[1]) * k; + this.#tpCurrent[2] += (targetZ - this.#tpCurrent[2]) * k; + } + this.#appliedOffset[0] = this.#tpCurrent[0]; + this.#appliedOffset[1] = this.#tpCurrent[1]; + this.#appliedOffset[2] = this.#tpCurrent[2]; + this.cam.x += this.#appliedOffset[0]; + this.cam.y += this.#appliedOffset[1]; + this.cam.z += this.#appliedOffset[2]; + } } // TODO: Also add touch controls here. act(e) { + // πŸ”‡ Window lost focus β€” drop every held input so keys don't "stick" when + // the user alt-tabs / switches apps mid-movement. Same for visibility loss. + if (e.is("defocus")) { + this.clearHeldKeys(); + return; + } + // πŸ’»οΈ Keyboard: WASD, SPACE and SHIFT for movement, ARROW KEYS for looking. if (e.is("keyboard:down:w")) this.#W = true; if (e.is("keyboard:down:s")) this.#S = true; @@ -212,7 +483,7 @@ export class CamDoll { if (e.is("gamepad")) { const deadzone = 0.05; - const moveDamp = 0.002; + const moveDamp = this.#moveDamp; const lookDamp = 1; // Detect controller type diff --git a/system/public/aesthetic.computer/lib/disk.mjs b/system/public/aesthetic.computer/lib/disk.mjs index 097e844e07..8ed8098062 100644 --- a/system/public/aesthetic.computer/lib/disk.mjs +++ b/system/public/aesthetic.computer/lib/disk.mjs @@ -9413,12 +9413,11 @@ async function load( let doll; boot = ($) => { - doll = new CamDoll($.Camera, $.Dolly, { - fov: 80, - z: 0, - y: 0, - sensitivity: 0.002, - }); + // Default camera opts; pieces can override via exported `fpsOpts` to + // enable grounded physics (gravity/jump/run/crouch) or just retune FOV. + const defaults = { fov: 80, z: 0, y: 0, sensitivity: 0.002 }; + const opts = { ...defaults, ...(module.fpsOpts || {}) }; + doll = new CamDoll($.Camera, $.Dolly, opts); $commonApi.system.fps = { doll, renderStats: graph.renderStats }; module?.boot?.($); }; diff --git a/system/public/aesthetic.computer/lib/graph.mjs b/system/public/aesthetic.computer/lib/graph.mjs index ebab7e23a9..cf0b661fc2 100644 --- a/system/public/aesthetic.computer/lib/graph.mjs +++ b/system/public/aesthetic.computer/lib/graph.mjs @@ -4055,8 +4055,10 @@ function fillShape(points) { // Draws a filled triangle with vertex colors using barycentric interpolation for smooth gradients // Now includes Z-depth testing for proper occlusion function drawGradientTriangle(x1, y1, color1, z1, x2, y2, color2, z2, x3, y3, color3, z3) { - // Skip triangles with any vertex having extreme coordinates (clipping failure) - const maxCoord = max(width, height) * 10; + // Skip triangles with any vertex having extreme coordinates (clipping failure). + // After screen-space clipping all vertices should be within [0, width]Γ—[0, height]; + // anything >2Γ— screen is a clipping artifact (e.g. a near-plane shoot-off). + const maxCoord = max(width, height) * 2; if (abs(x1) > maxCoord || abs(y1) > maxCoord || abs(x2) > maxCoord || abs(y2) > maxCoord || abs(x3) > maxCoord || abs(y3) > maxCoord) { @@ -4208,14 +4210,16 @@ function subdivideTriangleIfNeeded(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, // Each vertex has UV coordinates that map to a texture buffer // Uses perspective-correct interpolation with clip-space W values (not NDC Z!) // Also interpolates Z values for depth buffering -function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { - // Safety check: avoid division by zero or negative W values - if (w1 <= 0.001 || w2 <= 0.001 || w3 <= 0.001) { +function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { + // Safety check: avoid division by zero or near-zero W values. This is stricter + // than "finite math only" but much looser than the clip-space near plane so + // near-camera floor geometry can survive clipping without exploding. + if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { return; // Skip triangles with invalid depth } - - // Skip triangles with any vertex having extreme coordinates (clipping failure) - const maxCoord = max(width, height) * 10; + + // Skip triangles with any vertex having extreme coordinates (clipping failure). + const maxCoord = max(width, height) * 2; if (abs(x1) > maxCoord || abs(y1) > maxCoord || abs(x2) > maxCoord || abs(y2) > maxCoord || abs(x3) > maxCoord || abs(y3) > maxCoord) { @@ -8606,14 +8610,19 @@ class Form { const transformedA = a.transform(fullMatrix); const transformedB = b.transform(fullMatrix); - // Skip drawing if either vertex is behind the camera (negative Z) - if (transformedA.pos[2] <= 0 || transformedB.pos[2] <= 0) { - continue; - } - - // Apply perspective divide and screen space transformation - const screenA = toScreenSpace(perspectiveDivide(transformedA)); - const screenB = toScreenSpace(perspectiveDivide(transformedB)); + const clippedLine = clipLineToFrustum(transformedA, transformedB); + if (clippedLine.length < 2) continue; + const [lineA, lineB] = clippedLine; + + // Apply perspective divide and screen space transformation + const screenA = toScreenSpace(perspectiveDivide(lineA)); + const screenB = toScreenSpace(perspectiveDivide(lineB)); + if ( + !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || + !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) + ) { + continue; + } const clipped = clipLineToScreen( screenA.pos[0], @@ -8673,19 +8682,29 @@ class Form { // Clip against all frustum planes in clip-space (before perspective divide) clippedVertices = clipInClipSpace(clippedVertices, ["near", "left", "right", "bottom", "top"]); - + // Skip if triangle was completely clipped if (clippedVertices.length < 3) { continue; } + // Belt-and-suspenders: skip any polygon where a vertex has W too small + // for a reliable perspective divide. Clip-space clipping should already + // enforce this via NEAR_CLIP_Z, but numerical drift (especially with the + // projection's W_clip = Z_view convention) can leave a vertex just under. + let anyWTooSmall = false; + for (let vi = 0; vi < clippedVertices.length; vi++) { + if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } + } + if (anyWTooSmall) continue; + // Count clipped triangle (only if it resulted in a polygon) if (clippedVertices.length >= 3) { renderStats.clippedTriangles++; } // Calculate fade factor based on closest vertex to camera (use Z from clip space) - const nearPlane = 0.01; + const nearPlane = NEAR_CLIP_Z; const fadePlane = 0.5; const minZ = min(...clippedVertices.map(v => v.pos[2])); let alphaMultiplier = 1.0; @@ -8907,6 +8926,14 @@ class Vertex { // Sutherland-Hodgman clipping algorithm for clip-space (before perspective divide) // This properly handles near-plane clipping by testing against w component +// Minimum clip-space Z kept after near-plane clipping. Because the current +// projection has W_clip = Z_view, this is effectively the minimum W we allow +// post-clip, and must be comfortably above 0 so perspective divide stays stable. +// Too small β†’ near-camera triangles "shoot off" the screen. +// Too large β†’ noticeable geometry popping right in front of the camera. +const NEAR_CLIP_Z = 0.02; +const MIN_PERSPECTIVE_W = 0.001; + function clipInClipSpace(vertices, clippingBoundary) { let clipped = []; @@ -8923,7 +8950,12 @@ function clipInClipSpace(vertices, clippingBoundary) { case "top": return p[Y] <= w; case "near": - return p[Z] >= 0.01; // Near plane with small epsilon to prevent z-fighting + // Clip at Z_clip >= NEAR_CLIP_Z. With this projection W_clip = Z_view and + // Z_clip β‰ˆ Z_view - tiny, so this also guarantees W >= NEAR_CLIP_Z post-clip, + // which keeps the perspective divide numerically stable. A too-small threshold + // lets near-camera vertices through with tiny W, producing huge post-divide + // screen coordinates ("shooting off" triangles). + return p[Z] >= NEAR_CLIP_Z; case "far": return p[Z] <= w; } @@ -8933,7 +8965,7 @@ function clipInClipSpace(vertices, clippingBoundary) { const p1 = v1.pos; const p2 = v2.pos; let t; - + switch (edge) { case "left": t = (-p1[W] - p1[X]) / ((p2[X] - p1[X]) + (p2[W] - p1[W])); @@ -8948,7 +8980,7 @@ function clipInClipSpace(vertices, clippingBoundary) { t = (p1[W] - p1[Y]) / ((p2[Y] - p1[Y]) - (p2[W] - p1[W])); break; case "near": - t = (0.01 - p1[Z]) / (p2[Z] - p1[Z]); + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); break; case "far": t = (p1[W] - p1[Z]) / ((p2[Z] - p1[Z]) - (p2[W] - p1[W])); @@ -9106,13 +9138,13 @@ function clipLineToFrustum(v1, v2) { return p[X] <= p[W]; case "bottom": return p[Y] >= -p[W]; - case "top": - return p[Y] <= p[W]; - case "near": - return p[Z] >= 0; - case "far": - return p[Z] <= p[W]; - } + case "top": + return p[Y] <= p[W]; + case "near": + return p[Z] >= NEAR_CLIP_Z; + case "far": + return p[Z] <= p[W]; + } } function computeIntersection(p1, p2, edge) { @@ -9127,24 +9159,22 @@ function clipLineToFrustum(v1, v2) { case "bottom": t = (-p1[W] - p1[Y]) / (p2[Y] - p1[Y] + p2[W] - p1[W]); break; - case "top": - t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); - break; - case "near": - t = (0 - p1[Z]) / (p2[Z] - p1[Z]); - break; - case "far": - t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); - break; - } - - return [ - p1[X] + t * (p2[X] - p1[X]), - p1[Y] + t * (p2[Y] - p1[Y]), - p1[Z] + t * (p2[Z] - p1[Z]), - p1[W] + t * (p2[W] - p1[W]), - ]; - } + case "top": + t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); + break; + case "near": + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); + break; + case "far": + t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); + break; + } + + if (!Number.isFinite(t)) t = 0.5; + t = max(0, min(1, t)); + + return t; + } let clippedVertices = [v1, v2]; @@ -9162,10 +9192,22 @@ function clipLineToFrustum(v1, v2) { // Both outside, discard both clippedVertices = []; break; - } else { - // One inside, one outside - const intersection = computeIntersection(p1.pos, p2.pos, edge); - const intersectionVertex = new Vertex(intersection, p1.color); + } else { + // One inside, one outside + const t = computeIntersection(p1.pos, p2.pos, edge); + const intersectionVertex = new Vertex( + vec4.lerp(vec4.create(), p1.pos, p2.pos, t), + [ + p1.color[0] + (p2.color[0] - p1.color[0]) * t, + p1.color[1] + (p2.color[1] - p1.color[1]) * t, + p1.color[2] + (p2.color[2] - p1.color[2]) * t, + p1.color[3] + (p2.color[3] - p1.color[3]) * t, + ], + [ + p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, + p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, + ], + ); if (p1Inside) { // p1 inside, p2 outside @@ -9180,10 +9222,10 @@ function clipLineToFrustum(v1, v2) { return clippedVertices; } -function perspectiveDivide(vertex) { - // Safety check: avoid division by very small W values - const w = vertex.pos[W]; - if (abs(w) < 0.001) { +function perspectiveDivide(vertex) { + // Safety check: avoid division by very small W values + const w = vertex.pos[W]; + if (abs(w) < MIN_PERSPECTIVE_W) { // Return a vertex marked as invalid (will be filtered out) const vert = new Vertex([NaN, NaN, NaN, w]); vert.color = vertex.color; @@ -9279,7 +9321,11 @@ function lerpVertex(v1, v2, t) { const w2 = v2.pos[3]; // Fall back to simple linear interpolation if W values are degenerate - const safeW = w1 > 0.001 && w2 > 0.001 && Number.isFinite(w1) && Number.isFinite(w2); + const safeW = + w1 > MIN_PERSPECTIVE_W && + w2 > MIN_PERSPECTIVE_W && + Number.isFinite(w1) && + Number.isFinite(w2); let newW; if (safeW) {