meat/game/level.ts

533 lines
13 KiB
TypeScript

import { Color } from "../engine/render/Color"
import type { Material } from "../engine/render/Material"
import type { CloudLayer, SkyConfig } from "../engine/render/Sky"
import { ChunkBuilder } from "../engine/render/ChunkBuilder"
import { Actor, type ActorDefinition } from "../engine/scene/Actor"
import { Mesh } from "../engine/scene/Mesh"
import { MeshBuilder } from "../engine/scene/MeshBuilder"
import {
Prefab,
type PlacedPrefab,
type Prefab as PrefabDefinition,
} from "../engine/scene/Prefab"
import type { BoxCollider, Collider } from "../engine/world/Collider"
import { Level, type Level as RuntimeLevel } from "../engine/world/Level"
import {
Terrain,
type RollingTerrainConfig,
type Terrain as Ground,
} from "../engine/world/Terrain"
import { Boulder } from "./actors/Boulder"
import { Bush } from "./actors/Bush"
import { Flower, type FlowerStyle } from "./actors/Flower"
import type { Mob, MobState } from "./actors/Mob"
import type { Tree } from "./actors/Tree"
import { Bee } from "./actors/mobs/Bee"
import { Frog } from "./actors/mobs/Frog"
import { Robin } from "./actors/mobs/Robin"
import { Birch } from "./actors/trees/Birch"
import { Oak } from "./actors/trees/Oak"
import { Spruce } from "./actors/trees/Spruce"
import type { Textures } from "./textures"
type Materials = {
floor: Material
grass: Material
bark: Material
birch: Material
leaf: Material
needle: Material
rock: Material
flower: Material
wall: Material
crate: Material
npc: Material
frog: Material
bee: Material
robin: Material
}
type WeightedTree = {
definition: PrefabDefinition<Tree>
weight: number
}
type MobSpawn = {
definition: ActorDefinition<MobState, Ground>
count: number
minScale: number
maxScale: number
grounded: boolean
phase: (random: () => number) => number
}
const ARENA = 12
const WALL_HEIGHT = 4
const WALL_THICKNESS = 1.5
const CRATE = { x: -2, z: -2, half: 1, height: 1 }
const FLOOR_LIFT = 0.02
const TERRAIN_CONFIG: RollingTerrainConfig = {
inner: ARENA,
outer: ARENA * 10,
blend: 12,
amplitude: 5,
frequency: 0.14,
peakHeight: 0,
peakFrequency: 0.05,
peakStart: 0.45,
}
const TERRAIN = Terrain.rolling(TERRAIN_CONFIG)
const TREE_COUNT = 50
const TREE_SEED = 0x5EED
const TREE_REACH = 1
const BOULDER_COUNT = 50
const BOULDER_SEED = 0xB0142
const BOULDER_REACH = 1
const BUSH_COUNT = 50
const BUSH_SEED = 0xB554
const BUSH_REACH = 1
const FLOWER_COUNT = 50
const FLOWER_SEED = 0xF10E
const FLOWER_REACH = 0.3
const FLOWER_STYLES: FlowerStyle[] = [Flower.white, Flower.red, Flower.yellow]
const FROG_COUNT = 20
const BEE_COUNT = 20
const ROBIN_COUNT = 20
const MOB_SEED = 0x30B
const MOB_REACH = 1
const CHUNK_GRID = 12
const TERRAIN_SUBDIV = 5
const GROUND_UV = 0.25
export const basicCumulus: CloudLayer = {
kind: "basic",
color: Color.rgb(248, 250, 255),
coverage: 0.5,
scale: 0.9,
speed: 0.5,
edge: 0.005,
}
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,
}
/** Concrete playground data assembled through engine-owned level mechanisms. */
export function buildLevel(textures: Textures): RuntimeLevel<Ground> {
const materials = createMaterials(textures)
const floor = Mesh.create()
MeshBuilder.quad(
floor,
[-ARENA, FLOOR_LIFT, -ARENA],
[ARENA, FLOOR_LIFT, -ARENA],
[ARENA, FLOOR_LIFT, ARENA],
[-ARENA, FLOOR_LIFT, ARENA],
12,
12,
)
const walls = Mesh.create()
const thickness = WALL_THICKNESS
MeshBuilder.slab(
walls,
-ARENA,
ARENA,
ARENA - thickness,
ARENA,
0,
WALL_HEIGHT,
0.5,
)
MeshBuilder.slab(
walls,
ARENA - thickness,
ARENA,
-ARENA,
ARENA - thickness,
0,
WALL_HEIGHT,
0.5,
)
MeshBuilder.slab(
walls,
-ARENA,
-ARENA + thickness,
-ARENA,
ARENA - thickness,
0,
WALL_HEIGHT,
0.5,
)
const crate = Mesh.create()
MeshBuilder.box(crate, CRATE.x, CRATE.z, CRATE.half, 0, CRATE.height)
const npcPosition = { x: 2, y: 0, z: -1 }
const staticColliders: Collider[] = [
wall(-ARENA, ARENA, ARENA - thickness, ARENA),
wall(ARENA - thickness, ARENA, -ARENA, ARENA - thickness),
wall(-ARENA, -ARENA + thickness, -ARENA, ARENA - thickness),
{
shape: "box",
minX: CRATE.x - CRATE.half,
maxX: CRATE.x + CRATE.half,
minZ: CRATE.z - CRATE.half,
maxZ: CRATE.z + CRATE.half,
top: CRATE.height,
standable: true,
},
{
shape: "circle",
x: npcPosition.x,
z: npcPosition.z,
radius: 0.5,
top: Infinity,
standable: false,
},
]
const treeDefinitions: WeightedTree[] = [
{ definition: Oak.create(materials.bark, materials.leaf), weight: 0.4 },
{
definition: Spruce.create(materials.bark, materials.needle),
weight: 0.32,
},
{ definition: Birch.create(materials.birch, materials.leaf), weight: 0.28 },
]
const props = [
...placeTrees(treeDefinitions),
...placeBoulders(Boulder.create(materials.rock)),
...placeBushes(Bush.create(materials.leaf)),
...placeFlowers(Flower.create(materials.flower)),
]
for (const prop of props) {
if (prop.collider !== null) {
staticColliders.push(prop.collider)
}
}
const chunks = ChunkBuilder.build(
{
minX: TERRAIN.minX,
minZ: TERRAIN.minZ,
maxX: TERRAIN.maxX,
maxZ: TERRAIN.maxZ,
columns: CHUNK_GRID,
rows: CHUNK_GRID,
bakeCell(near, far, cell) {
const ground = near.mesh(materials.grass)
far.use(materials.grass, ground)
Terrain.patch(
TERRAIN,
ground,
cell.x0,
cell.z0,
cell.x1,
cell.z1,
TERRAIN_SUBDIV,
TERRAIN_SUBDIV,
GROUND_UV,
(x, z) => Math.max(Math.abs(x), Math.abs(z)) >= ARENA,
)
},
},
props,
)
const frog = Frog.create(materials.frog)
const bee = Bee.create(materials.bee)
const robin = Robin.create(materials.robin)
const actors = placeMobs([
{
definition: frog,
count: FROG_COUNT,
minScale: 0.5,
maxScale: 0.85,
grounded: true,
phase: () => 0,
},
{
definition: bee,
count: BEE_COUNT,
minScale: 0.5,
maxScale: 0.8,
grounded: false,
phase: (random) => random() * 10,
},
{
definition: robin,
count: ROBIN_COUNT,
minScale: 0.4,
maxScale: 0.65,
grounded: true,
phase: () => 0,
},
])
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,
skybox: { texture: textures.skybox },
}
return Level.create({
terrain: TERRAIN,
actorWorld: TERRAIN,
actors,
staticColliders,
staticGroups: [
{ mesh: floor, material: materials.floor },
{ mesh: walls, material: materials.wall },
{ mesh: crate, material: materials.crate },
],
chunks,
billboards: [
{
position: npcPosition,
size: { x: 1.1, y: 1.5 },
material: materials.npc,
},
],
sky,
})
}
function createMaterials(textures: Textures): Materials {
return {
floor: { texture: textures.floor, cull: false },
grass: { texture: textures.grass, cull: true },
bark: { texture: textures.bark, cull: true },
birch: { texture: textures.birch, cull: true },
leaf: { texture: textures.leaf, cull: true },
needle: { texture: textures.needle, cull: true },
rock: { texture: textures.rock, cull: true },
flower: { texture: textures.flower, cull: false },
wall: { texture: textures.wall, cull: false },
crate: { texture: textures.crate, cull: false },
npc: { texture: textures.npc, cull: false },
frog: { texture: textures.frog, cull: false },
bee: { texture: textures.bee, cull: false },
robin: { texture: textures.robin, cull: false },
}
}
function placeTrees(definitions: WeightedTree[]): PlacedPrefab[] {
const random = mulberry(TREE_SEED)
const maxDistance = TERRAIN_CONFIG.outer * TREE_REACH
const trees: PlacedPrefab[] = []
for (
let guard = 0;
trees.length < TREE_COUNT && guard < TREE_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 5, 3)
if (point === null) {
continue
}
const definition = weightedTree(definitions, random())
const tree: Tree = {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
growth: 0.08 + random() * 0.92,
seed: (random() * 0xFFFFFFFF) | 0,
}
trees.push(Prefab.place(definition, tree))
}
return trees
}
function placeBoulders(definition: PrefabDefinition<Boulder>): PlacedPrefab[] {
const random = mulberry(BOULDER_SEED)
const maxDistance = TERRAIN_CONFIG.outer * BOULDER_REACH
const boulders: PlacedPrefab[] = []
for (
let guard = 0;
boulders.length < BOULDER_COUNT && guard < BOULDER_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 4, 2)
if (point === null) {
continue
}
boulders.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
radius: 0.35 + random() * random() * 2.2,
seed: (random() * 0xFFFFFFFF) | 0,
}),
)
}
return boulders
}
function placeBushes(definition: PrefabDefinition<Bush>): PlacedPrefab[] {
const random = mulberry(BUSH_SEED)
const maxDistance = TERRAIN_CONFIG.outer * BUSH_REACH
const bushes: PlacedPrefab[] = []
for (
let guard = 0;
bushes.length < BUSH_COUNT && guard < BUSH_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 3, 2)
if (point === null) {
continue
}
bushes.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
size: 0.8 + random(),
seed: (random() * 0xFFFFFFFF) | 0,
}),
)
}
return bushes
}
function placeFlowers(definition: PrefabDefinition<Flower>): PlacedPrefab[] {
const random = mulberry(FLOWER_SEED)
const maxDistance = TERRAIN_CONFIG.outer * FLOWER_REACH
const flowers: PlacedPrefab[] = []
for (
let guard = 0;
flowers.length < FLOWER_COUNT && guard < FLOWER_COUNT * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 2, 1)
if (point === null) {
continue
}
flowers.push(
Prefab.place(definition, {
position: {
x: point.x,
y: TERRAIN.heightAt(point.x, point.z),
z: point.z,
},
style: FLOWER_STYLES[(random() * FLOWER_STYLES.length) | 0],
size: 0.28 + random() * 0.22,
seed: (random() * 0xFFFFFFFF) | 0,
}),
)
}
return flowers
}
function placeMobs(spawns: MobSpawn[]): Mob[] {
const random = mulberry(MOB_SEED)
const maxDistance = TERRAIN_CONFIG.outer * MOB_REACH
const mobs: Mob[] = []
for (const spawn of spawns) {
let placed = 0
for (
let guard = 0;
placed < spawn.count && guard < spawn.count * 20;
guard++
) {
const point = scatterPoint(random, maxDistance, 3, 2)
if (point === null) {
continue
}
const y = TERRAIN.heightAt(point.x, point.z)
const scale = spawn.minScale + random() * (spawn.maxScale - spawn.minScale)
const heading = random() * Math.PI * 2
const state: MobState = {
home: { x: point.x, y, z: point.z },
position: { x: point.x, y, z: point.z },
heading,
scale,
seed: (random() * 0xFFFFFFFF) | 0,
vx: 0,
vz: 0,
vy: 0,
timer: random() * 1.5,
phase: spawn.phase(random),
grounded: spawn.grounded,
}
mobs.push(Actor.create(spawn.definition, state))
placed++
}
}
return mobs
}
function scatterPoint(
random: () => number,
maxDistance: number,
clearance: number,
flatMargin: number,
): { x: number; z: number } | null {
const angle = random() * Math.PI * 2
const distance =
ARENA + clearance + random() * (maxDistance - ARENA - clearance)
const x = Math.cos(angle) * distance
const z = Math.sin(angle) * distance
return Math.max(Math.abs(x), Math.abs(z)) < TERRAIN_CONFIG.inner + flatMargin
? null
: { x, z }
}
function weightedTree(
definitions: WeightedTree[],
roll: number,
): PrefabDefinition<Tree> {
let cumulative = 0
for (const entry of definitions) {
cumulative += entry.weight
if (roll < cumulative) {
return entry.definition
}
}
return definitions[definitions.length - 1].definition
}
function wall(
minX: number,
maxX: number,
minZ: number,
maxZ: number,
): BoxCollider {
return {
shape: "box",
minX,
maxX,
minZ,
maxZ,
top: WALL_HEIGHT,
standable: true,
}
}
function mulberry(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6D2B79F5) | 0
let value = Math.imul(state ^ (state >>> 15), 1 | state)
value ^= value + Math.imul(value ^ (value >>> 7), 61 | value)
return ((value ^ (value >>> 14)) >>> 0) / 4294967296
}
}