@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
644 lines (478 loc) • 17.3 kB
JavaScript
import { assert } from "../../core/assert.js";
import { binarySearchLowIndex } from "../../core/collection/array/binarySearchLowIndex.js";
import { HashMap } from "../../core/collection/map/HashMap.js";
import { QuadTreeNode } from "../../core/geom/2d/quad-tree/QuadTreeNode.js";
import { randomFloatBetween } from "../../core/math/random/randomFloatBetween.js";
import { seededRandom } from "../../core/math/random/seededRandom.js";
import { number_compare_ascending } from "../../core/primitives/numbers/number_compare_ascending.js";
import Future from "../../core/process/Future.js";
import Task from "../../core/process/task/Task.js";
import TaskGroup from "../../core/process/task/TaskGroup.js";
import { TaskSignal } from "../../core/process/task/TaskSignal.js";
import { actionTask } from "../../core/process/task/util/actionTask.js";
import { countTask } from "../../core/process/task/util/countTask.js";
import { emptyTask } from "../../core/process/task/util/emptyTask.js";
import { futureTask } from "../../core/process/task/util/futureTask.js";
import { TerrainLayer } from "../../engine/ecs/terrain/ecs/layers/TerrainLayer.js";
import { TerrainFlags } from "../../engine/ecs/terrain/ecs/TerrainFlags.js";
import { obtainTerrain } from "../../engine/ecs/terrain/util/obtainTerrain.js";
import { Sampler2D } from "../../engine/graphics/texture/sampler/Sampler2D.js";
import { TerrainLayerRuleAggregator } from "./TerrainLayerRuleAggregator.js";
/**
*
* @param {HashMap<TerrainLayerDescription,number>} layer_mapping
* @param {TerrainTheme[]} themes
* @returns {number}
*/
function prepare_terrain_for_themes(layer_mapping, themes) {
let max = 0;
// collect unique layers
for (let i = 0; i < themes.length; i++) {
const theme = themes[i];
if (theme === undefined || theme === null) {
// skip
continue;
}
const rules = theme.rules;
for (let j = 0; j < rules.length; j++) {
const rule = rules[j];
const layer = rule.layer;
if (!layer_mapping.has(layer)) {
layer_mapping.set(layer, max);
max++;
}
}
}
return max;
}
/**
*
* @param {Terrain} terrain
* @returns {Task}
*/
function makeTaskEnsureAllTiles(terrain) {
return futureTask(new Future((resolve, reject) => {
const is_linked = terrain.getFlag(TerrainFlags.Linked);
if (!is_linked) {
terrain.startBuildService();
}
if (!terrain.getFlag(TerrainFlags.Built)) {
terrain.buildNonVisual();
}
terrain.promiseAllTiles()
.then(resolve, reject)
.finally(() => {
if (!is_linked && !terrain.getFlag(TerrainFlags.Linked)) {
// cleanup after ourselves
terrain.stopBuildService();
}
});
}), 'Wait for terrain tiles to be built');
}
export class ThemeEngine {
/**
*
* @type {QuadTreeNode<AreaTheme>}
*/
areas = new QuadTreeNode();
/**
* @readonly
* @type {function():number}
*/
random = seededRandom(1);
/**
*
* @type {AssetManager}
* @private
*/
__assetManager = null;
set assetManager(v) {
this.__assetManager = v;
}
/**
*
* @param {number} v
*/
set seed(v) {
this.random.setCurrentSeed(v);
}
/**
*
* @returns {number}
*/
get seed() {
return this.random.getCurrentSeed();
}
/**
*
* @param {AreaTheme} theme
*/
add(theme) {
assert.defined(theme, 'theme');
theme.mask.updateBounds();
theme.mask.updateDistanceField();
const bounds = theme.mask.bounds;
this.areas.add(theme, bounds.x0, bounds.y0, bounds.x1, bounds.y1);
}
/**
*
* @param {AreaTheme[]} result
* @param {number} x
* @param {number} y
* @returns {number}
*/
getThemesByPosition(result, x, y) {
const areaNodes = [];
const intersections = this.areas.requestDatumIntersectionsPoint(areaNodes, x, y);
let matches = 0;
for (let i = 0; i < intersections; i++) {
const areaNode = areaNodes[i];
/**
*
* @type {AreaTheme}
*/
const areaTheme = areaNode.data;
const filled = areaTheme.mask.mask.readChannel(x, y, 0);
if (filled === 0) {
continue;
}
result[matches++] = areaTheme;
}
return matches;
}
/**
*
* @param {number} seed
* @param {EntityComponentDataset} ecd
* @param {GridData} grid
* @returns {Task}
*/
initializeThemes(seed, ecd, grid) {
return actionTask(() => {
/**
*
* @type {AreaTheme[]}
*/
const areas = [];
this.areas.getRawData(areas);
/**
*
* @type {Theme[]}
*/
const themes = [];
const n = areas.length;
for (let i = 0; i < n; i++) {
const areaTheme = areas[i];
const theme = areaTheme.theme;
if (themes.indexOf(theme) === -1) {
themes.push(theme);
const seed = this.random();
theme.initialize(seed, ecd, grid);
}
}
});
}
/**
*
* @param {GridData} grid
* @param {Terrain} terrain
* @returns {TaskGroup}
*/
applyTerrainThemes(grid, terrain) {
assert.notNull(terrain);
assert.notNull(this.__assetManager, 'assetManager');
const width = grid.width;
const height = grid.height;
assert.equal(width, terrain.size.x);
assert.equal(height, terrain.size.y);
/**
*
* @type {AreaTheme[]}
*/
const matchingThemes = [];
/**
*
* @type {HashMap<TerrainLayerDescription, number>}
*/
const layer_index_map = new HashMap();
// collect layers
/**
*
* @type {AreaTheme[]}
*/
const all_areas = [];
this.areas.getRawData(all_areas);
const terrain_themes = all_areas.map(a => a.theme.terrain);
const layer_count = prepare_terrain_for_themes(layer_index_map, terrain_themes);
const splat = terrain.splat;
const aggregator = new TerrainLayerRuleAggregator(layer_count);
let matchingThemeCount = 0;
let splatWeightData = splat.weightData;
const splatWidth = splat.size.x;
const splatHeight = splat.size.y;
const splatLayerSize = splatWidth * splatHeight;
//splat map size can vary from the terrain size, for that reason we write splat weights into an intermediate storage so we can re-sample it to splat map after
const weights = Sampler2D.uint8(layer_count, width, height);
const tInitialize = actionTask(() => {
splat.resize(splat.size.x, splat.size.y, layer_count);
terrain.layers.clear();
/**
*
* @type {TerrainLayerDescription[]}
*/
const layer_descriptors = [];
for (const [description, index] of layer_index_map) {
layer_descriptors.push(description);
}
layer_descriptors.sort((a, b) => {
return layer_index_map.get(a) - layer_index_map.get(b);
});
layer_descriptors.forEach(l => {
terrain.layers.addLayer(TerrainLayer.fromDescription(l));
});
terrain.clearFlag(TerrainFlags.Built);
terrain.build(this.__assetManager);
splatWeightData = splat.weightData;
}, 'Initialize');
const tApplyThemes = countTask(0, width * height, (index) => {
const y = (index / width) | 0;
const x = index % width;
matchingThemeCount = this.getThemesByPosition(matchingThemes, x, y);
aggregator.clear();
for (let i = 0; i < matchingThemeCount; i++) {
/**
*
* @type {AreaTheme}
*/
const areaTheme = matchingThemes[i];
const areaMask = areaTheme.mask;
/**
*
* @type {Theme}
*/
const theme = areaTheme.theme;
const rules = theme.terrain.rules;
const ruleCount = rules.length;
for (let j = 0; j < ruleCount; j++) {
const terrainLayerRule = rules[j];
let power = terrainLayerRule.filter.execute(grid, x, y, 0);
if (power <= 0) {
continue;
}
/**
*
* @type {number}
*/
const layerIndex = layer_index_map.get(terrainLayerRule.layer);
if (matchingThemeCount > 1) {
const distance = areaMask.distanceField.readChannel(x, y, 0);
const influence = distance / matchingThemeCount;
power *= influence;
}
aggregator.add(layerIndex, power);
}
}
aggregator.normalize(255);
weights.write(x, y, aggregator.powers);
});
tApplyThemes.addDependency(tInitialize);
//re-sample weights
const weightSample = [];
const tResample = countTask(0, splatWidth * splatHeight, index => {
const y = (index / splatWidth) | 0;
const x = index % splatWidth;
const v = y / splatHeight;
const u = x / splatWidth;
const source_y = v * weights.height;
const source_x = u * weights.width;
weights.sampleBilinear(source_x, source_y, weightSample, 0);
for (let i = 0; i < layer_count; i++) {
const targetAddress = (y * splatWidth + x) + i * splatLayerSize;
splatWeightData[targetAddress] = weightSample[i];
}
});
tResample.addDependency(tApplyThemes);
return new TaskGroup([tInitialize, tApplyThemes, tResample], 'Applying a level generation theme');
}
/**
*
* @param {GridData} grid
* @param {EntityComponentDataset} ecd
* @param {number} seed
* @returns {Task|TaskGroup}
*/
applyNodes(grid, ecd, seed = 0) {
/**
*
* @type {MarkerNode[]}
*/
const nodes = [];
let i = 0;
/**
*
* @type {AreaTheme[]}
*/
const themeAreas = [];
/**
*
* @type {number[]}
*/
const themeInfluence = [];
const random = seededRandom(seed);
const cycleFunction = () => {
if (i >= nodes.length) {
return TaskSignal.EndSuccess;
}
const node = nodes[i];
i++;
const nodePosition = node.position;
const x = nodePosition.x;
const y = nodePosition.y;
const matchingThemeCount = this.getThemesByPosition(themeAreas, x, y);
if (matchingThemeCount === 0) {
return TaskSignal.Continue;
}
let influenceSum = 0;
//compute influences of active themes
for (let j = 0; j < matchingThemeCount; j++) {
/**
*
* @type {AreaTheme}
*/
const theme = themeAreas[j];
const areaMask = theme.mask;
const distance = areaMask.distanceField.readChannel(x, y, 0);
const influence = distance / matchingThemeCount;
themeInfluence[j] = influenceSum;
influenceSum += influence;
}
//select a theme
const t = randomFloatBetween(random, 0, influenceSum);
const themeIndex = binarySearchLowIndex(themeInfluence, t, number_compare_ascending);
const themeArea = themeAreas[themeIndex];
/**
*
* @type {Theme}
*/
const theme = themeArea.theme;
/**
*
* @type {MarkerNodeProcessingRuleSet}
*/
const ruleSet = theme.nodes;
ruleSet.processNode(grid, ecd, node);
return TaskSignal.Continue;
}
return new Task({
initializer() {
grid.markers.getRawData(nodes);
// sort nodes by priority
nodes.sort((a, b) => b.priority - a.priority);
},
cycleFunction
});
}
/**
*
* @param {GridData} grid
* @param {EntityComponentDataset} ecd
*/
applyCellRules(grid, ecd) {
const width = grid.width;
const height = grid.height;
/**
*
* @type {AreaTheme[]}
*/
const matchingThemes = [];
return countTask(0, width * height, (index) => {
const y = (index / width) | 0;
const x = index % width;
const matchingThemeCount = this.getThemesByPosition(matchingThemes, x, y);
for (let i = 0; i < matchingThemeCount; i++) {
const areaTheme = matchingThemes[i];
const theme = areaTheme.theme;
/**
*
* @type {CellProcessingRule[]}
*/
const cellRules = theme.cells.elements;
const cellRuleCount = cellRules.length;
for (let j = 0; j < cellRuleCount; j++) {
const rule = cellRules[j];
rule.action.execute(ecd, grid, x, y, 0, rule.filter);
}
}
});
}
/**
*
* @param {Terrain} terrain
* @returns {Task}
*/
updateTerrain(terrain) {
return emptyTask();
}
/**
*
* @returns {TaskGroup}
* @param {EntityComponentDataset} ecd
*/
optimize(ecd) {
const terrain = obtainTerrain(ecd);
const tLightMap = futureTask(new Future((resolve, reject) => {
terrain.buildLightMap().then(resolve, reject);
}), 'Building Lightmap');
return new TaskGroup([tLightMap]);
}
/**
*
* @param {GridData} grid
* @param {EntityComponentDataset} ecd
* @returns {TaskGroup}
*/
apply(grid, ecd) {
const terrain = obtainTerrain(ecd);
const tInitializeThemes = this.initializeThemes(this.random(), ecd, grid);
const tNodes = this.applyNodes(grid, ecd);
const tCells = this.applyCellRules(grid, ecd);
tNodes.addDependencies([
tInitializeThemes,
]);
tCells.addDependency(tInitializeThemes);
const tOptimize = this.optimize(ecd);
tOptimize.addDependencies([
tNodes,
tCells,
tInitializeThemes
]);
const subtasks = [tInitializeThemes, tNodes, tCells, tOptimize];
if (terrain !== null) {
// build terrain tiles
const tTerrain = this.applyTerrainThemes(grid, terrain);
tTerrain.addDependency(tInitializeThemes);
const tTerrainGeometry = makeTaskEnsureAllTiles(terrain);
tTerrainGeometry.addDependency(tTerrain);
const tUpdateTerrain = this.updateTerrain(terrain);
tUpdateTerrain.addDependencies([
tTerrain,
tNodes,
tCells,
tTerrainGeometry
]);
tNodes.addDependencies([
tTerrain,
tTerrainGeometry,
]);
tTerrainGeometry.addDependency(tCells);
tOptimize.addDependencies([
tUpdateTerrain,
tTerrain,
]);
subtasks.push(tTerrainGeometry, tTerrain, tUpdateTerrain);
} else {
console.warn(`No terrain present, skipping terrain theming`);
}
return new TaskGroup(subtasks);
}
}