From 61389f091c785cb1eb07f07a0831b3a597248d35 Mon Sep 17 00:00:00 2001 From: Errilaz Date: Tue, 4 Aug 2026 13:09:26 +0200 Subject: [PATCH] feat: better walls --- AGENTS.md | 18 +++++++++++------- app/level.ts | 39 ++++++++++++++++++++++++++++++--------- app/player.ts | 32 +++++++++++++++++++++++++++----- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 867c155..c59a067 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,8 +61,9 @@ rules live in `.agents/rules/*.md`. - `main.ts` — game loop, input, preset switching, canvas blit, FPS meter. - `assets.ts` — load `/assets/*.png` → `Texture` (zero-copy; ImageData bytes are already the `Color` layout). - - `level.ts` — builds the playground: a flat stone-floored room (three walls, - north side open) in the center of a big grassy `Terrain` world (~20x across). + - `level.ts` — builds the playground: a flat stone-floored room (three thick + walls via `slab`, north side open) in the center of a big grassy `Terrain` + world (~20x across). Per-texture meshes incl. the outdoor grass `ground`, `Aabb` colliders, NPC position, `Terrain` config + `GROUND_DIVISIONS`/`GROUND_UV`, sky/cloud config. The stone floor is lifted by `FLOOR_LIFT` (a z-bias) so it stays clean over the @@ -70,8 +71,10 @@ rules live in `.agents/rules/*.md`. quads -- no subdivision needed since texturing is perspective-correct; ground triangle count is `GROUND_DIVISIONS` (fixed grid, so world size sets cell chunkiness, not tri count). - - `player.ts` — feet-cylinder player: gravity/jump + circle-vs-AABB/-circle - collision; ground height comes from `Terrain.height` (plus standable AABBs). + - `player.ts` — feet-cylinder player: gravity/jump + Shift-run + (`RUN_MULTIPLIER`) + circle-vs-AABB/-circle collision, substepped so fast + running can't tunnel walls; ground height from `Terrain.height` (plus + standable AABBs). - `index.html` — Vite entry at repo root; holds the `#screen` canvas and the `#fps` meter div (styled inline). - `scripts/gen-assets.ts` — procedurally draws the placeholder textures and @@ -130,9 +133,10 @@ branching in the cloud shader. Cost scales with sky resolution — fine at ## Controls -WASD move · mouse look (click canvas to pointer-lock) · **Space** jump · -**1/2/3** switch look presets. FPS shown bottom-right. The room's north wall is -open — walk out onto the terrain and toward the peaks. +WASD move · **Shift** run (speed ×`RUN_MULTIPLIER` in `app/player.ts`) · mouse +look (click canvas to pointer-lock) · **Space** jump · **1/2/3** switch look +presets. FPS shown bottom-right. The room's north wall is open — walk out onto +the terrain and toward the peaks. ## Code style (oxlint-enforced — match it) diff --git a/app/level.ts b/app/level.ts index 17df18b..b917471 100644 --- a/app/level.ts +++ b/app/level.ts @@ -32,6 +32,10 @@ export type Level = { const ARENA = 12 const WALL_HEIGHT = 4 +/** How deep the perimeter walls are. Thick enough to read as solid walls (and to + * give the doorway real jambs); their outer faces sit flush with the room edge, + * so they eat into the interior, not the terrain. */ +const WALL_THICKNESS = 1.5 const CRATE = { x: -2, z: -2, half: 1, height: 1 } /** Z-bias lifting the stone floor above the terrain skirt that laps under the * room edge (see `buildLevel`). Big enough to beat depth precision, too small @@ -96,20 +100,21 @@ export function buildLevel(): Level { const walls = mesh() const h = WALL_HEIGHT - // Three inward-facing walls; the north (-Z) side is left open onto the world. - // No ceiling, so the sky shows above. - quad(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5) - quad(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5) - quad(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5) + const t = WALL_THICKNESS + // Three thick perimeter walls, outer faces flush with the room edge; the north + // (-Z) side is left open onto the world. No ceiling, so the sky shows above. + slab(walls, -ARENA, ARENA, ARENA - t, ARENA, 0, h, 0.5) // south (+Z) + slab(walls, ARENA - t, ARENA, -ARENA, ARENA - t, 0, h, 0.5) // east (+X) + slab(walls, -ARENA, -ARENA + t, -ARENA, ARENA - t, 0, h, 0.5) // west (-X) // Crate on the flat room floor. const crate = mesh() box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height) const colliders: Aabb[] = [ - wall(-ARENA, ARENA, ARENA - 1, ARENA), - wall(ARENA - 1, ARENA, -ARENA, ARENA), - wall(-ARENA, -ARENA + 1, -ARENA, ARENA), + wall(-ARENA, ARENA, ARENA - t, ARENA), + wall(ARENA - t, ARENA, -ARENA, ARENA - t), + wall(-ARENA, -ARENA + t, -ARENA, ARENA - t), { minX: CRATE.x - CRATE.half, maxX: CRATE.x + CRATE.half, @@ -138,8 +143,10 @@ function mesh(): Mesh { return { vertices: [], indices: [] } } +/** A perimeter wall collider: blocks from the sides, and `standable` so you can + * jump up and land on its top (given enough JUMP_SPEED to clear WALL_HEIGHT). */ function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb { - return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: false } + return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: true } } /** One flat quad (two tris). Corners run a (uv 0,0) -> b (us,0) -> c (us,vs) -> @@ -157,6 +164,20 @@ function quad(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, v m.indices.push(base, base + 1, base + 2, base, base + 2, base + 3) } +/** An axis-aligned box from (x0,z0)-(x1,z1), y0..y1: four sides + top, no bottom + * (never seen from below). `tpu` = texture tiles per world unit, so every face + * tiles at the same density whatever its size. Used for the thick walls. */ +function slab(m: Mesh, x0: number, x1: number, z0: number, z1: number, y0: number, y1: number, tpu: number): void { + const dx = (x1 - x0) * tpu + const dz = (z1 - z0) * tpu + const dy = (y1 - y0) * tpu + quad(m, [x0, y1, z0], [x1, y1, z0], [x1, y1, z1], [x0, y1, z1], dx, dz) + quad(m, [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0], dx, dy) + quad(m, [x1, y0, z1], [x0, y0, z1], [x0, y1, z1], [x1, y1, z1], dx, dy) + quad(m, [x0, y0, z1], [x0, y0, z0], [x0, y1, z0], [x0, y1, z1], dz, dy) + quad(m, [x1, y0, z0], [x1, y0, z1], [x1, y1, z1], [x1, y1, z0], dz, dy) +} + /** A box centered at (cx, cz), rising `height` units from `base`: top face plus * four sides, one uv tile per face. No bottom (never seen). */ function box(m: Mesh, cx: number, cz: number, half: number, base: number, height: number): void { diff --git a/app/player.ts b/app/player.ts index 7281d5d..5ba2a64 100644 --- a/app/player.ts +++ b/app/player.ts @@ -14,9 +14,13 @@ export type Player = { export const EYE_HEIGHT = 1.6 const RADIUS = 0.35 -const SPEED = 4 +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 = 8 +const JUMP_SPEED = 20 const NPC_RADIUS = 0.5 export namespace Player { @@ -26,13 +30,31 @@ export namespace Player { player.velocityY = JUMP_SPEED player.onGround = false } - moveHorizontal(player, keys, dt) - collide(player, level) + // 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 * dt + const speed = SPEED * runFactor(keys) * dt const fx = Math.sin(player.yaw) const fz = -Math.cos(player.yaw) const rx = Math.cos(player.yaw)