diff --git a/src/DataTypes/Collection.ts b/src/DataTypes/Collection.ts new file mode 100644 index 0000000..73b87ef --- /dev/null +++ b/src/DataTypes/Collection.ts @@ -0,0 +1,3 @@ +export default interface Collection{ + forEach(func: Function): void; +} \ No newline at end of file diff --git a/src/DataTypes/Map.ts b/src/DataTypes/Map.ts new file mode 100644 index 0000000..e3cd876 --- /dev/null +++ b/src/DataTypes/Map.ts @@ -0,0 +1,33 @@ +import Collection from "./Collection"; + +export default class Map implements Collection{ + private map: Record; + + constructor(){ + this.map = {}; + } + + add(key: string, value: T): void { + this.map[key] = value; + } + + get(key: string): T { + return this.map[key]; + } + + set(key: string, value: T): void { + this.add(key, value); + } + + has(key: string): boolean { + return this.map[key] !== undefined; + } + + keys(): Array { + return Object.keys(this.map); + } + + forEach(func: Function): void { + Object.keys(this.map).forEach(key => func(key)); + } +} \ No newline at end of file diff --git a/src/DataTypes/Queue.ts b/src/DataTypes/Queue.ts new file mode 100644 index 0000000..c00c008 --- /dev/null +++ b/src/DataTypes/Queue.ts @@ -0,0 +1,61 @@ +import Collection from "./Collection"; + +export default class Queue implements Collection{ + readonly MAX_ELEMENTS: number; + private q: Array; + private head: number; + private tail: number; + + constructor(maxElements: number = 100){ + this.MAX_ELEMENTS = maxElements; + this.q = new Array(this.MAX_ELEMENTS); + this.head = 0; + this.tail = 0; + } + + enqueue(item: T): void{ + if((this.tail + 1) % this.MAX_ELEMENTS === this.head){ + throw "Queue full - cannot add element" + } + + this.q[this.tail] = item; + this.tail = (this.tail + 1) % this.MAX_ELEMENTS; + } + + dequeue(): T { + if(this.head === this.tail){ + throw "Queue empty - cannot remove element" + } + + let item = this.q[this.head]; + this.head = (this.head + 1) % this.MAX_ELEMENTS; + + return item; + } + + peekNext(): T { + if(this.head === this.tail){ + throw "Queue empty - cannot get element" + } + + let item = this.q[this.head]; + + return item; + } + + hasItems(): boolean { + return this.head !== this.tail; + } + + clear(): void { + this.head = this.tail; + } + + forEach(func: Function): void { + let i = this.head; + while(i !== this.tail){ + func(this.q[i]); + i = (i + 1) % this.MAX_ELEMENTS; + } + } +} \ No newline at end of file diff --git a/src/DataTypes/Stack.ts b/src/DataTypes/Stack.ts new file mode 100644 index 0000000..c3644ee --- /dev/null +++ b/src/DataTypes/Stack.ts @@ -0,0 +1,68 @@ +import Collection from "./Collection"; + +export default class Stack implements Collection{ + readonly MAX_ELEMENTS: number; + private stack: Array; + private head: number; + + constructor(maxElements: number = 100){ + this.MAX_ELEMENTS = maxElements; + this.stack = new Array(this.MAX_ELEMENTS); + this.head = -1; + } + + /** + * Adds an item to the top of the stack + * @param {*} item The new item to add to the stack + */ + push(item: T): void { + if(this.head + 1 === this.MAX_ELEMENTS){ + throw "Stack full - cannot add element"; + } + this.head += 1; + this.stack[this.head] = item; + } + + /** + * Removes an item from the top of the stack + */ + pop(): T{ + if(this.head === -1){ + throw "Stack empty - cannot remove element"; + } + this.head -= 1; + return this.stack[this.head + 1]; + } + + /** + * Removes all elements from the stack + */ + clear(): void{ + this.head = -1; + } + + /** + * Returns the element currently at the top of the stack + */ + peek(): T { + if(this.head === -1){ + throw "Stack empty - cannot get element"; + } + return this.stack[this.head]; + } + + /** + * Returns the number of items currently in the stack + */ + size(): number { + return this.head + 1; + } + + forEach(func: Function): void{ + let i = 0; + while(i <= this.head){ + func(this.stack[i]); + i += 1; + } + } +} \ No newline at end of file diff --git a/src/DataTypes/Vec2.ts b/src/DataTypes/Vec2.ts new file mode 100644 index 0000000..9f02f21 --- /dev/null +++ b/src/DataTypes/Vec2.ts @@ -0,0 +1,79 @@ +export default class Vec2{ + + public x : number; + public y : number; + + constructor(x : number = 0, y : number = 0){ + this.x = x; + this.y = y; + } + + magSq() : number{ + return this.x*this.x + this.y*this.y; + } + + mag() : number { + return Math.sqrt(this.magSq()); + } + + normalize() : Vec2{ + if(this.x === 0 && this.y === 0) return this; + let mag = this.mag(); + this.x /= mag; + this.y /= mag; + return this; + } + + setToAngle(angle : number) : Vec2{ + this.x = Math.cos(angle); + this.y = Math.sin(angle); + return this; + } + + scaleTo(magnitude : number) : Vec2{ + return this.normalize().scale(magnitude); + } + + scale(factor : number, yFactor : number = null) : Vec2{ + if(yFactor !== null){ + this.x *= factor; + this.y *= yFactor; + return this; + } + this.x *= factor; + this.y *= factor; + return this; + } + + rotate(angle : number) : Vec2{ + let cs = Math.cos(angle); + let sn = Math.sin(angle); + let tempX = this.x*cs - this.y*sn; + let tempY = this.x*sn + this.y*cs; + this.x = tempX; + this.y = tempY; + return this; + } + + set(x : number, y : number) : Vec2{ + this.x = x; + this.y = y; + return this; + } + + add(other : Vec2) : Vec2{ + this.x += other.x; + this.y += other.y; + return this; + } + + sub(other : Vec2) : Vec2{ + this.x -= other.x; + this.y -= other.y; + return this; + } + + toString() : string{ + return "(" + this.x + ", " + this.y + ")"; + } +} \ No newline at end of file diff --git a/src/DataTypes/Vec4.ts b/src/DataTypes/Vec4.ts new file mode 100644 index 0000000..9245633 --- /dev/null +++ b/src/DataTypes/Vec4.ts @@ -0,0 +1,20 @@ +import Vec2 from "./Vec2"; + +export default class Vec4{ + + public x : number; + public y : number; + public z : number; + public w : number; + + constructor(x : number = 0, y : number = 0, z : number = 0, w : number = 0){ + this.x = x; + this.y = y; + this.z = z; + this.w = w; + } + + split() : [Vec2, Vec2]{ + return [new Vec2(this.x, this.y), new Vec2(this.z, this.w)]; + } +} \ No newline at end of file diff --git a/src/Events/EventQueue.ts b/src/Events/EventQueue.ts new file mode 100644 index 0000000..1fd967d --- /dev/null +++ b/src/Events/EventQueue.ts @@ -0,0 +1,66 @@ +import Queue from "../DataTypes/Queue"; +import Map from "../DataTypes/Map"; +import GameEvent from "./GameEvent"; +import Receiver from "./Receiver"; + +export default class EventQueue { + private static instance: EventQueue = null; + private readonly MAX_SIZE: number; + private q: Queue; + private receivers: Map> + + private constructor(){ + this.MAX_SIZE = 100; + this.q = new Queue(this.MAX_SIZE); + this.receivers = new Map>(); + } + + static getInstance(): EventQueue { + if(this.instance === null){ + this.instance = new EventQueue(); + } + + return this.instance; + } + + addEvent(event: GameEvent): void { + this.q.enqueue(event); + } + + subscribe(receiver: Receiver, type: string | Array): void { + if(type instanceof Array){ + // If it is an array, subscribe to all event types + for(let t of type){ + this.addListener(receiver, t); + } + } else { + this.addListener(receiver, type); + } + } + + private addListener(receiver: Receiver, type: string): void { + if(this.receivers.has(type)){ + this.receivers.get(type).push(receiver); + } else { + this.receivers.add(type, [receiver]); + } + } + + update(deltaT: number): void{ + while(this.q.hasItems()){ + let event = this.q.dequeue(); + + if(this.receivers.has(event.type)){ + for(let receiver of this.receivers.get(event.type)){ + receiver.receive(event); + } + } + + if(this.receivers.has("all")){ + for(let receiver of this.receivers.get("all")){ + receiver.receive(event); + } + } + } + } +} \ No newline at end of file diff --git a/src/Events/GameEvent.ts b/src/Events/GameEvent.ts new file mode 100644 index 0000000..2ee3d64 --- /dev/null +++ b/src/Events/GameEvent.ts @@ -0,0 +1,28 @@ +import Map from "../DataTypes/Map" + +export default class GameEvent{ + public type: string; + public data: Map; + public time: number; + + constructor(type: string, data: Map | Record = null){ + if (data === null) { + this.data = new Map(); + } else if (!(data instanceof Map)){ + // data is a raw object, unpack + this.data = new Map(); + for(let key in data){ + this.data.add(key, data[key]); + } + } else { + this.data = data; + } + + this.type = type; + this.time = Date.now(); + } + + toString(): string { + return this.type + ": @" + this.time; + } +} \ No newline at end of file diff --git a/src/Events/Receiver.ts b/src/Events/Receiver.ts new file mode 100644 index 0000000..5e71061 --- /dev/null +++ b/src/Events/Receiver.ts @@ -0,0 +1,32 @@ +import Queue from "../DataTypes/Queue"; +import GameEvent from "./GameEvent"; + +export default class Receiver{ + readonly MAX_SIZE: number; + private q: Queue; + + constructor(){ + this.MAX_SIZE = 100; + this.q = new Queue(this.MAX_SIZE); + } + + receive(event: GameEvent): void { + this.q.enqueue(event); + } + + getNextEvent(): GameEvent { + return this.q.dequeue(); + } + + peekNextEvent(): GameEvent { + return this.q.peekNext() + } + + hasNextEvent(): boolean { + return this.q.hasItems(); + } + + ignoreEvents(): void { + this.q.clear(); + } +} \ No newline at end of file diff --git a/src/GameState/GameState.ts b/src/GameState/GameState.ts new file mode 100644 index 0000000..01ca81f --- /dev/null +++ b/src/GameState/GameState.ts @@ -0,0 +1,33 @@ +import Stack from "../DataTypes/Stack"; +import Scene from "./Scene"; + +export default class GameState{ + private sceneStack: Stack; + + constructor(){ + this.sceneStack = new Stack(10); + } + + addScene(scene: Scene, pauseScenesBelow: boolean = true): void { + this.sceneStack.forEach((scene: Scene) => scene.setPaused(pauseScenesBelow)); + this.sceneStack.push(scene); + } + + removeScene(startNewTopScene: boolean = true): void { + this.sceneStack.pop(); + this.sceneStack.peek().setPaused(!startNewTopScene); + } + + changeScene(scene: Scene): void { + this.sceneStack.clear(); + this.sceneStack.push(scene); + } + + update(deltaT: number): void { + this.sceneStack.forEach((scene: Scene) => scene.update(deltaT)); + } + + render(ctx: CanvasRenderingContext2D): void { + this.sceneStack.forEach((scene: Scene) => scene.render(ctx)); + } +} \ No newline at end of file diff --git a/src/GameState/Scene.ts b/src/GameState/Scene.ts new file mode 100644 index 0000000..417b362 --- /dev/null +++ b/src/GameState/Scene.ts @@ -0,0 +1,56 @@ +import Vec2 from "../DataTypes/Vec2"; +import Viewport from "../SceneGraph/Viewport"; +import SceneGraph from "../SceneGraph/SceneGraph"; +import SceneGraphArray from "../SceneGraph/SceneGraphArray"; +import GameNode from "../Nodes/GameNode"; + +export default class Scene{ + private viewport: Viewport + private worldSize: Vec2; + private sceneGraph: SceneGraph; + private paused: boolean; + + constructor(){ + this.viewport = new Viewport(); + this.viewport.setSize(800, 500); + // TODO: Find a way to make this not a hard-coded value + this.worldSize = new Vec2(1600, 1000); + this.viewport.setBounds(0, 0, 1600, 1000); + this.sceneGraph = new SceneGraphArray(this.viewport); + this.paused = false; + } + + setPaused(pauseValue: boolean): void { + this.paused = pauseValue; + } + + isPaused(): boolean { + return this.paused; + } + + getViewport(): Viewport { + return this.viewport; + } + + add(children: Array | GameNode): void { + if(children instanceof Array){ + for(let child of children){ + this.sceneGraph.addNode(child); + } + } else { + this.sceneGraph.addNode(children); + } + } + + update(deltaT: number): void { + if(!this.paused){ + this.viewport.update(deltaT); + this.sceneGraph.update(deltaT); + } + } + + render(ctx: CanvasRenderingContext2D): void { + let visibleSet = this.sceneGraph.getVisibleSet(); + visibleSet.forEach(node => node.render(ctx, this.viewport.getPosition(), this.viewport.getSize())); + } +} \ No newline at end of file diff --git a/src/Input/InputHandler.ts b/src/Input/InputHandler.ts new file mode 100644 index 0000000..902d982 --- /dev/null +++ b/src/Input/InputHandler.ts @@ -0,0 +1,51 @@ +import EventQueue from "../Events/EventQueue"; +import Vec2 from "../DataTypes/Vec2"; +import GameEvent from "../Events/GameEvent"; + +export default class InputHandler{ + private eventQueue: EventQueue; + + constructor(canvas: HTMLCanvasElement){ + this.eventQueue = EventQueue.getInstance(); + + canvas.onmousedown = (event) => this.handleMouseDown(event, canvas); + canvas.onmouseup = (event) => this.handleMouseUp(event, canvas); + document.onkeydown = this.handleKeyDown; + document.onkeyup = this.handleKeyUp; + } + + private handleMouseDown = (event: MouseEvent, canvas: HTMLCanvasElement): void => { + let pos = this.getMousePosition(event, canvas); + let gameEvent = new GameEvent("mouse_down", {position: pos}); + this.eventQueue.addEvent(gameEvent); + } + + private handleMouseUp = (event: MouseEvent, canvas: HTMLCanvasElement): void => { + let pos = this.getMousePosition(event, canvas); + let gameEvent = new GameEvent("mouse_up", {position: pos}); + this.eventQueue.addEvent(gameEvent); + } + + private handleKeyDown = (event: KeyboardEvent): void => { + let key = this.getKey(event); + let gameEvent = new GameEvent("key_down", {key: key}); + this.eventQueue.addEvent(gameEvent); + } + + private handleKeyUp = (event: KeyboardEvent): void => { + let key = this.getKey(event); + let gameEvent = new GameEvent("key_up", {key: key}); + this.eventQueue.addEvent(gameEvent); + } + + private getKey(keyEvent: KeyboardEvent){ + return keyEvent.key.toLowerCase(); + } + + private getMousePosition(mouseEvent: MouseEvent, canvas: HTMLCanvasElement): Vec2 { + let rect = canvas.getBoundingClientRect(); + let x = mouseEvent.clientX - rect.left; + let y = mouseEvent.clientY - rect.top; + return new Vec2(x, y); + } +} \ No newline at end of file diff --git a/src/Input/InputReceiver.ts b/src/Input/InputReceiver.ts new file mode 100644 index 0000000..2a101b3 --- /dev/null +++ b/src/Input/InputReceiver.ts @@ -0,0 +1,93 @@ +import Receiver from "../Events/Receiver"; +import Map from "../DataTypes/Map"; +import Vec2 from "../DataTypes/Vec2"; +import EventQueue from "../Events/EventQueue"; + +export default class InputReceiver{ + private static instance: InputReceiver = null; + + private mousePressed: boolean; + private mouseJustPressed: boolean; + private keyJustPressed: Map; + private keyPressed: Map; + private mousePressPosition: Vec2; + private eventQueue: EventQueue; + private receiver: Receiver; + + private constructor(){ + this.mousePressed = false; + this.mouseJustPressed = false; + this.receiver = new Receiver(); + this.keyJustPressed = new Map(); + this.keyPressed = new Map(); + this.mousePressPosition = null; + + this.eventQueue = EventQueue.getInstance(); + this.eventQueue.subscribe(this.receiver, ["mouse_down", "mouse_up", "key_down", "key_up"]); + } + + static getInstance(): InputReceiver{ + if(this.instance === null){ + this.instance = new InputReceiver(); + } + return this.instance; + } + + update(deltaT: number): void { + // Reset the justPressed values to false + this.mouseJustPressed = false; + this.keyJustPressed.forEach((key: string) => this.keyJustPressed.set(key, false)); + + while(this.receiver.hasNextEvent()){ + let event = this.receiver.getNextEvent(); + if(event.type === "mouse_down"){ + this.mouseJustPressed = true; + this.mousePressed = true; + this.mousePressPosition = event.data.get("position"); + } + + if(event.type === "mouse_up"){ + this.mousePressed = false; + } + + if(event.type === "key_down"){ + let key = event.data.get("key") + this.keyJustPressed.set(key, true); + this.keyPressed.set(key, true); + } + + if(event.type === "key_up"){ + let key = event.data.get("key") + this.keyPressed.set(key, false); + } + } + } + + isJustPressed(key: string): boolean { + if(this.keyJustPressed.has(key)){ + return this.keyJustPressed.get(key) + } else { + return false; + } + } + + isPressed(key: string): boolean { + if(this.keyPressed.has(key)){ + return this.keyPressed.get(key) + } else { + return false; + } + } + + isMouseJustPressed(): boolean { + return this.mouseJustPressed; + } + + isMousePressed(): boolean { + return this.mousePressed; + } + + getMousePressPosition(): Vec2 { + return this.mousePressPosition; + } +} \ No newline at end of file diff --git a/src/Loop/GameLoop.ts b/src/Loop/GameLoop.ts new file mode 100644 index 0000000..086c068 --- /dev/null +++ b/src/Loop/GameLoop.ts @@ -0,0 +1,153 @@ +import EventQueue from "../Events/EventQueue"; +import InputReceiver from "../Input/InputReceiver"; +import InputHandler from "../Input/InputHandler"; +import Recorder from "../Playback/Recorder"; +import GameState from "../GameState/GameState"; + +export default class GameLoop{ + // The amount of time to spend on a physics step + private maxFPS: number; + private simulationTimestep: number; + + // The time when the last frame was drawn + private lastFrameTime: number; + + // The current frame of the game + private frame: number; + + // Keeping track of the fps + private runningFrameSum: number; + private numFramesInSum: number; + private maxFramesInSum: number; + private fps: number; + + private started: boolean; + private running: boolean; + private frameDelta: number; + + readonly GAME_CANVAS: HTMLCanvasElement; + readonly WIDTH: number; + readonly HEIGHT: number; + private ctx: CanvasRenderingContext2D; + private eventQueue: EventQueue; + private inputHandler: InputHandler; + private inputReceiver: InputReceiver; + private recorder: Recorder; + private gameState: GameState; + + constructor(){ + this.maxFPS = 60; + this.simulationTimestep = Math.floor(1000/this.maxFPS); + this.frame = 0; + this.runningFrameSum = 0; + this.numFramesInSum = 0; + this.maxFramesInSum = 30; + this.fps = this.maxFPS; + + this.started = false; + this.running = false; + + this.GAME_CANVAS = document.getElementById("game-canvas") as HTMLCanvasElement; + this.GAME_CANVAS.style.setProperty("background-color", "whitesmoke"); + + this.WIDTH = 800; + this.HEIGHT = 500; + this.ctx = this.initializeCanvas(this.GAME_CANVAS, this.WIDTH, this.HEIGHT); + + this.eventQueue = EventQueue.getInstance(); + this.inputHandler = new InputHandler(this.GAME_CANVAS); + this.inputReceiver = InputReceiver.getInstance(); + this.recorder = new Recorder(); + this.gameState = new GameState(); + } + + private initializeCanvas(canvas: HTMLCanvasElement, width: number, height: number): CanvasRenderingContext2D { + canvas.width = width; + canvas.height = height; + return canvas.getContext("2d"); + } + + setMaxFPS(initMax: number): void { + this.maxFPS = initMax; + this.simulationTimestep = Math.floor(1000/this.maxFPS); + } + + getGameState(): GameState { + return this.gameState; + } + + private renderFPS(ctx: CanvasRenderingContext2D): void { + ctx.fillStyle = "black"; + ctx.font = "30px Arial" + ctx.fillText(this.fps.toFixed(1), 5, 5 + 30); + } + + private updateFrameCount(timestep: number): void { + this.frame += 1; + this.numFramesInSum += 1; + this.runningFrameSum += timestep; + if(this.numFramesInSum >= this.maxFramesInSum){ + this.fps = 1000 * this.numFramesInSum / this.runningFrameSum; + this.numFramesInSum = 0; + this.runningFrameSum = 0; + } + } + + start(): void { + if(!this.started){ + this.started = true; + + window.requestAnimationFrame(this.startFrame); + } + } + + startFrame = (timestamp: number): void => { + this.running = true; + + this.render(); + + this.lastFrameTime = timestamp; + + window.requestAnimationFrame(this.doFrame); + } + + doFrame = (timestamp: number): void => { + // Request animation frame to prepare for another update or render + window.requestAnimationFrame(this.doFrame); + + // If we are trying to update too soon, return and do nothing + if(timestamp < this.lastFrameTime + this.simulationTimestep){ + return + } + + // Currently, update and draw are synced - eventually it would probably be good to desync these + this.frameDelta = timestamp - this.lastFrameTime; + this.lastFrameTime = timestamp; + + // Update while we can (This will present problems if we leave the window) + let i = 0; + while(this.frameDelta >= this.simulationTimestep){ + this.update(this.simulationTimestep/1000); + this.frameDelta -= this.simulationTimestep; + + // Update the frame of the game + this.updateFrameCount(this.simulationTimestep); + } + + // Updates are done, draw + this.render(); + } + + update(deltaT: number): void { + this.eventQueue.update(deltaT); + this.inputReceiver.update(deltaT); + this.recorder.update(deltaT); + this.gameState.update(deltaT); + } + + render(): void { + this.ctx.clearRect(0, 0, this.WIDTH, this.HEIGHT); + this.gameState.render(this.ctx); + this.renderFPS(this.ctx); + } +} \ No newline at end of file diff --git a/src/Nodes/ColoredCircle.ts b/src/Nodes/ColoredCircle.ts new file mode 100644 index 0000000..9561424 --- /dev/null +++ b/src/Nodes/ColoredCircle.ts @@ -0,0 +1,26 @@ +import GameNode from "./GameNode"; +import Color from "../Utils/Color"; +import Vec2 from "../DataTypes/Vec2"; +import RandUtils from "../Utils/RandUtils"; + +export default class ColoredCircle extends GameNode{ + private color: Color; + + constructor(){ + super(); + this.position = new Vec2(RandUtils.randInt(0, 1000), RandUtils.randInt(0, 1000)); + this.color = RandUtils.randColor(); + console.log(this.color.toStringRGB()); + this.size = new Vec2(50, 50); + } + + update(deltaT: number): void {} + + render(ctx: CanvasRenderingContext2D, viewportOrigin: Vec2, viewportSize: Vec2){ + ctx.fillStyle = this.color.toStringRGB(); + ctx.beginPath(); + ctx.arc(this.position.x + this.size.x/2 - viewportOrigin.x, this.position.y + this.size.y/2 - viewportOrigin.y, this.size.x/2, 0, Math.PI*2, false); + ctx.fill(); + ctx.closePath(); + } +} \ No newline at end of file diff --git a/src/Nodes/GameNode.ts b/src/Nodes/GameNode.ts new file mode 100644 index 0000000..f20856d --- /dev/null +++ b/src/Nodes/GameNode.ts @@ -0,0 +1,38 @@ +import EventQueue from "../Events/EventQueue"; +import InputReceiver from "../Input/InputReceiver"; +import Vec2 from "../DataTypes/Vec2"; + +export default abstract class GameNode{ + protected eventQueue: EventQueue; + protected input: InputReceiver; + protected position: Vec2; + protected size: Vec2; + + constructor(){ + this.eventQueue = EventQueue.getInstance(); + this.input = InputReceiver.getInstance(); + this.position = new Vec2(0, 0); + this.size = new Vec2(0, 0); + } + + getPosition(): Vec2 { + return this.position; + } + + getSize(): Vec2 { + return this.size; + } + + contains(x: number, y: number): boolean { + if(x > this.position.x && x < this.position.x + this.size.x){ + if(y > this.position.y && y < this.position.y + this.size.y){ + return true; + } + } + return false; + } + + abstract update(deltaT: number): void; + + abstract render(ctx: CanvasRenderingContext2D, viewportOrigin: Vec2, viewportSize: Vec2): void; +} \ No newline at end of file diff --git a/src/Nodes/Player.ts b/src/Nodes/Player.ts new file mode 100644 index 0000000..7c114ff --- /dev/null +++ b/src/Nodes/Player.ts @@ -0,0 +1,32 @@ +import GameNode from "./GameNode"; +import Vec2 from "../DataTypes/Vec2"; + +export default class Player extends GameNode{ + velocity: Vec2; + speed: number; + + constructor(){ + super(); + this.velocity = new Vec2(0, 0); + this.speed = 300; + this.size = new Vec2(50, 50); + }; + + update(deltaT: number): void { + let dir = new Vec2(0, 0); + dir.x += this.input.isPressed('a') ? -1 : 0; + dir.x += this.input.isPressed('d') ? 1 : 0; + dir.y += this.input.isPressed('s') ? 1 : 0; + dir.y += this.input.isPressed('w') ? -1 : 0; + + dir.normalize(); + + this.velocity = dir.scale(this.speed); + this.position = this.position.add(this.velocity.scale(deltaT)); + } + + render(ctx: CanvasRenderingContext2D, viewportOrigin: Vec2, viewportSize: Vec2){ + ctx.fillStyle = "#FF0000"; + ctx.fillRect(this.position.x - viewportOrigin.x, this.position.y - viewportOrigin.y, this.size.x, this.size.y); + } +} \ No newline at end of file diff --git a/src/Nodes/UIElement.ts b/src/Nodes/UIElement.ts new file mode 100644 index 0000000..2d75669 --- /dev/null +++ b/src/Nodes/UIElement.ts @@ -0,0 +1,89 @@ +import GameNode from "./GameNode"; +import Color from "../Utils/Color"; +import Vec2 from "../DataTypes/Vec2"; +import GameEvent from "../Events/GameEvent"; + +export default class UIElement extends GameNode{ + parent: GameNode; + children: Array; + text: string; + backgroundColor: Color; + textColor: Color; + onPress: Function; + onPressSignal: string; + onHover: Function; + + constructor(){ + super(); + + this.parent = null; + this.children = []; + this.text = ""; + this.backgroundColor = new Color(0, 0, 0, 0); + this.textColor = new Color(0, 0, 0, 1); + + this.onPress = null; + this.onPressSignal = null; + + this.onHover = null; + } + + setPosition(vecOrX: Vec2 | number, y: number = null): void { + if(vecOrX instanceof Vec2){ + this.position.set(vecOrX.x, vecOrX.y); + } else { + this.position.set(vecOrX, y); + } + } + + setSize(vecOrX: Vec2 | number, y: number = null): void { + if(vecOrX instanceof Vec2){ + this.size.set(vecOrX.x, vecOrX.y); + } else { + this.size.set(vecOrX, y); + } + } + + setText(text: string): void { + this.text = text; + } + + setBackgroundColor(color: Color): void { + this.backgroundColor = color; + } + + setTextColor(color: Color): void { + this.textColor = color; + } + + update(deltaT: number): void { + if(this.input.isMouseJustPressed()){ + let mousePos = this.input.getMousePressPosition(); + if(mousePos.x >= this.position.x && mousePos.x <= this.position.x + this.size.x){ + // Inside x bounds + if(mousePos.y >= this.position.y && mousePos.y <= this.position.y + this.size.y){ + // Inside element + if(this.onHover !== null){ + this.onHover(); + } + + if(this.onPress !== null){ + this.onPress(); + } + if(this.onPressSignal !== null){ + let event = new GameEvent(this.onPressSignal, {}); + this.eventQueue.addEvent(event); + } + } + } + } + } + + render(ctx: CanvasRenderingContext2D, viewportOrigin: Vec2, viewportSize: Vec2){ + ctx.fillStyle = this.backgroundColor.toStringRGBA(); + ctx.fillRect(this.position.x - viewportOrigin.x, this.position.y - viewportOrigin.y, this.size.x, this.size.y); + ctx.fillStyle = this.textColor.toStringRGBA(); + ctx.font = "30px Arial" + ctx.fillText(this.text, this.position.x - viewportOrigin.x, this.position.y - viewportOrigin.y + 30); + } +} \ No newline at end of file diff --git a/src/Playback/Recorder.ts b/src/Playback/Recorder.ts new file mode 100644 index 0000000..71683b4 --- /dev/null +++ b/src/Playback/Recorder.ts @@ -0,0 +1,90 @@ +import Queue from "../DataTypes/Queue"; +import Receiver from "../Events/Receiver"; +import GameEvent from "../Events/GameEvent"; +import EventQueue from "../Events/EventQueue"; + +export default class Recorder{ + private receiver: Receiver; + private log: Queue; + private recording: boolean; + private eventQueue: EventQueue; + private frame: number; + private playing: boolean; + + constructor(){ + this.receiver = new Receiver(); + this.log = new Queue(1000); + this.recording = false; + this.playing = false; + this.frame = 0; + + this.eventQueue = EventQueue.getInstance(); + this.eventQueue.subscribe(this.receiver, "all"); + } + + update(deltaT: number): void { + if(this.recording){ + this.frame += 1; + } + + if(this.playing){ + // If playing, ignore events, just feed the record to the event queue + this.receiver.ignoreEvents(); + + /* + While there is a next item, and while it should occur in this frame, + send the event. i.e., while current_frame * current_delta_t is greater + than recorded_frame * recorded_delta_t + */ + while(this.log.hasItems() + && this.log.peekNext().frame * this.log.peekNext().delta < this.frame * deltaT){ + let event = this.log.dequeue().event; + console.log(event); + this.eventQueue.addEvent(event); + } + + if(!this.log.hasItems()){ + this.playing = false; + } + + this.frame += 1; + } else { + // If not playing, handle events + while(this.receiver.hasNextEvent()){ + let event = this.receiver.getNextEvent(); + + if(event.type === "stop_button_press"){ + this.recording = false; + } + + if(this.recording){ + this.log.enqueue(new LogItem(this.frame, deltaT, event)); + } + + if(event.type === "record_button_press"){ + this.log.clear(); + this.recording = true; + this.frame = 0 + } + + if(event.type === "play_button_press"){ + this.frame = 0; + this.recording = false; + this.playing = true; + } + } + } + } +} + +class LogItem { + frame: number; + delta: number; + event: GameEvent; + + constructor(frame: number, deltaT: number, event: GameEvent){ + this.frame = frame; + this.delta = deltaT; + this.event = event; + } +} \ No newline at end of file diff --git a/src/SceneGraph/SceneGraph.ts b/src/SceneGraph/SceneGraph.ts new file mode 100644 index 0000000..187780a --- /dev/null +++ b/src/SceneGraph/SceneGraph.ts @@ -0,0 +1,55 @@ +import Viewport from "./Viewport"; +import GameNode from "../Nodes/GameNode"; +import Map from "../DataTypes/Map"; +import Vec2 from "../DataTypes/Vec2"; + +export default abstract class SceneGraph{ + protected viewport: Viewport; + protected nodeMap: Map; + protected idCounter: number; + + constructor(viewport: Viewport){ + this.viewport = viewport; + this.nodeMap = new Map(); + this.idCounter = 0; + } + + addNode(node: GameNode): number { + this.nodeMap.add(this.idCounter.toString(), node); + this.addNodeSpecific(node, this.idCounter.toString()); + this.idCounter += 1; + return this.idCounter - 1; + }; + + protected abstract addNodeSpecific(node: GameNode, id: string): void; + + removeNode(node: GameNode): void { + // Find and remove node in O(n) + // TODO: Can this be better? + let id = this.nodeMap.keys().filter((key: string) => this.nodeMap.get(key) === node)[0]; + if(id !== undefined){ + this.nodeMap.set(id, undefined); + this.removeNodeSpecific(node, id); + } + }; + + protected abstract removeNodeSpecific(node: GameNode, id: string): void; + + getNode(id: string): GameNode{ + return this.nodeMap.get(id); + }; + + getNodeAt(vecOrX: Vec2 | number, y: number = null): GameNode{ + if(vecOrX instanceof Vec2){ + return this.getNodeAtCoords(vecOrX.x, vecOrX.y); + } else { + return this.getNodeAtCoords(vecOrX, y); + } + } + + protected abstract getNodeAtCoords(x: number, y: number): GameNode; + + abstract update(deltaT: number): void; + + abstract getVisibleSet(): Array; +} \ No newline at end of file diff --git a/src/SceneGraph/SceneGraphArray.ts b/src/SceneGraph/SceneGraphArray.ts new file mode 100644 index 0000000..04772e0 --- /dev/null +++ b/src/SceneGraph/SceneGraphArray.ts @@ -0,0 +1,67 @@ +import SceneGraph from "./SceneGraph"; +import GameNode from "../Nodes/GameNode"; +import Viewport from "./Viewport"; + +export default class SceneGraphArray extends SceneGraph{ + private nodeList: Array; + private turnOffViewportCulling_demoTool: boolean; + + constructor(viewport: Viewport){ + super(viewport); + + this.nodeList = new Array(); + this.turnOffViewportCulling_demoTool = false; + } + + setViewportCulling_demoTool(bool: boolean): void { + this.turnOffViewportCulling_demoTool = bool; + } + + addNodeSpecific(node: GameNode, id: string): void { + this.nodeList.push(node); + } + + removeNodeSpecific(node: GameNode, id: string): void { + let index = this.nodeList.indexOf(node); + if(index > -1){ + this.nodeList.splice(index, 1); + } + } + + getNodeAtCoords(x: number, y: number): GameNode { + // TODO: This only returns the first node found. There is no notion of z coordinates + for(let node of this.nodeList){ + if(node.contains(x, y)){ + return node; + } + } + return null; + } + + update(deltaT: number): void { + for(let node of this.nodeList){ + node.update(deltaT); + } + } + + getVisibleSet(): Array { + // If viewport culling is turned off for demonstration + if(this.turnOffViewportCulling_demoTool){ + let visibleSet = new Array(); + for(let node of this.nodeList){ + visibleSet.push(node); + } + return visibleSet; + } + + let visibleSet = new Array(); + + for(let node of this.nodeList){ + if(this.viewport.includes(node)){ + visibleSet.push(node); + } + } + + return visibleSet; + } +} \ No newline at end of file diff --git a/src/SceneGraph/Viewport.ts b/src/SceneGraph/Viewport.ts new file mode 100644 index 0000000..9b54a48 --- /dev/null +++ b/src/SceneGraph/Viewport.ts @@ -0,0 +1,73 @@ +import Vec2 from "../DataTypes/Vec2"; +import Vec4 from "../DataTypes/Vec4"; +import GameNode from "../Nodes/GameNode"; +import MathUtils from "../Utils/MathUtils"; + +export default class Viewport{ + private position: Vec2; + private size: Vec2; + private bounds: Vec4; + private following: GameNode; + + constructor(){ + this.position = new Vec2(0, 0); + this.size = new Vec2(0, 0); + this.bounds = new Vec4(0, 0, 0, 0); + } + + getPosition(): Vec2 { + return this.position; + } + + setPosition(vecOrX: Vec2 | number, y: number = null): void { + if(vecOrX instanceof Vec2){ + this.position.set(vecOrX.x, vecOrX.y); + } else { + this.position.set(vecOrX, y); + } + } + + getSize(): Vec2{ + return this.size; + } + + setSize(vecOrX: Vec2 | number, y: number = null): void { + if(vecOrX instanceof Vec2){ + this.size.set(vecOrX.x, vecOrX.y); + } else { + this.size.set(vecOrX, y); + } + } + + includes(node: GameNode): boolean { + let nodePos = node.getPosition(); + let nodeSize = node.getSize(); + if(nodePos.x + nodeSize.x > this.position.x && nodePos.x < this.position.x + this.size.x){ + if(nodePos.y + nodeSize.y > this.position.y && nodePos.y < this.position.y + this.size.y){ + return true; + } + } + + return false; + } + + // TODO: Put some error handling on this for trying to make the bounds too small for the viewport + // TODO: This should probably be done automatically, or should consider the aspect ratio or something + setBounds(lowerX: number, lowerY: number, upperX: number, upperY: number): void { + this.bounds = new Vec4(lowerX, lowerY, upperX, upperY); + } + + follow(node: GameNode): void { + this.following = node; + } + + update(deltaT: number): void { + if(this.following){ + this.position.x = this.following.getPosition().x - this.size.x/2; + this.position.y = this.following.getPosition().y - this.size.y/2; + let [min, max] = this.bounds.split(); + this.position.x = MathUtils.clamp(this.position.x, min.x, max.x - this.size.x/2); + this.position.y = MathUtils.clamp(this.position.y, min.y, max.y - this.size.y/2); + } + } +} \ No newline at end of file diff --git a/src/Utils/Color.ts b/src/Utils/Color.ts new file mode 100644 index 0000000..ec054d9 --- /dev/null +++ b/src/Utils/Color.ts @@ -0,0 +1,30 @@ +import MathUtils from "./MathUtils"; + +export default class Color{ + public r: number; + public g: number; + public b: number; + public a: number; + + constructor(r: number = 0, g: number = 0, b: number = 0, a: number = null){ + this.r = r; + this.g = g; + this.b = b; + this.a = a; + } + + toString(): string{ + return "#" + MathUtils.toHex(this.r, 2) + MathUtils.toHex(this.g, 2) + MathUtils.toHex(this.b, 2); + } + + toStringRGB(){ + return "rgb(" + this.r.toString() + ", " + this.g.toString() + ", " + this.b.toString() + ")"; + } + + toStringRGBA(){ + if(this.a === null){ + throw "No alpha value assigned to color"; + } + return "rgb(" + this.r.toString() + ", " + this.g.toString() + ", " + this.b.toString() + ", " + this.a.toString() +")" + } +} \ No newline at end of file diff --git a/src/Utils/MathUtils.ts b/src/Utils/MathUtils.ts new file mode 100644 index 0000000..55d6d95 --- /dev/null +++ b/src/Utils/MathUtils.ts @@ -0,0 +1,37 @@ +export default class MathUtils{ + static clamp(x: number, min: number, max: number): number{ + if(x < min) return min; + if(x > max) return max; + return x; + } + + static toHex(num: number, minLength: number = null): string{ + let factor = 1; + while(factor*16 < num){ + factor *= 16; + } + let hexStr = ""; + while(num > 0){ + let digit = Math.floor(num/factor); + hexStr += MathUtils.toHexDigit(digit); + num -= digit * factor; + factor /= 16; + } + + if(minLength !== null){ + while(hexStr.length < minLength){ + hexStr = "0" + hexStr; + } + } + + return hexStr; + } + + static toHexDigit(num: number): string{ + if(num < 10){ + return "" + num; + } else { + return String.fromCharCode(65 + num - 10); + } + } +} \ No newline at end of file diff --git a/src/Utils/RandUtils.ts b/src/Utils/RandUtils.ts new file mode 100644 index 0000000..93901e5 --- /dev/null +++ b/src/Utils/RandUtils.ts @@ -0,0 +1,19 @@ +import MathUtils from "./MathUtils"; +import Color from "./Color"; + +export default class RandUtils{ + static randInt(min: number, max: number): number{ + return Math.floor(Math.random()*(max - min) + min); + } + + static randHex(min: number, max: number): string{ + return MathUtils.toHex(RandUtils.randInt(min, max)); + } + + static randColor(): Color{ + let r = RandUtils.randInt(0, 256); + let g = RandUtils.randInt(0, 256); + let b = RandUtils.randInt(0, 256); + return new Color(r, g, b); + } +} \ No newline at end of file diff --git a/src/greet.ts b/src/greet.ts deleted file mode 100644 index 025ca69..0000000 --- a/src/greet.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function sayHello(name: string){ - return `Hello from ${name}`; -} \ No newline at end of file diff --git a/src/index.html b/src/index.html index 9a7ef71..1891d11 100644 --- a/src/index.html +++ b/src/index.html @@ -1,11 +1,11 @@ - - - Hello World! - - -

Loading ...

- - + + + Hello World! + + + + + \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index fc711a2..cb1e0dd 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,89 @@ -import { sayHello } from './greet'; +import GameLoop from "./Loop/GameLoop"; +import Scene from "./GameState/Scene"; +import Player from "./Nodes/Player"; +import UIElement from "./Nodes/UIElement"; +import ColoredCircle from "./Nodes/ColoredCircle"; +import Color from "./Utils/Color"; -function showHello(divName: string, name: string) { - const elt = document.getElementById(divName); - elt.innerText = sayHello(name); +function main(){ + // Create the game object + let game = new GameLoop(); + + let mainScene = new Scene(); + let pauseMenu = new Scene(); + + // Initialize GameObjects + let player = new Player(); + + let recordButton = new UIElement(); + recordButton.setSize(100, 50); + recordButton.setText("Record"); + recordButton.setBackgroundColor(new Color(200, 100, 0, 0.3)); + recordButton.setPosition(400, 30); + recordButton.onPressSignal = "record_button_press"; + + let stopButton = new UIElement(); + stopButton.setSize(100, 50); + stopButton.setText("Stop"); + stopButton.setBackgroundColor(new Color(200, 0, 0, 0.3)); + stopButton.setPosition(550, 30); + stopButton.onPressSignal = "stop_button_press"; + + let playButton = new UIElement(); + playButton.setSize(100, 50); + playButton.setText("Play"); + playButton.setBackgroundColor(new Color(0, 200, 0, 0.3)); + playButton.setPosition(700, 30); + playButton.onPressSignal = "play_button_press"; + + let cycleFramerateButton = new UIElement(); + cycleFramerateButton.setSize(150, 50); + cycleFramerateButton.setText("Cycle FPS"); + cycleFramerateButton.setBackgroundColor(new Color(200, 0, 200, 0.3)); + cycleFramerateButton.setPosition(5, 400); + let i = 0; + let fps = [15, 30, 60]; + cycleFramerateButton.onPress = () => { + game.setMaxFPS(fps[i]); + i = (i + 1) % 3; + } + + let pauseButton = new UIElement(); + pauseButton.setSize(100, 50); + pauseButton.setText("Pause"); + pauseButton.setBackgroundColor(new Color(200, 0, 200, 1)); + pauseButton.setPosition(700, 400); + pauseButton.onPress = () => { + game.getGameState().addScene(pauseMenu); + } + + let modalBackground = new UIElement(); + modalBackground.setSize(400, 200); + modalBackground.setBackgroundColor(new Color(0, 0, 0, 0.4)); + modalBackground.setPosition(200, 100); + + let resumeButton = new UIElement(); + resumeButton.setSize(100, 50); + resumeButton.setText("Resume"); + resumeButton.setBackgroundColor(new Color(200, 0, 200, 1)); + resumeButton.setPosition(400, 200); + resumeButton.onPress = () => { + game.getGameState().removeScene(); + } + + let lotsOfCircs = []; + for(let i = 0; i < 10; i++){ + lotsOfCircs.push(new ColoredCircle()); + } + + + mainScene.add([...lotsOfCircs, player, recordButton, stopButton, playButton, cycleFramerateButton, pauseButton]); + mainScene.getViewport().follow(player); + pauseMenu.add([modalBackground, resumeButton]); + + game.getGameState().changeScene(mainScene); + + game.start(); } -showHello('greeting', 'TypeScript'); \ No newline at end of file +main(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 7cb5964..5a86195 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,40 @@ { "files": [ "src/main.ts", - "src/greet.ts" + + "src/DataTypes/Collection.ts", + "src/DataTypes/Map.ts", + "src/DataTypes/Queue.ts", + "src/DataTypes/Stack.ts", + "src/DataTypes/Vec2.ts", + "src/DataTypes/Vec4.ts", + + "src/Events/EventQueue.ts", + "src/Events/GameEvent.ts", + "src/Events/Receiver.ts", + + "src/GameState/GameState.ts", + "src/GameState/Scene.ts", + + "src/Input/InputHandler.ts", + "src/Input/InputReceiver.ts", + + "src/Loop/GameLoop.ts", + + "src/Nodes/ColoredCircle.ts", + "src/Nodes/GameNode.ts", + "src/Nodes/Player.ts", + "src/Nodes/UIElement.ts", + + "src/Playback/Recorder.ts", + + "src/SceneGraph/SceneGraph.ts", + "src/SceneGraph/SceneGraphArray.ts", + "src/SceneGraph/Viewport.ts", + + "src/Utils/Color.ts", + "src/Utils/MathUtils.ts", + "src/Utils/RandUtils.ts" ], "compilerOptions": { "noImplicitAny": true,