@jgengine/core 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/game/defineGame.d.ts +3 -0
- package/dist/game/playableGame.d.ts +10 -1
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +19 -1
- package/dist/physics/physicsWorld.d.ts +136 -0
- package/dist/physics/physicsWorld.js +489 -0
- package/dist/runtime/gameContext.d.ts +2 -0
- package/dist/runtime/gameContext.js +3 -0
- package/dist/scene/assetCatalog.d.ts +13 -0
- package/dist/scene/entityStore.d.ts +6 -0
- package/dist/scene/entityStore.js +15 -1
- package/dist/time/simClock.d.ts +62 -0
- package/dist/time/simClock.js +132 -0
- package/dist/world/buildingIndex.d.ts +16 -0
- package/dist/world/buildingIndex.js +63 -0
- package/dist/world/buildings.d.ts +98 -0
- package/dist/world/buildings.js +320 -0
- package/dist/world/features.d.ts +105 -1
- package/dist/world/features.js +95 -0
- package/dist/world/regions.d.ts +66 -0
- package/dist/world/regions.js +158 -0
- package/dist/world/scatter.d.ts +24 -0
- package/dist/world/scatter.js +126 -0
- package/dist/world/scatterItems.d.ts +38 -0
- package/dist/world/scatterItems.js +85 -0
- package/dist/world/terrain.d.ts +54 -0
- package/dist/world/terrain.js +181 -0
- package/dist/world/water.d.ts +27 -0
- package/dist/world/water.js +125 -0
- package/dist/world/wind.d.ts +17 -0
- package/dist/world/wind.js +40 -0
- package/package.json +1 -1
package/dist/world/features.d.ts
CHANGED
|
@@ -2,6 +2,103 @@ export interface WorldBounds {
|
|
|
2
2
|
w: number;
|
|
3
3
|
d: number;
|
|
4
4
|
}
|
|
5
|
+
export type EnvironmentVec2 = readonly [number, number];
|
|
6
|
+
export interface EnvironmentArea extends WorldBounds {
|
|
7
|
+
h?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface TerrainEnvironmentConfig {
|
|
10
|
+
bounds?: WorldBounds;
|
|
11
|
+
height?: number;
|
|
12
|
+
heightMap?: string;
|
|
13
|
+
material?: string;
|
|
14
|
+
seed?: string;
|
|
15
|
+
frequency?: number;
|
|
16
|
+
octaves?: number;
|
|
17
|
+
ridged?: boolean;
|
|
18
|
+
baseHeight?: number;
|
|
19
|
+
waterLevel?: number;
|
|
20
|
+
}
|
|
21
|
+
export interface RainEnvironmentConfig {
|
|
22
|
+
area?: EnvironmentArea;
|
|
23
|
+
density?: number;
|
|
24
|
+
speed?: number;
|
|
25
|
+
dropLength?: number;
|
|
26
|
+
wind?: EnvironmentVec2;
|
|
27
|
+
color?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface SnowEnvironmentConfig {
|
|
30
|
+
area?: EnvironmentArea;
|
|
31
|
+
density?: number;
|
|
32
|
+
speed?: number;
|
|
33
|
+
flakeSize?: number;
|
|
34
|
+
drift?: number;
|
|
35
|
+
wind?: EnvironmentVec2;
|
|
36
|
+
color?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface GrassEnvironmentConfig {
|
|
39
|
+
area?: WorldBounds;
|
|
40
|
+
density?: number;
|
|
41
|
+
bladeHeight?: readonly [number, number];
|
|
42
|
+
bladeWidth?: number;
|
|
43
|
+
windStrength?: number;
|
|
44
|
+
colors?: readonly string[];
|
|
45
|
+
seed?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface OceanEnvironmentConfig {
|
|
48
|
+
bounds?: WorldBounds;
|
|
49
|
+
level?: number;
|
|
50
|
+
waveHeight?: number;
|
|
51
|
+
waveScale?: number;
|
|
52
|
+
waveSpeed?: number;
|
|
53
|
+
color?: string;
|
|
54
|
+
}
|
|
55
|
+
export interface BuildingEnvironmentConfig {
|
|
56
|
+
count?: number;
|
|
57
|
+
footprint?: WorldBounds;
|
|
58
|
+
stories?: readonly [number, number];
|
|
59
|
+
storyHeight?: number;
|
|
60
|
+
spacing?: number;
|
|
61
|
+
style?: string;
|
|
62
|
+
seed?: string;
|
|
63
|
+
}
|
|
64
|
+
export type TerrainEnvironmentDescriptor = {
|
|
65
|
+
kind: "terrain";
|
|
66
|
+
} & Required<Pick<TerrainEnvironmentConfig, "bounds" | "height">> & Omit<TerrainEnvironmentConfig, "bounds" | "height">;
|
|
67
|
+
export type RainEnvironmentDescriptor = {
|
|
68
|
+
kind: "rain";
|
|
69
|
+
} & Required<Pick<RainEnvironmentConfig, "area" | "density" | "speed" | "dropLength" | "wind" | "color">>;
|
|
70
|
+
export type SnowEnvironmentDescriptor = {
|
|
71
|
+
kind: "snow";
|
|
72
|
+
} & Required<Pick<SnowEnvironmentConfig, "area" | "density" | "speed" | "flakeSize" | "drift" | "wind" | "color">>;
|
|
73
|
+
export type GrassEnvironmentDescriptor = {
|
|
74
|
+
kind: "grass";
|
|
75
|
+
} & Required<Pick<GrassEnvironmentConfig, "area" | "density" | "bladeHeight" | "bladeWidth" | "windStrength" | "colors">> & Pick<GrassEnvironmentConfig, "seed">;
|
|
76
|
+
export type OceanEnvironmentDescriptor = {
|
|
77
|
+
kind: "ocean";
|
|
78
|
+
} & Required<Pick<OceanEnvironmentConfig, "bounds" | "level" | "waveHeight" | "waveScale" | "waveSpeed" | "color">>;
|
|
79
|
+
export type BuildingEnvironmentDescriptor = {
|
|
80
|
+
kind: "building";
|
|
81
|
+
} & Required<Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style">> & Pick<BuildingEnvironmentConfig, "seed">;
|
|
82
|
+
export type WeatherEnvironmentDescriptor = RainEnvironmentDescriptor | SnowEnvironmentDescriptor;
|
|
83
|
+
export type VegetationEnvironmentDescriptor = GrassEnvironmentDescriptor;
|
|
84
|
+
export type WaterEnvironmentDescriptor = OceanEnvironmentDescriptor;
|
|
85
|
+
export type StructureEnvironmentDescriptor = BuildingEnvironmentDescriptor;
|
|
86
|
+
export type EnvironmentDescriptorList<T> = T | readonly T[];
|
|
87
|
+
export interface EnvironmentWorldConfig {
|
|
88
|
+
terrain?: TerrainEnvironmentDescriptor;
|
|
89
|
+
weather?: EnvironmentDescriptorList<WeatherEnvironmentDescriptor>;
|
|
90
|
+
vegetation?: EnvironmentDescriptorList<VegetationEnvironmentDescriptor>;
|
|
91
|
+
water?: EnvironmentDescriptorList<WaterEnvironmentDescriptor>;
|
|
92
|
+
structures?: EnvironmentDescriptorList<StructureEnvironmentDescriptor>;
|
|
93
|
+
}
|
|
94
|
+
export interface EnvironmentWorldFeature {
|
|
95
|
+
kind: "environment";
|
|
96
|
+
terrain?: TerrainEnvironmentDescriptor;
|
|
97
|
+
weather?: readonly WeatherEnvironmentDescriptor[];
|
|
98
|
+
vegetation?: readonly VegetationEnvironmentDescriptor[];
|
|
99
|
+
water?: readonly WaterEnvironmentDescriptor[];
|
|
100
|
+
structures?: readonly StructureEnvironmentDescriptor[];
|
|
101
|
+
}
|
|
5
102
|
export interface BiomesWorldConfig {
|
|
6
103
|
map: string;
|
|
7
104
|
zones: string;
|
|
@@ -29,9 +126,16 @@ export type WorldFeature = ({
|
|
|
29
126
|
kind: "plots";
|
|
30
127
|
} & PlotsWorldConfig) | ({
|
|
31
128
|
kind: "tilemap";
|
|
32
|
-
} & TilemapWorldConfig) | {
|
|
129
|
+
} & TilemapWorldConfig) | EnvironmentWorldFeature | {
|
|
33
130
|
kind: "flat";
|
|
34
131
|
};
|
|
132
|
+
export declare function environment(config?: EnvironmentWorldConfig): EnvironmentWorldFeature;
|
|
133
|
+
export declare function terrain(config?: TerrainEnvironmentConfig): TerrainEnvironmentDescriptor;
|
|
134
|
+
export declare function rain(config?: RainEnvironmentConfig): RainEnvironmentDescriptor;
|
|
135
|
+
export declare function snow(config?: SnowEnvironmentConfig): SnowEnvironmentDescriptor;
|
|
136
|
+
export declare function grass(config?: GrassEnvironmentConfig): GrassEnvironmentDescriptor;
|
|
137
|
+
export declare function ocean(config?: OceanEnvironmentConfig): OceanEnvironmentDescriptor;
|
|
138
|
+
export declare function building(config?: BuildingEnvironmentConfig): BuildingEnvironmentDescriptor;
|
|
35
139
|
export declare function biomes(config: BiomesWorldConfig): WorldFeature;
|
|
36
140
|
export declare function voxel(config: VoxelWorldConfig): WorldFeature;
|
|
37
141
|
export declare function plots(config?: PlotsWorldConfig): WorldFeature;
|
package/dist/world/features.js
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
+
function list(value) {
|
|
2
|
+
if (value === undefined) {
|
|
3
|
+
return undefined;
|
|
4
|
+
}
|
|
5
|
+
return Array.isArray(value) ? value : [value];
|
|
6
|
+
}
|
|
7
|
+
function withOptional(base, optional) {
|
|
8
|
+
return optional === undefined ? base : { ...base, ...optional };
|
|
9
|
+
}
|
|
10
|
+
export function environment(config = {}) {
|
|
11
|
+
const weather = list(config.weather);
|
|
12
|
+
const vegetation = list(config.vegetation);
|
|
13
|
+
const water = list(config.water);
|
|
14
|
+
const structures = list(config.structures);
|
|
15
|
+
return {
|
|
16
|
+
kind: "environment",
|
|
17
|
+
...(config.terrain === undefined ? {} : { terrain: config.terrain }),
|
|
18
|
+
...(weather === undefined ? {} : { weather }),
|
|
19
|
+
...(vegetation === undefined ? {} : { vegetation }),
|
|
20
|
+
...(water === undefined ? {} : { water }),
|
|
21
|
+
...(structures === undefined ? {} : { structures }),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function terrain(config = {}) {
|
|
25
|
+
return withOptional({
|
|
26
|
+
kind: "terrain",
|
|
27
|
+
bounds: config.bounds ?? { w: 512, d: 512 },
|
|
28
|
+
height: config.height ?? 0,
|
|
29
|
+
}, {
|
|
30
|
+
...(config.heightMap === undefined ? {} : { heightMap: config.heightMap }),
|
|
31
|
+
...(config.material === undefined ? {} : { material: config.material }),
|
|
32
|
+
...(config.seed === undefined ? {} : { seed: config.seed }),
|
|
33
|
+
...(config.frequency === undefined ? {} : { frequency: config.frequency }),
|
|
34
|
+
...(config.octaves === undefined ? {} : { octaves: config.octaves }),
|
|
35
|
+
...(config.ridged === undefined ? {} : { ridged: config.ridged }),
|
|
36
|
+
...(config.baseHeight === undefined ? {} : { baseHeight: config.baseHeight }),
|
|
37
|
+
...(config.waterLevel === undefined ? {} : { waterLevel: config.waterLevel }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
export function rain(config = {}) {
|
|
41
|
+
return {
|
|
42
|
+
kind: "rain",
|
|
43
|
+
area: config.area ?? { w: 256, d: 256, h: 80 },
|
|
44
|
+
density: config.density ?? 0.65,
|
|
45
|
+
speed: config.speed ?? 18,
|
|
46
|
+
dropLength: config.dropLength ?? 0.8,
|
|
47
|
+
wind: config.wind ?? [0, 0],
|
|
48
|
+
color: config.color ?? "#9ec8ff",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export function snow(config = {}) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "snow",
|
|
54
|
+
area: config.area ?? { w: 256, d: 256, h: 80 },
|
|
55
|
+
density: config.density ?? 0.35,
|
|
56
|
+
speed: config.speed ?? 2.4,
|
|
57
|
+
flakeSize: config.flakeSize ?? 0.08,
|
|
58
|
+
drift: config.drift ?? 0.4,
|
|
59
|
+
wind: config.wind ?? [0, 0],
|
|
60
|
+
color: config.color ?? "#ffffff",
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function grass(config = {}) {
|
|
64
|
+
return withOptional({
|
|
65
|
+
kind: "grass",
|
|
66
|
+
area: config.area ?? { w: 128, d: 128 },
|
|
67
|
+
density: config.density ?? 4,
|
|
68
|
+
bladeHeight: config.bladeHeight ?? [0.25, 0.9],
|
|
69
|
+
bladeWidth: config.bladeWidth ?? 0.035,
|
|
70
|
+
windStrength: config.windStrength ?? 0.35,
|
|
71
|
+
colors: config.colors ?? ["#3f7d2d", "#66a83f"],
|
|
72
|
+
}, config.seed === undefined ? undefined : { seed: config.seed });
|
|
73
|
+
}
|
|
74
|
+
export function ocean(config = {}) {
|
|
75
|
+
return {
|
|
76
|
+
kind: "ocean",
|
|
77
|
+
bounds: config.bounds ?? { w: 1024, d: 1024 },
|
|
78
|
+
level: config.level ?? 0,
|
|
79
|
+
waveHeight: config.waveHeight ?? 1.2,
|
|
80
|
+
waveScale: config.waveScale ?? 18,
|
|
81
|
+
waveSpeed: config.waveSpeed ?? 0.55,
|
|
82
|
+
color: config.color ?? "#1d7fa3",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function building(config = {}) {
|
|
86
|
+
return withOptional({
|
|
87
|
+
kind: "building",
|
|
88
|
+
count: config.count ?? 1,
|
|
89
|
+
footprint: config.footprint ?? { w: 8, d: 8 },
|
|
90
|
+
stories: config.stories ?? [1, 4],
|
|
91
|
+
storyHeight: config.storyHeight ?? 3,
|
|
92
|
+
spacing: config.spacing ?? 2,
|
|
93
|
+
style: config.style ?? "generic",
|
|
94
|
+
}, config.seed === undefined ? undefined : { seed: config.seed });
|
|
95
|
+
}
|
|
1
96
|
export function biomes(config) {
|
|
2
97
|
return { kind: "biomes", ...config };
|
|
3
98
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { WorldBounds } from "./features.js";
|
|
2
|
+
import { type TerrainField } from "./terrain.js";
|
|
3
|
+
export type Rgb = readonly [number, number, number];
|
|
4
|
+
export type ColorInput = string | Rgb;
|
|
5
|
+
export interface RegionHeight {
|
|
6
|
+
baseHeight: number;
|
|
7
|
+
amplitude: number;
|
|
8
|
+
frequency: number;
|
|
9
|
+
octaves?: number;
|
|
10
|
+
lacunarity?: number;
|
|
11
|
+
persistence?: number;
|
|
12
|
+
ridged?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A content-agnostic region. The engine blends `height` and the visual/physical
|
|
16
|
+
* knobs (`tint`, `water`, `fog`, `speedMultiplier`, …) across region borders and
|
|
17
|
+
* selects the dominant region by nearest center in `selector` space. `data` is an
|
|
18
|
+
* opaque payload the engine never inspects — games hang their own content off it
|
|
19
|
+
* (spawn tables, prop palettes, structure ids, names, …).
|
|
20
|
+
*/
|
|
21
|
+
export interface RegionDef<T = unknown> {
|
|
22
|
+
id: string;
|
|
23
|
+
selector: readonly number[];
|
|
24
|
+
height: RegionHeight;
|
|
25
|
+
tint?: ColorInput;
|
|
26
|
+
steepTint?: ColorInput;
|
|
27
|
+
water?: ColorInput;
|
|
28
|
+
fog?: ColorInput;
|
|
29
|
+
fogDensity?: number;
|
|
30
|
+
speedMultiplier?: number;
|
|
31
|
+
data?: T;
|
|
32
|
+
}
|
|
33
|
+
export interface RegionSample<T = unknown> {
|
|
34
|
+
region: RegionDef<T>;
|
|
35
|
+
weight: number;
|
|
36
|
+
selector: readonly number[];
|
|
37
|
+
tint: Rgb;
|
|
38
|
+
steepTint: Rgb;
|
|
39
|
+
water: Rgb | null;
|
|
40
|
+
fog: Rgb | null;
|
|
41
|
+
fogDensity: number;
|
|
42
|
+
speedMultiplier: number;
|
|
43
|
+
data: T | undefined;
|
|
44
|
+
}
|
|
45
|
+
export interface RegionField<T = unknown> extends TerrainField {
|
|
46
|
+
sampleRegion(x: number, z: number): RegionSample<T>;
|
|
47
|
+
readonly regions: readonly RegionDef<T>[];
|
|
48
|
+
readonly seaLevel: number;
|
|
49
|
+
readonly seed: number;
|
|
50
|
+
}
|
|
51
|
+
export interface RegionFieldConfig<T = unknown> {
|
|
52
|
+
regions: readonly RegionDef<T>[];
|
|
53
|
+
seed?: number;
|
|
54
|
+
bounds?: WorldBounds;
|
|
55
|
+
seaLevel?: number;
|
|
56
|
+
/** Noise frequency per selector axis (defaults to 0.004 for every axis). */
|
|
57
|
+
selectorFrequencies?: readonly number[];
|
|
58
|
+
/** Per-axis distance weight when picking the nearest region (defaults to 1). */
|
|
59
|
+
axisWeights?: readonly number[];
|
|
60
|
+
/** Border sharpness; higher = tighter blends. */
|
|
61
|
+
sharpness?: number;
|
|
62
|
+
/** Domain-warp distance applied to selector sampling (0 disables). */
|
|
63
|
+
warp?: number;
|
|
64
|
+
}
|
|
65
|
+
export declare function createRegionField<T = unknown>(config: RegionFieldConfig<T>): RegionField<T>;
|
|
66
|
+
export declare function isRegionField(field: TerrainField): field is RegionField;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { fractalNoise, withNormal, } from "./terrain.js";
|
|
2
|
+
function toRgb(color, fallback) {
|
|
3
|
+
if (color === undefined)
|
|
4
|
+
return fallback;
|
|
5
|
+
if (typeof color !== "string")
|
|
6
|
+
return color;
|
|
7
|
+
const value = color.startsWith("#") ? color.slice(1) : color;
|
|
8
|
+
const int = Number.parseInt(value, 16);
|
|
9
|
+
return [((int >> 16) & 255) / 255, ((int >> 8) & 255) / 255, (int & 255) / 255];
|
|
10
|
+
}
|
|
11
|
+
function clamp01(value) {
|
|
12
|
+
return value < 0 ? 0 : value > 1 ? 1 : value;
|
|
13
|
+
}
|
|
14
|
+
function selectorNoise(x, z, seed, frequency) {
|
|
15
|
+
const config = { seed, frequency, octaves: 3, lacunarity: 2, persistence: 0.5, ridged: false };
|
|
16
|
+
return clamp01(0.5 + fractalNoise(x, z, config) * 0.9);
|
|
17
|
+
}
|
|
18
|
+
const DEFAULT_SELECTOR_FREQUENCY = 0.004;
|
|
19
|
+
const DEFAULT_SHARPNESS = 6;
|
|
20
|
+
export function createRegionField(config) {
|
|
21
|
+
if (config.regions.length === 0)
|
|
22
|
+
throw new Error("createRegionField: regions must be non-empty");
|
|
23
|
+
const seed = config.seed ?? 1337;
|
|
24
|
+
const regions = config.regions;
|
|
25
|
+
const seaLevel = config.seaLevel ?? 0;
|
|
26
|
+
const axes = regions[0].selector.length;
|
|
27
|
+
const sharpness = config.sharpness ?? DEFAULT_SHARPNESS;
|
|
28
|
+
const warp = config.warp ?? 40;
|
|
29
|
+
const heightConfigs = regions.map((region) => ({
|
|
30
|
+
seed: seed ^ hashId(region.id),
|
|
31
|
+
frequency: region.height.frequency,
|
|
32
|
+
octaves: region.height.octaves ?? 4,
|
|
33
|
+
lacunarity: region.height.lacunarity ?? 2,
|
|
34
|
+
persistence: region.height.persistence ?? 0.5,
|
|
35
|
+
ridged: region.height.ridged ?? false,
|
|
36
|
+
}));
|
|
37
|
+
const tints = regions.map((region) => toRgb(region.tint, [0.4, 0.42, 0.38]));
|
|
38
|
+
const steeps = regions.map((region, index) => toRgb(region.steepTint, tints[index]));
|
|
39
|
+
const waters = regions.map((region) => (region.water !== undefined ? toRgb(region.water, [0, 0, 0]) : null));
|
|
40
|
+
const fogs = regions.map((region) => (region.fog !== undefined ? toRgb(region.fog, [0, 0, 0]) : null));
|
|
41
|
+
const axisSeeds = Array.from({ length: axes }, (_, axis) => seed ^ (0x1111 * (axis + 1)));
|
|
42
|
+
const axisFrequencies = Array.from({ length: axes }, (_, axis) => config.selectorFrequencies?.[axis] ?? DEFAULT_SELECTOR_FREQUENCY);
|
|
43
|
+
const axisWeights = Array.from({ length: axes }, (_, axis) => config.axisWeights?.[axis] ?? 1);
|
|
44
|
+
const warpConfig = { seed: seed ^ 0x9e3779b1, frequency: 0.006, octaves: 2, lacunarity: 2, persistence: 0.5, ridged: false };
|
|
45
|
+
const sampleSelector = (x, z) => {
|
|
46
|
+
const wx = warp === 0 ? x : x + fractalNoise(x, z, warpConfig) * warp;
|
|
47
|
+
const wz = warp === 0 ? z : z + fractalNoise(z, x, warpConfig) * warp;
|
|
48
|
+
const values = new Array(axes);
|
|
49
|
+
for (let axis = 0; axis < axes; axis += 1) {
|
|
50
|
+
values[axis] = selectorNoise(wx, wz, axisSeeds[axis], axisFrequencies[axis]);
|
|
51
|
+
}
|
|
52
|
+
return values;
|
|
53
|
+
};
|
|
54
|
+
const weightsFor = (selector) => {
|
|
55
|
+
const raw = new Array(regions.length);
|
|
56
|
+
let total = 0;
|
|
57
|
+
for (let index = 0; index < regions.length; index += 1) {
|
|
58
|
+
const center = regions[index].selector;
|
|
59
|
+
let distanceSq = 0;
|
|
60
|
+
for (let axis = 0; axis < axes; axis += 1) {
|
|
61
|
+
const delta = (selector[axis] - center[axis]) * axisWeights[axis];
|
|
62
|
+
distanceSq += delta * delta;
|
|
63
|
+
}
|
|
64
|
+
const weight = Math.exp(-distanceSq * sharpness * regions.length);
|
|
65
|
+
raw[index] = weight;
|
|
66
|
+
total += weight;
|
|
67
|
+
}
|
|
68
|
+
if (total > 0)
|
|
69
|
+
for (let index = 0; index < raw.length; index += 1)
|
|
70
|
+
raw[index] /= total;
|
|
71
|
+
return raw;
|
|
72
|
+
};
|
|
73
|
+
const regionHeight = (index, x, z) => regions[index].height.baseHeight + regions[index].height.amplitude * fractalNoise(x, z, heightConfigs[index]);
|
|
74
|
+
const sampleHeight = (x, z) => {
|
|
75
|
+
const weights = weightsFor(sampleSelector(x, z));
|
|
76
|
+
let height = 0;
|
|
77
|
+
for (let index = 0; index < weights.length; index += 1) {
|
|
78
|
+
if (weights[index] < 0.01)
|
|
79
|
+
continue;
|
|
80
|
+
height += weights[index] * regionHeight(index, x, z);
|
|
81
|
+
}
|
|
82
|
+
return height;
|
|
83
|
+
};
|
|
84
|
+
const sampleRegion = (x, z) => {
|
|
85
|
+
const selector = sampleSelector(x, z);
|
|
86
|
+
const weights = weightsFor(selector);
|
|
87
|
+
let dominant = 0;
|
|
88
|
+
const tint = [0, 0, 0];
|
|
89
|
+
const steep = [0, 0, 0];
|
|
90
|
+
const water = [0, 0, 0];
|
|
91
|
+
let waterWeight = 0;
|
|
92
|
+
const fog = [0, 0, 0];
|
|
93
|
+
let fogWeight = 0;
|
|
94
|
+
let fogDensity = 0;
|
|
95
|
+
let speed = 0;
|
|
96
|
+
let speedWeight = 0;
|
|
97
|
+
for (let index = 0; index < weights.length; index += 1) {
|
|
98
|
+
const weight = weights[index];
|
|
99
|
+
if (weight > weights[dominant])
|
|
100
|
+
dominant = index;
|
|
101
|
+
if (weight < 0.01)
|
|
102
|
+
continue;
|
|
103
|
+
for (let channel = 0; channel < 3; channel += 1) {
|
|
104
|
+
tint[channel] += tints[index][channel] * weight;
|
|
105
|
+
steep[channel] += steeps[index][channel] * weight;
|
|
106
|
+
}
|
|
107
|
+
const w = waters[index];
|
|
108
|
+
if (w !== null) {
|
|
109
|
+
for (let channel = 0; channel < 3; channel += 1)
|
|
110
|
+
water[channel] += w[channel] * weight;
|
|
111
|
+
waterWeight += weight;
|
|
112
|
+
}
|
|
113
|
+
const f = fogs[index];
|
|
114
|
+
if (f !== null) {
|
|
115
|
+
for (let channel = 0; channel < 3; channel += 1)
|
|
116
|
+
fog[channel] += f[channel] * weight;
|
|
117
|
+
fogWeight += weight;
|
|
118
|
+
fogDensity += (regions[index].fogDensity ?? 0) * weight;
|
|
119
|
+
}
|
|
120
|
+
speed += (regions[index].speedMultiplier ?? 1) * weight;
|
|
121
|
+
speedWeight += weight;
|
|
122
|
+
}
|
|
123
|
+
const normalize = (rgb, weight) => weight > 0 ? [rgb[0] / weight, rgb[1] / weight, rgb[2] / weight] : rgb;
|
|
124
|
+
return {
|
|
125
|
+
region: regions[dominant],
|
|
126
|
+
weight: weights[dominant],
|
|
127
|
+
selector,
|
|
128
|
+
tint,
|
|
129
|
+
steepTint: steep,
|
|
130
|
+
water: waterWeight > 0.05 ? normalize(water, waterWeight) : null,
|
|
131
|
+
fog: fogWeight > 0.05 ? normalize(fog, fogWeight) : null,
|
|
132
|
+
fogDensity: fogWeight > 0.05 ? fogDensity / fogWeight : 0,
|
|
133
|
+
speedMultiplier: speedWeight > 0 ? speed / speedWeight : 1,
|
|
134
|
+
data: regions[dominant].data,
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
return {
|
|
138
|
+
sampleHeight,
|
|
139
|
+
sampleNormal: withNormal(sampleHeight),
|
|
140
|
+
sampleRegion,
|
|
141
|
+
regions,
|
|
142
|
+
seaLevel,
|
|
143
|
+
seed,
|
|
144
|
+
waterLevel: seaLevel,
|
|
145
|
+
bounds: config.bounds,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function hashId(id) {
|
|
149
|
+
let hash = 2166136261;
|
|
150
|
+
for (let index = 0; index < id.length; index += 1) {
|
|
151
|
+
hash ^= id.charCodeAt(index);
|
|
152
|
+
hash = Math.imul(hash, 16777619);
|
|
153
|
+
}
|
|
154
|
+
return hash | 0;
|
|
155
|
+
}
|
|
156
|
+
export function isRegionField(field) {
|
|
157
|
+
return "sampleRegion" in field;
|
|
158
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Aabb, type Vec2 } from "./geometry.js";
|
|
2
|
+
export interface ScatterArea {
|
|
3
|
+
w: number;
|
|
4
|
+
d: number;
|
|
5
|
+
center?: Vec2;
|
|
6
|
+
}
|
|
7
|
+
export interface ScatterConfig {
|
|
8
|
+
area: ScatterArea | Aabb;
|
|
9
|
+
count?: number;
|
|
10
|
+
density?: number;
|
|
11
|
+
seed?: string | number;
|
|
12
|
+
minDistance?: number;
|
|
13
|
+
avoid?: readonly Aabb[];
|
|
14
|
+
avoidMargin?: number;
|
|
15
|
+
jitter?: number;
|
|
16
|
+
maxAttempts?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface ScatterPoint {
|
|
19
|
+
x: number;
|
|
20
|
+
z: number;
|
|
21
|
+
index: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function scatterAabb(area: ScatterArea | Aabb): Aabb;
|
|
24
|
+
export declare function scatter(config: ScatterConfig): ScatterPoint[];
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { expandAabb, pointInAabb } from "./geometry.js";
|
|
2
|
+
function isAabb(area) {
|
|
3
|
+
return (typeof area.minX === "number" &&
|
|
4
|
+
typeof area.minZ === "number" &&
|
|
5
|
+
typeof area.maxX === "number" &&
|
|
6
|
+
typeof area.maxZ === "number");
|
|
7
|
+
}
|
|
8
|
+
export function scatterAabb(area) {
|
|
9
|
+
if (isAabb(area)) {
|
|
10
|
+
return { minX: area.minX, minZ: area.minZ, maxX: area.maxX, maxZ: area.maxZ };
|
|
11
|
+
}
|
|
12
|
+
const cx = area.center ? area.center[0] : 0;
|
|
13
|
+
const cz = area.center ? area.center[1] : 0;
|
|
14
|
+
const halfW = area.w / 2;
|
|
15
|
+
const halfD = area.d / 2;
|
|
16
|
+
return { minX: cx - halfW, minZ: cz - halfD, maxX: cx + halfW, maxZ: cz + halfD };
|
|
17
|
+
}
|
|
18
|
+
function hashSeed(seed) {
|
|
19
|
+
const text = typeof seed === "number" ? seed.toString() : seed;
|
|
20
|
+
let h = 2166136261 >>> 0;
|
|
21
|
+
for (let i = 0; i < text.length; i++) {
|
|
22
|
+
h ^= text.charCodeAt(i);
|
|
23
|
+
h = Math.imul(h, 16777619);
|
|
24
|
+
}
|
|
25
|
+
return h >>> 0;
|
|
26
|
+
}
|
|
27
|
+
function mulberry32(state) {
|
|
28
|
+
let a = state >>> 0;
|
|
29
|
+
return () => {
|
|
30
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
31
|
+
let t = a;
|
|
32
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
33
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
34
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function scatter(config) {
|
|
38
|
+
const aabb = scatterAabb(config.area);
|
|
39
|
+
const width = aabb.maxX - aabb.minX;
|
|
40
|
+
const depth = aabb.maxZ - aabb.minZ;
|
|
41
|
+
const density = config.density ?? 0.01;
|
|
42
|
+
const target = config.count ?? Math.max(0, Math.floor(width * depth * density));
|
|
43
|
+
if (target <= 0 || width <= 0 || depth <= 0)
|
|
44
|
+
return [];
|
|
45
|
+
const minDistance = config.minDistance ?? 0;
|
|
46
|
+
const avoidMargin = config.avoidMargin ?? 0;
|
|
47
|
+
const jitter = config.jitter ?? 1;
|
|
48
|
+
const maxAttempts = config.maxAttempts ?? target * 30;
|
|
49
|
+
const avoid = (config.avoid ?? []).map((a) => (avoidMargin !== 0 ? expandAabb(a, avoidMargin) : a));
|
|
50
|
+
const rng = mulberry32(hashSeed(config.seed ?? "scatter"));
|
|
51
|
+
const useDistance = minDistance > 0;
|
|
52
|
+
const buckets = new Map();
|
|
53
|
+
const bucketKey = (x, z) => `${Math.floor(x / minDistance)}:${Math.floor(z / minDistance)}`;
|
|
54
|
+
const rejectedByAvoid = (point) => {
|
|
55
|
+
for (const a of avoid)
|
|
56
|
+
if (pointInAabb(point, a))
|
|
57
|
+
return true;
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
const rejectedByDistance = (point) => {
|
|
61
|
+
if (!useDistance)
|
|
62
|
+
return false;
|
|
63
|
+
const cx = Math.floor(point[0] / minDistance);
|
|
64
|
+
const cz = Math.floor(point[1] / minDistance);
|
|
65
|
+
const minSq = minDistance * minDistance;
|
|
66
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
67
|
+
for (let dz = -1; dz <= 1; dz++) {
|
|
68
|
+
const nearby = buckets.get(`${cx + dx}:${cz + dz}`);
|
|
69
|
+
if (!nearby)
|
|
70
|
+
continue;
|
|
71
|
+
for (const other of nearby) {
|
|
72
|
+
const ex = other[0] - point[0];
|
|
73
|
+
const ez = other[1] - point[1];
|
|
74
|
+
if (ex * ex + ez * ez < minSq)
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
const accepted = [];
|
|
82
|
+
const commit = (point) => {
|
|
83
|
+
accepted.push({ x: point[0], z: point[1], index: accepted.length });
|
|
84
|
+
if (useDistance) {
|
|
85
|
+
const key = bucketKey(point[0], point[1]);
|
|
86
|
+
const bucket = buckets.get(key);
|
|
87
|
+
if (bucket)
|
|
88
|
+
bucket.push(point);
|
|
89
|
+
else
|
|
90
|
+
buckets.set(key, [point]);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(target * (width / depth))));
|
|
94
|
+
const rows = Math.max(1, Math.ceil(target / cols));
|
|
95
|
+
const cellW = width / cols;
|
|
96
|
+
const cellD = depth / rows;
|
|
97
|
+
for (let row = 0; row < rows && accepted.length < target; row++) {
|
|
98
|
+
for (let col = 0; col < cols && accepted.length < target; col++) {
|
|
99
|
+
const baseX = aabb.minX + (col + 0.5) * cellW;
|
|
100
|
+
const baseZ = aabb.minZ + (row + 0.5) * cellD;
|
|
101
|
+
const x = baseX + jitter * (rng() - 0.5) * cellW;
|
|
102
|
+
const z = baseZ + jitter * (rng() - 0.5) * cellD;
|
|
103
|
+
const point = [x, z];
|
|
104
|
+
if (!pointInAabb(point, aabb))
|
|
105
|
+
continue;
|
|
106
|
+
if (rejectedByAvoid(point))
|
|
107
|
+
continue;
|
|
108
|
+
if (rejectedByDistance(point))
|
|
109
|
+
continue;
|
|
110
|
+
commit(point);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let attempts = 0;
|
|
114
|
+
while (accepted.length < target && attempts < maxAttempts) {
|
|
115
|
+
attempts++;
|
|
116
|
+
const x = aabb.minX + rng() * width;
|
|
117
|
+
const z = aabb.minZ + rng() * depth;
|
|
118
|
+
const point = [x, z];
|
|
119
|
+
if (rejectedByAvoid(point))
|
|
120
|
+
continue;
|
|
121
|
+
if (rejectedByDistance(point))
|
|
122
|
+
continue;
|
|
123
|
+
commit(point);
|
|
124
|
+
}
|
|
125
|
+
return accepted;
|
|
126
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Aabb } from "./geometry.js";
|
|
2
|
+
import type { RegionField, RegionSample } from "./regions.js";
|
|
3
|
+
/** One placeable class. `item` is an opaque id the caller maps to a mesh/entity. */
|
|
4
|
+
export interface ScatterLayer {
|
|
5
|
+
item: string;
|
|
6
|
+
density: number;
|
|
7
|
+
minScale?: number;
|
|
8
|
+
maxScale?: number;
|
|
9
|
+
maxSlope?: number;
|
|
10
|
+
aboveWaterOnly?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ScatterInstance {
|
|
13
|
+
id: string;
|
|
14
|
+
item: string;
|
|
15
|
+
x: number;
|
|
16
|
+
z: number;
|
|
17
|
+
y: number;
|
|
18
|
+
scale: number;
|
|
19
|
+
rotation: number;
|
|
20
|
+
regionId: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Deterministically place opaque items across `area`, grounded on a region field.
|
|
24
|
+
* For each grid cell it asks `layersFor` which items may appear in that region and
|
|
25
|
+
* rolls one against their densities. The engine never interprets `item` — a game
|
|
26
|
+
* maps it to a mesh or entity. Content scatter (region-driven density) as opposed to
|
|
27
|
+
* `scatter` in `./scatter`, which is renderer-free geometric point distribution.
|
|
28
|
+
*/
|
|
29
|
+
export declare function scatterItems<T>(field: RegionField<T>, area: Aabb, layersFor: (sample: RegionSample<T>) => readonly ScatterLayer[], options?: {
|
|
30
|
+
cell?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
saltKey?: number;
|
|
33
|
+
}): ScatterInstance[];
|
|
34
|
+
/** Weighted pick from opaque entries; `roll` in [0, 1). Returns null when empty. */
|
|
35
|
+
export declare function pickWeighted<T>(entries: readonly {
|
|
36
|
+
value: T;
|
|
37
|
+
weight: number;
|
|
38
|
+
}[], roll: number): T | null;
|