@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
566 lines (451 loc) • 15.2 kB
JavaScript
import { assert } from "../../assert.js";
import Signal from "../../events/signal/Signal.js";
import { IllegalStateException } from "../../fsm/exceptions/IllegalStateException.js";
import { objectKeyByValue } from "../../model/object/objectKeyByValue.js";
import { TaskSignal } from "../task/TaskSignal.js";
import TaskState from "../task/TaskState.js";
/**
*
* @param {TaskGroup|Task} t
* @returns {boolean}
*/
function isGroupTask(t) {
return t.children instanceof Array;
}
/**
* @class
*/
class ConcurrentExecutor {
/**
* How many time-slice cycles have been executed this far. This is a monotonically increasing counter
* @type {number}
*/
#cycle_count = 0;
/**
* Handle of the last scheduled `setTimeout`
* @type {number}
*/
#timeout_handle = -1;
/**
*
* @param {number} [quietTime] in milliseconds
* @param {number} [workTime] in milliseconds
* @constructor
*/
constructor(quietTime = 1, workTime = 15) {
/**
*
* @type {number}
*/
this.quietTime = quietTime;
/**
*
* @type {number}
*/
this.workTime = workTime;
/**
* Tasks in state pending resolution or initial sate are put here
* @type {Task[]}
*/
this.queueUnresolved = [];
/**
* ready tasks are those who's dependencies have all been completed
* @type {Task[]}
*/
this.queueReady = [];
this.on = {
task_started: new Signal(),
task_completed: new Signal(),
completed: new Signal()
};
this.busy = false;
/**
*
* @type {number|SchedulingPolicy}
*/
this.policy = SchedulingPolicy.ROUND_ROBIN;
}
/**
*
* @param {TaskGroup} taskGroup
*/
runGroup(taskGroup) {
assert.equal(taskGroup.isTaskGroup, true, 'taskGroup.isTaskGroup !== true');
const currentState = taskGroup.state.getValue();
if (currentState !== TaskState.INITIAL) {
throw new IllegalStateException(`Expected task state INITIAL, instead got ${objectKeyByValue(TaskState, currentState)}`);
}
const self = this;
const children = taskGroup.children;
const numChildren = children.length;
let pendingCount = numChildren;
let resolved = false;
function signalSuccess() {
if (!resolved) {
resolved = true;
taskGroup.state.set(TaskState.SUCCEEDED);
taskGroup.on.completed.send0();
self.prod();
}
}
function signalFailure(reason) {
if (!resolved) {
resolved = true;
taskGroup.state.set(TaskState.FAILED);
taskGroup.on.failed.send1(reason);
self.prod();
}
}
function subTaskCompleted() {
pendingCount--;
if (pendingCount <= 0) {
signalSuccess();
}
}
function subTaskFailed(reason) {
signalFailure(reason);
}
if (numChildren > 0) {
taskGroup.state.set(TaskState.RUNNING);
let i = 0;
for (; i < numChildren; i++) {
const child = children[i];
child.on.completed.add(subTaskCompleted);
child.on.failed.add(subTaskFailed);
if (isGroupTask(child)) {
this.runGroup(child);
} else {
this.run(child);
}
}
} else {
//no children, succeed immediately
signalSuccess();
}
}
/**
*
* @param {TaskGroup} group
*/
removeGroup(group) {
const children = group.children;
const n = children.length;
for (let i = 0; i < n; i++) {
const child = children[i];
if (isGroupTask(child)) {
this.removeGroup(child);
} else {
this.removeTask(child);
}
}
}
/**
*
* @param {Task} task
* @return {boolean}
*/
removeTask(task) {
//console.warn("Removing task:", task);
const readyIndex = this.queueReady.indexOf(task);
if (readyIndex !== -1) {
//is in ready queue, remove
this.queueReady.splice(readyIndex, 1);
return true;
}
const unresolvedIndex = this.queueUnresolved.indexOf(task);
if (unresolvedIndex !== -1) {
//found in unresolved queue, remove
this.queueUnresolved.splice(unresolvedIndex, 1);
return true;
}
//not found
return false;
}
/**
*
* @param {Task} task
*/
run(task) {
this.queueUnresolved.push(task);
this.prod();
}
/**
* Shortcut for {@link #run} method for scheduling multiple tasks at once
* @param {Task[]} tasks
*/
runMany(tasks) {
Array.prototype.push.apply(this.queueUnresolved, tasks);
this.prod();
}
/**
* @private
* @param {Task} task
* @returns {boolean}
*/
startTask(task) {
const timeInitializationStart = performance.now();
try {
task.initialize(task, this);
} catch (e) {
console.error(`Task initialization failed`, task, e);
task.state.set(TaskState.FAILED);
task.on.failed.dispatch(e);
return false;
} finally {
const timeInitializationEnd = performance.now();
const timeInitializationDuration = timeInitializationEnd - timeInitializationStart;
task.__executedCpuTime += timeInitializationDuration;
}
// console.log("Starting task", task);
task.state.set(TaskState.RUNNING);
//dispatch start notification
task.on.started.send1(this);
//add to the queue
this.queueReady.push(task);
this.on.task_started.send1(task);
return true;
}
/**
* Go through unresolved queue and move tasks whose dependencies have been completed to ready queue or fail them
*/
resolveTasks() {
const queueUnresolved = this.queueUnresolved;
let i = 0, l = queueUnresolved.length;
for (; i < l; i++) {
const unresolvedTask = queueUnresolved[i];
const resolution = tryResolve(unresolvedTask);
switch (resolution) {
case ResolutionType.READY:
//remove task from unresolved queue to prevent infinite recursion in case "resolveTasks" is attempted again inside task initializer
queueUnresolved.splice(i, 1);
//set state of task to READY
unresolvedTask.state.set(TaskState.READY);
//attempt to start the task
this.startTask(unresolvedTask);
//task start could have altered unresolved queue, re-initialize iteration
i = 0;
l = queueUnresolved.length;
break;
case ResolutionType.FAILED:
queueUnresolved.splice(i, 1);
l--;
i--;
break;
}
}
}
/**
*
* @param {Task} task
* @returns {boolean}
*/
contains(task) {
if (this.queueUnresolved.indexOf(task) !== -1) {
return true;
}
if (this.queueReady.indexOf(task) !== -1) {
return true;
}
return false;
}
/**
*
* @return {Task|undefined}
*/
#pick_next_task() {
const ready = this.queueReady;
switch (this.policy) {
case ConcurrentExecutor.POLICY.ROUND_ROBIN:
return ready[this.#cycle_count % ready.length];
default:
console.warn('Unknown scheduling policy: ', this.policy, 'Defaulting to sequential');
// fallthrough
case ConcurrentExecutor.POLICY.SEQUENTIAL:
return ready[0];
}
}
/**
*
* @param {Task} task
*/
#complete_task(task) {
const readyTasks = this.queueReady;
const taskIndex = readyTasks.indexOf(task);
if (taskIndex !== -1) {
readyTasks.splice(taskIndex, 1);
} else {
console.error("Failed to remove ready task, not found in the ready queue", task, readyTasks.slice());
}
task.state.set(TaskState.SUCCEEDED);
task.on.completed.send1(this);
this.resolveTasks();
this.on.task_completed.send1(task);
// console.warn(`Task complete '${task.name}', cycles=${task.__executedCycleCount}, time=${task.__executedCpuTime}`);
}
/**
*
* @param {Task} task
* @param {*} [reason]
*/
#fail_task(task, reason) {
const readyTasks = this.queueReady;
const taskIndex = readyTasks.indexOf(task);
if (taskIndex !== -1) {
readyTasks.splice(taskIndex, 1);
} else {
console.error("Failed to remove ready task, not found in the ready queue", task, readyTasks.slice());
}
task.state.set(TaskState.FAILED);
task.on.failed.send1(reason);
if (!task.on.failed.hasHandlers()) {
// no one is watching for task failure
console.warn(`Task '${task.name}' failed.`, reason);
}
this.resolveTasks();
this.on.task_completed.send1(task);
}
/**
*
* @param {Task} task
* @param {number} time in milliseconds
* @param {function} completionCallback
* @param {function} failureCallback
*/
#runTaskForTimeMonitored(task, time) {
let cycle_count = 0;
/**
*
* @type {function(): TaskSignal}
*/
const cycle = task.cycle;
const startTime = performance.now();
const endTime = startTime + time;
//We use tiny delta to avoid problems with timer accuracy on some systems that result in 0 measured execution time
let t = startTime + 0.000001;
let signal;
while (t < endTime) {
cycle_count++;
signal = cycle();
t = performance.now();
if (signal === TaskSignal.Continue) {
continue;
}
if (signal === TaskSignal.Yield) {
//give up current quanta
break;
}
if (signal === TaskSignal.EndSuccess) {
break;
} else if (signal === TaskSignal.EndFailure) {
break;
} else {
throw new Error(`Task '${task.name}' produced unknown signal: ` + signal);
}
}
const executionDuration = t - startTime;
task.__executedCpuTime += executionDuration;
task.__executedCycleCount += cycle_count;
if (signal === TaskSignal.EndSuccess) {
this.#complete_task(task);
} else if (signal === TaskSignal.EndFailure) {
this.#fail_task(task, "Task signalled failure");
}
return executionDuration;
}
#bound_executeTimeSlice = this.#executeTimeSlice.bind(this);
#executeTimeSlice() {
let sliceTimeLeft = this.workTime;
let executionTime = 0;
const queueReady = this.queueReady;
while (queueReady.length > 0) {
const task = this.#pick_next_task();
if (task === undefined) {
console.warn('Next task not found, likely result of removing task mid-execution');
break;
}
try {
executionTime = this.#runTaskForTimeMonitored(task, sliceTimeLeft);
} catch (e) {
const error = new Error("Task threw an exception");
error.cause = e;
// console.error(`Task threw an exception`, task, e);
this.#fail_task(task, error);
}
this.#cycle_count++;
//make sure that execution time that we subtract from current CPU slice is always reducing the slice
sliceTimeLeft -= Math.max(executionTime, 1);
if (sliceTimeLeft <= 0) {
break;
}
}
if (this.queueReady.length === 0) {
this.busy = false;
this.on.completed.send0();
} else {
//schedule next time slice
this.#timeout_handle = setTimeout(this.#bound_executeTimeSlice, this.quietTime);
}
}
/**
* kicks the scheduler into action, this is an internal method and should not be called from outside
* @private
*/
prod() {
this.resolveTasks();
if (!this.busy && this.queueReady.length > 0) {
this.busy = true;
this.#executeTimeSlice();
}
}
join(callback) {
if (this.queueReady.length === 0 && this.queueUnresolved.length === 0) {
callback();
} else {
this.on.completed.addOne(callback);
}
}
}
/**
* @readonly
* @enum {number}
*/
const SchedulingPolicy = {
ROUND_ROBIN: 0,
SEQUENTIAL: 1,
TIME_SLICE: 2
};
ConcurrentExecutor.POLICY = SchedulingPolicy;
const ResolutionType = {
READY: 0,
FAILED: 1,
UNRESOLVED: 2
};
/**
*
* @param {Task} unresolvedTask
* @returns {number}
*/
function tryResolve(unresolvedTask) {
const dependencies = unresolvedTask.dependencies;
let j = 0;
const jl = dependencies.length;
for (; j < jl; j++) {
const dependency = dependencies[j];
switch (dependency.state.getValue()) {
case TaskState.INITIAL:
case TaskState.RUNNING:
case TaskState.READY:
return ResolutionType.UNRESOLVED;
case TaskState.SUCCEEDED:
break;
default:
console.error("Unknown dependency task state", dependency.state, "dependency:", dependency, "dependant:", unresolvedTask, "Failing the task.");
//intended fallthrough
case TaskState.FAILED:
//fail dependant task
return ResolutionType.FAILED;
}
}
return ResolutionType.READY;
}
export default ConcurrentExecutor;