import { createOpaqueId } from "../core/ids"; import type { Vec3 } from "../core/vector"; export interface TerrainLayer { materialId: string | null; } export interface Terrain { id: string; kind: "terrain"; name?: string; visible: boolean; enabled: boolean; collisionEnabled: boolean; position: Vec3; sampleCountX: number; sampleCountZ: number; cellSize: number; heights: number[]; layers: TerrainLayer[]; paintWeights: number[]; } export const DEFAULT_TERRAIN_VISIBLE = true; export const DEFAULT_TERRAIN_ENABLED = true; export const DEFAULT_TERRAIN_COLLISION_ENABLED = true; export const MIN_TERRAIN_SAMPLE_COUNT = 2; export const DEFAULT_TERRAIN_SAMPLE_COUNT_X = 9; export const DEFAULT_TERRAIN_SAMPLE_COUNT_Z = 9; export const DEFAULT_TERRAIN_CELL_SIZE = 1; export const DEFAULT_TERRAIN_HEIGHT = 0; export const TERRAIN_LAYER_COUNT = 4; export const DEFAULT_TERRAIN_LAYER_MATERIAL_IDS = [ "patchy_grass_ground_250x250", "patchy_weedy_dirt_ground_300x300", "ground_sand_300x300", "concrete_wall_cladding_250x250" ] as const; function cloneVec3(vector: Vec3): Vec3 { return { x: vector.x, y: vector.y, z: vector.z }; } function areVec3Equal(left: Vec3, right: Vec3): boolean { return left.x === right.x && left.y === right.y && left.z === right.z; } function assertFiniteVec3(vector: Vec3, label: string) { if (!Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z)) { throw new Error(`${label} must be finite on every axis.`); } } export function normalizeTerrainName( name: string | null | undefined ): string | undefined { if (name === undefined || name === null) { return undefined; } const trimmedName = name.trim(); return trimmedName.length === 0 ? undefined : trimmedName; } export function normalizeTerrainSampleCount( value: number, label: string ): number { if (!Number.isInteger(value) || value < MIN_TERRAIN_SAMPLE_COUNT) { throw new Error( `${label} must be an integer greater than or equal to ${MIN_TERRAIN_SAMPLE_COUNT}.` ); } return value; } export function normalizeTerrainCellSize(value: number): number { if (!Number.isFinite(value) || value <= 0) { throw new Error("Terrain cell size must be a positive finite number."); } return value; } function normalizeTerrainLayerMaterialId( value: string | null | undefined, label: string ): string | null { if (value === null || value === undefined) { return null; } if (typeof value !== "string") { throw new Error(`${label} must be a string or null.`); } const normalizedValue = value.trim(); return normalizedValue.length === 0 ? null : normalizedValue; } function normalizeTerrainCollisionEnabled(value: boolean): boolean { if (typeof value !== "boolean") { throw new Error("Terrain collisionEnabled must be a boolean."); } return value; } export function getTerrainLayerLabel(layerIndex: number): string { if (!Number.isInteger(layerIndex) || layerIndex < 0 || layerIndex >= TERRAIN_LAYER_COUNT) { throw new Error(`Terrain layer index ${layerIndex} is out of range.`); } return layerIndex === 0 ? "Base Layer" : `Layer ${layerIndex + 1}`; } export function createDefaultTerrainLayers(): TerrainLayer[] { return Array.from({ length: TERRAIN_LAYER_COUNT }, (_, layerIndex) => ({ materialId: DEFAULT_TERRAIN_LAYER_MATERIAL_IDS[layerIndex] ?? null })); } export function cloneTerrainLayers( layers: readonly TerrainLayer[] ): TerrainLayer[] { return layers.map((layer, layerIndex) => ({ materialId: normalizeTerrainLayerMaterialId( layer.materialId, `Terrain layer ${layerIndex}` ) })); } function normalizeTerrainLayers( layers: readonly TerrainLayer[] | undefined ): TerrainLayer[] { if (layers === undefined) { return createDefaultTerrainLayers(); } if (layers.length !== TERRAIN_LAYER_COUNT) { throw new Error( `Terrain layers must contain exactly ${TERRAIN_LAYER_COUNT} layer slots.` ); } return layers.map((layer, layerIndex) => { if (typeof layer !== "object" || layer === null) { throw new Error(`Terrain layer ${layerIndex} must be an object.`); } return { materialId: normalizeTerrainLayerMaterialId( layer.materialId, `Terrain layer ${layerIndex}.materialId` ) }; }); } export function createFlatTerrainHeights( sampleCountX: number, sampleCountZ: number, height = DEFAULT_TERRAIN_HEIGHT ): number[] { const normalizedSampleCountX = normalizeTerrainSampleCount( sampleCountX, "Terrain sampleCountX" ); const normalizedSampleCountZ = normalizeTerrainSampleCount( sampleCountZ, "Terrain sampleCountZ" ); if (!Number.isFinite(height)) { throw new Error("Terrain height samples must be finite."); } return new Array(normalizedSampleCountX * normalizedSampleCountZ).fill(height); } export function createFlatTerrainPaintWeights( sampleCountX: number, sampleCountZ: number ): number[] { const normalizedSampleCountX = normalizeTerrainSampleCount( sampleCountX, "Terrain sampleCountX" ); const normalizedSampleCountZ = normalizeTerrainSampleCount( sampleCountZ, "Terrain sampleCountZ" ); return new Array( normalizedSampleCountX * normalizedSampleCountZ * (TERRAIN_LAYER_COUNT - 1) ).fill(0); } export function getTerrainSampleIndex( terrain: Pick, sampleX: number, sampleZ: number ): number { if ( !Number.isInteger(sampleX) || sampleX < 0 || sampleX >= terrain.sampleCountX ) { throw new Error(`Terrain sampleX ${sampleX} is out of range.`); } if ( !Number.isInteger(sampleZ) || sampleZ < 0 || sampleZ >= terrain.sampleCountZ ) { throw new Error(`Terrain sampleZ ${sampleZ} is out of range.`); } return sampleZ * terrain.sampleCountX + sampleX; } export function getTerrainPaintWeightSampleOffset( terrain: Pick, sampleX: number, sampleZ: number ): number { return getTerrainSampleIndex(terrain, sampleX, sampleZ) * (TERRAIN_LAYER_COUNT - 1); } export function getTerrainHeightAtSample( terrain: Terrain, sampleX: number, sampleZ: number ): number { return terrain.heights[getTerrainSampleIndex(terrain, sampleX, sampleZ)] ?? 0; } function normalizeTerrainPaintWeights( sampleCountX: number, sampleCountZ: number, paintWeights: readonly number[] | undefined ): number[] { const expectedLength = sampleCountX * sampleCountZ * (TERRAIN_LAYER_COUNT - 1); const normalizedPaintWeights = paintWeights === undefined ? createFlatTerrainPaintWeights(sampleCountX, sampleCountZ) : [...paintWeights]; if (normalizedPaintWeights.length !== expectedLength) { throw new Error( `Terrain paint weights must contain exactly ${expectedLength} values.` ); } for ( let sampleIndex = 0; sampleIndex < sampleCountX * sampleCountZ; sampleIndex += 1 ) { const offset = sampleIndex * (TERRAIN_LAYER_COUNT - 1); let weightSum = 0; for ( let layerOffset = 0; layerOffset < TERRAIN_LAYER_COUNT - 1; layerOffset += 1 ) { const value = normalizedPaintWeights[offset + layerOffset]; if (!Number.isFinite(value)) { throw new Error("Terrain paint weights must remain finite."); } const clampedValue = Math.min(1, Math.max(0, value)); normalizedPaintWeights[offset + layerOffset] = clampedValue; weightSum += clampedValue; } if (weightSum <= 1) { continue; } const scale = 1 / weightSum; for ( let layerOffset = 0; layerOffset < TERRAIN_LAYER_COUNT - 1; layerOffset += 1 ) { normalizedPaintWeights[offset + layerOffset] *= scale; } } return normalizedPaintWeights; } export function getTerrainSampleLayerWeights( terrain: Pick, sampleX: number, sampleZ: number ): [number, number, number, number] { const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ); const layer1 = terrain.paintWeights[offset] ?? 0; const layer2 = terrain.paintWeights[offset + 1] ?? 0; const layer3 = terrain.paintWeights[offset + 2] ?? 0; const baseLayer = Math.max(0, 1 - (layer1 + layer2 + layer3)); const weightSum = baseLayer + layer1 + layer2 + layer3; if (weightSum <= 0) { return [1, 0, 0, 0]; } return [ baseLayer / weightSum, layer1 / weightSum, layer2 / weightSum, layer3 / weightSum ]; } export function getTerrainWorldSamplePosition( terrain: Terrain, sampleX: number, sampleZ: number ): Vec3 { return { x: terrain.position.x + sampleX * terrain.cellSize, y: terrain.position.y + getTerrainHeightAtSample(terrain, sampleX, sampleZ), z: terrain.position.z + sampleZ * terrain.cellSize }; } export function getTerrainFootprintWidth( terrain: Pick ): number { return (terrain.sampleCountX - 1) * terrain.cellSize; } export function getTerrainFootprintDepth( terrain: Pick ): number { return (terrain.sampleCountZ - 1) * terrain.cellSize; } export function getTerrainBounds(terrain: Terrain): { min: Vec3; max: Vec3 } { const width = getTerrainFootprintWidth(terrain); const depth = getTerrainFootprintDepth(terrain); let minHeight = Number.POSITIVE_INFINITY; let maxHeight = Number.NEGATIVE_INFINITY; for (const height of terrain.heights) { minHeight = Math.min(minHeight, height); maxHeight = Math.max(maxHeight, height); } if (!Number.isFinite(minHeight) || !Number.isFinite(maxHeight)) { minHeight = 0; maxHeight = 0; } return { min: { x: terrain.position.x, y: terrain.position.y + minHeight, z: terrain.position.z }, max: { x: terrain.position.x + width, y: terrain.position.y + maxHeight, z: terrain.position.z + depth } }; } function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function lerp(start: number, end: number, alpha: number): number { return start + (end - start) * alpha; } function sampleTerrainHeightAtGridCoordinate( terrain: Terrain, sampleX: number, sampleZ: number ): number { const clampedSampleX = clamp(sampleX, 0, terrain.sampleCountX - 1); const clampedSampleZ = clamp(sampleZ, 0, terrain.sampleCountZ - 1); const minSampleX = Math.floor(clampedSampleX); const maxSampleX = Math.min(terrain.sampleCountX - 1, minSampleX + 1); const minSampleZ = Math.floor(clampedSampleZ); const maxSampleZ = Math.min(terrain.sampleCountZ - 1, minSampleZ + 1); const blendX = clampedSampleX - minSampleX; const blendZ = clampedSampleZ - minSampleZ; const height00 = getTerrainHeightAtSample(terrain, minSampleX, minSampleZ); const height10 = getTerrainHeightAtSample(terrain, maxSampleX, minSampleZ); const height01 = getTerrainHeightAtSample(terrain, minSampleX, maxSampleZ); const height11 = getTerrainHeightAtSample(terrain, maxSampleX, maxSampleZ); return lerp( lerp(height00, height10, blendX), lerp(height01, height11, blendX), blendZ ); } function getStoredTerrainPaintWeightAtSample( terrain: Terrain, sampleX: number, sampleZ: number, layerOffset: number ): number { const offset = getTerrainPaintWeightSampleOffset(terrain, sampleX, sampleZ); return terrain.paintWeights[offset + layerOffset] ?? 0; } function sampleTerrainPaintWeightAtGridCoordinate( terrain: Terrain, sampleX: number, sampleZ: number, layerOffset: number ): number { const clampedSampleX = clamp(sampleX, 0, terrain.sampleCountX - 1); const clampedSampleZ = clamp(sampleZ, 0, terrain.sampleCountZ - 1); const minSampleX = Math.floor(clampedSampleX); const maxSampleX = Math.min(terrain.sampleCountX - 1, minSampleX + 1); const minSampleZ = Math.floor(clampedSampleZ); const maxSampleZ = Math.min(terrain.sampleCountZ - 1, minSampleZ + 1); const blendX = clampedSampleX - minSampleX; const blendZ = clampedSampleZ - minSampleZ; const weight00 = getStoredTerrainPaintWeightAtSample( terrain, minSampleX, minSampleZ, layerOffset ); const weight10 = getStoredTerrainPaintWeightAtSample( terrain, maxSampleX, minSampleZ, layerOffset ); const weight01 = getStoredTerrainPaintWeightAtSample( terrain, minSampleX, maxSampleZ, layerOffset ); const weight11 = getStoredTerrainPaintWeightAtSample( terrain, maxSampleX, maxSampleZ, layerOffset ); return lerp( lerp(weight00, weight10, blendX), lerp(weight01, weight11, blendX), blendZ ); } function createTerrainPositionFromCenter( center: Vec3, sampleCountX: number, sampleCountZ: number, cellSize: number ): Vec3 { return { x: center.x - ((sampleCountX - 1) * cellSize) * 0.5, y: center.y, z: center.z - ((sampleCountZ - 1) * cellSize) * 0.5 }; } function getTerrainFootprintCenter(terrain: Terrain): Vec3 { return { x: terrain.position.x + getTerrainFootprintWidth(terrain) * 0.5, y: terrain.position.y, z: terrain.position.z + getTerrainFootprintDepth(terrain) * 0.5 }; } function createResampledTerrainHeights( terrain: Terrain, sampleCountX: number, sampleCountZ: number ): number[] { const heights = new Array(sampleCountX * sampleCountZ); for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) { const normalizedSampleZ = sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1); const sourceSampleZ = normalizedSampleZ * (terrain.sampleCountZ - 1); for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) { const normalizedSampleX = sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1); const sourceSampleX = normalizedSampleX * (terrain.sampleCountX - 1); heights[sampleZ * sampleCountX + sampleX] = sampleTerrainHeightAtGridCoordinate( terrain, sourceSampleX, sourceSampleZ ); } } return heights; } function createResampledTerrainPaintWeights( terrain: Terrain, sampleCountX: number, sampleCountZ: number ): number[] { const paintWeights = new Array( sampleCountX * sampleCountZ * (TERRAIN_LAYER_COUNT - 1) ); for (let sampleZ = 0; sampleZ < sampleCountZ; sampleZ += 1) { const normalizedSampleZ = sampleCountZ === 1 ? 0 : sampleZ / (sampleCountZ - 1); const sourceSampleZ = normalizedSampleZ * (terrain.sampleCountZ - 1); for (let sampleX = 0; sampleX < sampleCountX; sampleX += 1) { const normalizedSampleX = sampleCountX === 1 ? 0 : sampleX / (sampleCountX - 1); const sourceSampleX = normalizedSampleX * (terrain.sampleCountX - 1); const offset = (sampleZ * sampleCountX + sampleX) * (TERRAIN_LAYER_COUNT - 1); for ( let layerOffset = 0; layerOffset < TERRAIN_LAYER_COUNT - 1; layerOffset += 1 ) { paintWeights[offset + layerOffset] = sampleTerrainPaintWeightAtGridCoordinate( terrain, sourceSampleX, sourceSampleZ, layerOffset ); } } } return paintWeights; } export function resizeTerrainGrid( terrain: Terrain, options: Pick & { preserveCenter?: boolean; } ): Terrain { const sampleCountX = normalizeTerrainSampleCount( options.sampleCountX, "Terrain sampleCountX" ); const sampleCountZ = normalizeTerrainSampleCount( options.sampleCountZ, "Terrain sampleCountZ" ); const cellSize = normalizeTerrainCellSize(options.cellSize); const preserveCenter = options.preserveCenter ?? true; const nextPosition = preserveCenter ? createTerrainPositionFromCenter( getTerrainFootprintCenter(terrain), sampleCountX, sampleCountZ, cellSize ) : cloneVec3(terrain.position); return createTerrain({ ...terrain, position: nextPosition, sampleCountX, sampleCountZ, cellSize, heights: createResampledTerrainHeights(terrain, sampleCountX, sampleCountZ), paintWeights: createResampledTerrainPaintWeights( terrain, sampleCountX, sampleCountZ ) }); } function createDefaultTerrainPosition( sampleCountX: number, sampleCountZ: number, cellSize: number ): Vec3 { return { x: -((sampleCountX - 1) * cellSize) * 0.5, y: 0, z: -((sampleCountZ - 1) * cellSize) * 0.5 }; } export function createTerrain( overrides: Partial< Pick< Terrain, | "id" | "name" | "visible" | "enabled" | "collisionEnabled" | "position" | "sampleCountX" | "sampleCountZ" | "cellSize" | "heights" | "layers" | "paintWeights" > > = {} ): Terrain { const sampleCountX = normalizeTerrainSampleCount( overrides.sampleCountX ?? DEFAULT_TERRAIN_SAMPLE_COUNT_X, "Terrain sampleCountX" ); const sampleCountZ = normalizeTerrainSampleCount( overrides.sampleCountZ ?? DEFAULT_TERRAIN_SAMPLE_COUNT_Z, "Terrain sampleCountZ" ); const cellSize = normalizeTerrainCellSize( overrides.cellSize ?? DEFAULT_TERRAIN_CELL_SIZE ); const position = cloneVec3( overrides.position ?? createDefaultTerrainPosition(sampleCountX, sampleCountZ, cellSize) ); const heights = overrides.heights !== undefined ? [...overrides.heights] : createFlatTerrainHeights(sampleCountX, sampleCountZ); const layers = normalizeTerrainLayers(overrides.layers); const paintWeights = normalizeTerrainPaintWeights( sampleCountX, sampleCountZ, overrides.paintWeights ); const visible = overrides.visible ?? DEFAULT_TERRAIN_VISIBLE; const enabled = overrides.enabled ?? DEFAULT_TERRAIN_ENABLED; const collisionEnabled = normalizeTerrainCollisionEnabled( overrides.collisionEnabled ?? DEFAULT_TERRAIN_COLLISION_ENABLED ); assertFiniteVec3(position, "Terrain position"); if (typeof visible !== "boolean") { throw new Error("Terrain visible must be a boolean."); } if (typeof enabled !== "boolean") { throw new Error("Terrain enabled must be a boolean."); } if (heights.length !== sampleCountX * sampleCountZ) { throw new Error( `Terrain heights must contain exactly ${sampleCountX * sampleCountZ} samples.` ); } if (heights.some((height) => !Number.isFinite(height))) { throw new Error("Terrain heights must remain finite."); } return { id: overrides.id ?? createOpaqueId("terrain"), kind: "terrain", name: normalizeTerrainName(overrides.name), visible, enabled, collisionEnabled, position, sampleCountX, sampleCountZ, cellSize, heights, layers, paintWeights }; } export function cloneTerrain(terrain: Terrain): Terrain { return createTerrain(terrain); } export function areTerrainsEqual(left: Terrain, right: Terrain): boolean { return ( left.id === right.id && left.kind === right.kind && left.name === right.name && left.visible === right.visible && left.enabled === right.enabled && left.collisionEnabled === right.collisionEnabled && areVec3Equal(left.position, right.position) && left.sampleCountX === right.sampleCountX && left.sampleCountZ === right.sampleCountZ && left.cellSize === right.cellSize && left.heights.length === right.heights.length && left.heights.every((height, index) => height === right.heights[index]) && left.layers.length === right.layers.length && left.layers.every( (layer, index) => layer.materialId === right.layers[index]?.materialId ) && left.paintWeights.length === right.paintWeights.length && left.paintWeights.every( (weight, index) => weight === right.paintWeights[index] ) ); } export function compareTerrains(left: Terrain, right: Terrain): number { const leftName = left.name ?? ""; const rightName = right.name ?? ""; if (leftName !== rightName) { return leftName.localeCompare(rightName); } return left.id.localeCompare(right.id); } export function getTerrains(terrains: Record): Terrain[] { return Object.values(terrains).sort(compareTerrains); } export function getTerrainKindLabel(): string { return "Terrain"; }