UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

605 lines (447 loc) • 16.3 kB
import { assert } from "../../../core/assert.js"; import { randomMultipleFromArray } from "../../../core/collection/array/randomMultipleFromArray.js"; import { HashMap } from "../../../core/collection/map/HashMap.js"; import { returnTrue } from "../../../core/function/returnTrue.js"; import { randomFloatBetween } from "../../../core/math/random/randomFloatBetween.js"; import { randomFromArray } from "../../../core/math/random/randomFromArray.js"; import { ResourceAccessKind } from "../../../core/model/ResourceAccessKind.js"; import { ResourceAccessSpecification } from "../../../core/model/ResourceAccessSpecification.js"; import { number_compare_descending } from "../../../core/primitives/numbers/number_compare_descending.js"; import { computeStringHash } from "../../../core/primitives/strings/computeStringHash.js"; import { SequenceBehavior } from "../../intelligence/behavior/composite/SequenceBehavior.js"; import { OverrideContextBehavior } from "../../intelligence/behavior/decorator/OverrideContextBehavior.js"; import { BehaviorComponent } from "../../intelligence/behavior/ecs/BehaviorComponent.js"; import { DieBehavior } from "../../intelligence/behavior/ecs/DieBehavior.js"; import { Blackboard } from "../../intelligence/blackboard/Blackboard.js"; import { EntityProxyScope } from "../binding/EntityProxyScope.js"; import { SerializationMetadata } from "../components/SerializationMetadata.js"; import Tag from "../components/Tag.js"; import Entity from "../Entity.js"; import { EntityFlags } from "../EntityFlags.js"; import { AbstractContextSystem } from "../system/AbstractContextSystem.js"; import { SystemEntityContext } from "../system/SystemEntityContext.js"; import { DataScope } from "./DataScope.js"; import { DynamicActor } from "./DynamicActor.js"; import { RuleExecution } from "./RuleExecution.js"; import { computeContextualDynamicRuleDebugString } from "./rules/computeContextualDynamicRuleDebugString.js"; /** * In seconds * @type {number} */ const IDLE_EVENT_TIMEOUT_MIN = 2; /** * In seconds * @type {number} */ const IDLE_EVENT_TIMEOUT_MAX = 5; class Context extends SystemEntityContext { execution = new RuleExecution(); /** * * @type {number} */ next_idle_event_time = 0; /** * * @param {string} event * @param {*} data Event data */ handleEvent(event, data) { /** * * @type {DynamicActorSystem} */ const system = this.system; /** * @type {DynamicRuleDescription} */ const match = system.match( this.entity, event, data ); } link() { const ecd = this.getDataset(); this.next_idle_event_time = this.system.__current_time + randomFloatBetween(Math.random, IDLE_EVENT_TIMEOUT_MIN, IDLE_EVENT_TIMEOUT_MAX); ecd.addEntityAnyEventListener(this.entity, this.handleEvent, this); } unlink() { const ecd = this.getDataset(); const removed = ecd.removeEntityAnyEventListener(this.entity, this.handleEvent, this); if (!removed) { console.warn('Listener not removed', this.entity); } } } export class DynamicActorSystem extends AbstractContextSystem { dependencies = [DynamicActor]; components_used = [ ResourceAccessSpecification.from(Blackboard, ResourceAccessKind.Read) ]; /** * * @type {DynamicRuleDescriptionTable} */ database = null; /** * Scope used for dispatching actions * @type {DataScope} */ scope = new DataScope(); /** * When precisely each rule was last used * @type {HashMap<DynamicRuleDescription, number>} * @private */ __global_last_used_times = new HashMap({ keyEqualityFunction(a, b) { return a.id === b.id; }, keyHashFunction(k) { return computeStringHash(k.id); } }); /** * Time when a group with an ID will be cooled down * @type {Map<string, number>} * @private */ __global_cooldown_ready = new Map(); /** * * @type {MultiPredicateEvaluator} */ evaluator = null; /** * * @type {number} * @private */ __current_time = 0; /** * Print debug output into console * @type {boolean} * @private */ __debug = false; /** * * @param {Engine} engine */ constructor(engine) { super(Context); /** * * @type {Engine} */ this.engine = engine; } /** * * @return {number} */ getCurrentTime() { return this.engine.ticker.clock.getElapsedTime(); } /** * * @param {number} entity * @param {DynamicRuleDescription} rule * @param {*} context */ attemptRuleExecution(entity, rule, context) { /** * * @type {Context} */ const ctx = this.__getEntityContext(entity); const execution = ctx.execution; if (execution.executor !== null && execution.executor.getFlag(EntityFlags.Built)) { // there is an active rule being executed, see if this one has the right to interrupt if (rule.priority <= execution.rule.priority) { // currently running rule cannot be interrupted by new one return false; } } this.executeRule(entity, rule, context); } /** * * @param {number} entity */ terminateActiveExecution(entity) { /** * * @type {Context} */ const ctx = this.__getEntityContext(entity); const execution = ctx.execution; if (execution.executor !== null && execution.executor.getFlag(EntityFlags.Built)) { execution.executor.destroy(); } } /** * * @param {number} entity * @param {DynamicRuleDescription} rule * @param {*} context */ executeRule(entity, rule, context) { if (this.__debug) { console.warn(`Executing rule for ${entity}`, computeContextualDynamicRuleDebugString(rule, context)); } /** * * @type {Context} */ const ctx = this.__getEntityContext(entity); this.terminateActiveExecution(entity); const currentTime = this.getCurrentTime(); // record rule usage time this.__global_last_used_times.set(rule, currentTime); // set cooldowns const rule_global_cooldowns = rule.cooldowns_global; const rule_global_cooldowns_count = rule_global_cooldowns.length; for (let i = 0; i < rule_global_cooldowns_count; i++) { const cooldown = rule_global_cooldowns[i]; const ready_time = currentTime + cooldown.value.sampleRandom(Math.random); this.__global_cooldown_ready.set(cooldown.id, ready_time); } const ecd = this.entityManager.dataset; const behavior = rule.action.execute(entity, ecd, context, this); const entity_builder = new Entity() .add(BehaviorComponent.from(SequenceBehavior.from([ OverrideContextBehavior.from( { entity }, behavior ), DieBehavior.create() ]))) .add(Tag.fromJSON(['DynamicActor-RuleExecutor'])) .add(SerializationMetadata.Transient); const execution = ctx.execution; execution.rule = rule; execution.executor = entity_builder; entity_builder .build(ecd); } /** * * @param {number} entity * @param {DataScope} scope */ populateEntityScope(entity, scope) { assert.isNumber(entity, "entity"); const ecd = this.entityManager.dataset; // pull in dependency scopes const actor = ecd.getComponent(entity, DynamicActor); const context_count = actor.context.length; for (let i = 0; i < context_count; i++) { const ctx_entity = actor.context[i]; if (!ecd.entityExists(ctx_entity)) { continue; } const ctx_bb = ecd.getComponent(ctx_entity, Blackboard); if (ctx_bb === undefined) { continue; } scope.push(ctx_bb.getValueProxy()); } // inject current time const time = this.getCurrentTime(); scope.push({ now: time, entity }); // fetch blackboard /** * * @type {Blackboard} */ const blackboard = ecd.getComponent(entity, Blackboard); if (blackboard !== undefined) { scope.push(blackboard.getValueProxy()); } const entityProxyScope = new EntityProxyScope(); entityProxyScope.attach(entity, ecd); scope.push(entityProxyScope.scope); } /** * * @param {Object} context * @return {DynamicRuleDescription|undefined} */ matchRule(context) { const global_cooldown_ready_map = this.__global_cooldown_ready; /** * * @type {DynamicRuleDescription|undefined} */ let result = undefined; const evaluator = this.evaluator; evaluator.initialize(context); for (; ;) { const predicate = evaluator.next(); if (predicate === undefined) { break; } const rules = this.database.getRulesByPredicate(predicate); if (rules === undefined) { // no matches, go on continue; } // exclude rules that are on cooldown const candidates = rules.slice(); let candidate_count = candidates.length; candidate_loop: for (let i = candidate_count - 1; i >= 0; i--) { const rule = candidates[i]; // check cooldowns const cooldowns_global = rule.cooldowns_global; const cooldowns_global_count = cooldowns_global.length; for (let j = 0; j < cooldowns_global_count; j++) { const cooldown = cooldowns_global[j]; const cooldown_id = cooldown.id; const cooldown_ready_time = global_cooldown_ready_map.get(cooldown_id); if (cooldown_ready_time === undefined) { continue; } if (cooldown_ready_time > this.getCurrentTime()) { // rule is still on cooldown, exclude candidates.splice(i, 1); candidate_count--; continue candidate_loop; } } } if (candidate_count === 0) { continue; } result = randomFromArray(Math.random, candidates); break; } evaluator.finalize(); return result; } /** * Given a context, returns N actors that match that context best, filter is used to restrict search and reject certain actors entirely * Useful for picking an actor for a response action * * @param {Object} context * @param {function(entity:number,dataset:EntityComponentDataset):} [filter] * @param {*} [filterContext] * @param {number} [count] * @returns {{entity:number,rule:DynamicRuleDescription, scope: DataScope}[]} */ requestBestActors(context, filter = returnTrue, filterContext = null, count = 1) { /** * * @type {{entity:number, rule:DynamicRuleDescription, scope: DataScope}[]} */ const result = []; const ecd = this.entityManager.dataset; /** * * @type {{entity:number, rule:DynamicRuleDescription, scope: DataScope}[][]} */ const by_score = []; const scores = []; if (ecd !== null) { ecd.traverseComponents(DynamicActor, (actor, entity) => { const accepted_by_filter = filter.call(filterContext, entity, ecd); if (accepted_by_filter === false) { return; } const scope = new DataScope(); scope.push(context); this.populateEntityScope(entity, scope); const match = this.matchRule(scope.proxy); if (match === undefined) { // no match return; } const score = match.getPredicateComplexity(); if (by_score[score] === undefined) { scores.push(score); by_score[score] = []; } const match_entry = { entity, rule: match, scope: scope.proxy }; by_score[score].push(match_entry); }); } scores.sort(number_compare_descending); for (let i = 0; i < scores.length && result.length < count; i++) { const score = scores[i]; const matches = by_score[score]; const free_slots = count - result.length; const picked_count = randomMultipleFromArray(Math.random, matches, result, free_slots); } return result; } /** * * @param {number} entity * @param {string} event * @param {Object} context * @returns {DynamicRuleDescription|undefined} */ match(entity, event, context) { const top = this.scope.size(); this.populateEntityScope(entity, this.scope); if (typeof context === 'object') { this.scope.push(context); } this.scope.push({ event: event }); const scopeProxy = this.scope.proxy; // console.log('DA event ', event, objectShallowCopyByOwnKeys(scopeProxy)); // DEBUG /** * * @type {DynamicRuleDescription} */ const description = this.matchRule(scopeProxy); if (description !== undefined) { this.attemptRuleExecution(entity, description, scopeProxy); } this.scope.unwind(top); return description; } async startup(entityManager) { const staticKnowledge = this.engine.staticKnowledge; await staticKnowledge.promise(); this.database = staticKnowledge.getTable('dynamic-actions'); this.evaluator = this.database.buildEvaluator(); } /** * * @param {DynamicActor} actor * @param {number} entity * @private */ __update_visitDynamicActor(actor, entity) { /** * * @type {Context} */ const ctx = this.__getEntityContext(entity); while (ctx.next_idle_event_time < this.__current_time) { const timeout = randomFloatBetween(Math.random, IDLE_EVENT_TIMEOUT_MIN, IDLE_EVENT_TIMEOUT_MAX); ctx.next_idle_event_time += timeout; this.entityManager.dataset.sendEvent(entity, 'idle', {}); } } update(timeDelta) { this.__current_time += timeDelta; const dataset = this.entityManager.dataset; if (dataset !== null) { dataset.traverseComponents(DynamicActor, this.__update_visitDynamicActor, this); } } }