Import code from Ludum Dare/Jam 2018 "Sacrifices" that looks reusable.
This commit is contained in:
parent
9914c2e7bd
commit
e8415da145
24 changed files with 8680 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.cache/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
7307
package-lock.json
generated
Normal file
7307
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
17
package.json
Normal file
17
package.json
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"name": "ld43",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Sacrifices must be made",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack",
|
||||||
|
"buildProduction": "webpack --mode=production"
|
||||||
|
},
|
||||||
|
"author": "Tangent 128",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"parcel-bundler": "^1.12.4",
|
||||||
|
"sass": "^1.23.7",
|
||||||
|
"typescript": "^3.7.3"
|
||||||
|
}
|
||||||
|
}
|
11
plan.txt
Normal file
11
plan.txt
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
Open:
|
||||||
|
- Clone Interface
|
||||||
|
- Cloneable RNG that goes in state (use MurmurHash3 finalizer in counter mode?)
|
||||||
|
- Rollback state management
|
||||||
|
- state + input reducer function
|
||||||
|
- maintain predicted state
|
||||||
|
- inform of local actions
|
||||||
|
- maintain last-canonical state
|
||||||
|
- inform of server state
|
||||||
|
|
||||||
|
Done:
|
19
src/Applet/Audio.ts
Normal file
19
src/Applet/Audio.ts
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
export function PlaySfx(audio: HTMLAudioElement) {
|
||||||
|
audio.currentTime = 0;
|
||||||
|
audio.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentMusic: HTMLAudioElement|null = null;
|
||||||
|
export function PlayMusic(music: HTMLAudioElement|null, loop = true) {
|
||||||
|
if(music && currentMusic != music) {
|
||||||
|
music.currentTime = 0;
|
||||||
|
}
|
||||||
|
if(currentMusic) {
|
||||||
|
currentMusic.pause();
|
||||||
|
}
|
||||||
|
currentMusic = music;
|
||||||
|
if(currentMusic) {
|
||||||
|
currentMusic.loop = loop;
|
||||||
|
currentMusic.play();
|
||||||
|
}
|
||||||
|
}
|
40
src/Applet/Init.ts
Normal file
40
src/Applet/Init.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import { KeyControl } from "./Keyboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class decorator for automatically constructing
|
||||||
|
* class instances around elements on page load.
|
||||||
|
*/
|
||||||
|
export function Bind(selector: string) {
|
||||||
|
return (appletType: AppletConstructor) => {
|
||||||
|
const elements = document.querySelectorAll(selector);
|
||||||
|
for(let i = 0; i < elements.length; i++) {
|
||||||
|
const element = elements[i] as HTMLElement;
|
||||||
|
new appletType(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export interface AppletConstructor {
|
||||||
|
new(element: HTMLElement): any
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A class decorator for automatically constructing
|
||||||
|
* a KeyControl around a canvas on page load & fetching the render context.
|
||||||
|
*/
|
||||||
|
export function Game(selector: string, tabIndex = -1) {
|
||||||
|
return (gameType: GameConstructor) => {
|
||||||
|
const elements = document.querySelectorAll(selector);
|
||||||
|
for(let i = 0; i < elements.length; i++) {
|
||||||
|
const element = elements[i] as HTMLCanvasElement;
|
||||||
|
if(!(element instanceof HTMLCanvasElement)) continue;
|
||||||
|
|
||||||
|
const cx = element.getContext("2d") as CanvasRenderingContext2D;
|
||||||
|
const keys = new KeyControl(element, tabIndex);
|
||||||
|
|
||||||
|
new gameType(element, cx, keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export interface GameConstructor {
|
||||||
|
new(canvas: HTMLCanvasElement, cx: CanvasRenderingContext2D, keys: KeyControl): any
|
||||||
|
};
|
95
src/Applet/Keyboard.ts
Normal file
95
src/Applet/Keyboard.ts
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
export type KeyName = "up" | "down" | "left" | "right" | "a" | "b" | "menu";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mapper from keys to game actions;
|
||||||
|
* meant to be easy to swap out, so only one
|
||||||
|
* of these for each "screen" or "menu" is needed.
|
||||||
|
*/
|
||||||
|
export interface KeyHandler {
|
||||||
|
/// A key has been pressed
|
||||||
|
press?(key: KeyName): void,
|
||||||
|
|
||||||
|
/// A key has been released
|
||||||
|
release?(key: KeyName): void,
|
||||||
|
|
||||||
|
/// You are receiving control now, perhaps indicate this in the UI
|
||||||
|
activate?(): void,
|
||||||
|
|
||||||
|
/// Some other key handler's active now, best not assume you'll get release() events
|
||||||
|
block?(): void,
|
||||||
|
};
|
||||||
|
|
||||||
|
const KEY_NAMES: {[code: number]: KeyName} = {
|
||||||
|
// compact keys (WASD+ZXC)
|
||||||
|
90: "a",
|
||||||
|
88: "b",
|
||||||
|
67: "menu",
|
||||||
|
87: "up",
|
||||||
|
83: "down",
|
||||||
|
65: "left",
|
||||||
|
68: "right",
|
||||||
|
// full-board keys (arrows+space/shift/enter)
|
||||||
|
32: "a",
|
||||||
|
16: "b",
|
||||||
|
13: "menu",
|
||||||
|
38: "up",
|
||||||
|
40: "down",
|
||||||
|
37: "left",
|
||||||
|
39: "right",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class to read game input from a DOM element
|
||||||
|
* and dispatch it to a KeyHandler object.
|
||||||
|
*/
|
||||||
|
export class KeyControl {
|
||||||
|
private handler?: KeyHandler;
|
||||||
|
|
||||||
|
private keyUp = (evt: KeyboardEvent) => {
|
||||||
|
this.dispatch(evt, "release");
|
||||||
|
};
|
||||||
|
private keyDown = (evt: KeyboardEvent) => {
|
||||||
|
this.dispatch(evt, "press");
|
||||||
|
};
|
||||||
|
private blur = (evt: FocusEvent) => {
|
||||||
|
this.handler && this.handler.block && this.handler.block();
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(private element: HTMLElement, tabindex: number = -1) {
|
||||||
|
element.addEventListener("keyup", this.keyUp, false);
|
||||||
|
element.addEventListener("keydown", this.keyDown, false);
|
||||||
|
element.addEventListener("blur", this.blur, false);
|
||||||
|
element.setAttribute("tabindex", tabindex+"");
|
||||||
|
};
|
||||||
|
|
||||||
|
public dispose() {
|
||||||
|
this.element.removeEventListener("keyup", this.keyUp, false);
|
||||||
|
this.element.removeEventListener("keydown", this.keyDown, false);
|
||||||
|
this.element.removeEventListener("blur", this.blur, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
public setHandler(newHandler?: KeyHandler) {
|
||||||
|
this.handler && this.handler.block && this.handler.block();
|
||||||
|
this.handler = newHandler;
|
||||||
|
this.handler && this.handler.activate && this.handler.activate();
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(evt: KeyboardEvent, state: "press" | "release") {
|
||||||
|
let name = KEY_NAMES[evt.which];
|
||||||
|
if(name != null && this.handler) {
|
||||||
|
evt.preventDefault();
|
||||||
|
evt.stopPropagation();
|
||||||
|
|
||||||
|
if(state == "press" && this.handler.press) {
|
||||||
|
this.handler.press(name);
|
||||||
|
} else if(state == "release" && this.handler.release) {
|
||||||
|
this.handler.release(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public focus() {
|
||||||
|
this.element.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
63
src/Applet/Loop.ts
Normal file
63
src/Applet/Loop.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toplevel game/animation loop
|
||||||
|
*/
|
||||||
|
export class Loop {
|
||||||
|
|
||||||
|
private physicsTimeout?: number;
|
||||||
|
private renderTimeout?: number;
|
||||||
|
|
||||||
|
private lastPhysicsTick: number = (new Date()).getTime();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private fps: number,
|
||||||
|
private physicsCallback: (interval: number) => void,
|
||||||
|
private renderCallback: (dt: number) => void,
|
||||||
|
) {
|
||||||
|
};
|
||||||
|
|
||||||
|
public start() {
|
||||||
|
if(this.physicsTimeout == null) {
|
||||||
|
this.physicsTimeout = window.setTimeout(() => {
|
||||||
|
this.physicsTimeout = undefined;
|
||||||
|
this.physicsTick(1 / this.fps);
|
||||||
|
}, 1000 / this.fps);
|
||||||
|
}
|
||||||
|
if(this.renderTimeout == null) {
|
||||||
|
this.renderTimeout = window.requestAnimationFrame(() => {
|
||||||
|
this.renderTimeout = undefined;
|
||||||
|
this.renderTick();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public stop() {
|
||||||
|
if(this.physicsTimeout != null) {
|
||||||
|
window.clearTimeout(this.physicsTimeout);
|
||||||
|
this.physicsTimeout = undefined;
|
||||||
|
}
|
||||||
|
if(this.renderTimeout != null) {
|
||||||
|
window.cancelAnimationFrame(this.renderTimeout);
|
||||||
|
this.renderTimeout = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private physicsTick(interval: number) {
|
||||||
|
const now = (new Date()).getTime();
|
||||||
|
this.lastPhysicsTick = now;
|
||||||
|
|
||||||
|
this.physicsCallback(interval);
|
||||||
|
|
||||||
|
// schedule next tick
|
||||||
|
this.start();
|
||||||
|
};
|
||||||
|
private renderTick() {
|
||||||
|
const now = (new Date()).getTime();
|
||||||
|
|
||||||
|
this.renderCallback((now - this.lastPhysicsTick) / 1000);
|
||||||
|
|
||||||
|
// schedule next tick
|
||||||
|
this.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
101
src/Applet/Render.ts
Normal file
101
src/Applet/Render.ts
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* A thing that can be drawn.
|
||||||
|
*/
|
||||||
|
export interface Render {
|
||||||
|
z: number;
|
||||||
|
render(cx: CanvasRenderingContext2D, dt: number): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A global transform for many onscreen objects.
|
||||||
|
*/
|
||||||
|
export class Camera {
|
||||||
|
public x = 0;
|
||||||
|
public y = 0;
|
||||||
|
public zoom = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A "group" of Renderables that share a common z-order and
|
||||||
|
* "camera" transforms.
|
||||||
|
*/
|
||||||
|
export class Layer {
|
||||||
|
|
||||||
|
public Camera = new Camera();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public z: number,
|
||||||
|
public parallax = 1,
|
||||||
|
public scale = 1
|
||||||
|
) {};
|
||||||
|
|
||||||
|
public enter(cx: CanvasRenderingContext2D) {
|
||||||
|
const camera = this.Camera;
|
||||||
|
const scale = this.scale * camera.zoom;
|
||||||
|
const parallax = this.parallax;
|
||||||
|
|
||||||
|
cx.save();
|
||||||
|
|
||||||
|
cx.scale(scale, scale);
|
||||||
|
cx.translate(-camera.x * parallax, -camera.y * parallax);
|
||||||
|
};
|
||||||
|
public exit(cx: CanvasRenderingContext2D) {
|
||||||
|
cx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
public toRender(drawCode: (cx: CanvasRenderingContext2D, dt: number) => void): Render {
|
||||||
|
return {
|
||||||
|
z: this.z,
|
||||||
|
render: (cx: CanvasRenderingContext2D, dt: number) => {
|
||||||
|
this.enter(cx);
|
||||||
|
drawCode(cx, dt);
|
||||||
|
this.exit(cx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect items needing to be drawn, sort them,
|
||||||
|
* and render them.
|
||||||
|
*/
|
||||||
|
export class DrawSet {
|
||||||
|
renderables: Render[] = [];
|
||||||
|
|
||||||
|
queue(...render: Render[]) {
|
||||||
|
this.renderables.push(...render);
|
||||||
|
};
|
||||||
|
|
||||||
|
draw(cx: CanvasRenderingContext2D, dt = 0) {
|
||||||
|
|
||||||
|
// sort list by layer z index
|
||||||
|
this.renderables.sort((a, b) => {
|
||||||
|
return a.z - b.z;
|
||||||
|
});
|
||||||
|
|
||||||
|
for(const {z, render} of this.renderables) {
|
||||||
|
render(cx, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderables.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple spritesheet adapter
|
||||||
|
*/
|
||||||
|
export class SpriteSheet {
|
||||||
|
constructor(
|
||||||
|
public img: HTMLImageElement,
|
||||||
|
public w: number, public h: number
|
||||||
|
) {};
|
||||||
|
|
||||||
|
render(cx: CanvasRenderingContext2D, index: number, offsetX = 0, offsetY = 0) {
|
||||||
|
if(this.img.width > 0) {
|
||||||
|
const row = Math.floor((index * this.w) / this.img.width);
|
||||||
|
const x = Math.floor((index * this.w) % this.img.width);
|
||||||
|
const y = row * this.h;
|
||||||
|
cx.drawImage(this.img, x, y, this.w, this.h, offsetX, offsetY, this.w, this.h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
47
src/Applet/demo.ts
Normal file
47
src/Applet/demo.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { Bind } from "./Init";
|
||||||
|
import { KeyControl, KeyHandler, KeyName } from "./Keyboard";
|
||||||
|
import { Loop } from "./Loop";
|
||||||
|
|
||||||
|
@Bind("#KeyTest")
|
||||||
|
export class Test implements KeyHandler {
|
||||||
|
private keys: KeyControl;
|
||||||
|
|
||||||
|
constructor(public div: HTMLElement) {
|
||||||
|
this.keys = new KeyControl(this.div);
|
||||||
|
this.keys.setHandler(this);
|
||||||
|
this.keys.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose() {
|
||||||
|
this.keys.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.div.innerText = "Ready for input.";
|
||||||
|
}
|
||||||
|
|
||||||
|
press(key: KeyName) {
|
||||||
|
this.div.innerText = `Pressed ${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
release(key: KeyName) {
|
||||||
|
this.div.innerText = `Released ${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bind("#LoopTest")
|
||||||
|
export class LoopTest {
|
||||||
|
frames: number = 0;
|
||||||
|
|
||||||
|
constructor(public div: HTMLElement) {
|
||||||
|
const loop = new Loop(30,
|
||||||
|
interval => {
|
||||||
|
this.frames++;
|
||||||
|
},
|
||||||
|
dt => {
|
||||||
|
this.div.innerHTML = `<b>dt:</b> ${dt} <br /> ${this.frames} frames`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
}
|
||||||
|
}
|
119
src/Ecs/Collision.ts
Normal file
119
src/Ecs/Collision.ts
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { Data, Location, Polygon, CollisionClass } from "./Components";
|
||||||
|
import { Id, Join } from "./Data";
|
||||||
|
import { TfPolygon } from "./Location";
|
||||||
|
|
||||||
|
interface Candidate {
|
||||||
|
id: Id;
|
||||||
|
class: CollisionClass;
|
||||||
|
poly: Polygon;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hash(minX: number, minY: number, maxX: number, maxY: number, cellSize: number): string[] {
|
||||||
|
let minXCell = Math.floor(minX/cellSize);
|
||||||
|
let minYCell = Math.floor(minY/cellSize);
|
||||||
|
let maxXCell = Math.floor(maxX/cellSize);
|
||||||
|
let maxYCell = Math.floor(maxY/cellSize);
|
||||||
|
const results = [];
|
||||||
|
for(let xCell = minXCell; xCell <= maxXCell; xCell++) {
|
||||||
|
for(let yCell = minYCell; yCell <= maxYCell; yCell++) {
|
||||||
|
results.push(`${xCell},${yCell}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPolygon({points}: Polygon, cellSize: number): string[] {
|
||||||
|
let minX = 1/0;
|
||||||
|
let minY = 1/0;
|
||||||
|
let maxX = -1/0;
|
||||||
|
let maxY = -1/0;
|
||||||
|
for(let i = 0; i < points.length; i += 2) {
|
||||||
|
minX = Math.min(minX, points[i]);
|
||||||
|
minY = Math.min(minY, points[i+1]);
|
||||||
|
maxX = Math.max(maxX, points[i]);
|
||||||
|
maxY = Math.max(maxY, points[i+1]);
|
||||||
|
}
|
||||||
|
return hash(minX, minY, maxX, maxY, cellSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectPolygonToAxis(axisX: number, axisY: number, points: number[]): [number, number] {
|
||||||
|
let min = 1/0;
|
||||||
|
let max = -1/0;
|
||||||
|
for(let i = 0; i < points.length; i += 2) {
|
||||||
|
// dot product doesn't need normalizing since we only compare magnitudes
|
||||||
|
const value = (axisX * points[i]) + (axisY * points[i+1]);
|
||||||
|
min = Math.min(min, value);
|
||||||
|
max = Math.max(max, value);
|
||||||
|
}
|
||||||
|
return [min, max];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true if the axis contains a gap between the two polygons
|
||||||
|
*/
|
||||||
|
function testPolygonSeparatingAxis(axisX: number, axisY: number, aPoints: number[], bPoints: number[]): boolean {
|
||||||
|
const [minA, maxA] = projectPolygonToAxis(axisX, axisY, aPoints);
|
||||||
|
const [minB, maxB] = projectPolygonToAxis(axisX, axisY, bPoints);
|
||||||
|
return !((minA < maxB) && (minB < maxA));
|
||||||
|
}
|
||||||
|
|
||||||
|
function halfCollideConvexPolygons(aPoints: number[], bPoints: number[]): boolean {
|
||||||
|
for(let i = 2; i < aPoints.length; i += 2) {
|
||||||
|
const dx = aPoints[i] - aPoints[i-2];
|
||||||
|
const dy = aPoints[i+1] - aPoints[i-1];
|
||||||
|
if(testPolygonSeparatingAxis(-dy, dx, aPoints, bPoints)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const dx = aPoints[0] - aPoints[aPoints.length-2];
|
||||||
|
const dy = aPoints[1] - aPoints[aPoints.length-1];
|
||||||
|
if(testPolygonSeparatingAxis(-dy, dx, aPoints, bPoints)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no gaps found in this half
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collideConvexPolygons({points: aPoints}: Polygon, {points: bPoints}: Polygon): boolean {
|
||||||
|
return halfCollideConvexPolygons(aPoints, bPoints) && halfCollideConvexPolygons(bPoints, aPoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FindCollisions(data: Data, cellSize: number, callback: (className: string, sourceId: Id, targetId: Id) => void) {
|
||||||
|
const spatialMap: Record<string, Candidate[]> = {};
|
||||||
|
|
||||||
|
Join(data, "collisionTargetClass", "location", "bounds").map(
|
||||||
|
([id, targetClass, location, poly]) => {
|
||||||
|
const workingPoly = TfPolygon(poly, location);
|
||||||
|
const candidate = {
|
||||||
|
id, class: targetClass, poly: workingPoly
|
||||||
|
};
|
||||||
|
hashPolygon(workingPoly, cellSize).forEach(key => {
|
||||||
|
if(!spatialMap[key]) spatialMap[key] = [];
|
||||||
|
spatialMap[key].push(candidate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Join(data, "collisionSourceClass", "location", "bounds").map(
|
||||||
|
([id, sourceClass, location, poly]) => {
|
||||||
|
const workingPoly = TfPolygon(poly, location);
|
||||||
|
const candidates: Record<string, Candidate> = {};
|
||||||
|
hashPolygon(workingPoly, cellSize).forEach(key => {
|
||||||
|
if(spatialMap[key]) {
|
||||||
|
for(const candidate of spatialMap[key]) {
|
||||||
|
// dedup targets to test
|
||||||
|
candidates[candidate.id[0]] = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for(const bareId in candidates) {
|
||||||
|
const target = candidates[bareId];
|
||||||
|
if(collideConvexPolygons(workingPoly, target.poly)) {
|
||||||
|
const className = `${sourceClass.name}>${target.class.name}`;
|
||||||
|
callback(className, id, target.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
75
src/Ecs/Components.ts
Normal file
75
src/Ecs/Components.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
|
||||||
|
import { Data as CoreData, Store } from "./Data";
|
||||||
|
import { Layer, SpriteSheet } from "../Applet/Render";
|
||||||
|
|
||||||
|
export class Box {
|
||||||
|
constructor(
|
||||||
|
public x: number, public y: number,
|
||||||
|
public w: number, public h: number
|
||||||
|
) {};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return source moved towards target by speed, without going past.
|
||||||
|
*/
|
||||||
|
export function Approach(source: number, target: number, speed: number): number {
|
||||||
|
const delta = target - source;
|
||||||
|
if(Math.abs(delta) <= speed) {
|
||||||
|
return target;
|
||||||
|
} else {
|
||||||
|
return source + Math.sign(delta) * speed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pairs of vertex coordinates in ccw winding order
|
||||||
|
*/
|
||||||
|
export class Polygon {
|
||||||
|
constructor(
|
||||||
|
public points: number[]
|
||||||
|
) {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Location {
|
||||||
|
constructor(init?: Partial<Location>) {
|
||||||
|
init && Object.assign(this, init);
|
||||||
|
};
|
||||||
|
X = 0;
|
||||||
|
Y = 0;
|
||||||
|
Angle = 0;
|
||||||
|
VX = 0;
|
||||||
|
VY = 0;
|
||||||
|
VAngle = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CollisionClass {
|
||||||
|
constructor(
|
||||||
|
public name: string
|
||||||
|
) {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RenderBounds {
|
||||||
|
constructor(
|
||||||
|
public color = "#f00",
|
||||||
|
public layer: Layer
|
||||||
|
) {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class RenderSprite {
|
||||||
|
constructor(
|
||||||
|
public sheet: SpriteSheet,
|
||||||
|
public layer: Layer,
|
||||||
|
public index = 0,
|
||||||
|
public offsetX = 0,
|
||||||
|
public offsetY = 0
|
||||||
|
) {};
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Data extends CoreData {
|
||||||
|
location: Store<Location> = [];
|
||||||
|
bounds: Store<Polygon> = [];
|
||||||
|
renderBounds: Store<RenderBounds> = {};
|
||||||
|
renderSprite: Store<RenderSprite> = {};
|
||||||
|
collisionSourceClass: Store<CollisionClass> = {};
|
||||||
|
collisionTargetClass: Store<CollisionClass> = {};
|
||||||
|
}
|
248
src/Ecs/Data.ts
Normal file
248
src/Ecs/Data.ts
Normal file
|
@ -0,0 +1,248 @@
|
||||||
|
|
||||||
|
export interface Component {
|
||||||
|
generation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Id = [number, number];
|
||||||
|
|
||||||
|
export enum Liveness {
|
||||||
|
DEAD = 0,
|
||||||
|
ALIVE = 1,
|
||||||
|
INACTIVE = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityState extends Component {
|
||||||
|
alive: Liveness;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Store<T> = (T & Component)[] | Record<number, T & Component>;
|
||||||
|
export class Data {
|
||||||
|
entity: EntityState[] = [];
|
||||||
|
|
||||||
|
[name: string]: Store<{}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an entity in the store
|
||||||
|
* @param data store
|
||||||
|
* @param assign map of components to attach
|
||||||
|
* @param state Liveness state, allows creating an inactive entity
|
||||||
|
* @returns the new entity's ID and generation
|
||||||
|
*/
|
||||||
|
type StripKeys<T, N> = {
|
||||||
|
[P in keyof T]: P extends N ? never : P
|
||||||
|
}[keyof T];
|
||||||
|
type Assigner<DATA extends Data> = {
|
||||||
|
[S in keyof DATA]?: Pick<DATA[S][number], StripKeys<DATA[S][number], "generation">>
|
||||||
|
};
|
||||||
|
export function Create<DATA extends Data>(data: DATA, assign: Assigner<DATA>, state = Liveness.ALIVE): Id {
|
||||||
|
const entities = data.entity;
|
||||||
|
// find free ID
|
||||||
|
let freeId = -1;
|
||||||
|
let generation = -1;
|
||||||
|
for(let id = 0; id < entities.length; id++) {
|
||||||
|
if(entities[id].alive == Liveness.DEAD) {
|
||||||
|
freeId = id;
|
||||||
|
generation = entities[id].generation + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(freeId == -1) {
|
||||||
|
freeId = entities.length;
|
||||||
|
generation = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
entities[freeId] = {
|
||||||
|
generation,
|
||||||
|
alive: state
|
||||||
|
};
|
||||||
|
|
||||||
|
for(const key in assign) {
|
||||||
|
data[key][freeId] = {...(assign[key] as {}), generation};
|
||||||
|
}
|
||||||
|
|
||||||
|
return [freeId, generation];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Delete" an entity
|
||||||
|
* @param data store
|
||||||
|
* @param id entity ID
|
||||||
|
* @param generation entity ID generation
|
||||||
|
* @param state can be set to Liveness.INACTIVE to disable an entity without actually killing it, for later resurrection
|
||||||
|
*/
|
||||||
|
export function Remove<DATA extends Data>(data: DATA, [id, generation]: Id, state = Liveness.DEAD) {
|
||||||
|
if(data.entity[id] && data.entity[id].generation == generation) {
|
||||||
|
data.entity[id].alive = state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ergonomic Lookup typings
|
||||||
|
export function Lookup<
|
||||||
|
DATA extends Data,
|
||||||
|
A extends keyof DATA,
|
||||||
|
> (
|
||||||
|
data: DATA,
|
||||||
|
id: Id,
|
||||||
|
a: A,
|
||||||
|
): [
|
||||||
|
DATA[A][number] | null
|
||||||
|
];
|
||||||
|
export function Lookup<
|
||||||
|
DATA extends Data,
|
||||||
|
A extends keyof DATA,
|
||||||
|
B extends keyof DATA,
|
||||||
|
> (
|
||||||
|
data: DATA,
|
||||||
|
id: Id,
|
||||||
|
a: A,
|
||||||
|
b: B,
|
||||||
|
): [
|
||||||
|
DATA[A][number] | null,
|
||||||
|
DATA[B][number] | null
|
||||||
|
];
|
||||||
|
export function Lookup<
|
||||||
|
DATA extends Data,
|
||||||
|
A extends keyof DATA,
|
||||||
|
B extends keyof DATA,
|
||||||
|
C extends keyof DATA,
|
||||||
|
> (
|
||||||
|
data: DATA,
|
||||||
|
id: Id,
|
||||||
|
a: A,
|
||||||
|
b: B,
|
||||||
|
c: C,
|
||||||
|
): [
|
||||||
|
DATA[A][number] | null,
|
||||||
|
DATA[B][number] | null,
|
||||||
|
DATA[C][number]
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up components that may or may not exist for an entity
|
||||||
|
* @param data store
|
||||||
|
* @param param1 entity Id
|
||||||
|
* @param components names of components to look for
|
||||||
|
* @returns the cooresponding components, with unfound ones replaced by nulls
|
||||||
|
*/
|
||||||
|
export function Lookup<DATA extends Data, K extends keyof DATA>(data: DATA, [id, generation]: Id, ...components: K[]): (Component | null)[] {
|
||||||
|
const entity = data.entity[id];
|
||||||
|
// inactive entities are fine to lookup, but dead ones are not
|
||||||
|
if(entity && entity.generation == generation && entity.alive != Liveness.DEAD) {
|
||||||
|
return components.map(storeName => {
|
||||||
|
const component = data[storeName][id];
|
||||||
|
if(component && component.generation == generation) {
|
||||||
|
return component;
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return components.map(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ergonomic Join typings
|
||||||
|
export function Join<
|
||||||
|
DATA extends Data,
|
||||||
|
A extends keyof DATA,
|
||||||
|
> (
|
||||||
|
data: DATA,
|
||||||
|
a: A,
|
||||||
|
): [
|
||||||
|
Id,
|
||||||
|
DATA[A][number]
|
||||||
|
][];
|
||||||
|
export function Join<
|
||||||
|
DATA extends Data,
|
||||||
|
A extends keyof DATA,
|
||||||
|
B extends keyof DATA,
|
||||||
|
> (
|
||||||
|
data: DATA,
|
||||||
|
a: A,
|
||||||
|
b: B,
|
||||||
|
): [
|
||||||
|
Id,
|
||||||
|
DATA[A][number],
|
||||||
|
DATA[B][number]
|
||||||
|
][];
|
||||||
|
export function Join<
|
||||||
|
DATA extends Data,
|
||||||
|
A extends keyof DATA,
|
||||||
|
B extends keyof DATA,
|
||||||
|
C extends keyof DATA,
|
||||||
|
> (
|
||||||
|
data: DATA,
|
||||||
|
a: A,
|
||||||
|
b: B,
|
||||||
|
c: C,
|
||||||
|
): [
|
||||||
|
Id,
|
||||||
|
DATA[A][number],
|
||||||
|
DATA[B][number],
|
||||||
|
DATA[C][number]
|
||||||
|
][];
|
||||||
|
export function Join<
|
||||||
|
DATA extends Data,
|
||||||
|
A extends keyof DATA,
|
||||||
|
B extends keyof DATA,
|
||||||
|
C extends keyof DATA,
|
||||||
|
D extends keyof DATA,
|
||||||
|
> (
|
||||||
|
data: DATA,
|
||||||
|
a: A,
|
||||||
|
b: B,
|
||||||
|
c: C,
|
||||||
|
d: D,
|
||||||
|
): [
|
||||||
|
Id,
|
||||||
|
DATA[A][number],
|
||||||
|
DATA[B][number],
|
||||||
|
DATA[C][number],
|
||||||
|
DATA[D][number]
|
||||||
|
][];
|
||||||
|
/**
|
||||||
|
* Query a Data collection for all Alive entities possessing the named set of Components.
|
||||||
|
* @returns an array of tuples containing the matching entity [ID, generation]s & associated Components
|
||||||
|
*/
|
||||||
|
export function Join<DATA extends Data, K extends keyof DATA>(data: DATA, ...components: K[]): [Id, ...Component[]][] {
|
||||||
|
const entities = data.entity;
|
||||||
|
const stores: Store<{}>[] = components.map(name => data[name]);
|
||||||
|
|
||||||
|
const results: [Id, ...Component[]][] = [];
|
||||||
|
const firstStore = stores[0];
|
||||||
|
if(Array.isArray(firstStore)) {
|
||||||
|
for(let id = 0; id < firstStore.length; id++) {
|
||||||
|
JoinLoop(id, entities, stores, results);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for(const id in firstStore) {
|
||||||
|
JoinLoop(Number(id), entities, stores, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
function JoinLoop(id: number, entities: Store<EntityState>, stores: Store<{}>[], results: [Id, ...Component[]][]) {
|
||||||
|
const result: [Id, ...Component[]] = [[id, -1]];
|
||||||
|
|
||||||
|
let generation = -1;
|
||||||
|
for (const store of stores) {
|
||||||
|
const component = store[id];
|
||||||
|
if(component && (component.generation == generation || generation == -1)) {
|
||||||
|
generation = component.generation;
|
||||||
|
result.push(component);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// only accept active entities (do this check here)
|
||||||
|
const entity = entities[id];
|
||||||
|
if(entity.alive != Liveness.ALIVE || generation != entity.generation) return;
|
||||||
|
|
||||||
|
// backpatch generation now that it's known
|
||||||
|
result[0][1] = generation;
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
28
src/Ecs/Location.ts
Normal file
28
src/Ecs/Location.ts
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { Data, Location, Polygon } from "./Components";
|
||||||
|
import { Join } from "./Data";
|
||||||
|
|
||||||
|
export function TransformCx(cx: CanvasRenderingContext2D, location: Location, dt = 0) {
|
||||||
|
cx.translate(location.X + location.VX * dt, location.Y + location.VY * dt);
|
||||||
|
cx.rotate(-location.Angle + location.VAngle * dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TfPolygon({points}: Polygon, {X, Y, Angle}: Location): Polygon {
|
||||||
|
const sin = Math.sin(Angle);
|
||||||
|
const cos = Math.cos(Angle);
|
||||||
|
const result = new Polygon(new Array(points.length));
|
||||||
|
for(let i = 0; i < points.length; i += 2) {
|
||||||
|
const x = points[i];
|
||||||
|
const y = points[i+1];
|
||||||
|
result.points[i] = x*cos - y*sin + X;
|
||||||
|
result.points[i+1] = x*sin + y*cos + Y;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DumbMotion(data: Data, interval: number) {
|
||||||
|
Join(data, "location").forEach(([id, location]) => {
|
||||||
|
location.X += location.VX * interval;
|
||||||
|
location.Y += location.VY * interval;
|
||||||
|
location.Angle += location.VAngle * interval;
|
||||||
|
});
|
||||||
|
}
|
41
src/Ecs/Renderers.ts
Normal file
41
src/Ecs/Renderers.ts
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { Data } from "./Components";
|
||||||
|
import { Join } from "./Data";
|
||||||
|
import { TransformCx } from "./Location";
|
||||||
|
import { DrawSet, Layer } from "../Applet/Render";
|
||||||
|
|
||||||
|
export function RunRenderBounds(data: Data, drawSet: DrawSet) {
|
||||||
|
drawSet.queue(...Join(data, "renderBounds", "location", "bounds").map(
|
||||||
|
([id, {color, layer}, location, {points}]) => layer.toRender((cx, dt) => {
|
||||||
|
TransformCx(cx, location, dt);
|
||||||
|
cx.fillStyle = color;
|
||||||
|
cx.beginPath();
|
||||||
|
for(let i = 0; i < points.length; i += 2) {
|
||||||
|
cx.lineTo(points[i], points[i+1]);
|
||||||
|
}
|
||||||
|
cx.fill("nonzero");
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RunRenderSprites(data: Data, drawSet: DrawSet) {
|
||||||
|
drawSet.queue(...Join(data, "renderSprite", "location").map(
|
||||||
|
([id, {sheet, layer, index, offsetX, offsetY}, location]) => layer.toRender((cx, dt) => {
|
||||||
|
TransformCx(cx, location, dt);
|
||||||
|
sheet.render(cx, index, offsetX, offsetY);
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawDebug(debug: Record<string, any>, drawSet: DrawSet, layer: Layer, width: number, color: string) {
|
||||||
|
drawSet.queue(layer.toRender((cx, dt) => {
|
||||||
|
cx.font = "12px monospace";
|
||||||
|
cx.fillStyle = color;
|
||||||
|
let y = 12;
|
||||||
|
for(const label in debug) {
|
||||||
|
cx.textAlign = "left";
|
||||||
|
cx.textBaseline = "middle";
|
||||||
|
cx.fillText(`${label}: ${JSON.stringify(debug[label])}`, 0, y, width);
|
||||||
|
y += 14;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
174
src/Ecs/test.ts
Normal file
174
src/Ecs/test.ts
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
import { Bind, Game } from "../Applet/Init";
|
||||||
|
import { KeyControl } from "../Applet/Keyboard";
|
||||||
|
import { Loop } from "../Applet/Loop";
|
||||||
|
import { Layer, DrawSet } from "../Applet/Render";
|
||||||
|
import { Data, Location, Polygon, RenderBounds, CollisionClass } from "./Components";
|
||||||
|
import { FindCollisions } from "./Collision";
|
||||||
|
import { Component, Join, Liveness, Remove, Create, Lookup } from "./Data";
|
||||||
|
import { DumbMotion } from "./Location";
|
||||||
|
import { RunRenderBounds } from "./Renderers";
|
||||||
|
|
||||||
|
interface Apple extends Component {}
|
||||||
|
interface Banana extends Component {
|
||||||
|
peeled: boolean
|
||||||
|
}
|
||||||
|
interface Carrot extends Component {
|
||||||
|
cronch: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestData extends Data {
|
||||||
|
entity = [
|
||||||
|
{generation: 5, alive: Liveness.ALIVE},
|
||||||
|
{generation: 5, alive: Liveness.DEAD},
|
||||||
|
{generation: 5, alive: Liveness.ALIVE},
|
||||||
|
{generation: 5, alive: Liveness.ALIVE},
|
||||||
|
{generation: 5, alive: Liveness.INACTIVE},
|
||||||
|
{generation: 5, alive: Liveness.ALIVE},
|
||||||
|
];
|
||||||
|
apple: Apple[] = [
|
||||||
|
{generation: 5},
|
||||||
|
{generation: 5},
|
||||||
|
{generation: -1},
|
||||||
|
{generation: -1},
|
||||||
|
{generation: 5},
|
||||||
|
{generation: 5},
|
||||||
|
];
|
||||||
|
banana: Record<number, Banana> = {
|
||||||
|
3: {generation: 5, peeled: false},
|
||||||
|
4: {generation: 5, peeled: true},
|
||||||
|
};
|
||||||
|
carrot: Record<number, Carrot> = {
|
||||||
|
0: {generation: 5, cronch: 1},
|
||||||
|
1: {generation: 5, cronch: 1},
|
||||||
|
2: {generation: 4, cronch: 10},
|
||||||
|
3: {generation: 5, cronch: 1},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bind("#EcsJoinTest")
|
||||||
|
export class EcsJoinTest {
|
||||||
|
constructor(pre: HTMLElement) {
|
||||||
|
const data = new TestData();
|
||||||
|
pre.innerText = JSON.stringify({
|
||||||
|
"apples": Join(data, "apple"),
|
||||||
|
"bananas": Join(data, "banana"),
|
||||||
|
"carrots": Join(data, "carrot"),
|
||||||
|
"apples+carrots": Join(data, "apple", "carrot"),
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bind("#EcsLookupTest")
|
||||||
|
export class EcsLookupTest {
|
||||||
|
constructor(pre: HTMLElement) {
|
||||||
|
const data = new TestData();
|
||||||
|
const applesMaybeCarrots = Join(data, "apple").map(([id, apple]) => ({
|
||||||
|
apple,
|
||||||
|
maybeCarrot: Lookup(data, id, "carrot")[0]
|
||||||
|
}));
|
||||||
|
pre.innerText = JSON.stringify(applesMaybeCarrots, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bind("#EcsRemoveTest")
|
||||||
|
export class EcsRemoveTest {
|
||||||
|
constructor(pre: HTMLElement) {
|
||||||
|
const data = new TestData();
|
||||||
|
const beforeDelete = Join(data, "apple", "carrot");
|
||||||
|
Remove(data, [0, 5]);
|
||||||
|
const afterDelete = Join(data, "apple", "carrot");
|
||||||
|
pre.innerText = JSON.stringify({
|
||||||
|
beforeDelete,
|
||||||
|
afterDelete
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bind("#EcsCreateTest")
|
||||||
|
export class EcsCreateTest {
|
||||||
|
constructor(pre: HTMLElement) {
|
||||||
|
const data = new TestData();
|
||||||
|
const beforeCreate = Join(data, "apple", "banana", "carrot");
|
||||||
|
const createdId = Create(data, {
|
||||||
|
apple: {},
|
||||||
|
banana: {peeled: false},
|
||||||
|
carrot: {cronch: 11}
|
||||||
|
});
|
||||||
|
const afterCreate = Join(data, "apple", "banana", "carrot");
|
||||||
|
pre.innerText = JSON.stringify({
|
||||||
|
beforeCreate,
|
||||||
|
afterCreate,
|
||||||
|
createdId
|
||||||
|
}, null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Game("#RenderTest")
|
||||||
|
export class LoopTest {
|
||||||
|
data = new Data();
|
||||||
|
|
||||||
|
constructor(public canvas: HTMLCanvasElement, cx: CanvasRenderingContext2D, keys: KeyControl) {
|
||||||
|
const layer = new Layer(0);
|
||||||
|
const drawSet = new DrawSet();
|
||||||
|
|
||||||
|
const spinnerId = Create(this.data, {
|
||||||
|
location: new Location({
|
||||||
|
X: 200,
|
||||||
|
Y: 200,
|
||||||
|
VAngle: Math.PI
|
||||||
|
}),
|
||||||
|
bounds: new Polygon([-50, 50, -60, 250, 60, 250, 50, 50]),
|
||||||
|
collisionTargetClass: new CollisionClass("block"),
|
||||||
|
renderBounds: new RenderBounds(
|
||||||
|
"#0a0",
|
||||||
|
layer
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const triangleId = Create(this.data, {
|
||||||
|
location: new Location({
|
||||||
|
X: 200,
|
||||||
|
Y: 200,
|
||||||
|
VAngle: -Math.PI/10
|
||||||
|
}),
|
||||||
|
bounds: new Polygon([70, 0, 55, 40, 85, 40]),
|
||||||
|
collisionSourceClass: new CollisionClass("tri"),
|
||||||
|
renderBounds: new RenderBounds(
|
||||||
|
"#d40",
|
||||||
|
layer
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
const loop = new Loop(30,
|
||||||
|
interval => {
|
||||||
|
DumbMotion(this.data, interval);
|
||||||
|
|
||||||
|
const [triangleDebug] = Lookup(this.data, triangleId, "renderBounds");
|
||||||
|
(triangleDebug as RenderBounds).color = "#d40";
|
||||||
|
|
||||||
|
FindCollisions(this.data, 500, (className, sourceId, targetId) => {
|
||||||
|
switch(className) {
|
||||||
|
case "tri>block":
|
||||||
|
const [debug] = Lookup(this.data, sourceId, "renderBounds");
|
||||||
|
if(debug) debug.color = "#0ff";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
dt => {
|
||||||
|
cx.fillStyle = "#848";
|
||||||
|
cx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
RunRenderBounds(this.data, drawSet);
|
||||||
|
drawSet.draw(cx, dt);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
|
||||||
|
keys.setHandler({
|
||||||
|
press: key => {
|
||||||
|
if(key == "a") loop.start();
|
||||||
|
else if(key == "b") loop.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
72
src/Game/Death.ts
Normal file
72
src/Game/Death.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
import { PlaySfx } from "../Applet/Audio";
|
||||||
|
import { RenderBounds, Polygon, Location } from "../Ecs/Components";
|
||||||
|
import { Join, Remove, Lookup, Create, Id } from "../Ecs/Data";
|
||||||
|
import { Data, World, Lifetime, Teams } from "./GameComponents";
|
||||||
|
|
||||||
|
export function SelfDestructMinions(data: Data, world: World) {
|
||||||
|
const bossKilled = Join(data, "boss", "hp")
|
||||||
|
.filter(([id, boss, {hp}]) => hp < 0)
|
||||||
|
.length > 0;
|
||||||
|
|
||||||
|
if(bossKilled) {
|
||||||
|
Join(data, "hp")
|
||||||
|
.filter(([id, hp]) => hp.team == Teams.ENEMY)
|
||||||
|
.forEach(([id, hp]) => hp.hp = 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckHp(data: Data, world: World) {
|
||||||
|
Join(data, "hp").forEach(([id, hp]) => {
|
||||||
|
if(hp.hp <= 0) {
|
||||||
|
// remove from game
|
||||||
|
Remove(data, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CheckLifetime(data: Data, world: World, interval: number) {
|
||||||
|
let particles = 0;
|
||||||
|
Join(data, "lifetime").forEach(([id, lifetime]) => {
|
||||||
|
lifetime.time -= interval;
|
||||||
|
if(lifetime.time <= 0) {
|
||||||
|
// remove from game
|
||||||
|
Remove(data, id);
|
||||||
|
}
|
||||||
|
particles++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SmokeDamage(data: Data, world: World) {
|
||||||
|
Join(data, "hp", "location").forEach(([id, hp, {X, Y}]) => {
|
||||||
|
// convert dealt damage to particles
|
||||||
|
const puffs = Math.floor(hp.receivedDamage / 3);
|
||||||
|
SpawnBlast(data, world, X, Y, 2, "#000", puffs);
|
||||||
|
hp.receivedDamage = Math.floor(hp.receivedDamage % 3);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpawnBlast(data: Data, world: World, x: number, y: number, size: number, color: string, count: number) {
|
||||||
|
for(let puff = 0; puff < count; puff++) {
|
||||||
|
const angle = Math.PI * 2 * puff / count;
|
||||||
|
SpawnPuff(data, world, x, y, size, color, angle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpawnPuff(data: Data, world: World, x: number, y: number, size: number, color: string, angle: number): Id {
|
||||||
|
return Create(data, {
|
||||||
|
location: new Location({
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
VX: (Math.random() + 0.5) * 400 * Math.cos(angle),
|
||||||
|
VY: (Math.random() + 0.5) * 400 * -Math.sin(angle)
|
||||||
|
}),
|
||||||
|
bounds: new Polygon([
|
||||||
|
-size, -size,
|
||||||
|
-size, size,
|
||||||
|
size, size,
|
||||||
|
size, -size
|
||||||
|
]),
|
||||||
|
renderBounds: new RenderBounds(color, world.smokeLayer),
|
||||||
|
lifetime: new Lifetime(Math.random() / 3)
|
||||||
|
});
|
||||||
|
}
|
103
src/Game/GameComponents.ts
Normal file
103
src/Game/GameComponents.ts
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
import { Layer, DrawSet } from "../Applet/Render";
|
||||||
|
import { Store } from "../Ecs/Data";
|
||||||
|
import { Data as EcsData } from "../Ecs/Components";
|
||||||
|
|
||||||
|
export enum GamePhase {
|
||||||
|
TITLE,
|
||||||
|
PLAYING,
|
||||||
|
LOST,
|
||||||
|
WON
|
||||||
|
}
|
||||||
|
export type RGB = [number, number, number];
|
||||||
|
export class World {
|
||||||
|
width = 500;
|
||||||
|
height = 400;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Core Game Status
|
||||||
|
*/
|
||||||
|
phase = GamePhase.TITLE;
|
||||||
|
score = 0;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Drawing Layers
|
||||||
|
*/
|
||||||
|
groundLayer = new Layer(0);
|
||||||
|
debugLayer = new Layer(2);
|
||||||
|
bulletLayer = new Layer(10);
|
||||||
|
playerLayer = new Layer(15);
|
||||||
|
smokeLayer = new Layer(16);
|
||||||
|
hudLayer = new Layer(20);
|
||||||
|
|
||||||
|
bgColor: RGB = [255, 255, 255];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catch-all debug tool
|
||||||
|
*/
|
||||||
|
debug: Record<string, any> = {};
|
||||||
|
|
||||||
|
drawHud(drawSet: DrawSet) {
|
||||||
|
drawSet.queue(this.hudLayer.toRender((cx, dt) => {
|
||||||
|
cx.font = "16px monospace";
|
||||||
|
cx.textAlign = "left";
|
||||||
|
cx.textBaseline = "middle";
|
||||||
|
|
||||||
|
const score = `Score: ${this.score}`;
|
||||||
|
cx.fillStyle = "#000";
|
||||||
|
cx.fillText(score, this.width/3 + 1, this.height - 18 + 1, this.width/4);
|
||||||
|
cx.fillStyle = "#0ff";
|
||||||
|
cx.fillText(score, this.width/3, this.height - 18, this.width/4);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Data extends EcsData {
|
||||||
|
boss: Store<Boss> = {};
|
||||||
|
bullet: Store<Bullet> = {};
|
||||||
|
hp: Store<Hp> = {};
|
||||||
|
lifetime: Store<Lifetime> = {};
|
||||||
|
message: Store<Message> = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Teams {
|
||||||
|
PLAYER,
|
||||||
|
ENEMY
|
||||||
|
}
|
||||||
|
export class Bullet {
|
||||||
|
hit = false;
|
||||||
|
constructor(
|
||||||
|
public team: Teams,
|
||||||
|
public attack: number
|
||||||
|
) {};
|
||||||
|
}
|
||||||
|
export class Hp {
|
||||||
|
receivedDamage = 0;
|
||||||
|
constructor(
|
||||||
|
public team: Teams,
|
||||||
|
public hp: number
|
||||||
|
) {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Lifetime {
|
||||||
|
constructor(
|
||||||
|
public time: number
|
||||||
|
) {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Boss {
|
||||||
|
constructor(
|
||||||
|
public name: string
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Message {
|
||||||
|
targetY = 0;
|
||||||
|
constructor(
|
||||||
|
public layer: Layer,
|
||||||
|
public color: string,
|
||||||
|
public message: string,
|
||||||
|
public timeout = 3
|
||||||
|
) {}
|
||||||
|
}
|
69
src/Game/Message.ts
Normal file
69
src/Game/Message.ts
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import { Join, Remove } from "../Ecs/Data";
|
||||||
|
import { Data, World, GamePhase } from "./GameComponents";
|
||||||
|
import { DrawSet } from "../Applet/Render";
|
||||||
|
import { TransformCx } from "../Ecs/Location";
|
||||||
|
|
||||||
|
/*export function SpawnMessage(color: string, text: string) {
|
||||||
|
return function(data: Data, world: World, x: number, timeoutDelta = 0): Id {
|
||||||
|
return Create(data, {
|
||||||
|
location: new Location({
|
||||||
|
X: -world.width,
|
||||||
|
Y: world.height/2,
|
||||||
|
VX: world.width
|
||||||
|
}),
|
||||||
|
message: new Message(world.cloudLayer, color, text, 3 + timeoutDelta)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}*/
|
||||||
|
|
||||||
|
const FONT_SIZE = 16;
|
||||||
|
const ADVANCE = 20;
|
||||||
|
export function ArrangeMessages(data: Data, world: World, interval: number) {
|
||||||
|
const messages = Join(data, "message", "location");
|
||||||
|
messages.sort(([{}, {timeout: timeoutA}, {}], [{}, {timeout: timeoutB}, {}]) => timeoutA - timeoutB);
|
||||||
|
|
||||||
|
let y = world.height / 3;
|
||||||
|
messages.forEach(([id, message, location]) => {
|
||||||
|
message.targetY = y;
|
||||||
|
y += ADVANCE;
|
||||||
|
|
||||||
|
const delta = message.targetY - location.Y;
|
||||||
|
if(Math.abs(delta) < 100 * interval) {
|
||||||
|
location.Y = message.targetY;
|
||||||
|
location.VY = 0;
|
||||||
|
} else {
|
||||||
|
location.VY = Math.sign(delta) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if(location.X >= world.width / 2 && message.timeout >= 0) {
|
||||||
|
location.X = world.width / 2;
|
||||||
|
message.timeout -= interval;
|
||||||
|
location.VX = 0;
|
||||||
|
} else if(world.phase == GamePhase.PLAYING) {
|
||||||
|
location.VX = world.width;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReapMessages(data: Data, {width, height, debug}: World) {
|
||||||
|
let count = 0;
|
||||||
|
Join(data, "message", "location").forEach(([id, message, {X, Y}]) => {
|
||||||
|
count++;
|
||||||
|
if(X > width * 2) {
|
||||||
|
Remove(data, id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenderMessages(data: Data, drawSet: DrawSet) {
|
||||||
|
drawSet.queue(...Join(data, "message", "location").map(
|
||||||
|
([id, {layer, color, message}, location]) => layer.toRender((cx, dt) => {
|
||||||
|
TransformCx(cx, location, dt);
|
||||||
|
cx.font = `${FONT_SIZE}px monospace`;
|
||||||
|
cx.fillStyle = color;
|
||||||
|
cx.textAlign = "center";
|
||||||
|
cx.textBaseline = "middle";
|
||||||
|
cx.fillText(message, 0, 0);
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
12
src/assets.d.ts
vendored
Normal file
12
src/assets.d.ts
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
declare module "*.scss" {
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.ogg" {
|
||||||
|
const url: string;
|
||||||
|
export default url;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.png" {
|
||||||
|
const url: string;
|
||||||
|
export default url;
|
||||||
|
}
|
9
src/index.html
Normal file
9
src/index.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html><head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title></title>
|
||||||
|
<link rel="stylesheet" href="./index.scss" />
|
||||||
|
</head><body>
|
||||||
|
<canvas id="GameCanvas" width="500" height="400"></canvas>
|
||||||
|
<script src="./index.ts"></script>
|
||||||
|
</body></html>
|
18
src/index.scss
Normal file
18
src/index.scss
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
$line-height: 20px;
|
||||||
|
$line-vspace: 6px;
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #444;
|
||||||
|
color: #ffa;
|
||||||
|
margin: 0px;
|
||||||
|
padding: 0px;
|
||||||
|
min-height: 100vh;
|
||||||
|
|
||||||
|
font-size: $line-height - $line-vspace;
|
||||||
|
line-height: $line-height;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
1
src/index.ts
Normal file
1
src/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
import "./Game/Main";
|
8
tsconfig.json
Normal file
8
tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"alwaysStrict": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"strict": true,
|
||||||
|
"target": "es2015",
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue