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 = 4 const GRAVITY = 22 const JUMP_SPEED = 8 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 } moveHorizontal(player, keys, dt) collide(player, level) fall(player, dt, level) } function moveHorizontal(player: Player, keys: Set, dt: number): void { const speed = SPEED * 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 = 0 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 } }