Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

arena: Quake-style physics, death pit, body + tile highlights

- graph: tighten CPU rasterizer near-plane clip (NEAR_CLIP_Z=0.05) +
post-clip W guard + rasterizer maxCoord 2× screen — fixes "shooting
off" triangles near the camera.
- cam-doll: opt-in grounded physics (runSpeed, jumpVelocity, gravity,
eyeHeight, crouchEyeHeight, groundBounds, deathFloorY). Space jumps,
shift crouches (clamped to floor), gravity + floor clamp run in sim.
defocus event drops all held keys. Third-person view with lazy follow
via setThirdPerson/toggleThirdPerson; render camera lerps behind the
player. Death-floor clamp stops the frozen player on the lava.
- disk: fps system pieces can now export fpsOpts to configure CamDoll.
- arena: fork dialled for Quake feel (FOV 90, run 10 u/s, gravity 50).
Donut lava floor with animated stripe colours, bright platform edge,
shadow symbol per physics state, plumb line to feet, walk-over tile
trail + crosshair-raycast hover highlight, wireframe feet + arms that
yaw with the camera, YOU DIED overlay + tap-to-respawn, POV indicator
+ FOV/RUN/GROUND labels in HUD.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+972 -97
+579 -20
system/public/aesthetic.computer/disks/arena.mjs
··· 8 8 - [x] Fork from fps.mjs 9 9 - [x] Large pre-tessellated ground plane 10 10 - [x] Speed meter HUD + FPS counter 11 + - [x] Gravity + space-to-jump + shift-to-crouch (Quake-style) 11 12 #endregion */ 12 13 13 14 let groundPlane; 15 + let shadowGround; // ring + cross — standing on the ground 16 + let shadowAir; // diagonal X — airborne 17 + let shadowCrouch; // dense inner dot + outer ring — crouched 18 + let plumbLine; // vertical line from ground to the player's feet 19 + let bodyFeet; // two foot wireframes, anchored to ground + yaw 20 + let bodyArms; // two arm wireframes, anchored to eye + yaw 21 + let platformEdge; // bright outline at the ground's perimeter 22 + let FormRef; // captured at boot so sim/paint can build transient forms 14 23 let penLocked = false; 15 24 25 + // Walk-cycle phase (advanced in sim while moving) for gentle arm/foot bob. 26 + let walkPhase = 0; 27 + 28 + // 💀 Death / respawn state 29 + let playerAlive = true; 30 + let deathTickAge = 0; // how long we've been dead (sim ticks, for UI fade-in) 31 + 32 + // 🟨 Per-tile highlight state. 33 + // hoverTile: { row, col } of tile under the crosshair, or null. 34 + // walkedTiles: Map<tileKey, ageTicks>. A tile just stepped onto starts at 35 + // WALK_AGE_TICKS and decays each sim tick; drawn with alpha = age/TICKS. 36 + const WALK_AGE_TICKS = 90; // ≈ 0.75 s at 120 Hz 37 + let hoverTile = null; 38 + let prevPlayerTile = null; 39 + const walkedTiles = new Map(); 40 + 41 + function tileKey(row, col) { return row * GRID + col; } 42 + function tileFromKey(k) { return { row: Math.floor(k / GRID), col: k % GRID }; } 43 + 44 + // World XZ → tile (row, col) or null if outside the GRID bounds. 45 + function tileAt(worldX, worldZ) { 46 + const step = (GROUND_SIZE * 2) / GRID; 47 + const col = Math.floor((worldX + GROUND_SIZE) / step); 48 + const row = Math.floor((worldZ + GROUND_SIZE) / step); 49 + if (col < 0 || col >= GRID || row < 0 || row >= GRID) return null; 50 + return { row, col }; 51 + } 52 + 53 + // AC sim runs at a fixed 120 Hz (see lib/loop.mjs updateFps). All physics and 54 + // state updates happen in sim(), so the game runs at constant speed regardless 55 + // of paint FPS — the CPU rasterizer dropping to 30 fps while looking at the 56 + // ground will slow the *visual* update but not the simulation. 57 + const SIM_HZ = 120; 58 + 16 59 // Speed tracking 17 60 let prevX = 0, prevY = 0, prevZ = 0; 18 61 let speedSmoothed = 0; ··· 22 65 let frameTimes = []; 23 66 let lastFrameTime = 0; 24 67 25 - // Ground config 68 + // Ground config. GRID is odd so there's a centre tile under the player at the 69 + // origin (even grids put the player on a 4-way corner junction, which makes 70 + // tile highlights look offset by half a tile). 26 71 const GROUND_SIZE = 14; 27 - const GRID = 14; 72 + const GRID = 15; 28 73 const GROUND_Y = -1.5; 29 74 const FOG_START_SQ = 7 * 7; 30 75 const FOG_END_SQ = 14 * 14; 31 76 77 + // 🏃 Quake-style movement tuning (AC-scaled ≈ Quake ÷ 32). 78 + // Canonical Quake: FOV 90°, run 320 u/s, jump 270 u/s, gravity 800 u/s². 79 + // Here AC units ~ 32 Quake units, so numbers shrink proportionally. 80 + const FOV = 90; 81 + const RUN_SPEED = 10; // u/s (≈ 320 Quake u/s) 82 + const WALK_SPEED = 5; // u/s crouched 83 + const JUMP_VELOCITY = 8; // u/s initial → peak jump = jv²/(2g) AC units 84 + const GRAVITY = 50; // u/s² (heavier / less lofty than Quake's 800-scaled ≈25) 85 + const EYE_HEIGHT = 2.0; // AC units above ground (matches prior default cam.y=-0.5) 86 + const CROUCH_EYE = 1.2; 87 + 88 + // 💀 Death pit — declared before fpsOpts so it can be passed as deathFloorY. 89 + const DEATH_FLOOR_Y = -30; 90 + const DEATH_PAD = 60; // half-size of the lava floor (generous) 91 + const DEATH_TILES = 14; // (unused since donut build; kept for reference) 92 + const DEATH_STRIP_FREQ = 0.22; 93 + const DEATH_FLOW_SPEED = 1.8; 94 + 95 + export const fpsOpts = { 96 + fov: FOV, 97 + y: 0, 98 + z: 0, 99 + sensitivity: 0.002, 100 + runSpeed: RUN_SPEED, 101 + walkSpeed: WALK_SPEED, 102 + jumpVelocity: JUMP_VELOCITY, 103 + gravity: GRAVITY, 104 + groundY: GROUND_Y, 105 + eyeHeight: EYE_HEIGHT, 106 + crouchEyeHeight: CROUCH_EYE, 107 + // Outside this XZ rectangle the floor clamp is disabled so the player 108 + // falls into the pit below. Matches the ground plane's half-size. 109 + groundBounds: { 110 + xMin: -GROUND_SIZE, 111 + xMax: GROUND_SIZE, 112 + zMin: -GROUND_SIZE, 113 + zMax: GROUND_SIZE, 114 + }, 115 + // Dead players stop on the lava instead of falling forever. 116 + deathFloorY: DEATH_FLOOR_Y, 117 + }; 118 + 32 119 const BG = [45 / 255, 48 / 255, 55 / 255]; 33 120 const COLOR_A = [0.38, 0.35, 0.30, 1.0]; 34 121 const COLOR_B = [0.22, 0.20, 0.19, 1.0]; 35 122 123 + // Build a fresh lava floor Form with time-animated stripe colors. The lava 124 + // is a DONUT — no geometry sits under the main ground plane, so tile-edge 125 + // gaps in the ground rasterization can't expose lava. Only rendered outside 126 + // the arena bounds where it's actually visible from the pit. 127 + function buildLavaFloor(t) { 128 + if (!FormRef) return null; 129 + const positions = []; 130 + const colors = []; 131 + const deathY = DEATH_FLOOR_Y; 132 + 133 + const lavaTile = (x0, z0, x1, z1) => { 134 + const cx = (x0 + x1) / 2; 135 + const cz = (z0 + z1) / 2; 136 + const w1 = Math.sin(cx * DEATH_STRIP_FREQ - t * DEATH_FLOW_SPEED + cz * 0.08); 137 + const w2 = Math.sin(cx * 0.09 + cz * 0.27 - t * 0.9); 138 + const glow = (w1 + w2 * 0.6) * 0.5; 139 + const hot = 0.35 + Math.max(0, glow) * 0.65; 140 + const r = 0.45 + hot * 0.55; 141 + const g = hot * hot * 0.45; 142 + const b = hot * hot * hot * 0.08; 143 + const c = [r, g, b, 1.0]; 144 + positions.push( 145 + [x0, deathY, z0, 1], [x0, deathY, z1, 1], [x1, deathY, z1, 1], 146 + [x0, deathY, z0, 1], [x1, deathY, z1, 1], [x1, deathY, z0, 1], 147 + ); 148 + for (let i = 0; i < 6; i++) colors.push(c); 149 + }; 150 + 151 + // 4 strips forming a frame around the main arena. Each strip gets its own 152 + // local tessellation density so the stripes still look organic. 153 + const STEP = 4; // lava tile size (AC units) 154 + const gs = GROUND_SIZE; 155 + // North strip: z in [gs, DEATH_PAD], x in [-DEATH_PAD, DEATH_PAD] 156 + for (let z = gs; z < DEATH_PAD; z += STEP) { 157 + for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { 158 + lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, DEATH_PAD)); 159 + } 160 + } 161 + // South strip 162 + for (let z = -DEATH_PAD; z < -gs; z += STEP) { 163 + for (let x = -DEATH_PAD; x < DEATH_PAD; x += STEP) { 164 + lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, -gs)); 165 + } 166 + } 167 + // West strip (within arena's Z range) 168 + for (let x = -DEATH_PAD; x < -gs; x += STEP) { 169 + for (let z = -gs; z < gs; z += STEP) { 170 + lavaTile(x, z, Math.min(x + STEP, -gs), Math.min(z + STEP, gs)); 171 + } 172 + } 173 + // East strip 174 + for (let x = gs; x < DEATH_PAD; x += STEP) { 175 + for (let z = -gs; z < gs; z += STEP) { 176 + lavaTile(x, z, Math.min(x + STEP, DEATH_PAD), Math.min(z + STEP, gs)); 177 + } 178 + } 179 + 180 + const f = new FormRef( 181 + { type: "triangle", positions, colors }, 182 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 183 + ); 184 + f.noFade = true; 185 + return f; 186 + } 187 + 188 + // Ground fog: RGB lerps toward BG with distance so the arena "dissolves" into 189 + // the sky, but alpha stays a full 1.0. Dropping alpha lets the red death floor 190 + // show through the ground (no depth buffer → painter's order only), and the 191 + // RGB lerp already gives the visual fade. 36 192 function fogColor(base, distSq) { 37 193 if (distSq <= FOG_START_SQ) return base; 38 - if (distSq >= FOG_END_SQ) return [BG[0], BG[1], BG[2], 0.0]; 194 + if (distSq >= FOG_END_SQ) return [BG[0], BG[1], BG[2], 1.0]; 39 195 const t = (distSq - FOG_START_SQ) / (FOG_END_SQ - FOG_START_SQ); 40 196 return [ 41 197 base[0] + (BG[0] - base[0]) * t, 42 198 base[1] + (BG[1] - base[1]) * t, 43 199 base[2] + (BG[2] - base[2]) * t, 44 - base[3] * (1 - t), 200 + 1.0, 45 201 ]; 46 202 } 47 203 48 204 function boot({ Form, penLock, system }) { 49 205 penLock(); 206 + FormRef = Form; 50 207 51 208 const cam = system?.fps?.doll?.cam; 52 209 if (cam) { prevX = cam.x; prevY = cam.y; prevZ = cam.z; } ··· 84 241 { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 85 242 ); 86 243 groundPlane.noFade = true; 244 + 245 + // 🧱 Platform edge — a glowing rim so you can see where the floor ends. 246 + // Four line segments slightly above the ground, plus short drops at each 247 + // corner so the edge reads in depth even when approaching at a shallow 248 + // angle. 249 + const edgeY = GROUND_Y + 0.04; 250 + const edgeDrop = GROUND_Y - 0.2; 251 + const gs = GROUND_SIZE; 252 + const rim = [1, 0.9, 0.45, 0.95]; 253 + const drop = [1, 0.35, 0.15, 0.85]; 254 + platformEdge = new Form( 255 + { 256 + type: "line", 257 + positions: [ 258 + // Perimeter 259 + [-gs, edgeY, -gs, 1], [ gs, edgeY, -gs, 1], 260 + [ gs, edgeY, -gs, 1], [ gs, edgeY, gs, 1], 261 + [ gs, edgeY, gs, 1], [-gs, edgeY, gs, 1], 262 + [-gs, edgeY, gs, 1], [-gs, edgeY, -gs, 1], 263 + // Corner drops (short lines falling into the pit) 264 + [-gs, edgeY, -gs, 1], [-gs, edgeDrop, -gs, 1], 265 + [ gs, edgeY, -gs, 1], [ gs, edgeDrop, -gs, 1], 266 + [ gs, edgeY, gs, 1], [ gs, edgeDrop, gs, 1], 267 + [-gs, edgeY, gs, 1], [-gs, edgeDrop, gs, 1], 268 + ], 269 + colors: [ 270 + rim, rim, rim, rim, rim, rim, rim, rim, 271 + rim, drop, rim, drop, rim, drop, rim, drop, 272 + ], 273 + }, 274 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 275 + ); 276 + platformEdge.noFade = true; 277 + 278 + // 🫥 Feet reference — three shadow symbols swapped based on physics state, 279 + // plus a vertical plumb line from the ring up to the eye. 280 + const ringR = 0.35; 281 + const ringSegs = 16; 282 + 283 + const mkRing = (r, segs, color) => { 284 + const pos = [], col = []; 285 + for (let i = 0; i < segs; i++) { 286 + const a0 = (i / segs) * Math.PI * 2; 287 + const a1 = ((i + 1) / segs) * Math.PI * 2; 288 + pos.push( 289 + [Math.cos(a0) * r, 0, Math.sin(a0) * r, 1], 290 + [Math.cos(a1) * r, 0, Math.sin(a1) * r, 1], 291 + ); 292 + col.push(color, color); 293 + } 294 + return { pos, col }; 295 + }; 296 + 297 + // Ground: ring + small cross inside. 298 + const g = mkRing(ringR, ringSegs, [1, 1, 1, 0.75]); 299 + const crossArm = ringR * 0.6; 300 + g.pos.push( 301 + [-crossArm, 0, 0, 1], [crossArm, 0, 0, 1], 302 + [0, 0, -crossArm, 1], [0, 0, crossArm, 1], 303 + ); 304 + const gx = [1, 1, 1, 0.6]; 305 + g.col.push(gx, gx, gx, gx); 306 + shadowGround = new FormRef( 307 + { type: "line", positions: g.pos, colors: g.col }, 308 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 309 + ); 310 + shadowGround.noFade = true; 311 + 312 + // Air: diagonals only (bright X at feet so you can track your fall point). 313 + const xArm = ringR * 0.9; 314 + const airColor = [1, 0.9, 0.4, 0.85]; 315 + shadowAir = new FormRef( 316 + { 317 + type: "line", 318 + positions: [ 319 + [-xArm, 0, -xArm, 1], [xArm, 0, xArm, 1], 320 + [-xArm, 0, xArm, 1], [xArm, 0, -xArm, 1], 321 + ], 322 + colors: [airColor, airColor, airColor, airColor], 323 + }, 324 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 325 + ); 326 + shadowAir.noFade = true; 327 + 328 + // Crouch: smaller dense ring with a centre dot — reads as "compressed". 329 + const c = mkRing(ringR * 0.75, ringSegs, [1, 0.7, 0.3, 0.85]); 330 + const dotR = 0.06; 331 + const dot = [1, 0.85, 0.5, 0.95]; 332 + c.pos.push( 333 + [-dotR, 0, 0, 1], [dotR, 0, 0, 1], 334 + [0, 0, -dotR, 1], [0, 0, dotR, 1], 335 + ); 336 + c.col.push(dot, dot, dot, dot); 337 + shadowCrouch = new FormRef( 338 + { type: "line", positions: c.pos, colors: c.col }, 339 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 340 + ); 341 + shadowCrouch.noFade = true; 342 + 343 + // Plumb line: unit-length along +Y at local origin. Scale.y each frame to 344 + // the current eye-above-ground distance. Fade alpha bottom→top so the line 345 + // doesn't poke into the pupil as a stark white segment. 346 + plumbLine = new Form( 347 + { 348 + type: "line", 349 + positions: [[0, 0, 0, 1], [0, 1, 0, 1]], 350 + colors: [[1, 1, 1, 0.45], [1, 1, 1, 0.0]], 351 + }, 352 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: [1, 1, 1] }, 353 + ); 354 + plumbLine.noFade = true; 355 + 356 + // (Death floor is rebuilt each paint — see buildLavaFloor.) 357 + 358 + // 🦶 Feet — two stubby boxes (top + side edges as lines) anchored under the 359 + // player. Yaw rotates the whole form each frame so they face where you look. 360 + const footBox = (dx, ySpan, color) => { 361 + // Rectangle outline on top of the foot (local y = ySpan.max). 362 + const xL = dx - 0.08, xR = dx + 0.08; 363 + const zT = 0.35, zB = 0.05; // toe + heel in local forward (+Z) 364 + const yT = ySpan; // boot height 365 + const pts = [ 366 + // top rectangle 367 + [xL, yT, zB, 1], [xR, yT, zB, 1], 368 + [xR, yT, zB, 1], [xR, yT, zT, 1], 369 + [xR, yT, zT, 1], [xL, yT, zT, 1], 370 + [xL, yT, zT, 1], [xL, yT, zB, 1], 371 + // four corners dropping to ground 372 + [xL, 0, zB, 1], [xL, yT, zB, 1], 373 + [xR, 0, zB, 1], [xR, yT, zB, 1], 374 + [xR, 0, zT, 1], [xR, yT, zT, 1], 375 + [xL, 0, zT, 1], [xL, yT, zT, 1], 376 + ]; 377 + const cols = pts.map(() => color); 378 + return { pts, cols }; 379 + }; 380 + const footColor = [0.9, 0.9, 1.0, 0.85]; 381 + const leftFoot = footBox(-0.18, 0.15, footColor); 382 + const rightFoot = footBox(0.18, 0.15, footColor); 383 + bodyFeet = new Form( 384 + { 385 + type: "line", 386 + positions: [...leftFoot.pts, ...rightFoot.pts], 387 + colors: [...leftFoot.cols, ...rightFoot.cols], 388 + }, 389 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 390 + ); 391 + bodyFeet.noFade = true; 392 + 393 + // 🤲 Arms — two angled line segments from shoulder to wrist, extending 394 + // forward and down from the eye. Color fades darker at the wrists so they 395 + // don't scream at the reader. 396 + const armLocal = (side) => { 397 + const sx = 0.28 * side; 398 + const shoulder = [sx, -0.35, 0.05, 1]; 399 + const elbow = [sx * 0.9, -0.55, 0.45, 1]; 400 + const wrist = [sx * 0.75, -0.65, 0.75, 1]; 401 + const shoulderCol = [1, 1, 1, 0.7]; 402 + const wristCol = [1, 0.9, 0.7, 0.9]; 403 + return { 404 + pts: [shoulder, elbow, elbow, wrist], 405 + cols: [shoulderCol, wristCol, wristCol, wristCol], 406 + }; 407 + }; 408 + const leftArm = armLocal(-1); 409 + const rightArm = armLocal(+1); 410 + bodyArms = new Form( 411 + { 412 + type: "line", 413 + positions: [...leftArm.pts, ...rightArm.pts], 414 + colors: [...leftArm.cols, ...rightArm.cols], 415 + }, 416 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 417 + ); 418 + bodyArms.noFade = true; 87 419 } 88 420 89 421 function sim({ system }) { 90 - const cam = system?.fps?.doll?.cam; 422 + const doll = system?.fps?.doll; 423 + const cam = doll?.cam; 91 424 if (!cam) return; 92 425 93 - const dx = cam.x - prevX; 94 - const dy = cam.y - prevY; 95 - const dz = cam.z - prevZ; 96 - const speed = Math.sqrt(dx * dx + dy * dy + dz * dz); 426 + // Always use the *logical* player position (not the render camera) for 427 + // gameplay state. In 3P mode cam.x/z is offset behind the player. 428 + const phys = doll?.physics; 429 + const playerCamX = phys?.playerCamX ?? cam.x; 430 + const playerCamY = phys?.playerCamY ?? cam.y; 431 + const playerCamZ = phys?.playerCamZ ?? cam.z; 432 + 433 + // Horizontal speed only (ignore vertical so jump bursts don't spike the bar). 434 + const dx = playerCamX - prevX; 435 + const dz = playerCamZ - prevZ; 436 + const speed = Math.sqrt(dx * dx + dz * dz); 97 437 speedSmoothed += (speed - speedSmoothed) * SPEED_SMOOTH; 98 - prevX = cam.x; prevY = cam.y; prevZ = cam.z; 438 + prevX = playerCamX; prevY = playerCamY; prevZ = playerCamZ; 439 + 440 + // Walk cycle phase advances with horizontal speed; fallback to slow drift. 441 + walkPhase += Math.max(0.002, speedSmoothed) * 8; 442 + 443 + // Player world position — cam stores negated world coords (see Camera 444 + // #transform). cam.y is the one exception and uses opposite inversion. 445 + const pWorldX = -playerCamX; 446 + const pWorldZ = -playerCamZ; 447 + const pWorldY = -playerCamY; 448 + 449 + // --- 💀 Death detection: hitting the red floor kills the player. --- 450 + if (playerAlive && pWorldY <= DEATH_FLOOR_Y + EYE_HEIGHT + 0.05) { 451 + playerAlive = false; 452 + deathTickAge = 0; 453 + doll.setFrozen?.(true); 454 + doll.clearHeldKeys?.(); 455 + } 456 + if (!playerAlive) deathTickAge += 1; 457 + 458 + // --- Walked-tile trail: when the player's current tile changes, stamp it. --- 459 + const curTile = tileAt(pWorldX, pWorldZ); 460 + if (curTile) { 461 + const key = tileKey(curTile.row, curTile.col); 462 + if (!prevPlayerTile || prevPlayerTile !== key) { 463 + walkedTiles.set(key, WALK_AGE_TICKS); 464 + prevPlayerTile = key; 465 + } else { 466 + // Also refresh the age while standing still so the glow lingers under you. 467 + walkedTiles.set(key, WALK_AGE_TICKS); 468 + } 469 + } 470 + // Age + prune. 471 + for (const [k, age] of walkedTiles) { 472 + const next = age - 1; 473 + if (next <= 0) walkedTiles.delete(k); 474 + else walkedTiles.set(k, next); 475 + } 476 + 477 + // --- Hover tile: raycast camera-forward onto the ground plane. --- 478 + // Forward in world (rotY=0 → +Z). rotX positive = look up, so forward.y 479 + // = sin(rotX). If forward.y >= 0 we aren't looking at the floor. 480 + const rx = cam.rotX * Math.PI / 180; 481 + const ry = cam.rotY * Math.PI / 180; 482 + const cosPitch = Math.cos(rx); 483 + const fx = Math.sin(ry) * cosPitch; 484 + const fy = Math.sin(rx); 485 + const fz = Math.cos(ry) * cosPitch; 486 + hoverTile = null; 487 + if (fy < -0.05) { 488 + const camWorldY = -cam.y; 489 + const t = (GROUND_Y - camWorldY) / fy; 490 + if (t > 0 && t < 200) { 491 + const hitX = pWorldX + t * fx; 492 + const hitZ = pWorldZ + t * fz; 493 + hoverTile = tileAt(hitX, hitZ); 494 + } 495 + } 496 + 497 + // --- Anchor shadow / plumb / body at the logical player position (see top 498 + // of sim). Reusing playerCam* captured above. --- 499 + const playerWorldY = pWorldY; 500 + const feetY = GROUND_Y + 0.01; 501 + 502 + const shadows = [shadowGround, shadowAir, shadowCrouch]; 503 + for (const s of shadows) { 504 + if (!s) continue; 505 + s.position[0] = playerCamX; 506 + s.position[1] = feetY; 507 + s.position[2] = playerCamZ; 508 + } 509 + if (plumbLine) { 510 + plumbLine.position[0] = playerCamX; 511 + plumbLine.position[1] = feetY; 512 + plumbLine.position[2] = playerCamZ; 513 + plumbLine.scale[1] = Math.max(0, playerWorldY - GROUND_Y - 0.02); 514 + } 515 + 516 + if (bodyFeet) { 517 + const footBaseY = playerAlive 518 + ? GROUND_Y 519 + : playerWorldY - EYE_HEIGHT; 520 + bodyFeet.position[0] = playerCamX; 521 + bodyFeet.position[1] = footBaseY; 522 + bodyFeet.position[2] = playerCamZ; 523 + bodyFeet.rotation[1] = cam.rotY; 524 + } 525 + if (bodyArms) { 526 + const crouchDrop = (phys?.crouch ?? 0) * 0.2; 527 + const bob = Math.sin(walkPhase) * 0.03 * Math.min(1, speedSmoothed * 40); 528 + bodyArms.position[0] = playerCamX; 529 + bodyArms.position[1] = playerWorldY - crouchDrop + bob; 530 + bodyArms.position[2] = playerCamZ; 531 + bodyArms.rotation[1] = cam.rotY; 532 + } 99 533 } 100 534 101 - function paint({ wipe, ink, screen, write, box }) { 535 + function paint({ wipe, ink, screen, write, box, system }) { 102 536 // FPS calc 103 537 const now = performance.now(); 104 538 const dt = now - lastFrameTime; ··· 108 542 const avgDt = frameTimes.reduce((a, b) => a + b, 0) / frameTimes.length; 109 543 const fps = Math.round(1000 / avgDt); 110 544 111 - // Render scene 112 - wipe(45, 48, 55).form(groundPlane); 545 + // --- Tile highlights: build a single transient Form containing one quad 546 + // per visible highlight (hover + walked trail). Drawn *after* the ground, 547 + // just above floor-Y, with additive-ish semi-transparent tint. 548 + const step = (GROUND_SIZE * 2) / GRID; 549 + const hiPos = []; 550 + const hiCol = []; 551 + const pushQuad = (row, col, color) => { 552 + const x0 = -GROUND_SIZE + col * step; 553 + const z0 = -GROUND_SIZE + row * step; 554 + const x1 = x0 + step; 555 + const z1 = z0 + step; 556 + const y = GROUND_Y + 0.015; 557 + hiPos.push( 558 + [x0, y, z0, 1], [x0, y, z1, 1], [x1, y, z1, 1], 559 + [x0, y, z0, 1], [x1, y, z1, 1], [x1, y, z0, 1], 560 + ); 561 + for (let i = 0; i < 6; i++) hiCol.push(color); 562 + }; 563 + // Walked trail (bright yellow, fades with age — kept highly visible). 564 + // Skip the tile under the player's feet so the active standing tile reads as 565 + // "present" rather than already part of the trail. 566 + for (const [k, age] of walkedTiles) { 567 + if (k === prevPlayerTile) continue; 568 + const { row, col } = tileFromKey(k); 569 + const alpha = (age / WALK_AGE_TICKS) * 0.85; 570 + pushQuad(row, col, [1.0, 0.95, 0.3, alpha]); 571 + } 572 + // Hover (cyan, steady, steady alpha). 573 + if (hoverTile) { 574 + pushQuad(hoverTile.row, hoverTile.col, [0.4, 0.95, 1.0, 0.55]); 575 + } 576 + 577 + // Render scene — lava donut first (never under the main ground), then the 578 + // ground, its glowing edge, tile highlights, then the feet shadow + body. 579 + wipe(45, 48, 55); 580 + const lava = buildLavaFloor(now / 1000); 581 + if (lava) ink(255).form(lava); 582 + ink(255).form(groundPlane); 583 + if (platformEdge) ink(255).form(platformEdge); 584 + if (hiPos.length > 0 && FormRef) { 585 + const hi = new FormRef( 586 + { type: "triangle", positions: hiPos, colors: hiCol }, 587 + { pos: [0, 0, 0], rot: [0, 0, 0], scale: 1 }, 588 + ); 589 + hi.noFade = true; 590 + ink(255, 255, 255).form(hi); 591 + } 592 + 593 + // Pick the correct shadow symbol for the current physics state. 594 + const phys = system?.fps?.doll?.physics; 595 + let activeShadow = shadowGround; 596 + if (phys) { 597 + if (!phys.onGround) activeShadow = shadowAir; 598 + else if (phys.crouch > 0.5) activeShadow = shadowCrouch; 599 + } 600 + // Only draw ground-anchored shadow/plumb while on solid ground. 601 + const onSolidGround = phys?.onGround; 602 + if (activeShadow && onSolidGround) ink(255, 255, 255).form(activeShadow); 603 + if (plumbLine && onSolidGround && plumbLine.scale[1] > 0.05) { 604 + ink(255, 255, 255).form(plumbLine); 605 + } 606 + // Feet + arms render regardless of ground state (they fall with you). 607 + if (bodyFeet) ink(255).form(bodyFeet); 608 + if (bodyArms) ink(255).form(bodyArms); 113 609 114 610 // --- HUD (top-right) --- 115 611 const font = "MatrixChunky8"; 116 612 const margin = 4; 117 613 const lineH = 10; 118 614 const rX = screen.width - margin; // right edge 615 + const rightLabel = (txt, y) => { 616 + // MatrixChunky8 glyphs are ~4px wide; right-align by char count. 617 + write(txt, { x: rX - txt.length * 4, y }, undefined, undefined, false, font); 618 + }; 119 619 120 620 // FPS 121 621 ink(fps >= 30 ? "lime" : fps >= 15 ? "yellow" : "red"); 122 - write(`${fps} FPS`, { x: rX - 7 * 4, y: margin }, undefined, undefined, false, font); 622 + rightLabel(`${fps} FPS`, margin); 123 623 124 624 // Frame time 125 625 ink(180, 180, 180); 126 - write(`${avgDt.toFixed(1)}ms`, { x: rX - 7 * 4, y: margin + lineH }, undefined, undefined, false, font); 626 + rightLabel(`${avgDt.toFixed(1)}ms`, margin + lineH); 627 + 628 + // FOV + run speed (Quake-style spec) 629 + ink(150, 200, 255); 630 + rightLabel(`FOV ${FOV}`, margin + lineH * 2); 631 + rightLabel(`RUN ${RUN_SPEED.toFixed(1)}u/s`, margin + lineH * 3); 632 + 633 + // Grounded / airborne indicator (reuses `phys` captured above). 634 + if (phys) { 635 + const airborne = !phys.onGround; 636 + const crouching = phys.crouch > 0.5; 637 + ink(airborne ? "yellow" : crouching ? "orange" : "lime"); 638 + rightLabel( 639 + airborne ? "AIR" : crouching ? "CROUCH" : "GROUND", 640 + margin + lineH * 4, 641 + ); 642 + 643 + // POV indicator — 1P or 3P (middle-mouse toggles). 644 + ink(phys.thirdPerson ? "magenta" : "cyan"); 645 + rightLabel(phys.thirdPerson ? "3P" : "1P", margin + lineH * 5); 646 + } 127 647 128 - // Speed meter (bottom-center) 129 - const ups = speedSmoothed * 60; 130 - const barMaxUPS = 15; 648 + // Speed meter (bottom-center). speedSmoothed is per-sim-tick position delta; 649 + // sim runs at SIM_HZ, so ups = perTickDelta * SIM_HZ. 650 + const ups = speedSmoothed * SIM_HZ; 651 + const barMaxUPS = RUN_SPEED * 1.2; // headroom for strafe-jump bonuses later 131 652 const barFill = Math.min(1, ups / barMaxUPS); 132 653 133 654 const barW = Math.min(160, Math.floor(screen.width * 0.4)); ··· 145 666 146 667 ink("white"); 147 668 write(`${ups.toFixed(1)} u/s`, { x: barX + barW + 4, y: barY - 1 }, undefined, undefined, false, font); 669 + 670 + // --- 💀 Death screen overlay (fade-in) --- 671 + if (!playerAlive) { 672 + const fade = Math.min(1, deathTickAge / 24); // ~0.2 s ramp 673 + ink(60, 0, 0, Math.floor(fade * 160)); 674 + box(0, 0, screen.width, screen.height); 675 + 676 + const cx = screen.width / 2; 677 + const cy = screen.height / 2; 678 + ink(255, 80, 80, Math.floor(fade * 255)); 679 + const died = "YOU DIED"; 680 + // MatrixChunky8 ≈ 4px/char; centre roughly. 681 + write(died, { x: Math.floor(cx - died.length * 4), y: Math.floor(cy - 12) }, undefined, undefined, false, font); 682 + if (deathTickAge > 30) { 683 + ink(230, 230, 230, 220); 684 + const prompt = "TAP TO RESPAWN"; 685 + write(prompt, { x: Math.floor(cx - prompt.length * 4), y: Math.floor(cy + 6) }, undefined, undefined, false, font); 686 + } 687 + } 148 688 } 149 689 150 - function act({ event: e, penLock }) { 690 + function act({ event: e, penLock, system }) { 151 691 if (e.is("pen:locked")) penLocked = true; 152 692 if (e.is("pen:unlocked")) penLocked = false; 153 - if (!penLocked && e.is("touch")) penLock(); 693 + 694 + // 🎥 Middle-mouse toggles third-person (press once to enter, press again 695 + // to exit). Only trigger on touch so the release doesn't also flip. 696 + if (e.device === "mouse" && e.button === 1 && e.is("touch")) { 697 + system?.fps?.doll?.toggleThirdPerson?.(); 698 + } 699 + 700 + // While dead, any touch respawns; otherwise the first touch re-locks the pen. 701 + if (e.is("touch")) { 702 + if (!playerAlive && deathTickAge > 30) { 703 + playerAlive = true; 704 + deathTickAge = 0; 705 + walkedTiles.clear(); 706 + prevPlayerTile = null; 707 + system?.fps?.doll?.respawn?.(0, 0); 708 + return; 709 + } 710 + // Don't re-lock on middle-click — user is using it for 3P toggle. 711 + if (!penLocked && e.button !== 1) penLock(); 712 + } 154 713 } 155 714 156 715 export const system = "fps";
+287 -16
system/public/aesthetic.computer/lib/cam-doll.mjs
··· 56 56 57 57 #penLocked = false; 58 58 59 + // 🧗 Optional Quake-style grounded physics (enabled when opts.gravity is set). 60 + // When enabled, SPACE jumps only while grounded, SHIFT crouches (clamped to 61 + // ground, cannot dip below), WASD moves at runSpeed (walkSpeed while crouched), 62 + // and vertical motion is integrated with gravity + a ground clamp. 63 + // Conventions: speeds in world-units/second, gravity in u/s². cam.y is inverted 64 + // relative to world Y (positive cam.y = below origin), hence world-up = -cam.y. 65 + #physicsEnabled = false; 66 + #runSpeed = 0; 67 + #walkSpeed = 0; 68 + #jumpVelocity = 0; 69 + #gravity = 0; 70 + #groundY = 0; 71 + #eyeHeight = 1.5; 72 + #crouchEyeHeight = 0.8; 73 + #simHz = 120; 74 + #worldYVel = 0; // world-space vertical velocity, + is up 75 + #onGround = true; 76 + #crouchT = 0; // 0 standing → 1 crouched 77 + #moveDamp = 0.002; 78 + // Optional XZ rectangle bounding the solid floor. Outside these bounds the 79 + // floor clamp is disabled so the player falls off the edge. Format: 80 + // { xMin, xMax, zMin, zMax } in world units. 81 + #groundBounds = null; 82 + // When true, all physics input is ignored and the camera just free-falls. 83 + // External callers (e.g. a piece with a death state) set this via setFrozen(). 84 + #frozen = false; 85 + 86 + // 🎥 Third-person mode. When enabled, the render camera lazily follows a 87 + // target point (player − forward·distance + up·height). The current render 88 + // position is stored in #tpCurrent and lerped toward the target each tick. 89 + // Physics runs on the *logical* player position; the render offset is 90 + // applied at the end of sim and undone at the start of the next. 91 + #thirdPerson = false; 92 + #thirdPersonDistance = 4.5; 93 + #thirdPersonHeight = 1.5; 94 + #thirdPersonFollow = 0.08; // per-tick lerp factor (≈100 ms to catch up) 95 + #appliedOffset = [0, 0, 0]; 96 + #tpCurrent = null; // current render offset (cam-space), init lazy 97 + 98 + // 💀 Death floor clamp — when set, a frozen (dead) player lands on this 99 + // world Y instead of falling forever. 100 + #deathFloorY = null; 101 + #deathFloorEyeClearance = 0.3; 102 + 59 103 constructor(Camera, Dolly, opts) { 60 104 this.cam = new Camera(opts.fov || 80, { 61 105 z: opts.z || 0, ··· 64 108 }); 65 109 this.sensitivity = opts.sensitivity || 0.00025; 66 110 this.#dolly = new Dolly(this.cam); // moves the camera 111 + 112 + if (opts.gravity !== undefined) { 113 + this.#physicsEnabled = true; 114 + this.#gravity = opts.gravity; 115 + this.#runSpeed = opts.runSpeed ?? 10; 116 + this.#walkSpeed = opts.walkSpeed ?? this.#runSpeed * 0.5; 117 + this.#jumpVelocity = opts.jumpVelocity ?? 8; 118 + this.#groundY = opts.groundY ?? 0; 119 + this.#eyeHeight = opts.eyeHeight ?? 1.5; 120 + this.#crouchEyeHeight = opts.crouchEyeHeight ?? this.#eyeHeight * 0.55; 121 + this.#simHz = opts.simHz ?? 120; 122 + // Dolly steady-state speed = push / (1 - decay) = 10 * push (decay 0.9). 123 + // So analog gamepad moveDamp scales to hit runSpeed at full stick deflection. 124 + this.#moveDamp = this.#runSpeed / this.#simHz / 10; 125 + if (opts.groundBounds) this.#groundBounds = opts.groundBounds; 126 + if (typeof opts.deathFloorY === "number") this.#deathFloorY = opts.deathFloorY; 127 + // Snap camera onto the floor at boot. 128 + this.cam.y = -(this.#groundY + this.#eyeHeight); 129 + } 130 + } 131 + 132 + /** Clear every held input state. Called on window defocus / visibility loss 133 + * so keys don't "stick" if the user alt-tabs mid-movement. */ 134 + clearHeldKeys() { 135 + this.#W = this.#S = this.#A = this.#D = false; 136 + this.#SPACE = this.#SHIFT = false; 137 + this.#UP = this.#DOWN = this.#LEFT = this.#RIGHT = false; 138 + this.#BTN_LOOK_UP = this.#BTN_LOOK_DOWN = false; 139 + this.#BTN_LOOK_LEFT = this.#BTN_LOOK_RIGHT = false; 140 + this.#ANALOG.move.x = this.#ANALOG.move.y = this.#ANALOG.move.z = 0; 141 + this.#ANALOG.look.x = this.#ANALOG.look.y = this.#ANALOG.look.z = 0; 142 + } 143 + 144 + /** Teleport the camera back to standing on the floor at the configured 145 + * ground position, clearing velocity. Used for respawn. */ 146 + respawn(worldX = 0, worldZ = 0) { 147 + this.cam.x = -worldX; 148 + this.cam.z = -worldZ; 149 + this.cam.y = -(this.#groundY + this.#eyeHeight); 150 + this.#worldYVel = 0; 151 + this.#onGround = true; 152 + this.#frozen = false; 153 + // Kill any residual momentum in the dolly so we don't slide on spawn. 154 + this.#dolly.xVel = this.#dolly.yVel = this.#dolly.zVel = 0; 155 + this.clearHeldKeys(); 156 + } 157 + 158 + /** When frozen the physics pass is skipped entirely (e.g. during a death 159 + * animation). Player input is already zeroed via clearHeldKeys. */ 160 + setFrozen(v) { 161 + this.#frozen = !!v; 162 + if (v) this.clearHeldKeys(); 163 + } 164 + 165 + /** Enable / disable third-person view. On entry the render offset is 166 + * re-initialised so the camera snaps into position; on exit it instantly 167 + * returns to first person. */ 168 + setThirdPerson(v, distance, height) { 169 + this.#thirdPerson = !!v; 170 + if (typeof distance === "number") this.#thirdPersonDistance = distance; 171 + if (typeof height === "number") this.#thirdPersonHeight = height; 172 + if (!this.#thirdPerson) this.#tpCurrent = null; 173 + } 174 + 175 + /** Flip between 1P and 3P. Use for single-tap / middle-mouse bindings. */ 176 + toggleThirdPerson() { 177 + this.setThirdPerson(!this.#thirdPerson); 178 + } 179 + 180 + /** True if third-person mode is currently active. */ 181 + get thirdPerson() { return this.#thirdPerson; } 182 + 183 + /** Live telemetry for HUDs / debug panels. */ 184 + get physics() { 185 + if (!this.#physicsEnabled) return null; 186 + return { 187 + fov: this.cam.fov, 188 + runSpeed: this.#runSpeed, 189 + walkSpeed: this.#walkSpeed, 190 + jumpVelocity: this.#jumpVelocity, 191 + gravity: this.#gravity, 192 + onGround: this.#onGround, 193 + crouch: this.#crouchT, 194 + worldYVel: this.#worldYVel, 195 + thirdPerson: this.#thirdPerson, 196 + // Logical player position in the camera's negated-world coordinate 197 + // system (useful for anchoring body parts at the player, not at the 198 + // offset render camera). 199 + playerCamX: this.cam.x - this.#appliedOffset[0], 200 + playerCamY: this.cam.y - this.#appliedOffset[1], 201 + playerCamZ: this.cam.z - this.#appliedOffset[2], 202 + }; 67 203 } 68 204 69 205 sim() { 206 + // 🎥 Undo last frame's third-person offset so physics runs on the logical 207 + // player position. We'll reapply a fresh offset at the end of this tick. 208 + if (this.#appliedOffset[0] !== 0 || this.#appliedOffset[1] !== 0 || this.#appliedOffset[2] !== 0) { 209 + this.cam.x -= this.#appliedOffset[0]; 210 + this.cam.y -= this.#appliedOffset[1]; 211 + this.cam.z -= this.#appliedOffset[2]; 212 + this.#appliedOffset[0] = this.#appliedOffset[1] = this.#appliedOffset[2] = 0; 213 + } 214 + 70 215 // 🔫 FPS style camera movement. 71 216 let forward = 0, 72 217 updown = 0, 73 218 strafe = 0; 74 219 75 - if (this.#W) forward = -this.sensitivity; 76 - if (this.#S) forward = this.sensitivity; 77 - if (this.#A) strafe = this.sensitivity; // A moves left (positive in rotated space) 78 - if (this.#D) strafe = -this.sensitivity; // D moves right (negative in rotated space) 79 - if (this.#SPACE) updown = -this.sensitivity; 80 - if (this.#SHIFT) updown = this.sensitivity; 220 + if (this.#physicsEnabled) { 221 + // Quake-style: scale push so Dolly's 0.9-decay momentum settles at the 222 + // requested run/walk speed. Steady-state vel/frame = push/(1-decay), 223 + // and we want vel/frame = speed/simHz, so push = speed/(simHz*10). 224 + const speed = this.#SHIFT ? this.#walkSpeed : this.#runSpeed; 225 + const push = speed / this.#simHz / 10; 226 + if (this.#W) forward = -push; 227 + if (this.#S) forward = push; 228 + if (this.#A) strafe = push; 229 + if (this.#D) strafe = -push; 230 + // SPACE and SHIFT no longer push Y — handled in the physics pass below. 231 + if (this.#W || this.#S || this.#A || this.#D) { 232 + this.#dolly.push({ x: strafe, y: 0, z: forward }); 233 + } 234 + } else { 235 + if (this.#W) forward = -this.sensitivity; 236 + if (this.#S) forward = this.sensitivity; 237 + if (this.#A) strafe = this.sensitivity; // A moves left (positive in rotated space) 238 + if (this.#D) strafe = -this.sensitivity; // D moves right (negative in rotated space) 239 + if (this.#SPACE) updown = -this.sensitivity; 240 + if (this.#SHIFT) updown = this.sensitivity; 81 241 82 - if ( 83 - this.#W || 84 - this.#S || 85 - this.#A || 86 - this.#D || 87 - this.#SPACE || 88 - this.#SHIFT 89 - ) { 90 - this.#dolly.push({ x: strafe, y: updown, z: forward }); 242 + if ( 243 + this.#W || 244 + this.#S || 245 + this.#A || 246 + this.#D || 247 + this.#SPACE || 248 + this.#SHIFT 249 + ) { 250 + this.#dolly.push({ x: strafe, y: updown, z: forward }); 251 + } 91 252 } 92 253 93 254 if (abs(this.#ANALOG.move.z) > 0 || abs(this.#ANALOG.move.x) > 0) { ··· 148 309 if (this.cam.rotX < -maxPitch) this.cam.rotX = -maxPitch; 149 310 150 311 this.#dolly.sim(); 312 + 313 + // 🌍 Grounded physics pass — only when opted-in via opts.gravity. 314 + if (this.#physicsEnabled) { 315 + const dt = 1 / this.#simHz; 316 + 317 + // Jump: SPACE while grounded. Holding SPACE re-jumps on landing. 318 + if (!this.#frozen && this.#SPACE && this.#onGround) { 319 + this.#worldYVel = this.#jumpVelocity; 320 + this.#onGround = false; 321 + } 322 + 323 + // Gravity + vertical integration. Always runs, even when frozen, so a 324 + // dead player still falls into the pit at a constant rate. 325 + if (!this.#onGround || this.#frozen) { 326 + this.#worldYVel -= this.#gravity * dt; 327 + // cam.y is inverted from world Y (see constructor): world-up is -cam.y, 328 + // so a positive worldYVel (up) decreases cam.y. 329 + this.cam.y -= this.#worldYVel * dt; 330 + } 331 + 332 + // Crouch lerp (shift toggles between stand and crouch eye height). 333 + const crouchTarget = (!this.#frozen && this.#SHIFT) ? 1 : 0; 334 + this.#crouchT += (crouchTarget - this.#crouchT) * 0.25; 335 + if (this.#crouchT < 0.0005 && crouchTarget === 0) this.#crouchT = 0; 336 + if (this.#crouchT > 0.9995 && crouchTarget === 1) this.#crouchT = 1; 337 + const effEyeHeight = 338 + this.#eyeHeight + 339 + (this.#crouchEyeHeight - this.#eyeHeight) * this.#crouchT; 340 + 341 + // Only floor-clamp when the player is within the solid ground bounds. 342 + // Outside the rectangle, gravity continues uninterrupted → pit fall. 343 + let onSolidGround = true; 344 + if (this.#groundBounds) { 345 + const px = -this.cam.x; 346 + const pz = -this.cam.z; 347 + const b = this.#groundBounds; 348 + onSolidGround = 349 + px >= b.xMin && px <= b.xMax && pz >= b.zMin && pz <= b.zMax; 350 + } 351 + 352 + const floorCamY = -(this.#groundY + effEyeHeight); 353 + if (onSolidGround && !this.#frozen) { 354 + if (this.cam.y >= floorCamY) { 355 + this.cam.y = floorCamY; 356 + if (this.#worldYVel < 0) this.#worldYVel = 0; 357 + this.#onGround = true; 358 + } else if (this.#onGround) { 359 + // If we're above the floor but onGround was set (e.g., eye height 360 + // just shrank because of crouch release), keep the eye glued to the 361 + // ground by snapping cam.y — no falling from a crouch-release. 362 + this.cam.y = floorCamY; 363 + } 364 + } else { 365 + // Over the edge (or frozen/dead): never grounded, just keep falling. 366 + this.#onGround = false; 367 + } 368 + 369 + // 💀 Death floor clamp — once frozen and the eye reaches the lava, 370 + // stop falling so the player lies on top of the pit instead of 371 + // descending forever. 372 + if (this.#frozen && this.#deathFloorY !== null) { 373 + const lavaCamY = -(this.#deathFloorY + this.#deathFloorEyeClearance); 374 + if (this.cam.y >= lavaCamY) { 375 + this.cam.y = lavaCamY; 376 + this.#worldYVel = 0; 377 + } 378 + } 379 + } 380 + 381 + // 🎥 Third-person lazy-follow. Compute the target offset (behind + above 382 + // the player along the current look direction) and lerp the applied 383 + // offset toward it. The offset is added to cam.x/y/z for rendering only 384 + // and is undone at the start of the next tick. 385 + if (this.#thirdPerson) { 386 + const rx = this.cam.rotX * Math.PI / 180; 387 + const ry = this.cam.rotY * Math.PI / 180; 388 + const cp = Math.cos(rx); 389 + const fx = Math.sin(ry) * cp; 390 + const fy = Math.sin(rx); 391 + const fz = Math.cos(ry) * cp; 392 + const d = this.#thirdPersonDistance; 393 + const h = this.#thirdPersonHeight; 394 + // Target offset in cam-space coords. 395 + const targetX = d * fx; 396 + const targetZ = d * fz; 397 + const targetY = d * fy - h; 398 + 399 + // Lazy lerp: on the first frame after enabling 3P, snap; otherwise ease. 400 + if (!this.#tpCurrent) { 401 + this.#tpCurrent = [targetX, targetY, targetZ]; 402 + } else { 403 + const k = this.#thirdPersonFollow; 404 + this.#tpCurrent[0] += (targetX - this.#tpCurrent[0]) * k; 405 + this.#tpCurrent[1] += (targetY - this.#tpCurrent[1]) * k; 406 + this.#tpCurrent[2] += (targetZ - this.#tpCurrent[2]) * k; 407 + } 408 + this.#appliedOffset[0] = this.#tpCurrent[0]; 409 + this.#appliedOffset[1] = this.#tpCurrent[1]; 410 + this.#appliedOffset[2] = this.#tpCurrent[2]; 411 + this.cam.x += this.#appliedOffset[0]; 412 + this.cam.y += this.#appliedOffset[1]; 413 + this.cam.z += this.#appliedOffset[2]; 414 + } 151 415 } 152 416 153 417 // TODO: Also add touch controls here. 154 418 act(e) { 419 + // 🔇 Window lost focus — drop every held input so keys don't "stick" when 420 + // the user alt-tabs / switches apps mid-movement. Same for visibility loss. 421 + if (e.is("defocus")) { 422 + this.clearHeldKeys(); 423 + return; 424 + } 425 + 155 426 // 💻️ Keyboard: WASD, SPACE and SHIFT for movement, ARROW KEYS for looking. 156 427 if (e.is("keyboard:down:w")) this.#W = true; 157 428 if (e.is("keyboard:down:s")) this.#S = true; ··· 212 483 213 484 if (e.is("gamepad")) { 214 485 const deadzone = 0.05; 215 - const moveDamp = 0.002; 486 + const moveDamp = this.#moveDamp; 216 487 const lookDamp = 1; 217 488 218 489 // Detect controller type
+5 -6
system/public/aesthetic.computer/lib/disk.mjs
··· 9413 9413 let doll; 9414 9414 9415 9415 boot = ($) => { 9416 - doll = new CamDoll($.Camera, $.Dolly, { 9417 - fov: 80, 9418 - z: 0, 9419 - y: 0, 9420 - sensitivity: 0.002, 9421 - }); 9416 + // Default camera opts; pieces can override via exported `fpsOpts` to 9417 + // enable grounded physics (gravity/jump/run/crouch) or just retune FOV. 9418 + const defaults = { fov: 80, z: 0, y: 0, sensitivity: 0.002 }; 9419 + const opts = { ...defaults, ...(module.fpsOpts || {}) }; 9420 + doll = new CamDoll($.Camera, $.Dolly, opts); 9422 9421 $commonApi.system.fps = { doll, renderStats: graph.renderStats }; 9423 9422 module?.boot?.($); 9424 9423 };
+101 -55
system/public/aesthetic.computer/lib/graph.mjs
··· 4055 4055 // Draws a filled triangle with vertex colors using barycentric interpolation for smooth gradients 4056 4056 // Now includes Z-depth testing for proper occlusion 4057 4057 function drawGradientTriangle(x1, y1, color1, z1, x2, y2, color2, z2, x3, y3, color3, z3) { 4058 - // Skip triangles with any vertex having extreme coordinates (clipping failure) 4059 - const maxCoord = max(width, height) * 10; 4058 + // Skip triangles with any vertex having extreme coordinates (clipping failure). 4059 + // After screen-space clipping all vertices should be within [0, width]×[0, height]; 4060 + // anything >2× screen is a clipping artifact (e.g. a near-plane shoot-off). 4061 + const maxCoord = max(width, height) * 2; 4060 4062 if (abs(x1) > maxCoord || abs(y1) > maxCoord || 4061 4063 abs(x2) > maxCoord || abs(y2) > maxCoord || 4062 4064 abs(x3) > maxCoord || abs(y3) > maxCoord) { ··· 4208 4210 // Each vertex has UV coordinates that map to a texture buffer 4209 4211 // Uses perspective-correct interpolation with clip-space W values (not NDC Z!) 4210 4212 // Also interpolates Z values for depth buffering 4211 - function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { 4212 - // Safety check: avoid division by zero or negative W values 4213 - if (w1 <= 0.001 || w2 <= 0.001 || w3 <= 0.001) { 4213 + function drawTexturedTriangle(x1, y1, uv1, z1, w1, x2, y2, uv2, z2, w2, x3, y3, uv3, z3, w3, texture, alphaMultiplier = 1.0) { 4214 + // Safety check: avoid division by zero or near-zero W values. This is stricter 4215 + // than "finite math only" but much looser than the clip-space near plane so 4216 + // near-camera floor geometry can survive clipping without exploding. 4217 + if (w1 < MIN_PERSPECTIVE_W || w2 < MIN_PERSPECTIVE_W || w3 < MIN_PERSPECTIVE_W) { 4214 4218 return; // Skip triangles with invalid depth 4215 4219 } 4216 - 4217 - // Skip triangles with any vertex having extreme coordinates (clipping failure) 4218 - const maxCoord = max(width, height) * 10; 4220 + 4221 + // Skip triangles with any vertex having extreme coordinates (clipping failure). 4222 + const maxCoord = max(width, height) * 2; 4219 4223 if (abs(x1) > maxCoord || abs(y1) > maxCoord || 4220 4224 abs(x2) > maxCoord || abs(y2) > maxCoord || 4221 4225 abs(x3) > maxCoord || abs(y3) > maxCoord) { ··· 8606 8610 const transformedA = a.transform(fullMatrix); 8607 8611 const transformedB = b.transform(fullMatrix); 8608 8612 8609 - // Skip drawing if either vertex is behind the camera (negative Z) 8610 - if (transformedA.pos[2] <= 0 || transformedB.pos[2] <= 0) { 8611 - continue; 8612 - } 8613 - 8614 - // Apply perspective divide and screen space transformation 8615 - const screenA = toScreenSpace(perspectiveDivide(transformedA)); 8616 - const screenB = toScreenSpace(perspectiveDivide(transformedB)); 8613 + const clippedLine = clipLineToFrustum(transformedA, transformedB); 8614 + if (clippedLine.length < 2) continue; 8615 + const [lineA, lineB] = clippedLine; 8616 + 8617 + // Apply perspective divide and screen space transformation 8618 + const screenA = toScreenSpace(perspectiveDivide(lineA)); 8619 + const screenB = toScreenSpace(perspectiveDivide(lineB)); 8620 + if ( 8621 + !Number.isFinite(screenA.pos[0]) || !Number.isFinite(screenA.pos[1]) || 8622 + !Number.isFinite(screenB.pos[0]) || !Number.isFinite(screenB.pos[1]) 8623 + ) { 8624 + continue; 8625 + } 8617 8626 8618 8627 const clipped = clipLineToScreen( 8619 8628 screenA.pos[0], ··· 8673 8682 8674 8683 // Clip against all frustum planes in clip-space (before perspective divide) 8675 8684 clippedVertices = clipInClipSpace(clippedVertices, ["near", "left", "right", "bottom", "top"]); 8676 - 8685 + 8677 8686 // Skip if triangle was completely clipped 8678 8687 if (clippedVertices.length < 3) { 8679 8688 continue; 8680 8689 } 8681 8690 8691 + // Belt-and-suspenders: skip any polygon where a vertex has W too small 8692 + // for a reliable perspective divide. Clip-space clipping should already 8693 + // enforce this via NEAR_CLIP_Z, but numerical drift (especially with the 8694 + // projection's W_clip = Z_view convention) can leave a vertex just under. 8695 + let anyWTooSmall = false; 8696 + for (let vi = 0; vi < clippedVertices.length; vi++) { 8697 + if (clippedVertices[vi].pos[W] < MIN_PERSPECTIVE_W) { anyWTooSmall = true; break; } 8698 + } 8699 + if (anyWTooSmall) continue; 8700 + 8682 8701 // Count clipped triangle (only if it resulted in a polygon) 8683 8702 if (clippedVertices.length >= 3) { 8684 8703 renderStats.clippedTriangles++; 8685 8704 } 8686 8705 8687 8706 // Calculate fade factor based on closest vertex to camera (use Z from clip space) 8688 - const nearPlane = 0.01; 8707 + const nearPlane = NEAR_CLIP_Z; 8689 8708 const fadePlane = 0.5; 8690 8709 const minZ = min(...clippedVertices.map(v => v.pos[2])); 8691 8710 let alphaMultiplier = 1.0; ··· 8907 8926 8908 8927 // Sutherland-Hodgman clipping algorithm for clip-space (before perspective divide) 8909 8928 // This properly handles near-plane clipping by testing against w component 8929 + // Minimum clip-space Z kept after near-plane clipping. Because the current 8930 + // projection has W_clip = Z_view, this is effectively the minimum W we allow 8931 + // post-clip, and must be comfortably above 0 so perspective divide stays stable. 8932 + // Too small → near-camera triangles "shoot off" the screen. 8933 + // Too large → noticeable geometry popping right in front of the camera. 8934 + const NEAR_CLIP_Z = 0.02; 8935 + const MIN_PERSPECTIVE_W = 0.001; 8936 + 8910 8937 function clipInClipSpace(vertices, clippingBoundary) { 8911 8938 let clipped = []; 8912 8939 ··· 8923 8950 case "top": 8924 8951 return p[Y] <= w; 8925 8952 case "near": 8926 - return p[Z] >= 0.01; // Near plane with small epsilon to prevent z-fighting 8953 + // Clip at Z_clip >= NEAR_CLIP_Z. With this projection W_clip = Z_view and 8954 + // Z_clip ≈ Z_view - tiny, so this also guarantees W >= NEAR_CLIP_Z post-clip, 8955 + // which keeps the perspective divide numerically stable. A too-small threshold 8956 + // lets near-camera vertices through with tiny W, producing huge post-divide 8957 + // screen coordinates ("shooting off" triangles). 8958 + return p[Z] >= NEAR_CLIP_Z; 8927 8959 case "far": 8928 8960 return p[Z] <= w; 8929 8961 } ··· 8933 8965 const p1 = v1.pos; 8934 8966 const p2 = v2.pos; 8935 8967 let t; 8936 - 8968 + 8937 8969 switch (edge) { 8938 8970 case "left": 8939 8971 t = (-p1[W] - p1[X]) / ((p2[X] - p1[X]) + (p2[W] - p1[W])); ··· 8948 8980 t = (p1[W] - p1[Y]) / ((p2[Y] - p1[Y]) - (p2[W] - p1[W])); 8949 8981 break; 8950 8982 case "near": 8951 - t = (0.01 - p1[Z]) / (p2[Z] - p1[Z]); 8983 + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); 8952 8984 break; 8953 8985 case "far": 8954 8986 t = (p1[W] - p1[Z]) / ((p2[Z] - p1[Z]) - (p2[W] - p1[W])); ··· 9106 9138 return p[X] <= p[W]; 9107 9139 case "bottom": 9108 9140 return p[Y] >= -p[W]; 9109 - case "top": 9110 - return p[Y] <= p[W]; 9111 - case "near": 9112 - return p[Z] >= 0; 9113 - case "far": 9114 - return p[Z] <= p[W]; 9115 - } 9141 + case "top": 9142 + return p[Y] <= p[W]; 9143 + case "near": 9144 + return p[Z] >= NEAR_CLIP_Z; 9145 + case "far": 9146 + return p[Z] <= p[W]; 9147 + } 9116 9148 } 9117 9149 9118 9150 function computeIntersection(p1, p2, edge) { ··· 9127 9159 case "bottom": 9128 9160 t = (-p1[W] - p1[Y]) / (p2[Y] - p1[Y] + p2[W] - p1[W]); 9129 9161 break; 9130 - case "top": 9131 - t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); 9132 - break; 9133 - case "near": 9134 - t = (0 - p1[Z]) / (p2[Z] - p1[Z]); 9135 - break; 9136 - case "far": 9137 - t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); 9138 - break; 9139 - } 9140 - 9141 - return [ 9142 - p1[X] + t * (p2[X] - p1[X]), 9143 - p1[Y] + t * (p2[Y] - p1[Y]), 9144 - p1[Z] + t * (p2[Z] - p1[Z]), 9145 - p1[W] + t * (p2[W] - p1[W]), 9146 - ]; 9147 - } 9162 + case "top": 9163 + t = (p1[W] - p1[Y]) / (p2[Y] - p1[Y] - p2[W] + p1[W]); 9164 + break; 9165 + case "near": 9166 + t = (NEAR_CLIP_Z - p1[Z]) / (p2[Z] - p1[Z]); 9167 + break; 9168 + case "far": 9169 + t = (p1[W] - p1[Z]) / (p2[Z] - p1[Z] - p2[W] + p1[W]); 9170 + break; 9171 + } 9172 + 9173 + if (!Number.isFinite(t)) t = 0.5; 9174 + t = max(0, min(1, t)); 9175 + 9176 + return t; 9177 + } 9148 9178 9149 9179 let clippedVertices = [v1, v2]; 9150 9180 ··· 9162 9192 // Both outside, discard both 9163 9193 clippedVertices = []; 9164 9194 break; 9165 - } else { 9166 - // One inside, one outside 9167 - const intersection = computeIntersection(p1.pos, p2.pos, edge); 9168 - const intersectionVertex = new Vertex(intersection, p1.color); 9195 + } else { 9196 + // One inside, one outside 9197 + const t = computeIntersection(p1.pos, p2.pos, edge); 9198 + const intersectionVertex = new Vertex( 9199 + vec4.lerp(vec4.create(), p1.pos, p2.pos, t), 9200 + [ 9201 + p1.color[0] + (p2.color[0] - p1.color[0]) * t, 9202 + p1.color[1] + (p2.color[1] - p1.color[1]) * t, 9203 + p1.color[2] + (p2.color[2] - p1.color[2]) * t, 9204 + p1.color[3] + (p2.color[3] - p1.color[3]) * t, 9205 + ], 9206 + [ 9207 + p1.texCoords[0] + (p2.texCoords[0] - p1.texCoords[0]) * t, 9208 + p1.texCoords[1] + (p2.texCoords[1] - p1.texCoords[1]) * t, 9209 + ], 9210 + ); 9169 9211 9170 9212 if (p1Inside) { 9171 9213 // p1 inside, p2 outside ··· 9180 9222 return clippedVertices; 9181 9223 } 9182 9224 9183 - function perspectiveDivide(vertex) { 9184 - // Safety check: avoid division by very small W values 9185 - const w = vertex.pos[W]; 9186 - if (abs(w) < 0.001) { 9225 + function perspectiveDivide(vertex) { 9226 + // Safety check: avoid division by very small W values 9227 + const w = vertex.pos[W]; 9228 + if (abs(w) < MIN_PERSPECTIVE_W) { 9187 9229 // Return a vertex marked as invalid (will be filtered out) 9188 9230 const vert = new Vertex([NaN, NaN, NaN, w]); 9189 9231 vert.color = vertex.color; ··· 9279 9321 const w2 = v2.pos[3]; 9280 9322 9281 9323 // Fall back to simple linear interpolation if W values are degenerate 9282 - const safeW = w1 > 0.001 && w2 > 0.001 && Number.isFinite(w1) && Number.isFinite(w2); 9324 + const safeW = 9325 + w1 > MIN_PERSPECTIVE_W && 9326 + w2 > MIN_PERSPECTIVE_W && 9327 + Number.isFinite(w1) && 9328 + Number.isFinite(w2); 9283 9329 9284 9330 let newW; 9285 9331 if (safeW) {