Something went wrong. Try again.
Monorepo for Aesthetic.Computer aesthetic.computer
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710// TODO:// CamDoll// A 3D camera dolly with user input.
// TODO: Use key repeat / e.repeat to shorten code if necessary / prevent// unwanted resets of state / ✨ allow disabling of state... ✨ - 24.07.03.02.50
import { GAMEPAD_MAPPINGS } from "./gamepad-mappings.mjs";import { resolveObstacles } from "./pmove.mjs";
const { abs } = Math;
export class CamDoll { cam; sensitivity;
#dolly;
// Keyboard controls. #W; #S; #A; #D; #SPACE; #SHIFT; #UP; #DOWN; #LEFT; #RIGHT;
// Gamepad button look controls (8BitDo Micro face buttons) #BTN_LOOK_UP; #BTN_LOOK_DOWN; #BTN_LOOK_LEFT; #BTN_LOOK_RIGHT;
// Button look velocity for smoothing #buttonLookVelX = 0; #buttonLookVelY = 0; #buttonLookAccel = 0.05; // Even more subtle acceleration #buttonLookDecel = 0.85; #buttonLookMaxSpeed = 1.0; // Slower max speed
// Stick based controls. #ANALOG = { move: { x: 0, y: 0, z: 0, }, look: { x: 0, y: 0, z: 0, }, };
#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;
// 🏃 Quake-style air physics. Set to a number to enable strafe-jumping / // bunny-hop: in air the dolly's horizontal decay is replaced by an // air-accelerate primitive that adds velocity along wishdir without // touching perpendicular speed. On the ground a friction+accelerate model // takes over. Must mirror lib/pmove.mjs so the server arrives at the same // state. #airAccel = null; #groundAccel = 80; #airCapSpeed = 1.5; #groundFriction = 6; #obstacles = null; #playerRadius = 0.4; #deathFloorEyeClearance = 0.3;
// 🖖 Disable built-in touch controls for pieces that implement custom mobile UI #disableTouchControls = false;
constructor(Camera, Dolly, opts) { this.cam = new Camera(opts.fov || 80, { z: opts.z || 0, y: opts.y * -1 || -0.5, scale: [1, 1, 1], }); this.sensitivity = opts.sensitivity || 0.00025; this.#dolly = new Dolly(this.cam); // moves the camera
if (opts.disableTouchControls === true) this.#disableTouchControls = true;
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; // 🏃 Quake-style physics — opt-in. When enabled the dolly stops decaying // (we own velocity) and friction/air-accel run inline below. if (typeof opts.airAccel === "number") { this.#airAccel = opts.airAccel; if (typeof opts.groundAccel === "number") this.#groundAccel = opts.groundAccel; if (typeof opts.airCapSpeed === "number") this.#airCapSpeed = opts.airCapSpeed; if (typeof opts.groundFriction === "number") this.#groundFriction = opts.groundFriction; } if (Array.isArray(opts.obstacles)) this.#obstacles = opts.obstacles; if (typeof opts.playerRadius === "number") this.#playerRadius = opts.playerRadius; // Snap camera onto the floor at boot. this.cam.y = -(this.#groundY + this.#eyeHeight); } }
/** 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; }
/** Apply a one-shot impulse in world space. Horizontal goes through the * dolly (decay-driven, so it fades over ~10 frames); vertical adds to * worldYVel directly. Used by external systems like grenade explosions. */ applyImpulse({ x = 0, y = 0, z = 0 } = {}) { // cam.x = -worldX, cam.z = -worldZ; dolly applies xVel directly to cam.x, // so to push +worldX we feed -x into the dolly. // In Q3 mode our dolly.xVel is per-frame (= worldVel/simHz), so scale // accordingly. Legacy mode keeps the old per-tick convention untouched. const scale = this.#airAccel !== null ? 1 / this.#simHz : 1; if (x) this.#dolly.xVel += -x * scale; if (z) this.#dolly.zVel += -z * scale; if (y) { this.#worldYVel += y; if (y > 0) this.#onGround = false; } }
/** Set movement key state directly (for mobile UI buttons). */ setMovement(dir, pressed) { if (dir === "forward") this.#W = pressed; else if (dir === "back") this.#S = pressed; else if (dir === "left") this.#A = pressed; else if (dir === "right") this.#D = pressed; else if (dir === "jump") this.#SPACE = pressed; else if (dir === "crouch") this.#SHIFT = pressed; }
/** 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.#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. // In Q3 mode we own horizontal velocity directly (qStep below); skip // dolly.push so we don't double-apply input. if ((this.#W || this.#S || this.#A || this.#D) && this.#airAccel === null) { this.#dolly.push({ x: strafe, y: 0, z: forward }); } } else { 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) && this.#airAccel === null ) { // console.log("Over zero:", this.#ANALOG.move.z); this.#dolly.push({ x: this.#ANALOG.move.x, y: 0, z: this.#ANALOG.move.z, }); }
if (abs(this.#ANALOG.look.x) > 0 || abs(this.#ANALOG.look.y) > 0) { this.cam.rotX += this.#ANALOG.look.x; this.cam.rotY += this.#ANALOG.look.y; }
// Button-based looking with acceleration/deceleration (gamepad face buttons) // Apply acceleration when buttons are held if (this.#BTN_LOOK_UP || this.#BTN_LOOK_DOWN) { if (this.#BTN_LOOK_UP) this.#buttonLookVelY += this.#buttonLookAccel; if (this.#BTN_LOOK_DOWN) this.#buttonLookVelY -= this.#buttonLookAccel; // Clamp to max speed if (this.#buttonLookVelY > this.#buttonLookMaxSpeed) this.#buttonLookVelY = this.#buttonLookMaxSpeed; if (this.#buttonLookVelY < -this.#buttonLookMaxSpeed) this.#buttonLookVelY = -this.#buttonLookMaxSpeed; } else { // Decelerate when no button pressed this.#buttonLookVelY *= this.#buttonLookDecel; if (abs(this.#buttonLookVelY) < 0.01) this.#buttonLookVelY = 0; }
if (this.#BTN_LOOK_LEFT || this.#BTN_LOOK_RIGHT) { if (this.#BTN_LOOK_RIGHT) this.#buttonLookVelX += this.#buttonLookAccel; if (this.#BTN_LOOK_LEFT) this.#buttonLookVelX -= this.#buttonLookAccel; // Clamp to max speed if (this.#buttonLookVelX > this.#buttonLookMaxSpeed) this.#buttonLookVelX = this.#buttonLookMaxSpeed; if (this.#buttonLookVelX < -this.#buttonLookMaxSpeed) this.#buttonLookVelX = -this.#buttonLookMaxSpeed; } else { // Decelerate when no button pressed this.#buttonLookVelX *= this.#buttonLookDecel; if (abs(this.#buttonLookVelX) < 0.01) this.#buttonLookVelX = 0; }
// Apply button look velocity to camera this.cam.rotX += this.#buttonLookVelY; this.cam.rotY += this.#buttonLookVelX;
// Keyboard arrow keys if (this.#UP) this.cam.rotX += 1; if (this.#DOWN) this.cam.rotX -= 1; if (this.#LEFT) this.cam.rotY -= 1; if (this.#RIGHT) this.cam.rotY += 1; // Clamp pitch to prevent looking past straight up/down (prevents gimbal lock) const maxPitch = 89; if (this.cam.rotX > maxPitch) this.cam.rotX = maxPitch; if (this.cam.rotX < -maxPitch) this.cam.rotX = -maxPitch;
// 🏃 Q3 step: when airAccel is enabled we own the dolly's horizontal // velocity. Read previous-frame world velocity from the dolly, run // friction (ground) + accelerate (ground/air), and write back. The // dolly's decay is set to 1 so dolly.sim() acts as pure integration. if (this.#physicsEnabled && this.#airAccel !== null) { this.#dolly.dec = 1; const dt = 1 / this.#simHz;
// Per-frame dolly velocities are camera-coords (cam.x = -worldX), // so worldVel = -dollyVel * simHz. Convert in & out. let wvx = -this.#dolly.xVel * this.#simHz; let wvz = -this.#dolly.zVel * this.#simHz;
// Wishvel from W/S/A/D + analog (camera-local → world by yaw). let ix = 0, iz = 0; if (this.#D) ix += 1; if (this.#A) ix -= 1; if (this.#W) iz += 1; if (this.#S) iz -= 1; if (this.#moveDamp > 0) { const ax = this.#ANALOG.move.x / this.#moveDamp; const az = this.#ANALOG.move.z / this.#moveDamp; // Analog convention: dolly.push x = camera-local +x (A→strafe-left), // z = -forward (W → -push). Our ix is "right=+1", iz is "fwd=+1", so // negate to map. ix += -ax > 1 ? 1 : -ax < -1 ? -1 : -ax; iz += -az > 1 ? 1 : -az < -1 ? -1 : -az; } const ilen = Math.hypot(ix, iz); if (ilen > 1) { ix /= ilen; iz /= ilen; }
const speed = this.#SHIFT ? this.#walkSpeed : this.#runSpeed; const yr = this.cam.rotY * Math.PI / 180; const sy = Math.sin(yr), cy = Math.cos(yr); const wx = (ix * cy + iz * sy) * speed; const wz = (-ix * sy + iz * cy) * speed; const wishLen = Math.hypot(wx, wz); const wdx = wishLen > 1e-6 ? wx / wishLen : 0; const wdz = wishLen > 1e-6 ? wz / wishLen : 0;
// Edge-trigger jump BEFORE friction so a bhop landing keeps speed. if (!this.#frozen && this.#SPACE && this.#onGround) { this.#worldYVel = this.#jumpVelocity; this.#onGround = false; }
// Friction (ground only). if (this.#onGround && !this.#frozen) { const sp = Math.hypot(wvx, wvz); if (sp < 1e-4) { wvx = 0; wvz = 0; } else { const stop = Math.max(sp, this.#runSpeed * 0.5); const drop = stop * this.#groundFriction * dt; const k = Math.max(0, sp - drop) / sp; wvx *= k; wvz *= k; } }
// Accelerate. if (wishLen > 1e-6 && !this.#frozen) { const accel = this.#onGround ? this.#groundAccel : this.#airAccel; const cap = this.#onGround ? wishLen : Math.min(wishLen, this.#airCapSpeed); const cur = wvx * wdx + wvz * wdz; const addspeed = cap - cur; if (addspeed > 0) { let mag = accel * dt * cap; if (mag > addspeed) mag = addspeed; wvx += wdx * mag; wvz += wdz * mag; } }
// Write back as per-frame cam-coords for dolly.sim() to integrate. this.#dolly.xVel = -wvx / this.#simHz; this.#dolly.zVel = -wvz / this.#simHz; }
this.#dolly.sim();
// Static obstacle push-out (after horizontal integration). if (this.#airAccel !== null && this.#obstacles) { const ws = { x: -this.cam.x, z: -this.cam.z, y: -this.cam.y, vx: -this.#dolly.xVel * this.#simHz, vz: -this.#dolly.zVel * this.#simHz, }; resolveObstacles(ws, { obstacles: this.#obstacles, playerRadius: this.#playerRadius, eyeHeight: this.#eyeHeight, }); this.cam.x = -ws.x; this.cam.z = -ws.z; this.#dolly.xVel = -ws.vx / this.#simHz; this.#dolly.zVel = -ws.vz / this.#simHz; }
// 🌍 Grounded physics pass — only when opted-in via opts.gravity. if (this.#physicsEnabled) { const dt = 1 / this.#simHz;
// 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; if (e.is("keyboard:down:a")) this.#A = true; if (e.is("keyboard:down:d")) this.#D = true;
if (e.is("keyboard:up:w")) this.#W = false; if (e.is("keyboard:up:s")) this.#S = false; if (e.is("keyboard:up:a")) this.#A = false; if (e.is("keyboard:up:d")) this.#D = false;
if (e.is("keyboard:down:space")) this.#SPACE = true; if (e.ctrl === false && e.is("keyboard:down:shift")) this.#SHIFT = true; if (e.is("keyboard:up:space")) this.#SPACE = false; if (e.is("keyboard:up:shift")) this.#SHIFT = false;
if (e.is("keyboard:down:arrowup")) this.#UP = true; if (e.is("keyboard:down:arrowdown")) this.#DOWN = true; if (e.is("keyboard:down:arrowleft")) this.#LEFT = true; if (e.is("keyboard:down:arrowright")) this.#RIGHT = true;
if (e.is("keyboard:up:arrowup")) this.#UP = false; if (e.is("keyboard:up:arrowdown")) this.#DOWN = false; if (e.is("keyboard:up:arrowleft")) this.#LEFT = false; if (e.is("keyboard:up:arrowright")) this.#RIGHT = false;
if (e.is("pen:locked")) this.#penLocked = true; if (e.is("pen:unlocked")) this.#penLocked = false;
// Note: Sometimes multiple mouse buttons can be held... in which case // e.button only holds the original (duplicate events are not sent). // TODO: These rates should be adjusted on mobile. if ( (!this.#penLocked && e.is("draw")) || (this.#penLocked && e.is("move")) ) { if (this.cam.type === "perspective") { this.cam.rotX -= e.delta.y / 3.5; this.cam.rotY += e.delta.x / 3.5; // Clamp pitch to prevent looking past straight up/down const maxPitch = 89; if (this.cam.rotX > maxPitch) this.cam.rotX = maxPitch; if (this.cam.rotX < -maxPitch) this.cam.rotX = -maxPitch; } }
// 🖖 Touch (disabled if piece handles custom mobile UI) if (!this.#disableTouchControls) { // Two fingers for move forward. if (e.is("touch:2")) this.#W = true; if (e.is("lift:2")) this.#W = false;
// Three fingers for moving backward. if (e.is("touch:3")) this.#S = true; if (e.is("lift:3")) this.#S = false; }
// 🎮 Gamepad
if (e.is("gamepad")) { const deadzone = 0.05; const moveDamp = this.#moveDamp; const lookDamp = 1;
// Detect controller type const gamepadId = e.gamepadId || "standard"; const is8BitDoMicro = gamepadId.includes("8BitDo Micro"); const mapping = GAMEPAD_MAPPINGS[gamepadId] || GAMEPAD_MAPPINGS["standard"];
// 🕹️ D-PAD MOVEMENT (axes 0-1) // Axis 1: Forward/Backward (up/down = W/S) if (e.is("gamepad:0:axis:1")) { if (abs(e.value) < deadzone) { this.#ANALOG.move.z = 0; } else { this.#ANALOG.move.z = e.value * moveDamp; } }
// Axis 0: Strafe Left/Right (A/D) if (e.is("gamepad:0:axis:0")) { if (abs(e.value) < deadzone) { this.#ANALOG.move.x = 0; } else { // For 8BitDo Micro: left=-1 should move left (negative x), right=+1 should move right (positive x) this.#ANALOG.move.x = -e.value * moveDamp; // Negated to fix direction } }
// 🎮 Button-based looking (for controllers without right analog stick like 8BitDo Micro) // Check for button events if (e.button !== undefined && e.action) { const btn = e.button; const isPressed = e.action === "push"; // For 8BitDo Micro: use face buttons for looking if (is8BitDoMicro) { if (btn === 0) this.#BTN_LOOK_RIGHT = isPressed; // A (right) if (btn === 1) this.#BTN_LOOK_DOWN = isPressed; // B (bottom) if (btn === 3) this.#BTN_LOOK_UP = isPressed; // X (top) if (btn === 4) this.#BTN_LOOK_LEFT = isPressed; // Y (left) } }
// 🕹️ ANALOG STICK LOOKING (for standard controllers with right analog stick) if (!is8BitDoMicro) { // Axis 3: Look Up/Down (right stick vertical) → controls pitch (rotX) if (e.is("gamepad:0:axis:3:move")) { if (abs(e.value) < deadzone) { this.#ANALOG.look.x = 0; } else { this.#ANALOG.look.x = -e.value * lookDamp; } }
// Axis 2: Look Left/Right (right stick horizontal) → controls yaw (rotY) if (e.is("gamepad:0:axis:2:move")) { if (abs(e.value) < deadzone) { this.#ANALOG.look.y = 0; } else { this.#ANALOG.look.y = e.value * lookDamp; } } } } }}