import { Color } from "../engine/render/Color" import type { CloudLayer, SkyConfig } from "../engine/render/Sky" import type { Mesh } from "../engine/scene/Mesh" type Corner = [number, number, number] /** Axis-aligned solid. Blocks the player horizontally while their feet are * below `top`; if `standable`, its `top` also counts as ground to land on. */ export type Aabb = { minX: number maxX: number minZ: number maxZ: number top: number standable: boolean } /** The playground: geometry split by texture, its collision solids, where the * NPC stands, and the sky to draw behind it. */ export type Level = { floor: Mesh walls: Mesh crate: Mesh colliders: Aabb[] npcPosition: { x: number; y: number; z: number } sky: SkyConfig } const ARENA = 12 const WALL_HEIGHT = 4 const CRATE = { x: -2, z: -2, half: 1, top: 1 } /** Triangle density: grid divisions per world unit, applied to every textured * surface (floor, walls, crate). Higher = smaller triangles = each spans less * depth = less affine texture swim, at the cost of more geometry; lower = * chunkier, wilder PS1 warp (below ~0.5 the floor tips into the black-wedge * degeneration). Because it scales with surface size, one value keeps the big * floor and the little crate equally warp-free. Edit and Vite reloads. Pairs * with the `ps1` preset's `perspectiveCorrect`, which fights the same error * from the render side. */ const DIVISIONS_PER_UNIT = 1.3 /** The two cloud styles; swap which one the sky uses in `buildLevel`. * `basicCumulus` is cheap flat puffs; `fancyCumulus` is the pricier * heightfield-shaded, domain-warped version with faked volume. */ export const basicCumulus: CloudLayer = { kind: "basic", color: Color.rgb(248, 250, 255), coverage: 0.5, scale: 0.9, speed: 0.5, edge: 0.02, } export const fancyCumulus: CloudLayer = { kind: "fancy", color: Color.rgb(250, 251, 255), coverage: 0.5, scale: 0.6, speed: 0.5, edge: 0.02, warp: 0.4, relief: 7, } export function buildLevel(): Level { const floor = mesh() quadGrid(floor, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [-ARENA, 0, ARENA], 12, 12) const walls = mesh() const h = WALL_HEIGHT // Inward-facing perimeter, no ceiling so the sky shows above. quadGrid(walls, [-ARENA, 0, -ARENA], [ARENA, 0, -ARENA], [ARENA, h, -ARENA], [-ARENA, h, -ARENA], 12, 2.5) quadGrid(walls, [ARENA, 0, ARENA], [-ARENA, 0, ARENA], [-ARENA, h, ARENA], [ARENA, h, ARENA], 12, 2.5) quadGrid(walls, [ARENA, 0, -ARENA], [ARENA, 0, ARENA], [ARENA, h, ARENA], [ARENA, h, -ARENA], 12, 2.5) quadGrid(walls, [-ARENA, 0, ARENA], [-ARENA, 0, -ARENA], [-ARENA, h, -ARENA], [-ARENA, h, ARENA], 12, 2.5) const crate = mesh() box(crate, CRATE.x, CRATE.z, CRATE.half, CRATE.top) const colliders: Aabb[] = [ wall(-ARENA, ARENA, -ARENA, -ARENA + 1), wall(-ARENA, ARENA, ARENA - 1, ARENA), wall(ARENA - 1, ARENA, -ARENA, ARENA), wall(-ARENA, -ARENA + 1, -ARENA, ARENA), { minX: CRATE.x - CRATE.half, maxX: CRATE.x + CRATE.half, minZ: CRATE.z - CRATE.half, maxZ: CRATE.z + CRATE.half, top: CRATE.top, standable: true, }, ] const sky: SkyConfig = { zenith: Color.rgb(58, 108, 196), horizon: Color.rgb(178, 198, 226), sun: Color.rgb(255, 246, 214), sunDir: { x: 0.3, y: 0.5, z: -0.8 }, sunSize: 0.04, clouds: fancyCumulus, } return { floor, walls, crate, colliders, npcPosition: { x: 2, y: 0, z: -1 }, sky } } function mesh(): Mesh { return { vertices: [], indices: [] } } function wall(minX: number, maxX: number, minZ: number, maxZ: number): Aabb { return { minX, maxX, minZ, maxZ, top: WALL_HEIGHT, standable: false } } /** Tessellate a quad into a grid sized by DIVISIONS_PER_UNIT, so triangle size * (and thus affine warp) is consistent whatever the surface's scale. Corners * run a (uv 0,0) -> b (us,0) -> c (us,vs) -> d (0,vs). */ function quadGrid(m: Mesh, a: Corner, b: Corner, c: Corner, d: Corner, us: number, vs: number): void { const nu = divisions(a, b) const nv = divisions(a, d) const base = m.vertices.length const row = nu + 1 for (let i = 0; i <= nv; i++) { const t = i / nv for (let j = 0; j <= nu; j++) { const s = j / nu const wa = (1 - s) * (1 - t) const wb = s * (1 - t) const wc = s * t const wd = (1 - s) * t m.vertices.push({ pos: { x: a[0] * wa + b[0] * wb + c[0] * wc + d[0] * wd, y: a[1] * wa + b[1] * wb + c[1] * wc + d[1] * wd, z: a[2] * wa + b[2] * wb + c[2] * wc + d[2] * wd, }, uv: { x: us * s, y: vs * t }, }) } } for (let i = 0; i < nv; i++) { for (let j = 0; j < nu; j++) { const p = base + i * row + j m.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row) } } } /** Grid divisions along edge a->b, from its world length and the density. */ function divisions(a: Corner, b: Corner): number { const length = Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]) return Math.max(1, Math.round(length * DIVISIONS_PER_UNIT)) } /** A box centered at (cx, cz) on the floor: top face plus four sides, one uv * tile per face. No bottom (never seen). Tessellated like every other surface, * so it no longer warps up close. */ function box(m: Mesh, cx: number, cz: number, half: number, top: number): void { const x0 = cx - half const x1 = cx + half const z0 = cz - half const z1 = cz + half quadGrid(m, [x0, top, z0], [x1, top, z0], [x1, top, z1], [x0, top, z1], 1, 1) quadGrid(m, [x0, 0, z0], [x1, 0, z0], [x1, top, z0], [x0, top, z0], 1, 1) quadGrid(m, [x1, 0, z1], [x0, 0, z1], [x0, top, z1], [x1, top, z1], 1, 1) quadGrid(m, [x1, 0, z0], [x1, 0, z1], [x1, top, z1], [x1, top, z0], 1, 1) quadGrid(m, [x0, 0, z1], [x0, 0, z0], [x0, top, z0], [x0, top, z1], 1, 1) }