TURNCRAFT/src/player/collision.ts
jing 5a39e3a947 TURNCRAFT: contracts, docs, and all five lane deliverables (pre-integration)
Lanes A (engine), B (player), C (worldgen), D (machines/quest),
E (audio/fx/ui) as landed, each with HANDOFF.md + update docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 20:50:56 +10:00

112 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Lane B — AABB-vs-voxel collision. Pure functions that mutate the passed
// position/velocity tuples in place (zero per-call allocation on the hot path).
//
// The player is an axis-aligned box centered horizontally on `pos` (feet
// center), spanning [pos.x-HALF_W, pos.x+HALF_W] × [pos.y, pos.y+HEIGHT] ×
// [pos.z-HALF_W, pos.z+HALF_W]. Callers substep so |disp| stays small; each
// resolveAxis therefore only has to clamp against the single voxel layer the
// box newly entered along that axis.
import type { IVoxelWorld } from '../core/types';
import { PLAYER } from '../core/constants';
export const HALF_W = PLAYER.width / 2;
export const HEIGHT = PLAYER.height;
export const STEP_HEIGHT = 1.0; // auto-step ledges up to 1 voxel tall
const EPS = 1e-4;
// Scratch velocity for the step-assist vertical probes (single-threaded JS).
const _dummy: [number, number, number] = [0, 0, 0];
/**
* Move the box along one axis by `disp`, then clamp it out of any solid voxel
* it entered. Zeros vel[axis] on contact. Returns true if it hit something.
* axis: 0 = X, 1 = Y, 2 = Z.
*/
export function resolveAxis(
world: IVoxelWorld,
pos: number[],
vel: number[],
axis: 0 | 1 | 2,
disp: number,
): boolean {
pos[axis] += disp;
if (disp === 0) return false;
const minX = pos[0] - HALF_W, maxX = pos[0] + HALF_W;
const minY = pos[1], maxY = pos[1] + HEIGHT;
const minZ = pos[2] - HALF_W, maxZ = pos[2] + HALF_W;
const cx0 = Math.floor(minX + EPS), cx1 = Math.floor(maxX - EPS);
const cy0 = Math.floor(minY + EPS), cy1 = Math.floor(maxY - EPS);
const cz0 = Math.floor(minZ + EPS), cz1 = Math.floor(maxZ - EPS);
let hit = false;
if (axis === 0) {
const layer = disp > 0 ? cx1 : cx0;
for (let y = cy0; y <= cy1 && !hit; y++)
for (let z = cz0; z <= cz1; z++)
if (world.isSolid(layer, y, z)) { hit = true; break; }
if (hit) { pos[0] = disp > 0 ? layer - HALF_W : layer + 1 + HALF_W; vel[0] = 0; }
} else if (axis === 1) {
const layer = disp > 0 ? cy1 : cy0;
for (let x = cx0; x <= cx1 && !hit; x++)
for (let z = cz0; z <= cz1; z++)
if (world.isSolid(x, layer, z)) { hit = true; break; }
if (hit) { pos[1] = disp > 0 ? layer - HEIGHT : layer + 1; vel[1] = 0; }
} else {
const layer = disp > 0 ? cz1 : cz0;
for (let x = cx0; x <= cx1 && !hit; x++)
for (let y = cy0; y <= cy1; y++)
if (world.isSolid(x, y, layer)) { hit = true; break; }
if (hit) { pos[2] = disp > 0 ? layer - HALF_W : layer + 1 + HALF_W; vel[2] = 0; }
}
return hit;
}
/**
* Horizontal move (X then Z, Minecraft order) with auto-step. If the flat move
* hits a wall and `canStep` is set, retry the move raised by STEP_HEIGHT and
* settle back down; keep whichever result travelled further horizontally. A
* 2-voxel wall naturally fails the retry (still blocked one voxel up) and is
* left un-stepped. The caller's subsequent Y resolution re-grounds the player.
*/
export function moveHorizontalWithStep(
world: IVoxelWorld,
pos: number[],
vel: number[],
dx: number,
dz: number,
canStep: boolean,
): void {
const sx = pos[0], sz = pos[2], svx = vel[0], svz = vel[2];
const hitX = resolveAxis(world, pos, vel, 0, dx);
const hitZ = resolveAxis(world, pos, vel, 2, dz);
if (!canStep || (!hitX && !hitZ)) return;
// Remember the flat (non-stepped) outcome.
const nx = pos[0], nz = pos[2], nvx = vel[0], nvz = vel[2];
const flatDist = (nx - sx) * (nx - sx) + (nz - sz) * (nz - sz);
// Retry raised by one voxel.
pos[0] = sx; pos[2] = sz; vel[0] = svx; vel[2] = svz;
const startY = pos[1];
_dummy[0] = 0; _dummy[1] = 0; _dummy[2] = 0;
resolveAxis(world, pos, _dummy, 1, STEP_HEIGHT); // clamps if there's a ceiling
const climbed = pos[1] - startY;
resolveAxis(world, pos, vel, 0, dx);
resolveAxis(world, pos, vel, 2, dz);
resolveAxis(world, pos, _dummy, 1, -climbed); // settle onto the ledge
if (pos[1] < startY) pos[1] = startY;
const stepDist = (pos[0] - sx) * (pos[0] - sx) + (pos[2] - sz) * (pos[2] - sz);
if (stepDist <= flatDist + 1e-6) {
// Stepping gained nothing (e.g. a 2-voxel wall) — revert to the flat move.
pos[0] = nx; pos[1] = startY; pos[2] = nz; vel[0] = nvx; vel[2] = nvz;
}
}