feat: game/engine split refactor + skybox

This commit is contained in:
Dan Finch 2026-08-08 14:15:32 +02:00
parent 581e5892b0
commit eeedcb8e48
34 changed files with 610 additions and 218 deletions

View file

@ -0,0 +1,52 @@
import { Vec3 } from "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import { blob, lerp, limb, TAU, rng } from "./treekit"
// Silver birch: tall, slender, near-straight trunk under an airy, high, slightly
// drooping canopy -- a lean silhouette between the broad oak and conical spruce.
// Trunk = white birch bark, foliage = oak leaf (the white trunk carries the read).
export const birch: TreeSpecies = { kind: "birch", trunk: "birch", foliage: "leaf", build }
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
const base = tree.position
const g = tree.growth
const rand = rng(tree.seed)
const h = lerp(1, 8.5, g)
const rTrunk = lerp(0.03, 0.16, g)
const canopyY = base.y + h * 0.75
const blobR = h * 0.22
if (lod === "impostor") {
limb(trunk, base, { x: base.x, y: base.y + h * 0.9, z: base.z }, rTrunk, rTrunk * 0.5, 3)
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.1, rand, 4, 2)
return
}
limb(trunk, base, { x: base.x, y: base.y + h * 0.88, z: base.z }, rTrunk, rTrunk * 0.35, 5)
const spread = h * 0.22
// Sparse small blobs clustered high, biased downward so the crown droops.
const blobs = 2 + Math.round(g * 2)
for (let i = 0; i < blobs; i++) {
const angle = rand() * TAU
const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5)
const center = {
x: base.x + Math.cos(angle) * rad,
y: canopyY + (rand() - 0.6) * spread,
z: base.z + Math.sin(angle) * rad,
}
blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand)
}
// Grown birches trail a few thin, near-horizontal drooping twigs.
if (g > 0.5) {
const branches = 2 + Math.round(rand())
for (let i = 0; i < branches; i++) {
const angle = rand() * TAU
const dir = Vec3.normalize({ x: Math.cos(angle), y: 0.6, z: Math.sin(angle) })
const start = { x: base.x, y: base.y + h * 0.7, z: base.z }
const end = Vec3.add(start, Vec3.scale(dir, h * 0.22))
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.15, 4)
blob(leaves, end, blobR * 0.55, rand)
}
}
}

53
game/actors/trees/Oak.ts Normal file
View file

@ -0,0 +1,53 @@
import { Vec3 } from "../../../engine/math/Vec3"
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import { blob, lerp, limb, TAU, rng } from "./treekit"
// Oak: short tapered trunk, a couple of branches, a broad cluster of rounded canopy
// blobs (bushy, wider than tall). Trunk = brown bark, foliage = oak leaf.
export const oak: TreeSpecies = { kind: "oak", trunk: "bark", foliage: "leaf", build }
function build(tree: Tree, trunk: Mesh, leaves: Mesh, lod: "full" | "impostor"): void {
const base = tree.position
const g = tree.growth
const rand = rng(tree.seed)
const h = lerp(0.8, 7, g)
const rTrunk = lerp(0.04, 0.32, g)
const forkY = base.y + h * 0.5
const canopyY = base.y + h * 0.72
const blobR = h * 0.3
if (lod === "impostor") {
// One low-poly blob on a stubby trunk -- reads as an oak at distance.
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 3)
blob(leaves, { x: base.x, y: canopyY, z: base.z }, blobR * 1.15, rand, 4, 2)
return
}
limb(trunk, base, { x: base.x, y: forkY, z: base.z }, rTrunk, rTrunk * 0.6, 5)
const spread = h * 0.32
// Central blob plus, as it grows, a couple offset ones -> broad bushy crown.
const blobs = 1 + Math.round(g * 2)
for (let i = 0; i < blobs; i++) {
const angle = rand() * TAU
const rad = i === 0 ? 0 : spread * (0.5 + rand() * 0.5)
const center = {
x: base.x + Math.cos(angle) * rad,
y: canopyY + (rand() - 0.4) * spread,
z: base.z + Math.sin(angle) * rad,
}
blob(leaves, center, blobR * (0.7 + rand() * 0.4), rand)
}
// Grown oaks throw out a few branches, each tipped with a leaf tuft.
if (g > 0.55) {
const branches = 2 + Math.round(rand())
for (let i = 0; i < branches; i++) {
const angle = rand() * TAU
const dir = Vec3.normalize({ x: Math.cos(angle), y: 1.2, z: Math.sin(angle) })
const start = { x: base.x, y: base.y + h * 0.42, z: base.z }
const end = Vec3.add(start, Vec3.scale(dir, h * 0.3))
limb(trunk, start, end, rTrunk * 0.4, rTrunk * 0.2, 4)
blob(leaves, end, blobR * 0.6, rand)
}
}
}

View file

@ -0,0 +1,32 @@
import type { Mesh } from "../../../engine/scene/Mesh"
import type { Tree, TreeSpecies } from "../Tree"
import { cone, lerp, limb, rng } from "./treekit"
// Spruce: tall thin trunk under stacked cones that narrow to a point (tiered, taller
// than wide). Trunk = brown bark, foliage = spruce needle.
export const spruce: TreeSpecies = { kind: "spruce", trunk: "bark", foliage: "needle", build }
function build(tree: Tree, trunk: Mesh, needles: Mesh, lod: "full" | "impostor"): void {
const base = tree.position
const g = tree.growth
const rand = rng(tree.seed)
const h = lerp(0.6, 9, g)
const rTrunk = lerp(0.03, 0.2, g)
const impostor = lod === "impostor"
limb(trunk, base, { x: base.x, y: base.y + h, z: base.z }, rTrunk, rTrunk * 0.25, impostor ? 3 : 5)
// Stacked cones: widest low, shrinking to a point up top -> conical tiers. The
// impostor keeps the first two tiers at low sides (same seed => aligned).
const tiers = impostor ? 2 : 2 + Math.round(g * 3)
const sides = impostor ? 4 : 6
const bottom = base.y + h * 0.1
const span = h * 0.9
for (let i = 0; i < tiers; i++) {
const t = i / tiers
const y = bottom + t * span * 0.82
const radius = lerp(h * 0.3, h * 0.05, t) * (0.9 + rand() * 0.2)
const coneH = (span / tiers) * 1.9
cone(needles, { x: base.x, y, z: base.z }, coneH, radius, sides)
}
}

View file

@ -0,0 +1,95 @@
import { Vec3 } from "../../../engine/math/Vec3"
import { STRIDE, type Mesh } from "../../../engine/scene/Mesh"
// Shared faceted-geometry primitives + the per-tree RNG, used by the species
// modules (Oak/Spruce/Birch). Kept separate so a species and the `Tree` registry
// don't form an import cycle.
export const TAU = Math.PI * 2
/** A tapered tube between two points (trunk or branch), `sides`-gonal. */
export function limb(mesh: Mesh, a: Vec3, b: Vec3, ra: number, rb: number, sides: number): void {
const axis = Vec3.normalize(Vec3.sub(b, a))
const [u, v] = basis(axis)
const len = Vec3.length(Vec3.sub(b, a))
const start = mesh.verts.length / STRIDE
for (let i = 0; i <= sides; i++) {
const angle = (i / sides) * TAU
const dx = u.x * Math.cos(angle) + v.x * Math.sin(angle)
const dy = u.y * Math.cos(angle) + v.y * Math.sin(angle)
const dz = u.z * Math.cos(angle) + v.z * Math.sin(angle)
const s = i / sides
mesh.verts.push(a.x + dx * ra, a.y + dy * ra, a.z + dz * ra, s * 1.5, 0)
mesh.verts.push(b.x + dx * rb, b.y + dy * rb, b.z + dz * rb, s * 1.5, len * 0.5)
}
for (let i = 0; i < sides; i++) {
const p = start + i * 2
mesh.indices.push(p, p + 2, p + 3, p, p + 3, p + 1)
}
}
/** A cone standing on a base ring, apex `height` above it (one spruce tier). */
export function cone(mesh: Mesh, base: Vec3, height: number, radius: number, sides: number): void {
const start = mesh.verts.length / STRIDE
mesh.verts.push(base.x, base.y + height, base.z, 0.5, 0)
for (let i = 0; i <= sides; i++) {
const angle = (i / sides) * TAU
mesh.verts.push(base.x + Math.cos(angle) * radius, base.y, base.z + Math.sin(angle) * radius, (i / sides) * 2, 1)
}
for (let i = 0; i < sides; i++) {
// Wound so the outer surface faces out, matching the backface-cull sign.
mesh.indices.push(start, start + 2 + i, start + 1 + i)
}
}
/** A lumpy low-poly sphere (one canopy blob). Per-ring radius wobble keeps it
* organic without cracking the longitude seam. */
export function blob(mesh: Mesh, center: Vec3, radius: number, rand: () => number, seg = 5, rings = 3): void {
const start = mesh.verts.length / STRIDE
for (let r = 0; r <= rings; r++) {
const phi = (r / rings) * Math.PI
const cy = Math.cos(phi)
const cr = Math.sin(phi)
const scale = radius * (0.85 + rand() * 0.3)
for (let s = 0; s <= seg; s++) {
const theta = (s / seg) * TAU
mesh.verts.push(
center.x + cr * Math.cos(theta) * scale,
center.y + cy * scale,
center.z + cr * Math.sin(theta) * scale,
(s / seg) * 2,
(r / rings) * 2,
)
}
}
const row = seg + 1
for (let r = 0; r < rings; r++) {
for (let s = 0; s < seg; s++) {
const p = start + r * row + s
mesh.indices.push(p, p + 1, p + row + 1, p, p + row + 1, p + row)
}
}
}
/** Linear interpolation, for the sapling -> full-grown ramps. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
}
/** Deterministic 0..1 generator (mulberry32) seeded per tree. */
export function rng(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6D2B79F5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** Two unit vectors spanning the plane perpendicular to `axis`. */
function basis(axis: Vec3): [Vec3, Vec3] {
const ref = Math.abs(axis.y) < 0.99 ? { x: 0, y: 1, z: 0 } : { x: 1, y: 0, z: 0 }
const u = Vec3.normalize(Vec3.cross(ref, axis))
return [u, Vec3.cross(axis, u)]
}