Skip to content

Commit 3eea5ad

Browse files
committed
feat: create TaskScheduler, Task, and useful Actor subclasses of Task
feat: don't add task if already active refactor: rename addTask to enqueue feat: handle stacking/overriding of tasks feat: add a BreakType property to tasks feat: add a clear() method to the scheduler feat: create TaskStackGroup enum feat: add onStop callback to Task refactor: create TaskConfig container type feat: create ActorTask and WalkToTask feat: make breakType an array feat: allow `execute` to return a bool indicating task termination feat: create Task subclasses for Actor
1 parent 54bebc2 commit 3eea5ad

File tree

12 files changed

+931
-0
lines changed

12 files changed

+931
-0
lines changed

src/engine/task/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# Task system
2+
3+
This is a tick-based task system which allows for extensions of the `Task` class to be executed.
4+
5+
Tasks can be executed after a delay, or immediately. They can also be set to repeat indefinitely or continue.
6+
7+
## Scheduling a task
8+
9+
You can schedule a task by registering it with the scheduler.
10+
11+
The task in the example of below runs with an interval of `2`, i.e. it will be executed every 2 ticks.
12+
13+
```ts
14+
this.taskScheduler.addTask(new class extends Task {
15+
public constructor() {
16+
super(2);
17+
}
18+
19+
public execute(): void {
20+
sendGlobalMessage('2 ticks');
21+
}
22+
});
23+
```
24+
25+
Every two times that `taskScheduler.tick()` is called, it will run the `execute` function of your task.

src/engine/task/impl/actor-task.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Subscription } from 'rxjs';
2+
import { Actor } from '@engine/world/actor';
3+
import { TaskBreakType, TaskConfig } from '../types';
4+
import { Task } from '../task';
5+
6+
/**
7+
* A task that is executed by an actor.
8+
*
9+
* If the task has a break type of ON_MOVE, the ActorTask will subscribe to the actor's
10+
* movement events and will stop executing when the actor moves.
11+
*
12+
* @author jameskmonger
13+
*/
14+
export abstract class ActorTask<TActor extends Actor = Actor> extends Task {
15+
/**
16+
* A function that is called when a movement event is queued on the actor.
17+
*
18+
* This will be `null` if the task does not break on movement.
19+
*/
20+
private walkingQueueSubscription: Subscription | null = null;
21+
22+
/**
23+
* @param actor The actor executing this task.
24+
* @param config The task configuration.
25+
*/
26+
constructor(
27+
protected readonly actor: TActor,
28+
config?: TaskConfig
29+
) {
30+
super(config);
31+
32+
this.listenForMovement();
33+
}
34+
35+
/**
36+
* Called when the task is stopped and unsubscribes from the actor's walking queue if necessary.
37+
*
38+
* TODO (jameskmonger) unit test this
39+
*/
40+
public onStop(): void {
41+
if (this.walkingQueueSubscription) {
42+
this.walkingQueueSubscription.unsubscribe();
43+
}
44+
}
45+
46+
/**
47+
* If required, listen to the actor's walking queue to stop the task
48+
*
49+
* This function uses `setImmediate` to ensure that the subscription to the
50+
* walking queue is not created
51+
*
52+
* TODO (jameskmonger) unit test this
53+
*/
54+
private listenForMovement(): void {
55+
if (!this.breaksOn(TaskBreakType.ON_MOVE)) {
56+
return;
57+
}
58+
59+
setImmediate(() => {
60+
this.walkingQueueSubscription = this.actor.walkingQueue.movementQueued$.subscribe(() => {
61+
this.stop();
62+
});
63+
});
64+
}
65+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { Position } from '@engine/world/position';
2+
import { Actor } from '@engine/world/actor';
3+
import { TaskStackType, TaskBreakType, TaskStackGroup } from '../types';
4+
import { ActorTask } from './actor-task';
5+
6+
/**
7+
* This ActorWalkToTask interface allows us to merge with the ActorWalkToTask class
8+
* and add optional methods to the class.
9+
*
10+
* There is no way to add optional methods directly to an abstract class.
11+
*
12+
* @author jameskmonger
13+
*/
14+
export interface ActorWalkToTask<TActor extends Actor = Actor> extends ActorTask<TActor> {
15+
/**
16+
* An optional function that is called when the actor arrives at the destination.
17+
*/
18+
onArrive?(): void;
19+
}
20+
21+
/**
22+
* An abstract task that will make an Actor walk to a specific position,
23+
* before calling the `arrive` function and continuing execution.
24+
*
25+
* The task will be stopped if the adds a new movement to their walking queue.
26+
*
27+
* @author jameskmonger
28+
*/
29+
export abstract class ActorWalkToTask<TActor extends Actor = Actor> extends ActorTask<TActor> {
30+
private _atDestination: boolean = false;
31+
32+
/**
33+
* `true` if the actor has arrived at the destination.
34+
*/
35+
protected get atDestination(): boolean {
36+
return this._atDestination;
37+
}
38+
39+
/**
40+
* @param actor The actor executing this task.
41+
* @param destination The destination position.
42+
* @param distance The distance from the destination position that the actor must be within to arrive.
43+
*/
44+
constructor (
45+
actor: TActor,
46+
protected readonly destination: Position,
47+
protected readonly distance = 1,
48+
) {
49+
super(
50+
actor,
51+
{
52+
interval: 1,
53+
stackType: TaskStackType.NEVER,
54+
stackGroup: TaskStackGroup.ACTION,
55+
breakTypes: [ TaskBreakType.ON_MOVE ],
56+
immediate: false,
57+
repeat: true,
58+
}
59+
);
60+
61+
this.actor.pathfinding.walkTo(destination, { })
62+
}
63+
64+
/**
65+
* Every tick of the task, check if the actor has arrived at the destination.
66+
*
67+
* You can check `this.arrived` to see if the actor has arrived.
68+
*
69+
* If the actor has previously arrived at the destination, but is no longer within distance,
70+
* the task will be stopped.
71+
*
72+
* @returns `true` if the task was stopped this tick, `false` otherwise.
73+
*
74+
* TODO (jameskmonger) unit test this
75+
*/
76+
public execute() {
77+
if (!this.isActive) {
78+
return;
79+
}
80+
81+
// TODO this uses actual distances rather than tile distances
82+
// is this correct?
83+
const withinDistance = this.actor.position.withinInteractionDistance(this.destination, this.distance)
84+
85+
// the WalkToTask itself is complete when the actor has arrived at the destination
86+
// execution will now continue in the extended class
87+
if (this._atDestination) {
88+
// TODO consider making this optional
89+
if (!withinDistance) {
90+
this._atDestination = false;
91+
this.stop();
92+
}
93+
94+
return;
95+
}
96+
97+
if (withinDistance) {
98+
this._atDestination = true;
99+
100+
if (this.onArrive) {
101+
this.onArrive();
102+
}
103+
}
104+
}
105+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { WorldItem } from '@engine/world';
2+
import { Actor } from '../../world/actor/actor';
3+
import { ActorWalkToTask } from './actor-walk-to-task';
4+
5+
/**
6+
* A task for an actor to interact with a world item.
7+
*
8+
* This task extends {@link ActorWalkToTask} and will walk the actor to the world item.
9+
* Once the actor is within range of the world item, the task will expose the {@link worldItem} property
10+
*
11+
* @author jameskmonger
12+
*/
13+
export abstract class ActorWorldItemInteractionTask<TActor extends Actor = Actor> extends ActorWalkToTask<TActor> {
14+
private _worldItem: WorldItem;
15+
16+
/**
17+
* Gets the world item that this task is interacting with.
18+
*
19+
* @returns If the world item is still present, and the actor is at the destination, the world item.
20+
* Otherwise, `null`.
21+
*
22+
* TODO (jameskmonger) unit test this
23+
*/
24+
protected get worldItem(): WorldItem | null {
25+
// TODO (jameskmonger) consider if we want to do these checks rather than delegating to the child task
26+
// as currently the subclass has to store it in a subclass property if it wants to use it
27+
// without these checks
28+
if (!this.atDestination) {
29+
return null;
30+
}
31+
32+
if (!this._worldItem || this._worldItem.removed) {
33+
return null;
34+
}
35+
36+
return this._worldItem;
37+
}
38+
39+
/**
40+
* @param actor The actor executing this task.
41+
* @param worldItem The world item to interact with.
42+
*/
43+
constructor (
44+
actor: TActor,
45+
worldItem: WorldItem,
46+
) {
47+
super(
48+
actor,
49+
worldItem.position,
50+
1
51+
);
52+
53+
if (!worldItem) {
54+
this.stop();
55+
return;
56+
}
57+
58+
this._worldItem = worldItem;
59+
60+
}
61+
62+
/**
63+
* Checks for the continued presence of the world item and stops the task if it is no longer present.
64+
*
65+
* TODO (jameskmonger) unit test this
66+
*/
67+
public execute() {
68+
super.execute();
69+
70+
if (!this.isActive || !this.atDestination) {
71+
return;
72+
}
73+
74+
if (!this._worldItem || this._worldItem.removed) {
75+
this.stop();
76+
return;
77+
}
78+
}
79+
}

src/engine/task/impl/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { ActorTask } from './actor-task'
2+
export { ActorWalkToTask } from './actor-walk-to-task'
3+
export { ActorWorldItemInteractionTask } from './actor-world-item-interaction-task'

src/engine/task/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { Task } from './task'
2+
export { TaskScheduler } from './task-scheduler'
3+
export { TaskStackType, TaskBreakType, TaskStackGroup, TaskConfig } from './types'
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Task } from './task';
2+
import { TaskScheduler } from './task-scheduler';
3+
import { TaskStackType } from './types';
4+
import { createMockTask } from './utils/_testing';
5+
6+
describe('TaskScheduler', () => {
7+
let taskScheduler: TaskScheduler;
8+
beforeEach(() => {
9+
taskScheduler = new TaskScheduler();
10+
});
11+
12+
describe('when enqueueing a task', () => {
13+
let executeMock: jest.Mock;
14+
let task: Task
15+
beforeEach(() => {
16+
({ task, executeMock } = createMockTask());
17+
});
18+
19+
it('should add the task to the running list when ticked', () => {
20+
taskScheduler.enqueue(task);
21+
taskScheduler.tick();
22+
expect(executeMock).toHaveBeenCalled();
23+
});
24+
25+
it('should not add the task to the running list until the next tick', () => {
26+
taskScheduler.enqueue(task);
27+
expect(executeMock).not.toHaveBeenCalled();
28+
});
29+
30+
describe('when ticked multiple times', () => {
31+
beforeEach(() => {
32+
taskScheduler.enqueue(task);
33+
taskScheduler.tick();
34+
taskScheduler.tick();
35+
});
36+
37+
it('should tick the task twice', () => {
38+
expect(executeMock).toHaveBeenCalledTimes(2);
39+
});
40+
});
41+
42+
describe('when the task is stopped', () => {
43+
beforeEach(() => {
44+
taskScheduler.enqueue(task);
45+
taskScheduler.tick();
46+
});
47+
48+
it('should not tick the task after stopping', () => {
49+
task.stop();
50+
taskScheduler.tick();
51+
expect(executeMock).toHaveBeenCalledTimes(1);
52+
});
53+
});
54+
});
55+
56+
describe('when enqueueing a task that cannot stack', () => {
57+
const interval = 0;
58+
const stackType = TaskStackType.NEVER;
59+
const stackGroup = 'foo';
60+
61+
let firstExecuteMock: jest.Mock;
62+
let firstTask: Task
63+
beforeEach(() => {
64+
({ task: firstTask, executeMock: firstExecuteMock } = createMockTask(interval, stackType, stackGroup));
65+
});
66+
67+
it('should stop any other tasks with the same stack group', () => {
68+
const { task: secondTask, executeMock: secondExecuteMock } = createMockTask(interval, stackType, stackGroup);
69+
70+
taskScheduler.enqueue(firstTask);
71+
taskScheduler.enqueue(secondTask);
72+
taskScheduler.tick();
73+
74+
expect(firstExecuteMock).not.toHaveBeenCalled();
75+
expect(secondExecuteMock).toHaveBeenCalled();
76+
});
77+
78+
it('should not stop any other tasks with a different stack group', () => {
79+
const otherStackGroup = 'bar';
80+
const { task: secondTask, executeMock: secondExecuteMock } = createMockTask(interval, stackType, otherStackGroup);
81+
82+
taskScheduler.enqueue(firstTask);
83+
taskScheduler.enqueue(secondTask);
84+
taskScheduler.tick();
85+
86+
expect(firstExecuteMock).toHaveBeenCalled();
87+
expect(secondExecuteMock).toHaveBeenCalled();
88+
});
89+
});
90+
91+
describe('when clearing the scheduler', () => {
92+
let executeMock: jest.Mock;
93+
let task: Task
94+
beforeEach(() => {
95+
({ task, executeMock } = createMockTask());
96+
});
97+
98+
it('should stop all tasks', () => {
99+
taskScheduler.enqueue(task);
100+
taskScheduler.tick();
101+
taskScheduler.clear();
102+
taskScheduler.tick();
103+
expect(executeMock).toHaveBeenCalledTimes(1);
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)