Spaces:
Running
Running
| import * as THREE from 'three'; | |
| import { CFG } from './config.js'; | |
| import { G } from './globals.js'; | |
| import { getTerrainHeight, getNearbyTrees } from './world.js'; | |
| // Working vectors | |
| const FWD = new THREE.Vector3(); | |
| const RIGHT = new THREE.Vector3(); | |
| const NEXT = new THREE.Vector3(); | |
| const PREV = new THREE.Vector3(); | |
| // Helpers implementing Quake/Source-like movement in XZ plane | |
| function applyFriction(vel, friction, stopSpeed, dt) { | |
| const vx = vel.x, vz = vel.z; | |
| const speed = Math.hypot(vx, vz); | |
| if (speed <= 0.0001) return; | |
| const control = Math.max(speed, stopSpeed); | |
| const drop = control * friction * dt; | |
| const newSpeed = Math.max(0, speed - drop); | |
| if (newSpeed !== speed) { | |
| const k = newSpeed / speed; | |
| vel.x *= k; | |
| vel.z *= k; | |
| } | |
| } | |
| function accelerate(vel, wishDir, wishSpeed, accel, dt) { | |
| const current = vel.x * wishDir.x + vel.z * wishDir.z; | |
| let add = wishSpeed - current; | |
| if (add <= 0) return; | |
| const push = Math.min(accel * wishSpeed * dt, add); | |
| vel.x += wishDir.x * push; | |
| vel.z += wishDir.z * push; | |
| } | |
| function airAccelerate(vel, wishDir, wishSpeedCap, airAccel, dt) { | |
| const current = vel.x * wishDir.x + vel.z * wishDir.z; | |
| const wishSpeed = Math.min(wishSpeedCap, Math.hypot(wishDir.x, wishDir.z) > 0 ? wishSpeedCap : 0); | |
| let add = wishSpeed - current; | |
| if (add <= 0) return; | |
| const push = Math.min(airAccel * wishSpeed * dt, add); | |
| vel.x += wishDir.x * push; | |
| vel.z += wishDir.z * push; | |
| } | |
| export function updatePlayer(delta) { | |
| if (!G.player.alive) return; | |
| const P = G.player; | |
| const M = CFG.player.move; | |
| // Handle timed movement buff | |
| if (G.movementBuffTimer > 0) { | |
| G.movementBuffTimer -= delta; | |
| if (G.movementBuffTimer <= 0) { | |
| G.movementBuffTimer = 0; | |
| G.movementMult = 1; | |
| } | |
| } | |
| // Forward/right in the horizontal plane | |
| G.camera.getWorldDirection(FWD); | |
| FWD.y = 0; FWD.normalize(); | |
| RIGHT.crossVectors(FWD, G.camera.up).normalize(); | |
| // Build wish direction from inputs | |
| let wishX = 0, wishZ = 0; | |
| if (G.input.w) { wishX += FWD.x; wishZ += FWD.z; } | |
| if (G.input.s) { wishX -= FWD.x; wishZ -= FWD.z; } | |
| if (G.input.d) { wishX += RIGHT.x; wishZ += RIGHT.z; } | |
| if (G.input.a) { wishX -= RIGHT.x; wishZ -= RIGHT.z; } | |
| const wishLen = Math.hypot(wishX, wishZ); | |
| if (wishLen > 0.0001) { wishX /= wishLen; wishZ /= wishLen; } | |
| // Desired speeds | |
| let baseSpeed = P.speed * (G.movementMult || 1) * (G.input.sprint ? CFG.player.sprintMult : 1); | |
| const crouchMult = (CFG.player.crouchMult || 1); | |
| // If not sliding, crouch reduces speed | |
| if (G.input.crouch && !P.sliding) baseSpeed *= crouchMult; | |
| // Timers: jump buffer and coyote time | |
| // Buffer jump on key press (edge), not hold | |
| if (G.input.jump && !P.jumpHeld) { | |
| P.jumpBuffer = M.jumpBuffer; | |
| } else { | |
| P.jumpBuffer = Math.max(0, P.jumpBuffer - delta); | |
| } | |
| P.jumpHeld = !!G.input.jump; | |
| if (P.grounded) P.coyoteTimer = M.coyoteTime; else P.coyoteTimer = Math.max(0, P.coyoteTimer - delta); | |
| P.wallContactTimer = Math.max(0, P.wallContactTimer - delta); | |
| // Sliding enter/exit | |
| const horizSpeed = Math.hypot(P.vel.x, P.vel.z); | |
| if (P.grounded && G.input.crouch && (horizSpeed >= M.slideMinSpeed)) { | |
| P.sliding = true; | |
| } else if (!G.input.crouch || !P.grounded) { | |
| P.sliding = false; | |
| } | |
| // Jump handling (ground, coyote, or wall bounce) | |
| let skippedFriction = false; | |
| if (P.jumpBuffer > 0) { | |
| if (P.coyoteTimer > 0) { | |
| // Ground/coyote jump | |
| P.yVel = M.jumpSpeed; | |
| // Slide-jump: preserve momentum and add small boost | |
| if (P.sliding) { | |
| const sp = Math.hypot(P.vel.x, P.vel.z); | |
| if (sp > 0.0001) { | |
| const nx = P.vel.x / sp, nz = P.vel.z / sp; | |
| P.vel.x += nx * M.slideJumpBoost; | |
| P.vel.z += nz * M.slideJumpBoost; | |
| } | |
| skippedFriction = true; | |
| } | |
| P.grounded = false; | |
| P.jumpBuffer = 0; // consume | |
| P.coyoteTimer = 0; | |
| } else if (!P.grounded && P.wallContactTimer > 0) { | |
| // Wall bounce: reflect into-wall component and add outward pop | |
| const n = P.lastWallNormal; | |
| const dot = P.vel.x * n.x + P.vel.z * n.z; | |
| // Remove into-wall component | |
| P.vel.x -= n.x * dot; | |
| P.vel.z -= n.z * dot; | |
| // Add outward impulse | |
| P.vel.x += n.x * M.wallBounceImpulse; | |
| P.vel.z += n.z * M.wallBounceImpulse; | |
| // Give a small jump | |
| P.yVel = M.jumpSpeed; | |
| P.jumpBuffer = 0; | |
| P.wallContactTimer = 0; | |
| } | |
| } | |
| // State-based friction and acceleration | |
| if (P.grounded) { | |
| // Friction (skip on slide-jump frame) | |
| if (!skippedFriction) { | |
| const fric = P.sliding ? M.slideFriction : M.friction; | |
| applyFriction(P.vel, fric, M.stopSpeed, delta); | |
| } | |
| // Accelerate toward wishdir | |
| accelerate(P.vel, { x: wishX, z: wishZ }, baseSpeed, P.sliding ? M.slideAccel : M.groundAccel, delta); | |
| } else { | |
| // Air movement | |
| airAccelerate(P.vel, { x: wishX, z: wishZ }, M.airSpeedCap, M.airAccel, delta); | |
| } | |
| // Gravity (vertical only) | |
| P.yVel -= M.gravity * delta; | |
| // Integrate position | |
| NEXT.copy(P.pos); | |
| NEXT.x += P.vel.x * delta; | |
| NEXT.z += P.vel.z * delta; | |
| NEXT.y += P.yVel * delta; | |
| // Collide with tree trunks (cylinders in XZ), push out | |
| const nearTrees = getNearbyTrees(NEXT.x, NEXT.z, 3.5); | |
| for (let i = 0; i < nearTrees.length; i++) { | |
| const tree = nearTrees[i]; | |
| const dx = NEXT.x - tree.x; | |
| const dz = NEXT.z - tree.z; | |
| const dist = Math.hypot(dx, dz); | |
| const minDist = P.radius + tree.radius; | |
| if (dist < minDist && dist > 0) { | |
| const nx = dx / dist; | |
| const nz = dz / dist; | |
| const push = (minDist - dist); | |
| NEXT.x += nx * push; | |
| NEXT.z += nz * push; | |
| // Record wall contact for potential wall-bounce when airborne | |
| if (!P.grounded) { | |
| P.lastWallNormal.set(nx, 0, nz); | |
| P.wallContactTimer = Math.max(P.wallContactTimer, CFG.player.move.wallBounceWindow); | |
| } | |
| } | |
| } | |
| // Bounds clamp | |
| const halfSize = CFG.forestSize / 2 - P.radius; | |
| NEXT.x = Math.max(-halfSize, Math.min(halfSize, NEXT.x)); | |
| NEXT.z = Math.max(-halfSize, Math.min(halfSize, NEXT.z)); | |
| // Ground resolve against terrain (using eye height) | |
| const eye = G.input.crouch ? (CFG.player.crouchEyeHeight || 1.8) : (CFG.player.eyeHeight || 1.8); | |
| const groundEye = getTerrainHeight(NEXT.x, NEXT.z) + eye; | |
| if (NEXT.y <= groundEye) { | |
| NEXT.y = groundEye; | |
| P.yVel = 0; | |
| P.grounded = true; | |
| } else { | |
| P.grounded = false; | |
| } | |
| // Commit position and keep camera in sync | |
| PREV.copy(P.pos); | |
| P.pos.copy(NEXT); | |
| G.camera.position.copy(P.pos); | |
| } | |