From f4073696fddf51b5dbfc8959a9dd48d81dfe3209 Mon Sep 17 00:00:00 2001 From: Francois Date: Tue, 7 Oct 2025 18:33:59 +0900 Subject: [PATCH] feat: add diamond-square terrain generator --- ROADMAP.md | 2 +- docs/index.d.ts | 34 ++++++++ examples/diamondSquare.ts | 11 +++ src/index.ts | 9 ++ src/procedural/diamondSquare.ts | 150 ++++++++++++++++++++++++++++++++ tests/diamondSquare.test.ts | 30 +++++++ tests/index.test.ts | 2 + 7 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 examples/diamondSquare.ts create mode 100644 src/procedural/diamondSquare.ts create mode 100644 tests/diamondSquare.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 47e9de7..1cd4bb3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -36,7 +36,7 @@ - [x] Cellular automata cave/organic generator utilities - [x] Poisson disk sampling for even point distribution - [x] Voronoi diagram helpers for biome/territory generation - - [ ] Diamond-square terrain height map generator + - [x] Diamond-square terrain height map generator - [ ] L-system generator for foliage and organic structures - [ ] Dungeon generation suite (BSP subdivision, rooms & corridors variants) - [ ] Maze algorithms pack (Recursive backtracking, Prim's, Kruskal's, Wilson's, Aldous–Broder, Recursive Division) diff --git a/docs/index.d.ts b/docs/index.d.ts index 5bc7da5..13b0e5e 100644 --- a/docs/index.d.ts +++ b/docs/index.d.ts @@ -49,6 +49,7 @@ export const examples: { readonly cellularAutomataCave: 'examples/cellularAutomata.ts'; readonly poissonDiskSampling: 'examples/poissonDisk.ts'; readonly computeVoronoiDiagram: 'examples/voronoi.ts'; + readonly diamondSquare: 'examples/diamondSquare.ts'; }; readonly spatial: { readonly Quadtree: 'examples/sat.ts'; @@ -387,6 +388,7 @@ export interface PoissonDiskOptions { export function poissonDiskSampling(options: PoissonDiskOptions): Point[]; /** +<<<<<<< HEAD * Voronoi site definition. * Use for: labelling regions, associating metadata to cells. * Import: procedural/voronoi.ts @@ -437,6 +439,38 @@ export function computeVoronoiDiagram( sites: ReadonlyArray, options?: VoronoiOptions ): VoronoiCell[]; +======= + * Options configuring the diamond-square fractal algorithm. + * Use for: tuning roughness, deterministic height map generation. + * Import: procedural/diamondSquare.ts + */ +export interface DiamondSquareOptions { + size: number; + roughness?: number; + initialAmplitude?: number; + seed?: number; + normalize?: boolean; +} + +/** + * Diamond-square result containing sampled grid values and extrema. + * Use for: rendering terrain meshes, post-processing passes, biome assignment. + * Import: procedural/diamondSquare.ts + */ +export interface DiamondSquareResult { + grid: number[][]; + min: number; + max: number; +} + +/** + * Generates fractal height maps via the diamond-square algorithm. + * Use for: terrain synthesis, cloud height fields, noise layering. + * Performance: O(size^2) where size = 2^n + 1. + * Import: procedural/diamondSquare.ts + */ +export function diamondSquare(options: DiamondSquareOptions): DiamondSquareResult; +>>>>>>> c977c3c (feat: add diamond-square terrain generator) /** * Simplex noise generator for smooth gradients without directional artifacts. diff --git a/examples/diamondSquare.ts b/examples/diamondSquare.ts new file mode 100644 index 0000000..72f2e5a --- /dev/null +++ b/examples/diamondSquare.ts @@ -0,0 +1,11 @@ +import { diamondSquare } from '../src/index.js'; + +const { grid, min, max } = diamondSquare({ + size: 9, + roughness: 0.6, + seed: 42, +}); + +console.log('Min height:', min.toFixed(3)); +console.log('Max height:', max.toFixed(3)); +console.log('Sample row:', grid[4]?.map((value) => value.toFixed(3)).join(', ')); diff --git a/src/index.ts b/src/index.ts index 2c2c2d6..c1ef5e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,7 @@ export const examples = { cellularAutomataCave: 'examples/cellularAutomata.ts', poissonDiskSampling: 'examples/poissonDisk.ts', computeVoronoiDiagram: 'examples/voronoi.ts', + diamondSquare: 'examples/diamondSquare.ts', }, spatial: { Quadtree: 'examples/sat.ts', @@ -244,11 +245,19 @@ export { cellularAutomataCave } from './procedural/cellularAutomata.js'; export { poissonDiskSampling } from './procedural/poissonDisk.js'; /** +<<<<<<< HEAD * Voronoi diagram helper returning polygonal cells for each site. * * Example file: examples/voronoi.ts */ export { computeVoronoiDiagram } from './procedural/voronoi.js'; +======= + * Diamond-square fractal terrain generator. + * + * Example file: examples/diamondSquare.ts + */ +export { diamondSquare } from './procedural/diamondSquare.js'; +>>>>>>> c977c3c (feat: add diamond-square terrain generator) // ============================================================================ // 🎯 SPATIAL & COLLISION diff --git a/src/procedural/diamondSquare.ts b/src/procedural/diamondSquare.ts new file mode 100644 index 0000000..32ef439 --- /dev/null +++ b/src/procedural/diamondSquare.ts @@ -0,0 +1,150 @@ +import { createLinearCongruentialGenerator } from '../util/prng.js'; + +export interface DiamondSquareOptions { + /** + * Grid size must be 2^n + 1 (e.g. 5, 9, 17). + */ + size: number; + /** + * Controls how quickly perturbations shrink each iteration. + */ + roughness?: number; + /** + * Starting amplitude for corner offsets. + */ + initialAmplitude?: number; + /** + * Seed for deterministic noise generation. + */ + seed?: number; + /** + * Clamp height map to [0, 1] after generation. + */ + normalize?: boolean; +} + +export interface DiamondSquareResult { + grid: number[][]; + min: number; + max: number; +} + +const DEFAULT_ROUGHNESS = 0.55; +const DEFAULT_AMPLITUDE = 1; + +/** + * Generates a height map using the diamond-square fractal algorithm. + * Useful for: terrain synthesis, cloud layers, noise-based textures. + * Performance: O(size^2) where size = (2^n + 1). + */ +export function diamondSquare({ + size, + roughness = DEFAULT_ROUGHNESS, + initialAmplitude = DEFAULT_AMPLITUDE, + seed = Date.now(), + normalize = true, +}: DiamondSquareOptions): DiamondSquareResult { + validateSize(size); + if (roughness <= 0 || roughness >= 1) { + throw new Error('roughness must be between 0 and 1 (exclusive).'); + } + if (initialAmplitude <= 0) { + throw new Error('initialAmplitude must be greater than zero.'); + } + + const grid = Array.from({ length: size }, () => new Array(size).fill(0)); + const random = createLinearCongruentialGenerator(seed); + const last = size - 1; + + setCorner(grid, 0, 0, randomAmplitude(random, initialAmplitude)); + setCorner(grid, last, 0, randomAmplitude(random, initialAmplitude)); + setCorner(grid, 0, last, randomAmplitude(random, initialAmplitude)); + setCorner(grid, last, last, randomAmplitude(random, initialAmplitude)); + + let step = last; + let amplitude = initialAmplitude; + + while (step > 1) { + const half = step / 2; + + // Diamond step + for (let y = 0; y < last; y += step) { + for (let x = 0; x < last; x += step) { + const average = + (grid[y][x] + grid[y][x + step] + grid[y + step][x] + grid[y + step][x + step]) / 4; + grid[y + half][x + half] = average + randomAmplitude(random, amplitude); + } + } + + // Square step + for (let y = 0; y <= last; y += half) { + for (let x = (y + half) % step; x <= last; x += step) { + let sum = 0; + let count = 0; + + if (x - half >= 0) { + sum += grid[y][x - half]; + count += 1; + } + if (x + half <= last) { + sum += grid[y][x + half]; + count += 1; + } + if (y - half >= 0) { + sum += grid[y - half][x]; + count += 1; + } + if (y + half <= last) { + sum += grid[y + half][x]; + count += 1; + } + + const average = sum / count; + grid[y][x] = average + randomAmplitude(random, amplitude); + } + } + + amplitude *= roughness; + step = half; + } + + let min = Infinity; + let max = -Infinity; + for (const row of grid) { + for (const value of row) { + if (value < min) min = value; + if (value > max) max = value; + } + } + + if (normalize) { + const range = max - min || 1; + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + grid[y][x] = (grid[y][x] - min) / range; + } + } + min = 0; + max = 1; + } + + return { grid, min, max }; +} + +function validateSize(size: number): void { + if (!Number.isInteger(size) || size < 3) { + throw new Error('size must be an integer >= 3.'); + } + const exponent = Math.log2(size - 1); + if (!Number.isInteger(exponent)) { + throw new Error('size must satisfy size = 2^n + 1 (e.g. 5, 9, 17).'); + } +} + +function randomAmplitude(random: () => number, amplitude: number): number { + return (random() * 2 - 1) * amplitude; +} + +function setCorner(grid: number[][], x: number, y: number, value: number): void { + grid[y][x] = value; +} diff --git a/tests/diamondSquare.test.ts b/tests/diamondSquare.test.ts new file mode 100644 index 0000000..b7fc940 --- /dev/null +++ b/tests/diamondSquare.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { diamondSquare } from '../src/index.js'; + +describe('diamondSquare', () => { + it('produces deterministic grids for identical seeds', () => { + const options = { size: 9, seed: 123, roughness: 0.55, initialAmplitude: 1 } as const; + const a = diamondSquare(options); + const b = diamondSquare(options); + expect(a.grid).toEqual(b.grid); + expect(a.min).toBeCloseTo(b.min, 10); + expect(a.max).toBeCloseTo(b.max, 10); + }); + + it('normalizes heights to [0, 1] when requested', () => { + const { grid, min, max } = diamondSquare({ + size: 17, + seed: 99, + roughness: 0.6, + initialAmplitude: 2, + normalize: true, + }); + + expect(min).toBe(0); + expect(max).toBe(1); + for (const row of grid) { + expect(row.every((value) => value >= 0 && value <= 1)).toBe(true); + } + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 70cc6e4..0ee4ffa 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -11,6 +11,7 @@ describe('package entry point', () => { expect(examples.procedural.cellularAutomataCave).toBe('examples/cellularAutomata.ts'); expect(examples.procedural.poissonDiskSampling).toBe('examples/poissonDisk.ts'); expect(examples.procedural.computeVoronoiDiagram).toBe('examples/voronoi.ts'); + expect(examples.procedural.diamondSquare).toBe('examples/diamondSquare.ts'); expect(examples.search.Trie).toBe('examples/search.ts'); expect(examples.pathfinding.buildNavMesh).toBe('examples/navMesh.ts'); }); @@ -52,6 +53,7 @@ describe('package entry point', () => { | 'cellularAutomataCave' | 'poissonDiskSampling' | 'computeVoronoiDiagram' + | 'diamondSquare' >(); }); });