Import code from Ludum Dare/Jam 2018 "Sacrifices" that looks reusable.

This commit is contained in:
Tangent Wantwight 2019-12-14 18:11:00 -05:00
parent 9914c2e7bd
commit e8415da145
24 changed files with 8680 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
.cache/
node_modules/
dist/

7307
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

17
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
import "./Game/Main";

8
tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"alwaysStrict": true,
"experimentalDecorators": true,
"strict": true,
"target": "es2015",
}
}