Add basic gameobject system

This commit is contained in:
schmelczerandras 2020-07-19 13:50:03 +02:00
parent d5be727040
commit efb01476b2
33 changed files with 399 additions and 171 deletions

View file

@ -6,6 +6,7 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "webpack-dev-server --mode development", "start": "webpack-dev-server --mode development",
"start-unsafe": "webpack-dev-server --mode development --useLocalIp",
"build": "webpack && find dist -type f -not -name '*.html' | xargs rm" "build": "webpack && find dist -type f -not -name '*.html' | xargs rm"
}, },
"keywords": [], "keywords": [],
@ -25,6 +26,7 @@
"usedExports": true "usedExports": true
}, },
"devDependencies": { "devDependencies": {
"@types/uuid": "^8.0.0",
"autoprefixer": "^9.8.5", "autoprefixer": "^9.8.5",
"clean-webpack-plugin": "^3.0.0", "clean-webpack-plugin": "^3.0.0",
"css-loader": "^3.5.2", "css-loader": "^3.5.2",

View file

@ -17,7 +17,10 @@
<body> <body>
<main> <main>
<noscript><h1>Javascript is required for this website.</h1></noscript> <noscript><h1>Javascript is required for this website.</h1></noscript>
<div class="canvas-container">
<div id="overlay"></div>
<canvas id="main"></canvas> <canvas id="main"></canvas>
</div>
</main> </main>
</body> </body>
</html> </html>

View file

@ -1,4 +1,4 @@
import './styles/main.scss'; import './styles/main.scss';
import { main } from './scripts/main'; import { Game } from './scripts/game';
main(); new Game();

View file

@ -1,3 +1,6 @@
import { Typed } from '../transport/serializable'; import { Typed } from '../transport/serializable';
import { Id } from '../identity/identity';
export abstract class Command extends Typed {} export abstract class Command extends Typed {
target?: Id;
}

View file

@ -0,0 +1,8 @@
import { Drawer } from '../../drawing/drawer';
import { Command } from '../command';
export class DrawCommand extends Command {
public constructor(public readonly drawer?: Drawer) {
super();
}
}

View file

@ -1,7 +1,7 @@
import { Command } from '../command'; import { Command } from '../command';
export class KeyDownCommand extends Command { export class KeyDownCommand extends Command {
public constructor(public readonly key: string) { public constructor(public readonly key?: string) {
super(); super();
} }
} }

View file

@ -1,7 +1,7 @@
import { Command } from '../command'; import { Command } from '../command';
export class KeyUpCommand extends Command { export class KeyUpCommand extends Command {
public constructor(public readonly key: string) { public constructor(public readonly key?: string) {
super(); super();
} }
} }

View file

@ -0,0 +1,9 @@
import { Drawer } from '../../drawing/drawer';
import { Command } from '../command';
import { Vec2 } from '../../math/vec2';
export class MoveToCommand extends Command {
public constructor(public readonly position?: Vec2) {
super();
}
}

View file

@ -2,7 +2,7 @@ import { Command } from '../command';
import { Vec2 } from '../../math/vec2'; import { Vec2 } from '../../math/vec2';
export class PrimaryActionCommand extends Command { export class PrimaryActionCommand extends Command {
public constructor(public readonly position: Vec2) { public constructor(public readonly position?: Vec2) {
super(); super();
} }
} }

View file

@ -2,7 +2,7 @@ import { Command } from '../command';
import { Vec2 } from '../../math/vec2'; import { Vec2 } from '../../math/vec2';
export class SecondaryActionCommand extends Command { export class SecondaryActionCommand extends Command {
public constructor(public readonly position: Vec2) { public constructor(public readonly position?: Vec2) {
super(); super();
} }
} }

View file

@ -0,0 +1,9 @@
import { Command } from '../command';
export class StepCommand extends Command {
public constructor(
public readonly deltaTimeInMiliseconds?: DOMHighResTimeStamp
) {
super();
}
}

View file

@ -2,7 +2,7 @@ import { Command } from '../command';
import { Vec2 } from '../../math/vec2'; import { Vec2 } from '../../math/vec2';
export class SwipeCommand extends Command { export class SwipeCommand extends Command {
public constructor(public readonly delta: Vec2) { public constructor(public readonly delta?: Vec2) {
super(); super();
} }
} }

View file

@ -0,0 +1,9 @@
import { Vec2 } from '../math/vec2';
export interface Drawer {
startWaitingForInstructions();
finishWaitingForInstructions();
setCameraPosition(position: Vec2);
setViewBoxSize(size: Vec2);
drawCornerText(text: string);
}

View file

@ -0,0 +1 @@
//export function

View file

@ -1,10 +1,9 @@
const twgl = require('twgl.js'); const twgl = require('twgl.js');
import { TimeIt } from '../helper/timing'; import { Drawer } from './drawer';
import { Vec2 } from '../math/vec2'; import { Vec2 } from '../math/vec2';
import { ObjectContainer } from '../objects/object-container';
export class Renderer { export class WebGl2Renderer implements Drawer {
private gl: WebGL2RenderingContext; private gl: WebGL2RenderingContext;
private programInfo: any; private programInfo: any;
private bufferInfo: any; private bufferInfo: any;
@ -12,7 +11,7 @@ export class Renderer {
constructor( constructor(
private canvas: HTMLCanvasElement, private canvas: HTMLCanvasElement,
private objects: ObjectContainer, private overlay: HTMLElement,
shaderSources: Array<string> shaderSources: Array<string>
) { ) {
twgl.setDefaults({ attribPrefix: 'a_' }); twgl.setDefaults({ attribPrefix: 'a_' });
@ -42,7 +41,14 @@ export class Renderer {
); );
} }
public render(time: number) { startWaitingForInstructions() {
//throw new Error("Method not implemented.");
}
private cameraPosition: Vec2;
private viewBoxSize: Vec2;
finishWaitingForInstructions() {
const gl = this.gl; const gl = this.gl;
twgl.resizeCanvasToDisplaySize(this.canvas); twgl.resizeCanvasToDisplaySize(this.canvas);
@ -51,8 +57,8 @@ export class Renderer {
gl.clear(gl.COLOR_BUFFER_BIT); gl.clear(gl.COLOR_BUFFER_BIT);
const uniforms = { const uniforms = {
cameraPosition: this.objects.camera.position.list, cameraPosition: this.cameraPosition.list,
viewBoxSize: [this.gl.canvas.width, this.gl.canvas.height], viewBoxSize: this.viewBoxSize.list,
resolution: [this.gl.canvas.width, this.gl.canvas.height], resolution: [this.gl.canvas.width, this.gl.canvas.height],
}; };
@ -63,4 +69,18 @@ export class Renderer {
twgl.setUniforms(this.programInfo, uniforms); twgl.setUniforms(this.programInfo, uniforms);
twgl.drawBufferInfo(this.gl, this.bufferInfo); twgl.drawBufferInfo(this.gl, this.bufferInfo);
} }
setCameraPosition(position: Vec2) {
this.cameraPosition = position;
}
setViewBoxSize(size: Vec2) {
this.viewBoxSize = size;
}
drawCornerText(text: string) {
if (this.overlay.innerText != text) {
this.overlay.innerText = text;
}
}
} }

View file

@ -1,54 +0,0 @@
import { Command } from '../commands/command';
import { CommandReceiver } from '../commands/command-receiver';
import { ObjectContainer } from '../objects/object-container';
import { KeyDownCommand } from '../commands/types/key-down';
import { KeyUpCommand } from '../commands/types/key-up';
import { SwipeCommand } from '../commands/types/swipe';
export class GameLogic implements CommandReceiver {
private commandBuffer: Array<Command> = [];
private keysDown: Set<string> = new Set();
constructor(private objects: ObjectContainer) {}
public step(time: DOMHighResTimeStamp) {
while (this.commandBuffer.length > 0) {
const command = this.commandBuffer.pop();
console.log(command);
if (command instanceof KeyDownCommand) {
this.keysDown.add(command.key);
}
if (command instanceof KeyUpCommand) {
this.keysDown.delete(command.key);
}
if (command instanceof SwipeCommand) {
this.objects.camera.position = this.objects.camera.position.subtract(
command.delta.scale(200)
);
}
}
if (this.keysDown.has('w')) {
this.objects.camera.position.y += 10;
}
if (this.keysDown.has('s')) {
this.objects.camera.position.y -= 10;
}
if (this.keysDown.has('a')) {
this.objects.camera.position.x -= 10;
}
if (this.keysDown.has('d')) {
this.objects.camera.position.x += 10;
}
}
public sendCommand(command: Command) {
this.commandBuffer.push(command);
}
}

View file

@ -0,0 +1,60 @@
import { WebGl2Renderer } from './drawing/renderer';
import { KeyboardListener } from './input/keyboard-listener';
import { MouseListener } from './input/mouse-listener';
import { TouchListener } from './input/touch-listener';
import { CommandBroadcaster } from './commands/command-broadcaster';
import { ObjectContainer } from './objects/object-container';
import { DrawCommand } from './commands/types/draw';
import passthroughVertexShader from '../shaders/passthrough.vert';
import distanceFragmentShader from '../shaders/dist.frag';
import { StepCommand } from './commands/types/step';
import { Character } from './objects/types/character';
import { InfoText } from './objects/types/info-text';
import { timeIt } from './helper/timing';
export class Game {
private previousTime: DOMHighResTimeStamp = 0;
private objects: ObjectContainer = new ObjectContainer();
private renderer: WebGl2Renderer;
constructor() {
const canvas: HTMLCanvasElement = document.querySelector('canvas#main');
const overlay: HTMLElement = document.querySelector('#overlay');
new CommandBroadcaster(
[
new KeyboardListener(document.body),
new MouseListener(canvas),
new TouchListener(canvas),
],
[this.objects]
);
this.renderer = new WebGl2Renderer(canvas, overlay, [
passthroughVertexShader,
distanceFragmentShader,
]);
this.initializeScene();
requestAnimationFrame(this.gameLoop.bind(this));
}
private initializeScene() {
this.objects.addObject(new Character(this.objects));
this.objects.addObject(new InfoText());
}
@timeIt()
private gameLoop(time: DOMHighResTimeStamp) {
const deltaTime = time - this.previousTime;
this.previousTime = time;
this.objects.sendCommand(new StepCommand(deltaTime));
this.renderer.startWaitingForInstructions();
this.objects.sendCommand(new DrawCommand(this.renderer));
this.renderer.finishWaitingForInstructions();
requestAnimationFrame(this.gameLoop.bind(this));
}
}

View file

@ -1,22 +1,37 @@
export const TimeIt = (func, interval = 60) => { import { InfoText } from '../objects/types/info-text';
let i = 0;
let values = [];
return () => { export function timeIt(interval = 60) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
let i = 0;
let previousTimes: Array<DOMHighResTimeStamp> = [];
const targetFunction = descriptor.value;
descriptor.value = function (...values) {
const start = performance.now(); const start = performance.now();
func(); targetFunction.bind(this)(...values);
const end = performance.now(); const end = performance.now();
values.push(end - start); previousTimes.push(end - start);
if (++i % interval == 0) {
values.sort();
console.log(
`${func.name}\n\tMax ${values[values.length - 1].toFixed(
2
)} ms\n\tMedian ${values[Math.floor(values.length / 2)].toFixed(2)} ms`
);
values = []; if (i++ % interval == 0) {
previousTimes.sort();
const text = `Max: ${previousTimes[previousTimes.length - 1].toFixed(
2
)} ms\n\tMedian: ${previousTimes[
Math.floor(previousTimes.length / 2)
].toFixed(2)} ms`;
InfoText.modifyRecord(propertyKey, text);
previousTimes = [];
} }
}; };
return descriptor;
}; };
}

View file

@ -1,4 +1,8 @@
import { Id } from './identity';
import { v4 } from 'uuid';
export class IdentityManager { export class IdentityManager {
public static sourceId: string; public static generateId(): Id {
public static targetId: string; return v4();
}
} }

View file

@ -0,0 +1 @@
export type Id = string;

View file

@ -3,7 +3,7 @@ import { KeyDownCommand } from '../commands/types/key-down';
import { KeyUpCommand } from '../commands/types/key-up'; import { KeyUpCommand } from '../commands/types/key-up';
export class KeyboardListener extends CommandGenerator { export class KeyboardListener extends CommandGenerator {
constructor(target: Element = document.body) { constructor(target: Element) {
super(); super();
target.addEventListener('keydown', (event: KeyboardEvent) => { target.addEventListener('keydown', (event: KeyboardEvent) => {

View file

@ -8,7 +8,7 @@ export class MouseListener extends CommandGenerator {
private previousPosition: Vec2 = null; private previousPosition: Vec2 = null;
private isMouseDown = false; private isMouseDown = false;
constructor(private target: Element = document.body) { constructor(private target: Element) {
super(); super();
target.addEventListener('mousedown', (event: MouseEvent) => { target.addEventListener('mousedown', (event: MouseEvent) => {
@ -48,9 +48,11 @@ export class MouseListener extends CommandGenerator {
} }
private positionFromEvent(event: MouseEvent): Vec2 { private positionFromEvent(event: MouseEvent): Vec2 {
const bb = this.target.getBoundingClientRect();
return new Vec2( return new Vec2(
-event.clientX / this.target.clientWidth, (event.clientX - bb.x) / bb.width,
event.clientY / this.target.clientHeight 1 - (event.clientY - bb.y) / bb.height
); ).clamped_0_1;
} }
} }

View file

@ -7,10 +7,12 @@ import { SwipeCommand } from '../commands/types/swipe';
export class TouchListener extends CommandGenerator { export class TouchListener extends CommandGenerator {
private previousPosition: Vec2 = null; private previousPosition: Vec2 = null;
constructor(private target: Element = document.body) { constructor(private target: HTMLElement) {
super(); super();
target.addEventListener('touchstart', (event: TouchEvent) => { target.addEventListener('touchstart', (event: TouchEvent) => {
event.preventDefault();
const touchCount = event.touches.length; const touchCount = event.touches.length;
const position = this.positionFromEvent(event); const position = this.positionFromEvent(event);
this.previousPosition = position; this.previousPosition = position;
@ -23,6 +25,8 @@ export class TouchListener extends CommandGenerator {
}); });
target.addEventListener('touchmove', (event: TouchEvent) => { target.addEventListener('touchmove', (event: TouchEvent) => {
event.preventDefault();
const position = this.positionFromEvent(event); const position = this.positionFromEvent(event);
this.sendCommand( this.sendCommand(
@ -34,9 +38,11 @@ export class TouchListener extends CommandGenerator {
} }
private positionFromEvent(event: TouchEvent): Vec2 { private positionFromEvent(event: TouchEvent): Vec2 {
const bb = this.target.getBoundingClientRect();
return new Vec2( return new Vec2(
event.touches[0].clientX / this.target.clientWidth, 1 - (event.touches[0].clientX - bb.x) / bb.width,
-event.touches[0].clientY / this.target.clientHeight (event.touches[0].clientY - bb.y) / bb.height
); ).clamped_0_1;
} }
} }

View file

@ -1,48 +0,0 @@
import { Renderer } from './drawing/renderer';
import passthroughVertexShader from '../shaders/passthrough.vert';
import distanceFragmentShader from '../shaders/dist.frag';
import { KeyboardListener } from './input/keyboard-listener';
import { MouseListener } from './input/mouse-listener';
import { TouchListener } from './input/touch-listener';
import { CommandBroadcaster } from './commands/command-broadcaster';
import { GameLogic } from './game-logic/game-logic';
import { GameObject } from './objects/game-object';
import { ObjectContainer } from './objects/object-container';
import { Camera } from './objects/types/camera';
const startGameLoop = (gameLogic: GameLogic, renderer: Renderer) => {
const loop = (time: DOMHighResTimeStamp) => {
gameLogic.step(time);
renderer.render(time);
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
};
export const main = async () => {
try {
const canvas: HTMLCanvasElement = document.querySelector('canvas#main');
const objects = new ObjectContainer(new Camera());
const gameLogic = new GameLogic(objects);
new CommandBroadcaster(
[
new KeyboardListener(document.body),
new MouseListener(canvas),
new TouchListener(canvas),
],
[gameLogic]
);
const renderer = new Renderer(canvas, objects, [
passthroughVertexShader,
distanceFragmentShader,
]);
startGameLoop(gameLogic, renderer);
} catch (e) {
console.error(e);
}
};

View file

@ -13,6 +13,10 @@ export class Vec2 extends Typed {
return new Vec2(this.x * scalar, this.y * scalar); return new Vec2(this.x * scalar, this.y * scalar);
} }
public times(other: Vec2): Vec2 {
return new Vec2(this.x * other.x, this.y * other.y);
}
public add(other: Vec2): Vec2 { public add(other: Vec2): Vec2 {
return new Vec2(this.x + other.x, this.y + other.y); return new Vec2(this.x + other.x, this.y + other.y);
} }
@ -21,6 +25,21 @@ export class Vec2 extends Typed {
return new Vec2(this.x - other.x, this.y - other.y); return new Vec2(this.x - other.x, this.y - other.y);
} }
public get length(): number {
return Math.sqrt(this.x * this.x + this.y * this.y);
}
public get normalized(): Vec2 {
return this.scale(1 / this.length);
}
public get clamped_0_1(): Vec2 {
return new Vec2(
Math.min(1, Math.max(0, this.x)),
Math.min(1, Math.max(0, this.y))
);
}
public get list(): [number, number] { public get list(): [number, number] {
return [this.x, this.y]; return [this.x, this.y];
} }

View file

@ -1,7 +1,38 @@
import { Typed } from '../transport/serializable'; import { Typed } from '../transport/serializable';
import { Vec2 } from '../math/vec2'; import { Vec2 } from '../math/vec2';
import { IdentityManager } from '../identity/identity-manager';
import { Command } from '../commands/command';
import { CommandReceiver } from '../commands/command-receiver';
export class GameObject extends Typed { export abstract class GameObject extends Typed implements CommandReceiver {
public position = new Vec2(); public readonly id = IdentityManager.generateId();
public boundingBoxSize = new Vec2();
protected _position = new Vec2();
public get position(): Vec2 {
return this._position;
}
protected _boundingBoxSize = new Vec2();
public get boundingBoxSize(): Vec2 {
return this._boundingBoxSize;
}
private commandExecutors: {
[commandName: string]: (e: Command) => void;
} = {};
protected addCommandExecutor<T extends Command>(
commandType: new () => T,
handler: (command: T) => void
) {
this.commandExecutors[commandType.name] = handler;
}
public sendCommand(command: Command) {
const commandType = command.constructor.name;
if (this.commandExecutors.hasOwnProperty(commandType)) {
this.commandExecutors[commandType](command);
}
}
} }

View file

@ -1,8 +1,24 @@
import { GameObject } from './game-object'; import { GameObject } from './game-object';
import { Camera } from './types/camera'; import { Id } from '../identity/identity';
import { Command } from '../commands/command';
import { CommandReceiver } from '../commands/command-receiver';
export class ObjectContainer { export class ObjectContainer implements CommandReceiver {
private objects: Array<GameObject> = []; private objects: Map<Id, GameObject> = new Map();
constructor(public camera: Camera) {} public addObject(o: GameObject) {
this.objects.set(o.id, o);
}
public removeObject(o: GameObject) {
this.objects.delete(o.id);
}
public sendCommandToSingleObject(id: Id, e: Command) {
this.objects.get(id)?.sendCommand(e);
}
public sendCommand(e: Command) {
this.objects.forEach((o, _) => o.sendCommand(e));
}
} }

View file

@ -1,3 +1,23 @@
import { GameObject } from '../game-object'; import { GameObject } from '../game-object';
import { DrawCommand } from '../../commands/types/draw';
import { Vec2 } from '../../math/vec2';
import { MoveToCommand } from '../../commands/types/move-to';
export class Camera extends GameObject {} export class Camera extends GameObject {
constructor() {
super();
this._boundingBoxSize = new Vec2(1200, 800);
this.addCommandExecutor(DrawCommand, this.draw.bind(this));
this.addCommandExecutor(MoveToCommand, this.moveTo.bind(this));
}
private draw(e: DrawCommand) {
e.drawer.setCameraPosition(this.position);
e.drawer.setViewBoxSize(this.boundingBoxSize);
}
private moveTo(e: MoveToCommand) {
this._position = e.position;
}
}

View file

@ -0,0 +1,53 @@
import { GameObject } from '../game-object';
import { Vec2 } from '../../math/vec2';
import { ObjectContainer } from '../object-container';
import { Camera } from './camera';
import { MoveToCommand } from '../../commands/types/move-to';
import { StepCommand } from '../../commands/types/step';
import { KeyDownCommand } from '../../commands/types/key-down';
import { KeyUpCommand } from '../../commands/types/key-up';
import { SwipeCommand } from '../../commands/types/swipe';
export class Character extends GameObject {
private keysDown: Set<string> = new Set();
private camera = new Camera();
private static speed = 0.5;
constructor(objects: ObjectContainer) {
super();
objects.addObject(this.camera);
this.addCommandExecutor(StepCommand, this.stepHandler.bind(this));
this.addCommandExecutor(KeyDownCommand, (c) => this.keysDown.add(c.key));
this.addCommandExecutor(KeyUpCommand, (c) => this.keysDown.delete(c.key));
this.addCommandExecutor(SwipeCommand, (c) =>
this.setPosition(
this.position.add(c.delta.times(this.camera.boundingBoxSize))
)
);
}
private setPosition(value: Vec2) {
this._position = value;
this.camera.sendCommand(new MoveToCommand(this.position));
}
public stepHandler(c: StepCommand) {
const deltaTime = c.deltaTimeInMiliseconds;
const up = ~~this.keysDown.has('w');
const down = ~~this.keysDown.has('s');
const left = ~~this.keysDown.has('a');
const right = ~~this.keysDown.has('d');
const movementVector = new Vec2(right - left, up - down);
if (movementVector.length > 0) {
this.setPosition(
this.position.add(
movementVector.normalized.scale(Character.speed * deltaTime)
)
);
}
}
}

View file

@ -0,0 +1,22 @@
import { GameObject } from '../game-object';
import { DrawCommand } from '../../commands/types/draw';
export class InfoText extends GameObject {
constructor() {
super();
this.addCommandExecutor(DrawCommand, this.draw.bind(this));
}
private static records: Map<string, string> = new Map();
public static modifyRecord(key: string, value: string) {
InfoText.records.set(key, value);
}
private draw(e: DrawCommand) {
let text = '';
InfoText.records.forEach((v, k) => (text += `${k}\n\t${v}\n`));
e.drawer.drawCornerText(text);
}
}

View file

@ -1,19 +1,39 @@
html, body, main { html,
body,
main {
height: 100%; height: 100%;
} }
body { body {
margin: 0; margin: 0;
background-color: #333; background-color: #333;
main {
display: flex;
justify-content: center;
align-items: center;
canvas { main {
position: relative;
.canvas-container {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 100%;
#overlay {
font-family: Helvetica, Arial, sans-serif;
font-size: 1em;
padding: 0.5em 1em;
user-select: none;
pointer-events: none;
position: absolute;
right: 0;
-webkit-text-stroke: 1px black;
-webkit-text-fill-color: white;
white-space: pre;
}
canvas#main {
width: 100%; width: 100%;
background-color: hotpink; background-color: hotpink;
} }
} }
} }
}

View file

@ -6,6 +6,7 @@
"module": "es6", "module": "es6",
"target": "es5", "target": "es5",
"downlevelIteration": true, "downlevelIteration": true,
"allowJs": true "allowJs": true,
"experimentalDecorators": true
} }
} }

View file

@ -7,7 +7,6 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin'); const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin');
const Sharp = require('responsive-loader/sharp'); const Sharp = require('responsive-loader/sharp');
const Sass = require('sass'); const Sass = require('sass');
const tsNameof = require('ts-nameof');
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
@ -123,9 +122,6 @@ module.exports = {
test: /\.ts$/, test: /\.ts$/,
use: { use: {
loader: 'ts-loader', loader: 'ts-loader',
options: {
getCustomTransformers: () => ({ before: [tsNameof] }),
},
}, },
exclude: /node_modules/, exclude: /node_modules/,
}, },