import { Terrain } from "./Terrain" import type { Vec3 } from "../engine/math/Vec3" import type { Aabb, Level } from "./level" /** The player as a vertical cylinder. `position` is at the feet; the camera * eye sits EYE_HEIGHT above it. */ export type Player = { position: Vec3 yaw: number pitch: number velocityY: number onGround: boolean } export const EYE_HEIGHT = 1.6 const RADIUS = 0.35 const SPEED = 6 /** Speed multiplier while a Run key (Shift) is held. Tweak to taste; set high to * blast across the big terrain -- move+collision is substepped, so walls stay * solid even at big multipliers. */ const RUN_MULTIPLIER = 2 const GRAVITY = 22 const JUMP_SPEED = 14 const NPC_RADIUS = 0.5 export namespace Player { /** Advance the player one frame: jump, horizontal move + collision, gravity. */ export function update(player: Player, keys: Set, dt: number, level: Level): void { if (keys.has("Space") && player.onGround) { player.velocityY = JUMP_SPEED player.onGround = false } // Move + collide in small substeps: collision is discrete (move, then push // out), so a single big running step could otherwise skip clean through a // wall. Substepping keeps each advance short enough to always hit it. const steps = moveSubsteps(keys, dt) for (let i = 0; i < steps; i++) { moveHorizontal(player, keys, dt / steps) collide(player, level) } fall(player, dt, level) } /** Run-speed factor for the frame: RUN_MULTIPLIER while Shift is held, else 1. */ function runFactor(keys: Set): number { return keys.has("ShiftLeft") || keys.has("ShiftRight") ? RUN_MULTIPLIER : 1 } /** Number of move+collide substeps so each advances at most ~RADIUS, keeping * the player from tunneling walls however fast they run. */ function moveSubsteps(keys: Set, dt: number): number { const perFrame = SPEED * runFactor(keys) * dt * Math.SQRT2 return Math.max(1, Math.ceil(perFrame / RADIUS)) } function moveHorizontal(player: Player, keys: Set, dt: number): void { const speed = SPEED * runFactor(keys) * dt const fx = Math.sin(player.yaw) const fz = -Math.cos(player.yaw) const rx = Math.cos(player.yaw) const rz = Math.sin(player.yaw) const p = player.position if (keys.has("KeyW")) { p.x += fx * speed p.z += fz * speed } if (keys.has("KeyS")) { p.x -= fx * speed p.z -= fz * speed } if (keys.has("KeyD")) { p.x += rx * speed p.z += rz * speed } if (keys.has("KeyA")) { p.x -= rx * speed p.z -= rz * speed } } /** Push the player's circle out of any solid it overlaps: level colliders it * is not standing above, and the NPC. This is what makes walls and the NPC * impassable while still letting you stand on the crate. */ function collide(player: Player, level: Level): void { for (const aabb of level.colliders) { if (player.position.y < aabb.top - 0.01) { pushFromAabb(player.position, aabb) } } pushFromCircle(player.position, level.npcPosition.x, level.npcPosition.z, NPC_RADIUS) } /** Apply gravity and land on the highest ground under the player. */ function fall(player: Player, dt: number, level: Level): void { player.velocityY -= GRAVITY * dt player.position.y += player.velocityY * dt const ground = groundHeight(player.position, level) if (player.position.y <= ground) { player.position.y = ground player.velocityY = 0 player.onGround = true } else { player.onGround = false } } function groundHeight(position: Vec3, level: Level): number { let ground = Terrain.height(level.terrain, position.x, position.z) for (const aabb of level.colliders) { if ( aabb.standable && position.x >= aabb.minX && position.x <= aabb.maxX && position.z >= aabb.minZ && position.z <= aabb.maxZ ) { ground = Math.max(ground, aabb.top) } } return ground } function pushFromAabb(position: Vec3, aabb: Aabb): void { const cx = Math.max(aabb.minX, Math.min(aabb.maxX, position.x)) const cz = Math.max(aabb.minZ, Math.min(aabb.maxZ, position.z)) const dx = position.x - cx const dz = position.z - cz const d2 = dx * dx + dz * dz if (d2 >= RADIUS * RADIUS) { return } if (d2 > 1e-6) { const d = Math.sqrt(d2) const push = (RADIUS - d) / d position.x += dx * push position.z += dz * push return } // Center is inside the box: eject through the nearest face. const left = position.x - aabb.minX const rightSide = aabb.maxX - position.x const near = position.z - aabb.minZ const far = aabb.maxZ - position.z const m = Math.min(left, rightSide, near, far) if (m === left) { position.x = aabb.minX - RADIUS } else if (m === rightSide) { position.x = aabb.maxX + RADIUS } else if (m === near) { position.z = aabb.minZ - RADIUS } else { position.z = aabb.maxZ + RADIUS } } function pushFromCircle(position: Vec3, cx: number, cz: number, otherRadius: number): void { const dx = position.x - cx const dz = position.z - cz const reach = RADIUS + otherRadius const d2 = dx * dx + dz * dz if (d2 >= reach * reach || d2 < 1e-6) { return } const d = Math.sqrt(d2) const push = (reach - d) / d position.x += dx * push position.z += dz * push } }