Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

arena: click-to-fire grenade launcher with pill-shaped blast

One in-flight grenade per player; left-click after pen-lock fires a
Q3-style projectile that bounces, fuses for 1s, then detonates as a
wireframe pill. Blast applies a falloff impulse via a new cam-doll
applyImpulse() — strong enough to punt the firer off the platform
into the lava. Reconciliation is suspended ~1.2s post-detonation so
the server's pmove prediction doesn't yank you back mid-flight.
"Gonk" launcher SFX + boom modeled on lib/percussion.mjs's TR-808 kick.

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

+278 -1
+264 -1
system/public/aesthetic.computer/disks/arena.mjs
··· 241 241 // yanked to wherever they are. So just skip local pmove reconciliation 242 242 // (we still update cam to follow them below if we want spectator-follow). 243 243 if (netSpectator) return; 244 + // 🎆 Knockback grace: a grenade explosion just punted us via applyImpulse. 245 + // The server doesn't know about that impulse (it sees only WASD cmds), so 246 + // its predicted state will diverge by several units. Skip reconciliation 247 + // briefly so the blast actually launches us instead of being snapped back. 248 + if (Date.now() < knockbackGraceUntil) return; 244 249 const cam = reconCamRef; 245 250 246 251 // Build a pmove-compatible state from the wire blob. ··· 605 610 let playerAlive = true; 606 611 let deathTickAge = 0; // how long we've been dead (sim ticks, for UI fade-in) 607 612 613 + // 🎆 Grenade SFX — synthesis informed by lib/percussion.mjs's TR-808 kick: 614 + // `gonk` is a wood-tom-like thunk for the launcher (high noise tick + tonal 615 + // mid-low pop); `boom` is a stacked noise/sine burst for the detonation. 616 + // Voiced through sound.synth (same surface notepat.mjs uses). 617 + function playGonk(sound) { 618 + if (!sound?.synth) return; 619 + const tone = 420 + Math.random() * 80; 620 + sound.synth({ type: "noise", tone: 4500, duration: 0.005, volume: 0.45, attack: 0.0001, decay: 0.0049, pan: 0 }); 621 + sound.synth({ type: "square", tone, duration: 0.025, volume: 0.55, attack: 0.0005, decay: 0.024, pan: 0 }); 622 + sound.synth({ type: "sine", tone: tone*0.5,duration: 0.060, volume: 0.95, attack: 0.001, decay: 0.058, pan: 0 }); 623 + sound.synth({ type: "sine", tone: 90, duration: 0.18, volume: 0.55, attack: 0.002, decay: 0.175, pan: 0 }); 624 + } 625 + function playBoom(sound) { 626 + if (!sound?.synth) return; 627 + sound.synth({ type: "noise", tone: 1800, duration: 0.18, volume: 1.0, attack: 0.001, decay: 0.18, pan: 0 }); 628 + sound.synth({ type: "noise", tone: 600, duration: 0.45, volume: 0.7, attack: 0.005, decay: 0.44, pan: 0 }); 629 + sound.synth({ type: "sine", tone: 75, duration: 0.55, volume: 1.1, attack: 0.003, decay: 0.54, pan: 0 }); 630 + sound.synth({ type: "triangle", tone: 45, duration: 0.40, volume: 0.6, attack: 0.005, decay: 0.39, pan: 0 }); 631 + } 632 + 633 + function fireGrenade(cam) { 634 + if (!cam || myGrenade || !playerAlive || netSpectator) return; 635 + // Camera world position (cam stores negated world coords for x,z; cam.y is 636 + // the eye world-Y inverted). Spawning at cam (not the 3P offset) means the 637 + // grenade leaves the muzzle, not the chase cam. 638 + const cx = -cam.x, cy = -cam.y, cz = -cam.z; 639 + // View forward in world space — same convention as the hover-ray (see sim). 640 + const yaw = cam.rotY * Math.PI / 180; 641 + const pit = cam.rotX * Math.PI / 180; 642 + const cy2 = Math.cos(yaw), sy = Math.sin(yaw); 643 + const cp = Math.cos(pit), sp = Math.sin(pit); 644 + const flipX = (hoverFlipMode & 1) !== 0; 645 + const flipZ = (hoverFlipMode & 2) !== 0; 646 + let fx = sy * cp; 647 + let fy = sp; 648 + let fz = cy2 * cp; 649 + if (flipX) fx = -fx; 650 + if (flipZ) fz = -fz; 651 + myGrenade = { 652 + x: cx + fx * 0.6, 653 + y: cy + fy * 0.6 - 0.25, // muzzle just below eye 654 + z: cz + fz * 0.6, 655 + vx: fx * GRENADE_SPEED, 656 + vy: fy * GRENADE_SPEED + 2.0, // gentle upward arc 657 + vz: fz * GRENADE_SPEED, 658 + t: 0, 659 + }; 660 + playGonk(soundRef); 661 + } 662 + 663 + function simGrenade(dt, doll, cam) { 664 + if (!myGrenade) return; 665 + myGrenade.t += dt; 666 + myGrenade.vy -= GRENADE_GRAVITY * dt; 667 + myGrenade.x += myGrenade.vx * dt; 668 + myGrenade.y += myGrenade.vy * dt; 669 + myGrenade.z += myGrenade.vz * dt; 670 + 671 + // Floor bounce — only on top of the platform; outside groundBounds the 672 + // grenade falls into the pit and silently dies. 673 + const b = ARENA_CFG.groundBounds; 674 + const onPlatform = myGrenade.x >= b.xMin && myGrenade.x <= b.xMax && 675 + myGrenade.z >= b.zMin && myGrenade.z <= b.zMax; 676 + const floorY = ARENA_CFG.groundY + GRENADE_RADIUS; 677 + if (onPlatform && myGrenade.y <= floorY && myGrenade.vy < 0) { 678 + myGrenade.y = floorY; 679 + myGrenade.vy = -myGrenade.vy * GRENADE_BOUNCE_RESTITUTION; 680 + myGrenade.vx *= GRENADE_BOUNCE_FRICTION; 681 + myGrenade.vz *= GRENADE_BOUNCE_FRICTION; 682 + if (Math.abs(myGrenade.vy) < GRENADE_REST_VY) myGrenade.vy = 0; 683 + } 684 + 685 + // Bounce off platform edge walls (treat the platform XZ rect like solid box). 686 + if (myGrenade.y < ARENA_CFG.groundY + 0.5) { 687 + if (myGrenade.x < b.xMin && myGrenade.vx < 0) { myGrenade.x = b.xMin; myGrenade.vx = -myGrenade.vx * GRENADE_BOUNCE_RESTITUTION; } 688 + if (myGrenade.x > b.xMax && myGrenade.vx > 0) { myGrenade.x = b.xMax; myGrenade.vx = -myGrenade.vx * GRENADE_BOUNCE_RESTITUTION; } 689 + if (myGrenade.z < b.zMin && myGrenade.vz < 0) { myGrenade.z = b.zMin; myGrenade.vz = -myGrenade.vz * GRENADE_BOUNCE_RESTITUTION; } 690 + if (myGrenade.z > b.zMax && myGrenade.vz > 0) { myGrenade.z = b.zMax; myGrenade.vz = -myGrenade.vz * GRENADE_BOUNCE_RESTITUTION; } 691 + } 692 + 693 + // Fell into the lava → fizzle, no big boom (and no knockback either). 694 + if (myGrenade.y < ARENA_CFG.deathFloorY) { 695 + myGrenade = null; 696 + return; 697 + } 698 + 699 + if (myGrenade.t >= GRENADE_FUSE) { 700 + detonate(myGrenade.x, myGrenade.y, myGrenade.z, doll, cam); 701 + myGrenade = null; 702 + } 703 + } 704 + 705 + function detonate(x, y, z, doll, cam) { 706 + activeExplosion = { x, y, z, age: 0 }; 707 + playBoom(soundRef); 708 + if (!doll || !cam) return; 709 + const phys = doll.physics; 710 + const pcx = phys?.playerCamX ?? cam.x; 711 + const pcy = phys?.playerCamY ?? cam.y; 712 + const pcz = phys?.playerCamZ ?? cam.z; 713 + const wx = -pcx, wy = -pcy, wz = -pcz; 714 + const dx = wx - x, dy = wy - y, dz = wz - z; 715 + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); 716 + if (dist >= EXPLOSION_RADIUS) return; 717 + const t = 1 - dist / EXPLOSION_RADIUS; // 1 at center → 0 at edge 718 + const minD = Math.max(dist, 0.4); 719 + const nx = dx / minD; 720 + const nz = dz / minD; 721 + const horForce = EXPLOSION_KICK_HOR * (0.4 + 0.6 * t); 722 + const vertForce = EXPLOSION_KICK_VERT * (0.3 + 0.7 * t) + EXPLOSION_VERT_BIAS; 723 + doll.applyImpulse?.({ 724 + x: nx * horForce, 725 + y: vertForce, 726 + z: nz * horForce, 727 + }); 728 + // Suspend pmove reconciliation briefly so the local knockback isn't yanked 729 + // back to the server's prediction (server doesn't know about the blast). 730 + knockbackGraceUntil = Date.now() + 1200; 731 + } 732 + 733 + function buildGrenadeForm(g) { 734 + if (!FormRef) return null; 735 + const r = GRENADE_RADIUS; 736 + const heat = Math.min(1, g.t / GRENADE_FUSE); 737 + const blink = (Math.sin(g.t * 36) * 0.5 + 0.5) * heat; 738 + const col = [ 739 + 1.0, 740 + 0.85 - heat * 0.55 + blink * 0.2, 741 + 0.25 - heat * 0.2 + blink * 0.05, 742 + 1.0, 743 + ]; 744 + // Octahedron — 6 verts, 8 triangles. Reads as a small spinning shell. 745 + const v = [ 746 + [ 0, r, 0, 1], [ 0, -r, 0, 1], 747 + [ r, 0, 0, 1], [-r, 0, 0, 1], 748 + [ 0, 0, r, 1], [ 0, 0,-r, 1], 749 + ]; 750 + const tris = [ 751 + [0,2,4],[0,4,3],[0,3,5],[0,5,2], 752 + [1,4,2],[1,3,4],[1,5,3],[1,2,5], 753 + ]; 754 + const positions = [], colors = []; 755 + for (const [a, b, c] of tris) { 756 + positions.push(v[a], v[b], v[c]); 757 + colors.push(col, col, col); 758 + } 759 + // Form.position uses (-worldX, worldY, -worldZ) — same as remote bodies. 760 + const f = new FormRef( 761 + { type: "triangle", positions, colors }, 762 + { pos: [-g.x, g.y, -g.z], rot: [0, g.t * 240, 0], scale: 1 }, 763 + ); 764 + f.noFade = true; 765 + return f; 766 + } 767 + 768 + function buildExplosionPill(ex) { 769 + if (!FormRef) return null; 770 + const t = ex.age / EXPLOSION_DURATION; 771 + if (t >= 1) return null; 772 + const ease = 1 - (1 - t) * (1 - t); // ease-out grow 773 + const scale = EXPLOSION_RADIUS * (0.35 + ease * 0.65); 774 + const alpha = Math.max(0, 1 - t); 775 + const R = scale * 0.55; 776 + const H = scale * 0.7; 777 + const ringSegs = 14; 778 + const hemiSegs = 4; 779 + 780 + const ringPoints = (y, r) => { 781 + const pts = []; 782 + for (let i = 0; i < ringSegs; i++) { 783 + const a = (i / ringSegs) * Math.PI * 2; 784 + pts.push([Math.cos(a) * r, y, Math.sin(a) * r, 1]); 785 + } 786 + return pts; 787 + }; 788 + const allRings = []; 789 + for (let i = 0; i <= hemiSegs; i++) { 790 + const phi = -Math.PI / 2 + (i / hemiSegs) * (Math.PI / 2); 791 + allRings.push(ringPoints(-H / 2 + R * Math.sin(phi), R * Math.cos(phi))); 792 + } 793 + for (let i = 1; i <= hemiSegs; i++) { 794 + const phi = (i / hemiSegs) * (Math.PI / 2); 795 + allRings.push(ringPoints( H / 2 + R * Math.sin(phi), R * Math.cos(phi))); 796 + } 797 + const positions = [], colors = []; 798 + const orange = [1.0, 0.65, 0.2, alpha]; 799 + const yellow = [1.0, 0.95, 0.55, alpha * 0.85]; 800 + for (const ring of allRings) { 801 + for (let i = 0; i < ringSegs; i++) { 802 + positions.push(ring[i], ring[(i + 1) % ringSegs]); 803 + colors.push(orange, orange); 804 + } 805 + } 806 + for (let i = 0; i < ringSegs; i += 2) { 807 + for (let r = 0; r < allRings.length - 1; r++) { 808 + positions.push(allRings[r][i], allRings[r + 1][i]); 809 + colors.push(yellow, yellow); 810 + } 811 + } 812 + const f = new FormRef( 813 + { type: "line", positions, colors }, 814 + { pos: [-ex.x, ex.y, -ex.z], rot: [0, 0, 0], scale: 1 }, 815 + ); 816 + f.noFade = true; 817 + return f; 818 + } 819 + 608 820 // Shared respawn trigger — touch, gamepad A, gamepad Start, and Space all 609 821 // route through this so the "TAP TO RESPAWN" prompt accepts every input. 610 822 function tryRespawn(system) { ··· 666 878 const PERF_HIGH_MS = 14; // above ~70fps → return to HIGH 667 879 let perfSamplesSinceSwitch = 0; 668 880 881 + // 🎆 Grenade launcher — Q3-style projectile, click to fire one at a time. 882 + // One in-flight grenade per player; fuse-detonates after a beat into a pill- 883 + // shaped blast that knocks the firer (and the cam-doll) outward. The blast 884 + // can punt the player off the platform and into the lava — that's the trick. 885 + const GRENADE_RADIUS = 0.18; 886 + const GRENADE_SPEED = 18; // initial m/s along the view dir 887 + const GRENADE_GRAVITY = 22; // m/s² (lighter than player gravity → arcs) 888 + const GRENADE_BOUNCE_RESTITUTION = 0.55; 889 + const GRENADE_BOUNCE_FRICTION = 0.78; // horizontal vel scaling per bounce 890 + const GRENADE_FUSE = 1.0; // seconds before detonation 891 + const GRENADE_REST_VY = 1.0; // settle threshold (avoid eternal jitter) 892 + const EXPLOSION_RADIUS = 4.5; // world units; blast falloff distance 893 + const EXPLOSION_DURATION = 0.45; // seconds the pill stays visible 894 + const EXPLOSION_KICK_HOR = 1.6; // dolly impulse → ~14u horizontal flight 895 + const EXPLOSION_KICK_VERT = 14; // worldYVel impulse (+ is up), units/sec 896 + const EXPLOSION_VERT_BIAS = 6; // baseline upward bump even at edge 897 + 898 + let myGrenade = null; // { x, y, z, vx, vy, vz, t } — local single-shot 899 + let activeExplosion = null; // { x, y, z, age } — local visual effect 900 + let knockbackGraceUntil = 0; // ms timestamp; while < now() pmove reconcile is suspended 901 + let soundRef = null; // captured in boot for synth access 902 + 669 903 // 🔎 Camera zoom — wheel scroll steps between 1P and 3P at discrete distances. 670 904 // Level 0 = first person. Levels 1..N = third person, pulling the camera back 671 905 // further each click. More close-in steps for shoulder-camera views. ··· 855 1089 ]; 856 1090 } 857 1091 858 - function boot({ Form, penLock, system, screen, ui, api, painting, net, handle, send, debug }) { 1092 + function boot({ Form, penLock, system, screen, ui, api, painting, net, handle, send, debug, sound }) { 859 1093 penLock(); 860 1094 FormRef = Form; 861 1095 paintingRef = painting; 1096 + soundRef = sound; 862 1097 863 1098 const cam = system?.fps?.doll?.cam; 864 1099 if (cam) { prevX = cam.x; prevY = cam.y; prevZ = cam.z; } ··· 1510 1745 // 🏟️ Multiplayer: compose usercmd, batch & flush at CMD_RATE. 1511 1746 netSim(cam); 1512 1747 1748 + // 🎆 Grenade physics — integrate position, bounce, detonate after fuse. 1749 + // Runs at SIM_HZ so the trajectory is identical regardless of paint FPS. 1750 + simGrenade(1 / SIM_HZ, doll, cam); 1751 + if (activeExplosion) { 1752 + activeExplosion.age += 1 / SIM_HZ; 1753 + if (activeExplosion.age >= EXPLOSION_DURATION) activeExplosion = null; 1754 + } 1755 + 1513 1756 // 📱 Update mobile button states 1514 1757 if (mobileButtons && doll) { 1515 1758 for (const [name, btnData] of Object.entries(mobileButtons)) { ··· 1879 2122 // 🏟️ Remote players (interpolated from server snapshots, rendered ~100ms behind). 1880 2123 paintRemotes(ink, undefined, FormRef); 1881 2124 2125 + // 🎆 Local grenade + pill-shaped explosion. Rebuilt per-frame because color 2126 + // alpha is baked into the geometry (matches the lava-floor approach above). 2127 + if (myGrenade) { 2128 + const gf = buildGrenadeForm(myGrenade); 2129 + if (gf) ink(255).form(gf); 2130 + } 2131 + if (activeExplosion) { 2132 + const ef = buildExplosionPill(activeExplosion); 2133 + if (ef) ink(255).form(ef); 2134 + } 2135 + 1882 2136 // --- HUD (top-right) --- 1883 2137 // Layout, top to bottom: 1884 2138 // 1. minimap (square showing live player positions) ··· 2439 2693 if (tryRespawn(system)) return; 2440 2694 // Don't re-lock on middle-click (1) or right-click (2) — reserved for camera control. 2441 2695 if (!penLocked && e.button !== 1 && e.button !== 2) penLock(); 2696 + // 🎆 Click-to-fire grenade — only after pen is locked, only when alive, 2697 + // and only one in-flight grenade at a time. The first click of a session 2698 + // locks the pen (above) and does not fire; subsequent left-clicks launch. 2699 + else if (e.button === 0 && playerAlive && !myGrenade && !netSpectator) { 2700 + fireGrenade(system?.fps?.doll?.cam); 2701 + } 2442 2702 } 2443 2703 2444 2704 // Space (jump key) also respawns when dead — parallels gamepad A. ··· 2504 2764 // deleted immediately instead of waiting for the 30s stale sweep. 2505 2765 function leave() { 2506 2766 try { netServer?.send("arena:bye", { handle: myHandle }); } catch {} 2767 + myGrenade = null; 2768 + activeExplosion = null; 2769 + knockbackGraceUntil = 0; 2507 2770 } 2508 2771 2509 2772 export const system = "fps";
+14
system/public/aesthetic.computer/lib/cam-doll.mjs
··· 185 185 /** True if third-person mode is currently active. */ 186 186 get thirdPerson() { return this.#thirdPerson; } 187 187 188 + /** Apply a one-shot impulse in world space. Horizontal goes through the 189 + * dolly (decay-driven, so it fades over ~10 frames); vertical adds to 190 + * worldYVel directly. Used by external systems like grenade explosions. */ 191 + applyImpulse({ x = 0, y = 0, z = 0 } = {}) { 192 + // cam.x = -worldX, cam.z = -worldZ; dolly applies xVel directly to cam.x, 193 + // so to push +worldX we feed -x into the dolly. 194 + if (x) this.#dolly.xVel += -x; 195 + if (z) this.#dolly.zVel += -z; 196 + if (y) { 197 + this.#worldYVel += y; 198 + if (y > 0) this.#onGround = false; 199 + } 200 + } 201 + 188 202 /** Set movement key state directly (for mobile UI buttons). */ 189 203 setMovement(dir, pressed) { 190 204 if (dir === "forward") this.#W = pressed;