UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

644 lines (478 loc) • 17.3 kB
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); } }