added AI support

This commit is contained in:
Joe Weaver 2020-10-07 15:00:28 -04:00
parent 254462993a
commit f3449c1526
15 changed files with 495 additions and 17 deletions

14
src/Behaviors/Behavior.ts Normal file
View File

@ -0,0 +1,14 @@
import Emitter from "../Events/Emitter";
import Receiver from "../Events/Receiver";
export default abstract class Behavior {
protected receiver: Receiver;
protected emitter: Emitter;
constructor(){
this.receiver = new Receiver();
this.emitter = new Emitter();
}
abstract doBehavior(deltaT: number): void;
}

57
src/BoidDemo.ts Normal file
View File

@ -0,0 +1,57 @@
import Vec2 from "./DataTypes/Vec2";
import Debug from "./Debug/Debug";
import Point from "./Nodes/Graphics/Point";
import Scene from "./Scene/Scene";
import SceneGraphQuadTree from "./SceneGraph/SceneGraphQuadTree";
import Color from "./Utils/Color";
import Boid from "./_DemoClasses/Boid";
import BoidBehavior from "./_DemoClasses/BoidBehavior";
import FlockBehavior from "./_DemoClasses/FlockBehavior";
/**
* This demo emphasizes an ai system for the game engine with component architecture
* Boids move around with components
* Boids have randomized affects (maybe?)
* Boids respond to player movement
*/
export default class BoidDemo extends Scene {
boids: Array<Boid>;
startScene(){
// Set the world size
this.worldSize = new Vec2(800, 600);
this.sceneGraph = new SceneGraphQuadTree(this.viewport, this);
this.viewport.setBounds(0, 0, 800, 600)
this.viewport.setCenter(400, 300);
let layer = this.addLayer()
this.boids = new Array();
// Create a bunch of boids
for(let i = 0; i < 200; i++){
let boid = this.add.graphic(Boid, layer, new Vec2(this.worldSize.x*Math.random(), this.worldSize.y*Math.random()));
let separation = 3;
let alignment = 1;
let cohesion = 3;
boid.addBehavior(new BoidBehavior(this, boid, separation, alignment, cohesion));
boid.addBehavior(new FlockBehavior(this, boid, this.boids, 75, 50));
boid.setSize(5, 5);
this.boids.push(boid);
}
}
updateScene(deltaT: number): void {
for(let boid of this.boids){
boid.setColor(Color.RED);
}
for(let boid of this.boids){
boid.getBehavior(FlockBehavior).doBehavior(deltaT);
}
for(let boid of this.boids){
boid.getBehavior(BoidBehavior).doBehavior(deltaT);
}
}
}

View File

@ -1,5 +1,6 @@
import Shape from "./Shape";
import Vec2 from "./Vec2";
import MathUtils from "../Utils/MathUtils";
export default class AABB extends Shape {
@ -101,6 +102,68 @@ export default class AABB extends Shape {
&& point.y > this.y - this.hh && point.y <= this.y + this.hh
}
/**
* Returns the data from the intersection of this AABB with a line segment from a point in a direction
* @param point The point that the line segment starts from
* @param direction The direction the point will go
* @param distance The length of the line segment, if the direction is a unit vector
* @param paddingX Pads the AABB in the x axis
* @param paddingY Pads the AABB in the y axis
*/
intersectSegment(point: Vec2, direction: Vec2, distance?: number, paddingX?: number, paddingY?: number): Hit {
// Scale by the distance if it has been provided
if(distance){
direction = direction.scaled(distance);
}
let _paddingX = paddingX ? paddingX : 0;
let _paddingY = paddingY ? paddingY : 0;
let scaleX = 1/direction.x;
let scaleY = 1/direction.y;
let signX = MathUtils.sign(scaleX);
let signY = MathUtils.sign(scaleY);
let tnearx = scaleX*(this.center.x - signX*(this.halfSize.x + _paddingX) - point.x);
let tneary = scaleX*(this.center.y - signY*(this.halfSize.y + _paddingY) - point.y);
let tfarx = scaleY*(this.center.x + signX*(this.halfSize.x + _paddingX) - point.x);
let tfary = scaleY*(this.center.y + signY*(this.halfSize.y + _paddingY) - point.y);
if(tnearx > tfary || tneary > tfarx){
// We aren't colliding - we clear one axis before intersecting another
return null;
}
let tnear = Math.max(tnearx, tneary);
let tfar = Math.min(tfarx, tfary);
if(tnear >= 1 || tfar <= 0){
return null;
}
// We are colliding
let hit = new Hit();
hit.t = MathUtils.clamp01(tnear);
if(tnearx > tneary){
// We hit on the left or right size
hit.normal.x = -signX;
hit.normal.y = 0;
} else {
hit.normal.x = 0;
hit.normal.y = -signY;
}
hit.delta.x = (1.0 - hit.t) * -direction.x;
hit.delta.y = (1.0 - hit.t) * -direction.y;
hit.pos.x = point.x + direction.x * hit.t;
hit.pos.y = point.y + direction.y * hit.t;
return hit;
}
/**
* A simple boolean check of whether this AABB overlaps another
* @param other
@ -137,4 +200,11 @@ export default class AABB extends Shape {
return dx*dy;
}
}
export class Hit {
t: number;
pos: Vec2 = Vec2.ZERO;
delta: Vec2 = Vec2.ZERO;
normal: Vec2 = Vec2.ZERO;
}

View File

@ -178,10 +178,10 @@ export default class QuadTree<T extends Region & Unique> implements Collection {
let hw = this.boundary.hw;
let hh = this.boundary.hh;
this.nw = new QuadTree(new Vec2(x-hw/2, y-hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1);
this.ne = new QuadTree(new Vec2(x+hw/2, y-hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1)
this.sw = new QuadTree(new Vec2(x-hw/2, y+hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1)
this.se = new QuadTree(new Vec2(x+hw/2, y+hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1)
this.nw = new QuadTree(new Vec2(x-hw/2, y-hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1, this.capacity);
this.ne = new QuadTree(new Vec2(x+hw/2, y-hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1, this.capacity);
this.sw = new QuadTree(new Vec2(x-hw/2, y+hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1, this.capacity);
this.se = new QuadTree(new Vec2(x+hw/2, y+hh/2), new Vec2(hw/2, hh/2), this.maxDepth - 1, this.capacity);
this.distributeItems();
}
@ -213,7 +213,6 @@ export default class QuadTree<T extends Region & Unique> implements Collection {
* @param ctx
*/
public render_demo(ctx: CanvasRenderingContext2D): void {
return;
ctx.strokeStyle = "#0000FF";
ctx.strokeRect(this.boundary.x - this.boundary.hw, this.boundary.y - this.boundary.hh, 2*this.boundary.hw, 2*this.boundary.hh);

View File

@ -80,6 +80,14 @@ export default class Vec2 {
return this;
}
/**
* Returns a new vector that is the normalized version of this one
*/
normalized(){
let mag = this.mag();
return new Vec2(this.x/mag, this.y/mag);
}
/**
* Sets the vector's x and y based on the angle provided. Goes counter clockwise.
* @param angle The angle in radians
@ -205,6 +213,22 @@ export default class Vec2 {
return (this.x - other.x)*(this.x - other.x) + (this.y - other.y)*(this.y - other.y);
}
/**
* Returns the distance between this vector and another vector
* @param other
*/
distanceTo(other: Vec2): number {
return Math.sqrt(this.distanceSqTo(other));
}
/**
* Returns the dot product of this vector and another
* @param other
*/
dot(other: Vec2): number {
return this.x*other.x + this.y*other.y;
}
/**
* Returns a string representation of this vector rounded to 1 decimal point
*/

View File

@ -27,7 +27,9 @@ export default class GameLoop {
private started: boolean;
private running: boolean;
private frameDelta: number;
private frameDelta: number;
private panic: boolean;
private numUpdateSteps: number;
// Game canvas and its width and height
readonly GAME_CANVAS: HTMLCanvasElement;
@ -61,7 +63,7 @@ export default class GameLoop {
this.running = false;
// Get the game canvas and give it a background color
this.GAME_CANVAS = document.getElementById("game-canvas") as HTMLCanvasElement;
this.GAME_CANVAS = <HTMLCanvasElement>document.getElementById("game-canvas");
this.GAME_CANVAS.style.setProperty("background-color", "whitesmoke");
// Give the canvas a size and get the rendering context
@ -169,17 +171,40 @@ export default class GameLoop {
this.lastFrameTime = timestamp;
// Update while we can (This will present problems if we leave the window)
let i = 0;
this.numUpdateSteps = 0;
while(this.frameDelta >= this.simulationTimestep){
this.update(this.simulationTimestep/1000);
this.frameDelta -= this.simulationTimestep;
this.numUpdateSteps++;
if(this.numUpdateSteps > 100){
this.panic = true;
}
// Update the frame of the game
this.updateFrameCount(this.simulationTimestep);
}
// Updates are done, draw
this.render();
// End the frame
this.end();
this.panic = false;
}
end(){
if(this.panic) {
var discardedTime = Math.round(this.resetFrameDelta());
console.warn('Main loop panicked, probably because the browser tab was put in the background. Discarding ' + discardedTime + 'ms');
}
}
resetFrameDelta() : number {
var oldFrameDelta = this.frameDelta;
this.frameDelta = 0;
return oldFrameDelta;
}
/**

View File

@ -7,6 +7,7 @@ import Scene from "../Scene/Scene";
import Layer from "../Scene/Layer";
import { Positioned, Unique } from "../DataTypes/Interfaces/Descriptors"
import UIElement from "./UIElement";
import Behavior from "../Behaviors/Behavior";
/**
* The representation of an object in the game world
@ -19,6 +20,7 @@ export default abstract class GameNode implements Positioned, Unique {
protected scene: Scene;
protected layer: Layer;
private id: number;
protected behaviors: Array<Behavior>;
constructor(){
this.input = InputReceiver.getInstance();
@ -26,6 +28,7 @@ export default abstract class GameNode implements Positioned, Unique {
this._position.setOnChange(this.positionChanged);
this.receiver = new Receiver();
this.emitter = new Emitter();
this.behaviors = new Array();
}
setScene(scene: Scene): void {
@ -74,6 +77,33 @@ export default abstract class GameNode implements Positioned, Unique {
return this.id;
}
/**
* Adds a behavior to the list of behaviors in this GameNode
* @param behavior The behavior to add to this GameNode
*/
addBehavior(behavior: Behavior): void {
this.behaviors.push(behavior);
}
/**
* Does all of the behaviors of this GameNode
*/
doBehaviors(deltaT: number): void {
this.behaviors.forEach(behavior => behavior.doBehavior(deltaT));
}
getBehavior<T extends Behavior>(constr: new (...args: any) => T): T {
let query = null;
for(let behavior of this.behaviors){
if(behavior instanceof constr){
query = <T>behavior;
}
}
return query;
}
/**
* Called if the position vector is modified or replaced
*/

View File

@ -45,7 +45,15 @@ export default class SceneGraphArray extends SceneGraph{
}
getNodesInRegion(boundary: AABB): Array<CanvasNode> {
return [];
let results = [];
for(let node of this.nodeList){
if(boundary.overlapArea(node.getBoundary())){
results.push(node);
}
}
return results;
}
update(deltaT: number): void {

View File

@ -14,7 +14,7 @@ export default class SceneGraphQuadTree extends SceneGraph {
super(viewport, scene);
let size = this.scene.getWorldSize();
this.qt = new RegionQuadTree(size.clone().scale(1/2), size.clone().scale(1/2), 5);
this.qt = new RegionQuadTree(size.clone().scale(1/2), size.clone().scale(1/2), 5, 30);
this.nodes = new Array();
}
@ -44,11 +44,13 @@ export default class SceneGraphQuadTree extends SceneGraph {
this.qt.insert(node);
}
this.qt.forEach((node: CanvasNode) => {
if(!node.getLayer().isPaused()){
node.update(deltaT);
}
});
this.nodes.forEach((node: CanvasNode) => node.update(deltaT));
// TODO: forEach is buggy, some nodes are update multiple times
// this.qt.forEach((node: CanvasNode) => {
// if(!node.getLayer().isPaused()){
// node.update(deltaT);
// }
// });
}
render(ctx: CanvasRenderingContext2D): void {

View File

@ -158,6 +158,20 @@ export default class Viewport {
pos.x = MathUtils.clamp(pos.x, this.boundary.left + this.view.hw, this.boundary.right - this.view.hw);
pos.y = MathUtils.clamp(pos.y, this.boundary.top + this.view.hh, this.boundary.bottom - this.view.hh);
this.view.setCenter(pos);
} else {
if(this.lastPositions.getSize() > this.smoothingFactor){
this.lastPositions.dequeue();
}
let pos = Vec2.ZERO;
this.lastPositions.forEach(position => pos.add(position));
pos.scale(1/this.lastPositions.getSize());
// Set this position either to the object or to its bounds
pos.x = MathUtils.clamp(pos.x, this.boundary.left + this.view.hw, this.boundary.right - this.view.hw);
pos.y = MathUtils.clamp(pos.y, this.boundary.top + this.view.hh, this.boundary.bottom - this.view.hh);
this.view.setCenter(pos);
}
}

View File

@ -1,4 +1,14 @@
import Vec2 from "../DataTypes/Vec2";
export default class MathUtils {
/**
* Returns the sign of the value provided
* @param x The value to extract the sign from
*/
static sign(x: number): number {
return x < 0 ? -1 : 1;
}
/**
* Clamps the value x to the range [min, max], rounding up or down if needed
* @param x The value to be clamped
@ -11,7 +21,23 @@ export default class MathUtils {
return x;
}
/**
/**
* Clamps the value x to the range between 0 and 1
* @param x The value to be clamped
*/
static clamp01(x: number): number {
return MathUtils.clamp(x, 0, 1);
}
static clampMagnitude(v: Vec2, m: number): Vec2 {
if(v.magSq() > m*m){
return v.scaleTo(m);
} else{
return v;
}
}
/**
* Linear Interpolation
* @param a The first value for the interpolation bound
* @param b The second value for the interpolation bound

43
src/_DemoClasses/Boid.ts Normal file
View File

@ -0,0 +1,43 @@
import Vec2 from "../DataTypes/Vec2";
import Graphic from "../Nodes/Graphic";
import BoidBehavior from "./BoidBehavior";
export default class Boid extends Graphic {
direction: Vec2 = Vec2.UP.rotateCCW(Math.random()*2*Math.PI);
acceleration: Vec2 = Vec2.ZERO;
velocity: Vec2 = Vec2.ZERO;
constructor(position: Vec2){
super();
this.position = position;
}
update(deltaT: number){
this.position.add(this.velocity.scaled(deltaT));
this.position.x = (this.position.x + this.scene.getWorldSize().x)%this.scene.getWorldSize().x;
this.position.y = (this.position.y + this.scene.getWorldSize().y)%this.scene.getWorldSize().y;
}
render(ctx: CanvasRenderingContext2D): void {
let origin = this.getViewportOriginWithParallax();
let dirVec = this.direction.scaled(this.size.x, this.size.y);
let finVec1 = this.direction.clone().rotateCCW(Math.PI/2).scale(this.size.x/2, this.size.y/2).sub(this.direction.scaled(this.size.x/1.5, this.size.y/1.5));
let finVec2 = this.direction.clone().rotateCCW(-Math.PI/2).scale(this.size.x/2, this.size.y/2).sub(this.direction.scaled(this.size.x/1.5, this.size.y/1.5));
ctx.lineWidth = 1;
ctx.fillStyle = this.color.toString();
ctx.beginPath();
ctx.moveTo(this.position.x + dirVec.x, this.position.y + dirVec.y);
ctx.lineTo(this.position.x + finVec1.x, this.position.y + finVec1.y);
ctx.lineTo(this.position.x - dirVec.x/3, this.position.y - dirVec.y/3);
ctx.lineTo(this.position.x + finVec2.x,this.position.y + finVec2.y);
ctx.lineTo(this.position.x + dirVec.x, this.position.y + dirVec.y);
ctx.fill();
// ctx.fillStyle = this.color.toStringRGBA();
// ctx.fillRect(this.position.x - origin.x - this.size.x/2, this.position.y - origin.y - this.size.y/2,
// this.size.x, this.size.y);
}
}

View File

@ -0,0 +1,82 @@
import Behavior from "../Behaviors/Behavior";
import AABB from "../DataTypes/AABB";
import Vec2 from "../DataTypes/Vec2";
import Debug from "../Debug/Debug";
import Point from "../Nodes/Graphics/Point";
import Scene from "../Scene/Scene";
import Color from "../Utils/Color";
import MathUtils from "../Utils/MathUtils";
import Boid from "./Boid";
import FlockBehavior from "./FlockBehavior";
export default class BoidBehavior extends Behavior {
scene: Scene;
actor: Boid;
separationFactor: number;
alignmentFactor: number;
cohesionFactor: number;
static MIN_SPEED: number = 80;
static START_SPEED: number = 90;
static MAX_SPEED: number = 100;
static MAX_STEER_FORCE: number = 300;
constructor(scene: Scene, actor: Boid, separationFactor: number, alignmentFactor: number, cohesionFactor: number){
super();
this.scene = scene;
this.actor = actor;
this.separationFactor = separationFactor;
this.alignmentFactor = alignmentFactor;
this.cohesionFactor = cohesionFactor;
}
doBehavior(deltaT: number): void {
if(this.actor.getId() < 1){
this.actor.setColor(Color.GREEN);
}
if(this.actor.velocity.x === 0 && this.actor.velocity.y === 0){
this.actor.velocity = this.actor.direction.scaled(BoidBehavior.START_SPEED * deltaT);
}
let flock = this.actor.getBehavior(FlockBehavior);
if(!flock.hasNeighbors){
// No neighbors, don't change velocity;
return;
}
let flockCenter = flock.flockCenter;
let flockHeading = flock.flockHeading;
let separationHeading = flock.separationHeading;
let offsetToFlockmateCenter = flockCenter.sub(this.actor.position);
let separationForce = this.steerTowards(separationHeading).scale(this.separationFactor);
let alignmentForce = this.steerTowards(flockHeading).scale(this.alignmentFactor);
let cohesionForce = this.steerTowards(offsetToFlockmateCenter).scale(this.cohesionFactor);
this.actor.acceleration = Vec2.ZERO;
this.actor.acceleration.add(separationForce).add(alignmentForce).add(cohesionForce);
this.actor.velocity.add(this.actor.acceleration.scaled(deltaT));
let speed = this.actor.velocity.mag();
this.actor.velocity.normalize();
this.actor.direction = this.actor.velocity.clone();
speed = MathUtils.clamp(speed, BoidBehavior.MIN_SPEED, BoidBehavior.MAX_SPEED);
this.actor.velocity.scale(speed);
if(this.actor.getId() < 1){
Debug.log("BoidDir", "Velocity: " + this.actor.velocity.toString());
Debug.log("BoidSep", "Separation: " + separationForce.toString());
Debug.log("BoidAl", "Alignment: " + alignmentForce.toString());
Debug.log("BoidCo", "Cohesion: " + cohesionForce.toString());
Debug.log("BoidSpd", "Speed: " + speed);
}
}
steerTowards(vec: Vec2){
let v = vec.normalize().scale(BoidBehavior.MAX_SPEED).sub(this.actor.velocity);
return MathUtils.clampMagnitude(v, BoidBehavior.MAX_STEER_FORCE);
}
}

View File

@ -0,0 +1,83 @@
import Behavior from "../Behaviors/Behavior";
import AABB from "../DataTypes/AABB";
import Vec2 from "../DataTypes/Vec2";
import Point from "../Nodes/Graphics/Point";
import Scene from "../Scene/Scene";
import Color from "../Utils/Color";
import Boid from "./Boid";
import BoidBehavior from "./BoidBehavior";
export default class FlockBehavior extends Behavior {
scene: Scene;
actor: Boid;
flock: Array<Boid>;
visibleRegion: AABB;
avoidRadius: number;
hasNeighbors: boolean;
flockCenter: Vec2;
flockHeading: Vec2;
separationHeading: Vec2;
constructor(scene: Scene, actor: Boid, flock: Array<Boid>, visionRange: number, avoidRadius: number) {
super();
this.scene = scene;
this.actor = actor;
this.flock = flock;
this.visibleRegion = new AABB(this.actor.getPosition().clone(), new Vec2(visionRange, visionRange));
this.avoidRadius = avoidRadius;
}
doBehavior(deltaT: number): void {
// Update the visible region
this.visibleRegion.setCenter(this.actor.getPosition().clone());
let neighbors = this.scene.getSceneGraph().getNodesInRegion(this.visibleRegion);
neighbors = neighbors.filter(neighbor => {
return (neighbor instanceof Boid)
&& (neighbor !== this.actor)
&& this.actor.direction.dot(neighbor.position.clone().sub(this.actor.position).normalize()) > -0.866;
});
if(neighbors.length <= 0){
this.hasNeighbors = false;
return;
} else {
this.hasNeighbors = true;
}
// Draw a group
if(this.actor.getId() < 1){
this.actor.setColor(Color.GREEN);
for(let neighbor of neighbors){
if(neighbor === this.actor) continue;
(<Point>neighbor).setColor(Color.BLUE)
}
}
let flockCenter = Vec2.ZERO;
let flockHeading = Vec2.ZERO;
let separationHeading = Vec2.ZERO;
for(let neighbor of neighbors){
let neighborPos = neighbor.position;
flockCenter.add(neighborPos);
flockHeading.add((<Boid>neighbor).direction);
let dist = this.actor.position.distanceSqTo(neighborPos);
if(dist < this.avoidRadius*this.avoidRadius){
separationHeading.add(this.actor.position.clone().sub(neighborPos).scale(1/dist));
}
}
flockCenter.scale(1/neighbors.length);
this.flockCenter = flockCenter;
this.flockHeading = flockHeading;
this.separationHeading = separationHeading;
}
}

View File

@ -2,13 +2,14 @@ import GameLoop from "./Loop/GameLoop";
import {} from "./index";
import MainScene from "./MainScene"
import QuadTreeScene from "./QuadTreeScene";
import BoidDemo from "./BoidDemo";
function main(){
// Create the game object
let game = new GameLoop({viewportSize: {x: 800, y: 600}});
game.start();
let sm = game.getSceneManager();
sm.addScene(MainScene);
sm.addScene(BoidDemo);
}
CanvasRenderingContext2D.prototype.roundedRect = function(x: number, y: number, w: number, h: number, r: number): void {