@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
605 lines (447 loc) • 16.3 kB
JavaScript
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);
}
}
}