diff --git a/packages/procgen/module.json b/packages/procgen/module.json new file mode 100644 index 00000000..55104892 --- /dev/null +++ b/packages/procgen/module.json @@ -0,0 +1,24 @@ +{ + "id": "procgen", + "name": "@esengine/procgen", + "globalKey": "procgen", + "displayName": "Procedural Generation", + "description": "程序化生成工具,包含噪声函数和随机工具 | Procedural generation tools with noise functions and random utilities", + "version": "1.0.0", + "category": "Math", + "icon": "Wand", + "tags": ["noise", "random", "procedural", "generation"], + "isCore": false, + "defaultEnabled": true, + "isEngineModule": true, + "canContainContent": false, + "platforms": ["web", "desktop"], + "dependencies": ["core"], + "exports": { + "components": [], + "systems": [] + }, + "requiresWasm": false, + "outputPath": "dist/index.js", + "pluginExport": "ProcGenPlugin" +} diff --git a/packages/procgen/package.json b/packages/procgen/package.json new file mode 100644 index 00000000..7848f973 --- /dev/null +++ b/packages/procgen/package.json @@ -0,0 +1,40 @@ +{ + "name": "@esengine/procgen", + "version": "1.0.0", + "description": "Procedural generation tools for ECS Framework / ECS 框架的程序化生成工具", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "module.json" + ], + "scripts": { + "build": "tsup", + "build:watch": "tsup --watch", + "type-check": "tsc --noEmit", + "clean": "rimraf dist" + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "devDependencies": { + "@esengine/ecs-framework": "workspace:*", + "@esengine/blueprint": "workspace:*", + "@esengine/build-config": "workspace:*", + "@types/node": "^20.19.17", + "rimraf": "^5.0.0", + "tsup": "^8.0.0", + "typescript": "^5.8.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/procgen/src/index.ts b/packages/procgen/src/index.ts new file mode 100644 index 00000000..671440ab --- /dev/null +++ b/packages/procgen/src/index.ts @@ -0,0 +1,82 @@ +/** + * @esengine/procgen + * + * @zh 程序化生成工具包 + * @en Procedural Generation Toolkit + * + * @zh 提供噪声函数、随机工具和蓝图节点 + * @en Provides noise functions, random utilities, and blueprint nodes + */ + +// ============================================================================= +// Noise Functions | 噪声函数 +// ============================================================================= + +export { + PerlinNoise, + createPerlinNoise, + SimplexNoise, + createSimplexNoise, + WorleyNoise, + createWorleyNoise, + FBM, + createFBM +} from './noise'; + +export type { + DistanceFunction, + INoise2D, + INoise3D, + FBMConfig +} from './noise'; + +// ============================================================================= +// Random Utilities | 随机工具 +// ============================================================================= + +export { + SeededRandom, + createSeededRandom, + WeightedRandom, + weightedPick, + weightedPickFromMap, + createWeightedRandom, + shuffle, + shuffleCopy, + pickOne, + sample, + sampleWithReplacement, + randomIntegers, + weightedSample +} from './random'; + +export type { WeightedItem } from './random'; + +// ============================================================================= +// Blueprint Nodes | 蓝图节点 +// ============================================================================= + +export { + // Templates + SampleNoise2DTemplate, + SampleFBMTemplate, + SeededRandomTemplate, + SeededRandomIntTemplate, + WeightedPickTemplate, + ShuffleArrayTemplate, + PickRandomTemplate, + SampleArrayTemplate, + RandomPointInCircleTemplate, + // Executors + SampleNoise2DExecutor, + SampleFBMExecutor, + SeededRandomExecutor, + SeededRandomIntExecutor, + WeightedPickExecutor, + ShuffleArrayExecutor, + PickRandomExecutor, + SampleArrayExecutor, + RandomPointInCircleExecutor, + // Collection + ProcGenNodeDefinitions +} from './nodes'; diff --git a/packages/procgen/src/nodes/ProcGenNodes.ts b/packages/procgen/src/nodes/ProcGenNodes.ts new file mode 100644 index 00000000..6f718908 --- /dev/null +++ b/packages/procgen/src/nodes/ProcGenNodes.ts @@ -0,0 +1,475 @@ +/** + * @zh 程序化生成蓝图节点 + * @en Procedural Generation Blueprint Nodes + */ + +import type { BlueprintNodeTemplate, BlueprintNode, INodeExecutor, ExecutionResult } from '@esengine/blueprint'; +import { PerlinNoise } from '../noise/PerlinNoise'; +import { SimplexNoise } from '../noise/SimplexNoise'; +import { WorleyNoise } from '../noise/WorleyNoise'; +import { FBM } from '../noise/FBM'; +import { SeededRandom } from '../random/SeededRandom'; +import { weightedPick } from '../random/WeightedRandom'; +import type { WeightedItem } from '../random/WeightedRandom'; +import { shuffle, pickOne, sample } from '../random/Shuffle'; + +// ============================================================================= +// 噪声缓存 | Noise Cache +// ============================================================================= + +const noiseCache = new Map(); +const rngCache = new Map(); + +function getPerlinNoise(seed: number): PerlinNoise { + const key = `perlin_${seed}`; + if (!noiseCache.has(key)) { + noiseCache.set(key, new PerlinNoise(seed)); + } + return noiseCache.get(key) as PerlinNoise; +} + +function getSimplexNoise(seed: number): SimplexNoise { + const key = `simplex_${seed}`; + if (!noiseCache.has(key)) { + noiseCache.set(key, new SimplexNoise(seed)); + } + return noiseCache.get(key) as SimplexNoise; +} + +function getWorleyNoise(seed: number): WorleyNoise { + const key = `worley_${seed}`; + if (!noiseCache.has(key)) { + noiseCache.set(key, new WorleyNoise(seed)); + } + return noiseCache.get(key) as WorleyNoise; +} + +function getSeededRandom(seed: number): SeededRandom { + if (!rngCache.has(seed)) { + rngCache.set(seed, new SeededRandom(seed)); + } + return rngCache.get(seed)!; +} + +// ============================================================================= +// 执行上下文接口 | Execution Context Interface +// ============================================================================= + +interface ProcGenContext { + evaluateInput(nodeId: string, pinName: string, defaultValue?: unknown): unknown; + setOutputs(nodeId: string, outputs: Record): void; +} + +// ============================================================================= +// SampleNoise2D 节点 | SampleNoise2D Node +// ============================================================================= + +export const SampleNoise2DTemplate: BlueprintNodeTemplate = { + type: 'SampleNoise2D', + title: 'Sample Noise 2D', + category: 'math', + description: 'Sample 2D noise at coordinates / 在坐标处采样 2D 噪声', + keywords: ['noise', 'perlin', 'simplex', 'random', 'procedural'], + menuPath: ['Procedural', 'Noise', 'Sample Noise 2D'], + isPure: true, + inputs: [ + { name: 'x', displayName: 'X', type: 'float' }, + { name: 'y', displayName: 'Y', type: 'float' }, + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'noiseType', displayName: 'Type', type: 'string' } + ], + outputs: [ + { name: 'value', displayName: 'Value', type: 'float' } + ], + color: '#9c27b0' +}; + +export class SampleNoise2DExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const x = ctx.evaluateInput(node.id, 'x', 0) as number; + const y = ctx.evaluateInput(node.id, 'y', 0) as number; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const noiseType = ctx.evaluateInput(node.id, 'noiseType', 'perlin') as string; + + let value = 0; + switch (noiseType.toLowerCase()) { + case 'simplex': + value = getSimplexNoise(seed).noise2D(x, y); + break; + case 'worley': + value = getWorleyNoise(seed).noise2D(x, y); + break; + case 'perlin': + default: + value = getPerlinNoise(seed).noise2D(x, y); + break; + } + + return { outputs: { value } }; + } +} + +// ============================================================================= +// SampleFBM 节点 | SampleFBM Node +// ============================================================================= + +export const SampleFBMTemplate: BlueprintNodeTemplate = { + type: 'SampleFBM', + title: 'Sample FBM', + category: 'math', + description: 'Sample Fractal Brownian Motion noise / 采样分形布朗运动噪声', + keywords: ['noise', 'fbm', 'fractal', 'octave', 'terrain'], + menuPath: ['Procedural', 'Noise', 'Sample FBM'], + isPure: true, + inputs: [ + { name: 'x', displayName: 'X', type: 'float' }, + { name: 'y', displayName: 'Y', type: 'float' }, + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'octaves', displayName: 'Octaves', type: 'int' }, + { name: 'frequency', displayName: 'Frequency', type: 'float' }, + { name: 'persistence', displayName: 'Persistence', type: 'float' } + ], + outputs: [ + { name: 'value', displayName: 'Value', type: 'float' } + ], + color: '#9c27b0' +}; + +export class SampleFBMExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const x = ctx.evaluateInput(node.id, 'x', 0) as number; + const y = ctx.evaluateInput(node.id, 'y', 0) as number; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const octaves = ctx.evaluateInput(node.id, 'octaves', 6) as number; + const frequency = ctx.evaluateInput(node.id, 'frequency', 1) as number; + const persistence = ctx.evaluateInput(node.id, 'persistence', 0.5) as number; + + const noise = getPerlinNoise(seed); + const fbm = new FBM(noise, { octaves, frequency, persistence }); + const value = fbm.noise2D(x, y); + + return { outputs: { value } }; + } +} + +// ============================================================================= +// SeededRandom 节点 | SeededRandom Node +// ============================================================================= + +export const SeededRandomTemplate: BlueprintNodeTemplate = { + type: 'SeededRandom', + title: 'Seeded Random', + category: 'math', + description: 'Generate deterministic random number / 生成确定性随机数', + keywords: ['random', 'seed', 'deterministic', 'procedural'], + menuPath: ['Procedural', 'Random', 'Seeded Random'], + isPure: true, + inputs: [ + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'index', displayName: 'Index', type: 'int' } + ], + outputs: [ + { name: 'value', displayName: 'Value', type: 'float' } + ], + color: '#9c27b0' +}; + +export class SeededRandomExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const index = ctx.evaluateInput(node.id, 'index', 0) as number; + + // Create deterministic value from seed and index + const rng = new SeededRandom(seed + index * 12345); + const value = rng.next(); + + return { outputs: { value } }; + } +} + +// ============================================================================= +// SeededRandomInt 节点 | SeededRandomInt Node +// ============================================================================= + +export const SeededRandomIntTemplate: BlueprintNodeTemplate = { + type: 'SeededRandomInt', + title: 'Seeded Random Int', + category: 'math', + description: 'Generate deterministic random integer / 生成确定性随机整数', + keywords: ['random', 'seed', 'integer', 'deterministic'], + menuPath: ['Procedural', 'Random', 'Seeded Random Int'], + isPure: true, + inputs: [ + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'index', displayName: 'Index', type: 'int' }, + { name: 'min', displayName: 'Min', type: 'int' }, + { name: 'max', displayName: 'Max', type: 'int' } + ], + outputs: [ + { name: 'value', displayName: 'Value', type: 'int' } + ], + color: '#9c27b0' +}; + +export class SeededRandomIntExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const index = ctx.evaluateInput(node.id, 'index', 0) as number; + const min = ctx.evaluateInput(node.id, 'min', 0) as number; + const max = ctx.evaluateInput(node.id, 'max', 100) as number; + + const rng = new SeededRandom(seed + index * 12345); + const value = rng.nextInt(min, max); + + return { outputs: { value } }; + } +} + +// ============================================================================= +// WeightedPick 节点 | WeightedPick Node +// ============================================================================= + +export const WeightedPickTemplate: BlueprintNodeTemplate = { + type: 'WeightedPick', + title: 'Weighted Pick', + category: 'math', + description: 'Pick from weighted options / 从加权选项中选择', + keywords: ['random', 'weight', 'pick', 'select', 'loot'], + menuPath: ['Procedural', 'Random', 'Weighted Pick'], + isPure: true, + inputs: [ + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'index', displayName: 'Index', type: 'int' }, + { name: 'items', displayName: 'Items', type: 'array' }, + { name: 'weights', displayName: 'Weights', type: 'array' } + ], + outputs: [ + { name: 'value', displayName: 'Value', type: 'any' }, + { name: 'selectedIndex', displayName: 'Index', type: 'int' } + ], + color: '#9c27b0' +}; + +export class WeightedPickExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const index = ctx.evaluateInput(node.id, 'index', 0) as number; + const items = ctx.evaluateInput(node.id, 'items', []) as unknown[]; + const weights = ctx.evaluateInput(node.id, 'weights', []) as number[]; + + if (items.length === 0) { + return { outputs: { value: null, selectedIndex: -1 } }; + } + + const rng = new SeededRandom(seed + index * 12345); + + // Build weighted items + const weightedItems: WeightedItem<{ value: unknown; index: number }>[] = items.map((item, i) => ({ + value: { value: item, index: i }, + weight: weights[i] ?? 1 + })); + + const result = weightedPick(weightedItems, rng); + + return { outputs: { value: result.value, selectedIndex: result.index } }; + } +} + +// ============================================================================= +// ShuffleArray 节点 | ShuffleArray Node +// ============================================================================= + +export const ShuffleArrayTemplate: BlueprintNodeTemplate = { + type: 'ShuffleArray', + title: 'Shuffle Array', + category: 'math', + description: 'Shuffle array with seed / 使用种子洗牌数组', + keywords: ['random', 'shuffle', 'array', 'order'], + menuPath: ['Procedural', 'Random', 'Shuffle Array'], + isPure: true, + inputs: [ + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'array', displayName: 'Array', type: 'array' } + ], + outputs: [ + { name: 'result', displayName: 'Result', type: 'array' } + ], + color: '#9c27b0' +}; + +export class ShuffleArrayExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const array = ctx.evaluateInput(node.id, 'array', []) as unknown[]; + + const rng = new SeededRandom(seed); + const result = shuffle([...array], rng); + + return { outputs: { result } }; + } +} + +// ============================================================================= +// PickRandom 节点 | PickRandom Node +// ============================================================================= + +export const PickRandomTemplate: BlueprintNodeTemplate = { + type: 'PickRandom', + title: 'Pick Random', + category: 'math', + description: 'Pick random element from array / 从数组中随机选择元素', + keywords: ['random', 'pick', 'array', 'select'], + menuPath: ['Procedural', 'Random', 'Pick Random'], + isPure: true, + inputs: [ + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'index', displayName: 'Index', type: 'int' }, + { name: 'array', displayName: 'Array', type: 'array' } + ], + outputs: [ + { name: 'value', displayName: 'Value', type: 'any' } + ], + color: '#9c27b0' +}; + +export class PickRandomExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const index = ctx.evaluateInput(node.id, 'index', 0) as number; + const array = ctx.evaluateInput(node.id, 'array', []) as unknown[]; + + if (array.length === 0) { + return { outputs: { value: null } }; + } + + const rng = new SeededRandom(seed + index * 12345); + const value = pickOne(array, rng); + + return { outputs: { value } }; + } +} + +// ============================================================================= +// SampleArray 节点 | SampleArray Node +// ============================================================================= + +export const SampleArrayTemplate: BlueprintNodeTemplate = { + type: 'SampleArray', + title: 'Sample Array', + category: 'math', + description: 'Sample N unique elements from array / 从数组中采样 N 个不重复元素', + keywords: ['random', 'sample', 'array', 'unique'], + menuPath: ['Procedural', 'Random', 'Sample Array'], + isPure: true, + inputs: [ + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'array', displayName: 'Array', type: 'array' }, + { name: 'count', displayName: 'Count', type: 'int' } + ], + outputs: [ + { name: 'result', displayName: 'Result', type: 'array' } + ], + color: '#9c27b0' +}; + +export class SampleArrayExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const array = ctx.evaluateInput(node.id, 'array', []) as unknown[]; + const count = ctx.evaluateInput(node.id, 'count', 1) as number; + + if (array.length === 0 || count <= 0) { + return { outputs: { result: [] } }; + } + + const rng = new SeededRandom(seed); + const actualCount = Math.min(count, array.length); + const result = sample(array, actualCount, rng); + + return { outputs: { result } }; + } +} + +// ============================================================================= +// RandomPointInCircle 节点 | RandomPointInCircle Node +// ============================================================================= + +export const RandomPointInCircleTemplate: BlueprintNodeTemplate = { + type: 'RandomPointInCircle', + title: 'Random Point In Circle', + category: 'math', + description: 'Generate random point inside circle / 在圆内生成随机点', + keywords: ['random', 'point', 'circle', 'position'], + menuPath: ['Procedural', 'Random', 'Random Point In Circle'], + isPure: true, + inputs: [ + { name: 'seed', displayName: 'Seed', type: 'int' }, + { name: 'index', displayName: 'Index', type: 'int' }, + { name: 'centerX', displayName: 'Center X', type: 'float' }, + { name: 'centerY', displayName: 'Center Y', type: 'float' }, + { name: 'radius', displayName: 'Radius', type: 'float' } + ], + outputs: [ + { name: 'x', displayName: 'X', type: 'float' }, + { name: 'y', displayName: 'Y', type: 'float' } + ], + color: '#9c27b0' +}; + +export class RandomPointInCircleExecutor implements INodeExecutor { + execute(node: BlueprintNode, context: unknown): ExecutionResult { + const ctx = context as ProcGenContext; + const seed = ctx.evaluateInput(node.id, 'seed', 0) as number; + const index = ctx.evaluateInput(node.id, 'index', 0) as number; + const centerX = ctx.evaluateInput(node.id, 'centerX', 0) as number; + const centerY = ctx.evaluateInput(node.id, 'centerY', 0) as number; + const radius = ctx.evaluateInput(node.id, 'radius', 1) as number; + + const rng = new SeededRandom(seed + index * 12345); + const point = rng.nextPointInCircle(radius); + + return { + outputs: { + x: centerX + point.x, + y: centerY + point.y + } + }; + } +} + +// ============================================================================= +// 节点定义集合 | Node Definition Collection +// ============================================================================= + +export const ProcGenNodeDefinitions = { + templates: [ + SampleNoise2DTemplate, + SampleFBMTemplate, + SeededRandomTemplate, + SeededRandomIntTemplate, + WeightedPickTemplate, + ShuffleArrayTemplate, + PickRandomTemplate, + SampleArrayTemplate, + RandomPointInCircleTemplate + ], + executors: new Map([ + ['SampleNoise2D', new SampleNoise2DExecutor()], + ['SampleFBM', new SampleFBMExecutor()], + ['SeededRandom', new SeededRandomExecutor()], + ['SeededRandomInt', new SeededRandomIntExecutor()], + ['WeightedPick', new WeightedPickExecutor()], + ['ShuffleArray', new ShuffleArrayExecutor()], + ['PickRandom', new PickRandomExecutor()], + ['SampleArray', new SampleArrayExecutor()], + ['RandomPointInCircle', new RandomPointInCircleExecutor()] + ]) +}; diff --git a/packages/procgen/src/nodes/index.ts b/packages/procgen/src/nodes/index.ts new file mode 100644 index 00000000..9f02e1b9 --- /dev/null +++ b/packages/procgen/src/nodes/index.ts @@ -0,0 +1,29 @@ +/** + * @zh 程序化生成蓝图节点模块 + * @en Procedural Generation Blueprint Nodes Module + */ + +export { + // Templates + SampleNoise2DTemplate, + SampleFBMTemplate, + SeededRandomTemplate, + SeededRandomIntTemplate, + WeightedPickTemplate, + ShuffleArrayTemplate, + PickRandomTemplate, + SampleArrayTemplate, + RandomPointInCircleTemplate, + // Executors + SampleNoise2DExecutor, + SampleFBMExecutor, + SeededRandomExecutor, + SeededRandomIntExecutor, + WeightedPickExecutor, + ShuffleArrayExecutor, + PickRandomExecutor, + SampleArrayExecutor, + RandomPointInCircleExecutor, + // Collection + ProcGenNodeDefinitions +} from './ProcGenNodes'; diff --git a/packages/procgen/src/noise/FBM.ts b/packages/procgen/src/noise/FBM.ts new file mode 100644 index 00000000..d7566be5 --- /dev/null +++ b/packages/procgen/src/noise/FBM.ts @@ -0,0 +1,214 @@ +/** + * @zh 分形布朗运动 (FBM) + * @en Fractal Brownian Motion (FBM) + * + * @zh 通过叠加多层噪声创建更自然的效果 + * @en Creates more natural effects by layering multiple noise octaves + */ + +/** + * @zh 噪声函数接口 + * @en Noise function interface + */ +export interface INoise2D { + noise2D(x: number, y: number): number; +} + +/** + * @zh 噪声函数接口 (3D) + * @en Noise function interface (3D) + */ +export interface INoise3D { + noise3D(x: number, y: number, z: number): number; +} + +/** + * @zh FBM 配置 + * @en FBM configuration + */ +export interface FBMConfig { + /** + * @zh 八度数(层数) + * @en Number of octaves (layers) + */ + octaves: number; + + /** + * @zh 频率倍增因子 + * @en Frequency multiplier per octave + */ + lacunarity: number; + + /** + * @zh 振幅衰减因子 + * @en Amplitude decay per octave + */ + persistence: number; + + /** + * @zh 初始频率 + * @en Initial frequency + */ + frequency: number; + + /** + * @zh 初始振幅 + * @en Initial amplitude + */ + amplitude: number; +} + +const DEFAULT_CONFIG: FBMConfig = { + octaves: 6, + lacunarity: 2.0, + persistence: 0.5, + frequency: 1.0, + amplitude: 1.0 +}; + +/** + * @zh FBM 噪声生成器 + * @en FBM noise generator + */ +export class FBM { + private readonly _noise: INoise2D & Partial; + private readonly _config: FBMConfig; + + /** + * @zh 创建 FBM 噪声生成器 + * @en Create FBM noise generator + * + * @param noise - @zh 基础噪声函数 @en Base noise function + * @param config - @zh 配置 @en Configuration + */ + constructor(noise: INoise2D & Partial, config?: Partial) { + this._noise = noise; + this._config = { ...DEFAULT_CONFIG, ...config }; + } + + /** + * @zh 2D FBM 噪声 + * @en 2D FBM noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @returns @zh 噪声值 @en Noise value + */ + noise2D(x: number, y: number): number { + let value = 0; + let frequency = this._config.frequency; + let amplitude = this._config.amplitude; + let maxValue = 0; + + for (let i = 0; i < this._config.octaves; i++) { + value += this._noise.noise2D(x * frequency, y * frequency) * amplitude; + maxValue += amplitude; + amplitude *= this._config.persistence; + frequency *= this._config.lacunarity; + } + + return value / maxValue; + } + + /** + * @zh 3D FBM 噪声 + * @en 3D FBM noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @param z - @zh Z 坐标 @en Z coordinate + * @returns @zh 噪声值 @en Noise value + */ + noise3D(x: number, y: number, z: number): number { + if (!this._noise.noise3D) { + throw new Error('Base noise does not support 3D'); + } + + let value = 0; + let frequency = this._config.frequency; + let amplitude = this._config.amplitude; + let maxValue = 0; + + for (let i = 0; i < this._config.octaves; i++) { + value += this._noise.noise3D(x * frequency, y * frequency, z * frequency) * amplitude; + maxValue += amplitude; + amplitude *= this._config.persistence; + frequency *= this._config.lacunarity; + } + + return value / maxValue; + } + + /** + * @zh Ridged FBM(脊状,适合山脉) + * @en Ridged FBM (suitable for mountains) + */ + ridged2D(x: number, y: number): number { + let value = 0; + let frequency = this._config.frequency; + let amplitude = this._config.amplitude; + let weight = 1; + + for (let i = 0; i < this._config.octaves; i++) { + let signal = this._noise.noise2D(x * frequency, y * frequency); + signal = 1 - Math.abs(signal); + signal *= signal; + signal *= weight; + weight = Math.max(0, Math.min(1, signal * 2)); + value += signal * amplitude; + frequency *= this._config.lacunarity; + amplitude *= this._config.persistence; + } + + return value; + } + + /** + * @zh Turbulence(湍流,使用绝对值) + * @en Turbulence (using absolute value) + */ + turbulence2D(x: number, y: number): number { + let value = 0; + let frequency = this._config.frequency; + let amplitude = this._config.amplitude; + let maxValue = 0; + + for (let i = 0; i < this._config.octaves; i++) { + value += Math.abs(this._noise.noise2D(x * frequency, y * frequency)) * amplitude; + maxValue += amplitude; + amplitude *= this._config.persistence; + frequency *= this._config.lacunarity; + } + + return value / maxValue; + } + + /** + * @zh Billowed(膨胀,适合云朵) + * @en Billowed (suitable for clouds) + */ + billowed2D(x: number, y: number): number { + let value = 0; + let frequency = this._config.frequency; + let amplitude = this._config.amplitude; + let maxValue = 0; + + for (let i = 0; i < this._config.octaves; i++) { + const n = this._noise.noise2D(x * frequency, y * frequency); + value += (Math.abs(n) * 2 - 1) * amplitude; + maxValue += amplitude; + amplitude *= this._config.persistence; + frequency *= this._config.lacunarity; + } + + return value / maxValue; + } +} + +/** + * @zh 创建 FBM 噪声生成器 + * @en Create FBM noise generator + */ +export function createFBM(noise: INoise2D & Partial, config?: Partial): FBM { + return new FBM(noise, config); +} diff --git a/packages/procgen/src/noise/PerlinNoise.ts b/packages/procgen/src/noise/PerlinNoise.ts new file mode 100644 index 00000000..f993e238 --- /dev/null +++ b/packages/procgen/src/noise/PerlinNoise.ts @@ -0,0 +1,160 @@ +/** + * @zh Perlin 噪声实现 + * @en Perlin Noise Implementation + * + * @zh 基于 Ken Perlin 的改进版噪声算法 + * @en Based on Ken Perlin's improved noise algorithm + */ + +/** + * @zh Perlin 噪声生成器 + * @en Perlin noise generator + */ +export class PerlinNoise { + private readonly _perm: Uint8Array; + private readonly _gradP: Float32Array; + + /** + * @zh 创建 Perlin 噪声生成器 + * @en Create Perlin noise generator + * + * @param seed - @zh 随机种子 @en Random seed + */ + constructor(seed: number = 0) { + this._perm = new Uint8Array(512); + this._gradP = new Float32Array(512 * 3); + this._seed(seed); + } + + private _seed(seed: number): void { + const p = new Uint8Array(256); + + // Initialize permutation array + for (let i = 0; i < 256; i++) { + p[i] = i; + } + + // Shuffle using seed + let n = seed; + for (let i = 255; i > 0; i--) { + n = (n * 16807) % 2147483647; + const j = n % (i + 1); + [p[i], p[j]] = [p[j], p[i]]; + } + + // Duplicate permutation array + for (let i = 0; i < 512; i++) { + this._perm[i] = p[i & 255]; + } + + // Precompute gradient vectors + const grad3 = [ + 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, + 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, + 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1 + ]; + + for (let i = 0; i < 512; i++) { + const gi = (this._perm[i] % 12) * 3; + this._gradP[i * 3] = grad3[gi]; + this._gradP[i * 3 + 1] = grad3[gi + 1]; + this._gradP[i * 3 + 2] = grad3[gi + 2]; + } + } + + private _fade(t: number): number { + return t * t * t * (t * (t * 6 - 15) + 10); + } + + private _lerp(a: number, b: number, t: number): number { + return a + t * (b - a); + } + + private _dot2(gi: number, x: number, y: number): number { + return this._gradP[gi * 3] * x + this._gradP[gi * 3 + 1] * y; + } + + private _dot3(gi: number, x: number, y: number, z: number): number { + return this._gradP[gi * 3] * x + this._gradP[gi * 3 + 1] * y + this._gradP[gi * 3 + 2] * z; + } + + /** + * @zh 2D Perlin 噪声 + * @en 2D Perlin noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @returns @zh 噪声值 [-1, 1] @en Noise value [-1, 1] + */ + noise2D(x: number, y: number): number { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + + x -= Math.floor(x); + y -= Math.floor(y); + + const u = this._fade(x); + const v = this._fade(y); + + const A = this._perm[X] + Y; + const B = this._perm[X + 1] + Y; + + return this._lerp( + this._lerp(this._dot2(this._perm[A], x, y), this._dot2(this._perm[B], x - 1, y), u), + this._lerp(this._dot2(this._perm[A + 1], x, y - 1), this._dot2(this._perm[B + 1], x - 1, y - 1), u), + v + ); + } + + /** + * @zh 3D Perlin 噪声 + * @en 3D Perlin noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @param z - @zh Z 坐标 @en Z coordinate + * @returns @zh 噪声值 [-1, 1] @en Noise value [-1, 1] + */ + noise3D(x: number, y: number, z: number): number { + const X = Math.floor(x) & 255; + const Y = Math.floor(y) & 255; + const Z = Math.floor(z) & 255; + + x -= Math.floor(x); + y -= Math.floor(y); + z -= Math.floor(z); + + const u = this._fade(x); + const v = this._fade(y); + const w = this._fade(z); + + const A = this._perm[X] + Y; + const AA = this._perm[A] + Z; + const AB = this._perm[A + 1] + Z; + const B = this._perm[X + 1] + Y; + const BA = this._perm[B] + Z; + const BB = this._perm[B + 1] + Z; + + return this._lerp( + this._lerp( + this._lerp(this._dot3(this._perm[AA], x, y, z), this._dot3(this._perm[BA], x - 1, y, z), u), + this._lerp(this._dot3(this._perm[AB], x, y - 1, z), this._dot3(this._perm[BB], x - 1, y - 1, z), u), + v + ), + this._lerp( + this._lerp(this._dot3(this._perm[AA + 1], x, y, z - 1), this._dot3(this._perm[BA + 1], x - 1, y, z - 1), u), + this._lerp(this._dot3(this._perm[AB + 1], x, y - 1, z - 1), this._dot3(this._perm[BB + 1], x - 1, y - 1, z - 1), u), + v + ), + w + ); + } +} + +/** + * @zh 创建 Perlin 噪声生成器 + * @en Create Perlin noise generator + */ +export function createPerlinNoise(seed: number = 0): PerlinNoise { + return new PerlinNoise(seed); +} diff --git a/packages/procgen/src/noise/SimplexNoise.ts b/packages/procgen/src/noise/SimplexNoise.ts new file mode 100644 index 00000000..1eb4431a --- /dev/null +++ b/packages/procgen/src/noise/SimplexNoise.ts @@ -0,0 +1,222 @@ +/** + * @zh Simplex 噪声实现 + * @en Simplex Noise Implementation + * + * @zh 比 Perlin 噪声更快且没有方向性伪影 + * @en Faster than Perlin noise with no directional artifacts + */ + +const F2 = 0.5 * (Math.sqrt(3) - 1); +const G2 = (3 - Math.sqrt(3)) / 6; +const F3 = 1 / 3; +const G3 = 1 / 6; + +/** + * @zh Simplex 噪声生成器 + * @en Simplex noise generator + */ +export class SimplexNoise { + private readonly _perm: Uint8Array; + private readonly _permMod12: Uint8Array; + + private static readonly _grad3 = new Float32Array([ + 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, + 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, + 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1 + ]); + + /** + * @zh 创建 Simplex 噪声生成器 + * @en Create Simplex noise generator + * + * @param seed - @zh 随机种子 @en Random seed + */ + constructor(seed: number = 0) { + this._perm = new Uint8Array(512); + this._permMod12 = new Uint8Array(512); + this._seed(seed); + } + + private _seed(seed: number): void { + const p = new Uint8Array(256); + + for (let i = 0; i < 256; i++) { + p[i] = i; + } + + let n = seed; + for (let i = 255; i > 0; i--) { + n = (n * 16807) % 2147483647; + const j = n % (i + 1); + [p[i], p[j]] = [p[j], p[i]]; + } + + for (let i = 0; i < 512; i++) { + this._perm[i] = p[i & 255]; + this._permMod12[i] = this._perm[i] % 12; + } + } + + /** + * @zh 2D Simplex 噪声 + * @en 2D Simplex noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @returns @zh 噪声值 [-1, 1] @en Noise value [-1, 1] + */ + noise2D(x: number, y: number): number { + const grad3 = SimplexNoise._grad3; + let n0 = 0, n1 = 0, n2 = 0; + + const s = (x + y) * F2; + const i = Math.floor(x + s); + const j = Math.floor(y + s); + + const t = (i + j) * G2; + const X0 = i - t; + const Y0 = j - t; + const x0 = x - X0; + const y0 = y - Y0; + + let i1: number, j1: number; + if (x0 > y0) { + i1 = 1; + j1 = 0; + } else { + i1 = 0; + j1 = 1; + } + + const x1 = x0 - i1 + G2; + const y1 = y0 - j1 + G2; + const x2 = x0 - 1 + 2 * G2; + const y2 = y0 - 1 + 2 * G2; + + const ii = i & 255; + const jj = j & 255; + const gi0 = this._permMod12[ii + this._perm[jj]] * 3; + const gi1 = this._permMod12[ii + i1 + this._perm[jj + j1]] * 3; + const gi2 = this._permMod12[ii + 1 + this._perm[jj + 1]] * 3; + + let t0 = 0.5 - x0 * x0 - y0 * y0; + if (t0 >= 0) { + t0 *= t0; + n0 = t0 * t0 * (grad3[gi0] * x0 + grad3[gi0 + 1] * y0); + } + + let t1 = 0.5 - x1 * x1 - y1 * y1; + if (t1 >= 0) { + t1 *= t1; + n1 = t1 * t1 * (grad3[gi1] * x1 + grad3[gi1 + 1] * y1); + } + + let t2 = 0.5 - x2 * x2 - y2 * y2; + if (t2 >= 0) { + t2 *= t2; + n2 = t2 * t2 * (grad3[gi2] * x2 + grad3[gi2 + 1] * y2); + } + + return 70 * (n0 + n1 + n2); + } + + /** + * @zh 3D Simplex 噪声 + * @en 3D Simplex noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @param z - @zh Z 坐标 @en Z coordinate + * @returns @zh 噪声值 [-1, 1] @en Noise value [-1, 1] + */ + noise3D(x: number, y: number, z: number): number { + const grad3 = SimplexNoise._grad3; + let n0 = 0, n1 = 0, n2 = 0, n3 = 0; + + const s = (x + y + z) * F3; + const i = Math.floor(x + s); + const j = Math.floor(y + s); + const k = Math.floor(z + s); + + const t = (i + j + k) * G3; + const X0 = i - t; + const Y0 = j - t; + const Z0 = k - t; + const x0 = x - X0; + const y0 = y - Y0; + const z0 = z - Z0; + + let i1: number, j1: number, k1: number; + let i2: number, j2: number, k2: number; + + if (x0 >= y0) { + if (y0 >= z0) { + i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 1; k2 = 0; + } else if (x0 >= z0) { + i1 = 1; j1 = 0; k1 = 0; i2 = 1; j2 = 0; k2 = 1; + } else { + i1 = 0; j1 = 0; k1 = 1; i2 = 1; j2 = 0; k2 = 1; + } + } else { + if (y0 < z0) { + i1 = 0; j1 = 0; k1 = 1; i2 = 0; j2 = 1; k2 = 1; + } else if (x0 < z0) { + i1 = 0; j1 = 1; k1 = 0; i2 = 0; j2 = 1; k2 = 1; + } else { + i1 = 0; j1 = 1; k1 = 0; i2 = 1; j2 = 1; k2 = 0; + } + } + + const x1 = x0 - i1 + G3; + const y1 = y0 - j1 + G3; + const z1 = z0 - k1 + G3; + const x2 = x0 - i2 + 2 * G3; + const y2 = y0 - j2 + 2 * G3; + const z2 = z0 - k2 + 2 * G3; + const x3 = x0 - 1 + 3 * G3; + const y3 = y0 - 1 + 3 * G3; + const z3 = z0 - 1 + 3 * G3; + + const ii = i & 255; + const jj = j & 255; + const kk = k & 255; + const gi0 = this._permMod12[ii + this._perm[jj + this._perm[kk]]] * 3; + const gi1 = this._permMod12[ii + i1 + this._perm[jj + j1 + this._perm[kk + k1]]] * 3; + const gi2 = this._permMod12[ii + i2 + this._perm[jj + j2 + this._perm[kk + k2]]] * 3; + const gi3 = this._permMod12[ii + 1 + this._perm[jj + 1 + this._perm[kk + 1]]] * 3; + + let t0 = 0.6 - x0 * x0 - y0 * y0 - z0 * z0; + if (t0 >= 0) { + t0 *= t0; + n0 = t0 * t0 * (grad3[gi0] * x0 + grad3[gi0 + 1] * y0 + grad3[gi0 + 2] * z0); + } + + let t1 = 0.6 - x1 * x1 - y1 * y1 - z1 * z1; + if (t1 >= 0) { + t1 *= t1; + n1 = t1 * t1 * (grad3[gi1] * x1 + grad3[gi1 + 1] * y1 + grad3[gi1 + 2] * z1); + } + + let t2 = 0.6 - x2 * x2 - y2 * y2 - z2 * z2; + if (t2 >= 0) { + t2 *= t2; + n2 = t2 * t2 * (grad3[gi2] * x2 + grad3[gi2 + 1] * y2 + grad3[gi2 + 2] * z2); + } + + let t3 = 0.6 - x3 * x3 - y3 * y3 - z3 * z3; + if (t3 >= 0) { + t3 *= t3; + n3 = t3 * t3 * (grad3[gi3] * x3 + grad3[gi3 + 1] * y3 + grad3[gi3 + 2] * z3); + } + + return 32 * (n0 + n1 + n2 + n3); + } +} + +/** + * @zh 创建 Simplex 噪声生成器 + * @en Create Simplex noise generator + */ +export function createSimplexNoise(seed: number = 0): SimplexNoise { + return new SimplexNoise(seed); +} diff --git a/packages/procgen/src/noise/WorleyNoise.ts b/packages/procgen/src/noise/WorleyNoise.ts new file mode 100644 index 00000000..f7ddb6b0 --- /dev/null +++ b/packages/procgen/src/noise/WorleyNoise.ts @@ -0,0 +1,196 @@ +/** + * @zh Worley (Cellular) 噪声实现 + * @en Worley (Cellular) Noise Implementation + * + * @zh 基于 Voronoi 图的噪声,适合生成细胞、石头纹理 + * @en Voronoi-based noise, suitable for cellular and stone textures + */ + +/** + * @zh 距离函数类型 + * @en Distance function type + */ +export type DistanceFunction = 'euclidean' | 'manhattan' | 'chebyshev'; + +/** + * @zh Worley 噪声生成器 + * @en Worley noise generator + */ +export class WorleyNoise { + private readonly _seed: number; + private readonly _distanceFunc: DistanceFunction; + + /** + * @zh 创建 Worley 噪声生成器 + * @en Create Worley noise generator + * + * @param seed - @zh 随机种子 @en Random seed + * @param distanceFunc - @zh 距离函数 @en Distance function + */ + constructor(seed: number = 0, distanceFunc: DistanceFunction = 'euclidean') { + this._seed = seed; + this._distanceFunc = distanceFunc; + } + + private _hash(x: number, y: number, z: number = 0): number { + let h = this._seed; + h ^= x * 374761393; + h ^= y * 668265263; + h ^= z * 1274126177; + h = Math.imul(h ^ (h >>> 13), 1274126177); + return h; + } + + private _randomPoint(cellX: number, cellY: number, index: number): { x: number; y: number } { + const h1 = this._hash(cellX, cellY, index); + const h2 = this._hash(cellX, cellY, index + 1000); + return { + x: cellX + (h1 & 0xFFFF) / 65536, + y: cellY + (h2 & 0xFFFF) / 65536 + }; + } + + private _randomPoint3D(cellX: number, cellY: number, cellZ: number, index: number): { x: number; y: number; z: number } { + const h1 = this._hash(cellX, cellY, cellZ * 1000 + index); + const h2 = this._hash(cellX, cellY, cellZ * 1000 + index + 1000); + const h3 = this._hash(cellX, cellY, cellZ * 1000 + index + 2000); + return { + x: cellX + (h1 & 0xFFFF) / 65536, + y: cellY + (h2 & 0xFFFF) / 65536, + z: cellZ + (h3 & 0xFFFF) / 65536 + }; + } + + private _distance2D(x1: number, y1: number, x2: number, y2: number): number { + const dx = x2 - x1; + const dy = y2 - y1; + + switch (this._distanceFunc) { + case 'manhattan': + return Math.abs(dx) + Math.abs(dy); + case 'chebyshev': + return Math.max(Math.abs(dx), Math.abs(dy)); + case 'euclidean': + default: + return Math.sqrt(dx * dx + dy * dy); + } + } + + private _distance3D(x1: number, y1: number, z1: number, x2: number, y2: number, z2: number): number { + const dx = x2 - x1; + const dy = y2 - y1; + const dz = z2 - z1; + + switch (this._distanceFunc) { + case 'manhattan': + return Math.abs(dx) + Math.abs(dy) + Math.abs(dz); + case 'chebyshev': + return Math.max(Math.abs(dx), Math.abs(dy), Math.abs(dz)); + case 'euclidean': + default: + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + } + + /** + * @zh 2D Worley 噪声 + * @en 2D Worley noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @param pointsPerCell - @zh 每个单元格的点数 @en Points per cell + * @returns @zh 到最近点的距离 [0, ~1.4] @en Distance to nearest point [0, ~1.4] + */ + noise2D(x: number, y: number, pointsPerCell: number = 1): number { + const cellX = Math.floor(x); + const cellY = Math.floor(y); + + let minDist = Infinity; + + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const cx = cellX + dx; + const cy = cellY + dy; + + for (let i = 0; i < pointsPerCell; i++) { + const point = this._randomPoint(cx, cy, i); + const dist = this._distance2D(x, y, point.x, point.y); + minDist = Math.min(minDist, dist); + } + } + } + + return minDist; + } + + /** + * @zh 3D Worley 噪声 + * @en 3D Worley noise + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @param z - @zh Z 坐标 @en Z coordinate + * @param pointsPerCell - @zh 每个单元格的点数 @en Points per cell + * @returns @zh 到最近点的距离 @en Distance to nearest point + */ + noise3D(x: number, y: number, z: number, pointsPerCell: number = 1): number { + const cellX = Math.floor(x); + const cellY = Math.floor(y); + const cellZ = Math.floor(z); + + let minDist = Infinity; + + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dz = -1; dz <= 1; dz++) { + const cx = cellX + dx; + const cy = cellY + dy; + const cz = cellZ + dz; + + for (let i = 0; i < pointsPerCell; i++) { + const point = this._randomPoint3D(cx, cy, cz, i); + const dist = this._distance3D(x, y, z, point.x, point.y, point.z); + minDist = Math.min(minDist, dist); + } + } + } + } + + return minDist; + } + + /** + * @zh 获取到第 N 近点的距离(用于更复杂的纹理) + * @en Get distance to Nth nearest point (for more complex textures) + * + * @param x - @zh X 坐标 @en X coordinate + * @param y - @zh Y 坐标 @en Y coordinate + * @param n - @zh 第 N 近 (1 = 最近) @en Nth nearest (1 = nearest) + * @returns @zh 距离值 @en Distance value + */ + nthNearest2D(x: number, y: number, n: number = 1): number { + const cellX = Math.floor(x); + const cellY = Math.floor(y); + const distances: number[] = []; + + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + const cx = cellX + dx; + const cy = cellY + dy; + const point = this._randomPoint(cx, cy, 0); + distances.push(this._distance2D(x, y, point.x, point.y)); + } + } + + distances.sort((a, b) => a - b); + return distances[Math.min(n - 1, distances.length - 1)]; + } +} + +/** + * @zh 创建 Worley 噪声生成器 + * @en Create Worley noise generator + */ +export function createWorleyNoise(seed: number = 0, distanceFunc: DistanceFunction = 'euclidean'): WorleyNoise { + return new WorleyNoise(seed, distanceFunc); +} diff --git a/packages/procgen/src/noise/index.ts b/packages/procgen/src/noise/index.ts new file mode 100644 index 00000000..482bb5ee --- /dev/null +++ b/packages/procgen/src/noise/index.ts @@ -0,0 +1,11 @@ +/** + * @zh 噪声函数模块 + * @en Noise Functions Module + */ + +export { PerlinNoise, createPerlinNoise } from './PerlinNoise'; +export { SimplexNoise, createSimplexNoise } from './SimplexNoise'; +export { WorleyNoise, createWorleyNoise } from './WorleyNoise'; +export type { DistanceFunction } from './WorleyNoise'; +export { FBM, createFBM } from './FBM'; +export type { INoise2D, INoise3D, FBMConfig } from './FBM'; diff --git a/packages/procgen/src/random/SeededRandom.ts b/packages/procgen/src/random/SeededRandom.ts new file mode 100644 index 00000000..3c8bda7d --- /dev/null +++ b/packages/procgen/src/random/SeededRandom.ts @@ -0,0 +1,192 @@ +/** + * @zh 种子随机数生成器 + * @en Seeded Random Number Generator + * + * @zh 基于 xorshift128+ 算法的确定性伪随机数生成器 + * @en Deterministic PRNG based on xorshift128+ algorithm + */ + +/** + * @zh 种子随机数生成器 + * @en Seeded random number generator + */ +export class SeededRandom { + private _s0: number; + private _s1: number; + private readonly _initialS0: number; + private readonly _initialS1: number; + + /** + * @zh 创建种子随机数生成器 + * @en Create seeded random number generator + * + * @param seed - @zh 随机种子 @en Random seed + */ + constructor(seed: number = Date.now()) { + // Initialize with MurmurHash3 mixing + let h = seed | 0; + h = Math.imul(h ^ (h >>> 16), 0x85ebca6b); + h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35); + h ^= h >>> 16; + + this._s0 = h >>> 0; + this._s1 = (h * 0x9e3779b9) >>> 0; + + // Ensure non-zero state + if (this._s0 === 0) this._s0 = 1; + if (this._s1 === 0) this._s1 = 1; + + this._initialS0 = this._s0; + this._initialS1 = this._s1; + + // Warm up + for (let i = 0; i < 10; i++) { + this.next(); + } + } + + /** + * @zh 重置到初始状态 + * @en Reset to initial state + */ + reset(): void { + this._s0 = this._initialS0; + this._s1 = this._initialS1; + + for (let i = 0; i < 10; i++) { + this.next(); + } + } + + /** + * @zh 生成下一个随机数 [0, 1) + * @en Generate next random number [0, 1) + */ + next(): number { + let s1 = this._s0; + const s0 = this._s1; + this._s0 = s0; + s1 ^= s1 << 23; + s1 ^= s1 >>> 17; + s1 ^= s0; + s1 ^= s0 >>> 26; + this._s1 = s1; + return ((this._s0 + this._s1) >>> 0) / 4294967296; + } + + /** + * @zh 生成整数 [min, max] + * @en Generate integer [min, max] + */ + nextInt(min: number, max: number): number { + return Math.floor(this.next() * (max - min + 1)) + min; + } + + /** + * @zh 生成浮点数 [min, max) + * @en Generate float [min, max) + */ + nextFloat(min: number, max: number): number { + return this.next() * (max - min) + min; + } + + /** + * @zh 生成布尔值 + * @en Generate boolean + * + * @param probability - @zh 为 true 的概率 [0, 1] @en Probability of true [0, 1] + */ + nextBool(probability: number = 0.5): boolean { + return this.next() < probability; + } + + /** + * @zh 生成正态分布随机数 (Box-Muller 变换) + * @en Generate normally distributed random number (Box-Muller transform) + * + * @param mean - @zh 均值 @en Mean + * @param stdDev - @zh 标准差 @en Standard deviation + */ + nextGaussian(mean: number = 0, stdDev: number = 1): number { + const u1 = this.next(); + const u2 = this.next(); + const z0 = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + return z0 * stdDev + mean; + } + + /** + * @zh 生成指数分布随机数 + * @en Generate exponentially distributed random number + * + * @param lambda - @zh 率参数 @en Rate parameter + */ + nextExponential(lambda: number = 1): number { + return -Math.log(1 - this.next()) / lambda; + } + + /** + * @zh 在圆内生成均匀分布的随机点 + * @en Generate uniformly distributed random point in circle + * + * @param radius - @zh 半径 @en Radius + */ + nextPointInCircle(radius: number = 1): { x: number; y: number } { + const r = Math.sqrt(this.next()) * radius; + const theta = this.next() * 2 * Math.PI; + return { + x: r * Math.cos(theta), + y: r * Math.sin(theta) + }; + } + + /** + * @zh 在圆环上生成随机点 + * @en Generate random point on circle + * + * @param radius - @zh 半径 @en Radius + */ + nextPointOnCircle(radius: number = 1): { x: number; y: number } { + const theta = this.next() * 2 * Math.PI; + return { + x: radius * Math.cos(theta), + y: radius * Math.sin(theta) + }; + } + + /** + * @zh 在球内生成均匀分布的随机点 + * @en Generate uniformly distributed random point in sphere + * + * @param radius - @zh 半径 @en Radius + */ + nextPointInSphere(radius: number = 1): { x: number; y: number; z: number } { + const r = Math.cbrt(this.next()) * radius; + const theta = this.next() * 2 * Math.PI; + const phi = Math.acos(2 * this.next() - 1); + return { + x: r * Math.sin(phi) * Math.cos(theta), + y: r * Math.sin(phi) * Math.sin(theta), + z: r * Math.cos(phi) + }; + } + + /** + * @zh 生成随机方向向量 + * @en Generate random direction vector + */ + nextDirection2D(): { x: number; y: number } { + const theta = this.next() * 2 * Math.PI; + return { + x: Math.cos(theta), + y: Math.sin(theta) + }; + } +} + +/** + * @zh 创建种子随机数生成器 + * @en Create seeded random number generator + */ +export function createSeededRandom(seed?: number): SeededRandom { + return new SeededRandom(seed); +} diff --git a/packages/procgen/src/random/Shuffle.ts b/packages/procgen/src/random/Shuffle.ts new file mode 100644 index 00000000..d76b02e0 --- /dev/null +++ b/packages/procgen/src/random/Shuffle.ts @@ -0,0 +1,193 @@ +/** + * @zh 洗牌和采样工具 + * @en Shuffle and Sampling Utilities + */ + +import type { SeededRandom } from './SeededRandom'; + +/** + * @zh Fisher-Yates 洗牌算法(原地修改) + * @en Fisher-Yates shuffle algorithm (in-place) + * + * @param array - @zh 要洗牌的数组 @en Array to shuffle + * @param rng - @zh 随机数生成器 @en Random number generator + * @returns @zh 洗牌后的数组(同一数组)@en Shuffled array (same array) + */ +export function shuffle(array: T[], rng: SeededRandom | { next(): number }): T[] { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(rng.next() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + return array; +} + +/** + * @zh 创建洗牌后的副本(不修改原数组) + * @en Create shuffled copy (does not modify original) + * + * @param array - @zh 原数组 @en Original array + * @param rng - @zh 随机数生成器 @en Random number generator + * @returns @zh 洗牌后的新数组 @en New shuffled array + */ +export function shuffleCopy(array: readonly T[], rng: SeededRandom | { next(): number }): T[] { + return shuffle([...array], rng); +} + +/** + * @zh 从数组中随机选择一个元素 + * @en Pick a random element from array + * + * @param array - @zh 数组 @en Array + * @param rng - @zh 随机数生成器 @en Random number generator + * @returns @zh 随机元素 @en Random element + */ +export function pickOne(array: readonly T[], rng: SeededRandom | { next(): number }): T { + if (array.length === 0) { + throw new Error('Cannot pick from empty array'); + } + return array[Math.floor(rng.next() * array.length)]; +} + +/** + * @zh 从数组中随机采样 N 个不重复元素 + * @en Sample N unique elements from array + * + * @param array - @zh 数组 @en Array + * @param count - @zh 采样数量 @en Sample count + * @param rng - @zh 随机数生成器 @en Random number generator + * @returns @zh 采样结果 @en Sample result + */ +export function sample(array: readonly T[], count: number, rng: SeededRandom | { next(): number }): T[] { + if (count > array.length) { + throw new Error('Sample count exceeds array length'); + } + + if (count === array.length) { + return shuffleCopy(array, rng); + } + + // For small sample sizes relative to array, use selection + if (count < array.length / 2) { + const result: T[] = []; + const indices = new Set(); + + while (result.length < count) { + const index = Math.floor(rng.next() * array.length); + if (!indices.has(index)) { + indices.add(index); + result.push(array[index]); + } + } + + return result; + } + + // For large sample sizes, shuffle and take first N + return shuffleCopy(array, rng).slice(0, count); +} + +/** + * @zh 从数组中随机采样 N 个元素(可重复) + * @en Sample N elements from array (with replacement) + * + * @param array - @zh 数组 @en Array + * @param count - @zh 采样数量 @en Sample count + * @param rng - @zh 随机数生成器 @en Random number generator + * @returns @zh 采样结果 @en Sample result + */ +export function sampleWithReplacement( + array: readonly T[], + count: number, + rng: SeededRandom | { next(): number } +): T[] { + if (array.length === 0) { + throw new Error('Cannot sample from empty array'); + } + + const result: T[] = []; + for (let i = 0; i < count; i++) { + result.push(pickOne(array, rng)); + } + return result; +} + +/** + * @zh 生成范围内的随机整数数组(不重复) + * @en Generate array of random unique integers in range + * + * @param min - @zh 最小值(包含)@en Minimum (inclusive) + * @param max - @zh 最大值(包含)@en Maximum (inclusive) + * @param count - @zh 数量 @en Count + * @param rng - @zh 随机数生成器 @en Random number generator + * @returns @zh 随机整数数组 @en Array of random integers + */ +export function randomIntegers( + min: number, + max: number, + count: number, + rng: SeededRandom | { next(): number } +): number[] { + const range = max - min + 1; + if (count > range) { + throw new Error('Count exceeds range'); + } + + // Generate all numbers and sample + const numbers: number[] = []; + for (let i = min; i <= max; i++) { + numbers.push(i); + } + + return sample(numbers, count, rng); +} + +/** + * @zh 按权重从数组中采样(不重复) + * @en Sample from array by weight (without replacement) + * + * @param items - @zh 项目数组 @en Item array + * @param weights - @zh 权重数组 @en Weight array + * @param count - @zh 采样数量 @en Sample count + * @param rng - @zh 随机数生成器 @en Random number generator + */ +export function weightedSample( + items: readonly T[], + weights: readonly number[], + count: number, + rng: SeededRandom | { next(): number } +): T[] { + if (items.length !== weights.length) { + throw new Error('Items and weights must have same length'); + } + if (count > items.length) { + throw new Error('Sample count exceeds array length'); + } + + const result: T[] = []; + const remaining = [...items]; + const remainingWeights = [...weights]; + + for (let i = 0; i < count; i++) { + let totalWeight = 0; + for (const w of remainingWeights) { + totalWeight += w; + } + + let r = rng.next() * totalWeight; + let selectedIndex = 0; + + for (let j = 0; j < remainingWeights.length; j++) { + r -= remainingWeights[j]; + if (r <= 0) { + selectedIndex = j; + break; + } + } + + result.push(remaining[selectedIndex]); + remaining.splice(selectedIndex, 1); + remainingWeights.splice(selectedIndex, 1); + } + + return result; +} diff --git a/packages/procgen/src/random/WeightedRandom.ts b/packages/procgen/src/random/WeightedRandom.ts new file mode 100644 index 00000000..6fd0362c --- /dev/null +++ b/packages/procgen/src/random/WeightedRandom.ts @@ -0,0 +1,177 @@ +/** + * @zh 加权随机工具 + * @en Weighted Random Utilities + */ + +import type { SeededRandom } from './SeededRandom'; + +/** + * @zh 加权项 + * @en Weighted item + */ +export interface WeightedItem { + /** + * @zh 项目值 + * @en Item value + */ + value: T; + + /** + * @zh 权重(> 0) + * @en Weight (> 0) + */ + weight: number; +} + +/** + * @zh 加权随机选择器 + * @en Weighted random selector + */ +export class WeightedRandom { + private readonly _items: WeightedItem[]; + private readonly _cumulativeWeights: number[]; + private readonly _totalWeight: number; + + /** + * @zh 创建加权随机选择器 + * @en Create weighted random selector + * + * @param items - @zh 加权项数组 @en Array of weighted items + */ + constructor(items: WeightedItem[]) { + if (items.length === 0) { + throw new Error('Items array cannot be empty'); + } + + this._items = [...items]; + this._cumulativeWeights = []; + + let total = 0; + for (const item of this._items) { + if (item.weight <= 0) { + throw new Error('Weights must be positive'); + } + total += item.weight; + this._cumulativeWeights.push(total); + } + + this._totalWeight = total; + } + + /** + * @zh 随机选择一个项目 + * @en Randomly select an item + * + * @param rng - @zh 随机数生成器 @en Random number generator + */ + pick(rng: SeededRandom | { next(): number }): T { + const r = rng.next() * this._totalWeight; + + // Binary search for the selected item + let left = 0; + let right = this._cumulativeWeights.length - 1; + + while (left < right) { + const mid = (left + right) >>> 1; + if (this._cumulativeWeights[mid] < r) { + left = mid + 1; + } else { + right = mid; + } + } + + return this._items[left].value; + } + + /** + * @zh 使用 Math.random 选择 + * @en Pick using Math.random + */ + pickRandom(): T { + return this.pick({ next: () => Math.random() }); + } + + /** + * @zh 获取项目的选中概率 + * @en Get selection probability of an item + */ + getProbability(index: number): number { + if (index < 0 || index >= this._items.length) { + throw new Error('Index out of bounds'); + } + return this._items[index].weight / this._totalWeight; + } + + /** + * @zh 获取所有项目数量 + * @en Get total item count + */ + get size(): number { + return this._items.length; + } + + /** + * @zh 获取总权重 + * @en Get total weight + */ + get totalWeight(): number { + return this._totalWeight; + } +} + +/** + * @zh 从加权数组中随机选择 + * @en Pick from weighted array + * + * @param items - @zh 加权项数组 @en Array of weighted items + * @param rng - @zh 随机数生成器 @en Random number generator + */ +export function weightedPick( + items: WeightedItem[], + rng: SeededRandom | { next(): number } +): T { + if (items.length === 0) { + throw new Error('Items array cannot be empty'); + } + + let totalWeight = 0; + for (const item of items) { + totalWeight += item.weight; + } + + let r = rng.next() * totalWeight; + for (const item of items) { + r -= item.weight; + if (r <= 0) { + return item.value; + } + } + + return items[items.length - 1].value; +} + +/** + * @zh 从权重映射中随机选择 + * @en Pick from weight map + * + * @param weights - @zh 值到权重的映射 @en Map of values to weights + * @param rng - @zh 随机数生成器 @en Random number generator + */ +export function weightedPickFromMap( + weights: Record, + rng: SeededRandom | { next(): number } +): T { + const items: WeightedItem[] = []; + for (const key in weights) { + items.push({ value: key as T, weight: weights[key] }); + } + return weightedPick(items, rng); +} + +/** + * @zh 创建加权随机选择器 + * @en Create weighted random selector + */ +export function createWeightedRandom(items: WeightedItem[]): WeightedRandom { + return new WeightedRandom(items); +} diff --git a/packages/procgen/src/random/index.ts b/packages/procgen/src/random/index.ts new file mode 100644 index 00000000..7b98656a --- /dev/null +++ b/packages/procgen/src/random/index.ts @@ -0,0 +1,17 @@ +/** + * @zh 随机工具模块 + * @en Random Utilities Module + */ + +export { SeededRandom, createSeededRandom } from './SeededRandom'; +export { WeightedRandom, weightedPick, weightedPickFromMap, createWeightedRandom } from './WeightedRandom'; +export type { WeightedItem } from './WeightedRandom'; +export { + shuffle, + shuffleCopy, + pickOne, + sample, + sampleWithReplacement, + randomIntegers, + weightedSample +} from './Shuffle'; diff --git a/packages/procgen/tsconfig.build.json b/packages/procgen/tsconfig.build.json new file mode 100644 index 00000000..bf8abf7b --- /dev/null +++ b/packages/procgen/tsconfig.build.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "resolveJsonModule": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/procgen/tsconfig.json b/packages/procgen/tsconfig.json new file mode 100644 index 00000000..f852ad22 --- /dev/null +++ b/packages/procgen/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + "references": [ + { "path": "../core" }, + { "path": "../blueprint" } + ] +} diff --git a/packages/procgen/tsup.config.ts b/packages/procgen/tsup.config.ts new file mode 100644 index 00000000..a8a929a5 --- /dev/null +++ b/packages/procgen/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + tsconfig: 'tsconfig.build.json' +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6ef6d52..7d81af3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1581,6 +1581,34 @@ importers: specifier: ^5.8.3 version: 5.9.3 + packages/procgen: + dependencies: + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@esengine/blueprint': + specifier: workspace:* + version: link:../blueprint + '@esengine/build-config': + specifier: workspace:* + version: link:../build-config + '@esengine/ecs-framework': + specifier: workspace:* + version: link:../core + '@types/node': + specifier: ^20.19.17 + version: 20.19.25 + rimraf: + specifier: ^5.0.0 + version: 5.0.10 + tsup: + specifier: ^8.0.0 + version: 8.5.1(@microsoft/api-extractor@7.55.1(@types/node@20.19.25))(@swc/core@1.15.3)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.1) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + packages/rapier2d: devDependencies: rimraf: