meat/app/player.ts
2026-08-04 02:09:54 +02:00

146 lines
4.2 KiB
TypeScript

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<string>, 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<string>, 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
}
}