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