388 lines
8.6 KiB
TypeScript
388 lines
8.6 KiB
TypeScript
export interface IGame {
|
|
update(): void;
|
|
draw(): void;
|
|
}
|
|
|
|
export class Color {
|
|
readonly r: number;
|
|
readonly g: number;
|
|
readonly b: number;
|
|
readonly a: number;
|
|
|
|
constructor(r: number, g: number, b: number, a?: number) {
|
|
this.r = r;
|
|
this.g = g;
|
|
this.b = b;
|
|
this.a = a ?? 255;
|
|
}
|
|
|
|
static parseHexCode(hexCode: string) {
|
|
const regex1 =
|
|
/#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})?/;
|
|
const regex2 =
|
|
/#([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})([0-9a-fA-F]{1})?/;
|
|
let result = regex1.exec(hexCode) ?? regex2.exec(hexCode);
|
|
if (result == null) {
|
|
throw `could not parse color: ${hexCode}`;
|
|
}
|
|
|
|
let parseGroup = (s: string | undefined): number => {
|
|
if (s === undefined) {
|
|
return 255;
|
|
}
|
|
if (s.length == 1) {
|
|
return 17 * parseInt(s, 16);
|
|
}
|
|
return parseInt(s, 16);
|
|
};
|
|
return new Color(
|
|
parseGroup(result[1]),
|
|
parseGroup(result[2]),
|
|
parseGroup(result[3]),
|
|
parseGroup(result[4]),
|
|
);
|
|
}
|
|
|
|
toStyle(): string {
|
|
return `rgba(${this.r}, ${this.g}, ${this.b}, ${this.a / 255.0})`;
|
|
}
|
|
}
|
|
|
|
export class Point {
|
|
readonly x: number;
|
|
readonly y: number;
|
|
|
|
constructor(x: number, y: number) {
|
|
this.x = x;
|
|
this.y = y;
|
|
}
|
|
|
|
toString(): string {
|
|
return `${this.x},${this.y}`;
|
|
}
|
|
|
|
offset(other: Point | Size): Point {
|
|
if (other instanceof Point) {
|
|
return new Point(this.x + other.x, this.y + other.y);
|
|
}
|
|
|
|
return new Point(this.x + other.w, this.y + other.h);
|
|
}
|
|
|
|
negate() {
|
|
return new Point(-this.x, -this.y);
|
|
}
|
|
|
|
equals(other: Point): boolean {
|
|
return this.x == other.x && this.y == other.y;
|
|
}
|
|
|
|
scale(other: Point | Size) {
|
|
if (other instanceof Point) {
|
|
return new Point(this.x * other.x, this.y * other.y);
|
|
}
|
|
return new Point(this.x * other.w, this.y * other.h);
|
|
}
|
|
|
|
unscale(other: Point | Size) {
|
|
if (other instanceof Point) {
|
|
return new Point(this.x / other.x, this.y / other.y);
|
|
}
|
|
return new Point(this.x / other.w, this.y / other.h);
|
|
}
|
|
|
|
subtract(top: Point): Size {
|
|
return new Size(this.x - top.x, this.y - top.y);
|
|
}
|
|
|
|
manhattan(other: Point) {
|
|
return Math.abs(this.x - other.x) + Math.abs(this.y - other.y);
|
|
}
|
|
|
|
snap(x: number, y: number) {
|
|
return new Point(
|
|
lerp(x, Math.floor(this.x), Math.ceil(this.x)),
|
|
lerp(y, Math.floor(this.y), Math.ceil(this.y)),
|
|
);
|
|
}
|
|
|
|
distance(other: Point) {
|
|
let dx = other.x - this.x;
|
|
let dy = other.y - this.y;
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
neighbors(): Point[] {
|
|
return [
|
|
new Point(this.x, this.y - 1),
|
|
new Point(this.x - 1, this.y),
|
|
new Point(this.x, this.y + 1),
|
|
new Point(this.x + 1, this.y),
|
|
];
|
|
}
|
|
}
|
|
|
|
export class Size {
|
|
readonly w: number;
|
|
readonly h: number;
|
|
|
|
constructor(w: number, h: number) {
|
|
this.w = w;
|
|
this.h = h;
|
|
}
|
|
|
|
add(other: Size) {
|
|
return new Size(this.w + other.w, this.h + other.h);
|
|
}
|
|
|
|
equals(other: Size) {
|
|
return this.w == other.w && this.h == other.h;
|
|
}
|
|
|
|
toString(): string {
|
|
return `${this.w}x${this.h}`;
|
|
}
|
|
}
|
|
|
|
export class Circle {
|
|
readonly center: Point;
|
|
readonly radius: number;
|
|
|
|
constructor(center: Point, radius: number) {
|
|
this.center = center;
|
|
this.radius = radius;
|
|
}
|
|
|
|
getContactWithRect(rect: Rect): Point | null {
|
|
// port: https://www.jeffreythompson.org/collision-detection/circle-rect.php
|
|
let cx = this.center.x;
|
|
let cy = this.center.y;
|
|
let testX = this.center.x;
|
|
let testY = this.center.y;
|
|
|
|
let rx = rect.top.x;
|
|
let ry = rect.top.y;
|
|
let rw = rect.size.w;
|
|
let rh = rect.size.h;
|
|
|
|
if (cx < rx) {
|
|
testX = rx;
|
|
} else if (cx > rx + rw) {
|
|
testX = rx + rw;
|
|
}
|
|
if (cy < ry) {
|
|
testY = ry;
|
|
} else if (cy > ry + rh) {
|
|
testY = ry + rh;
|
|
}
|
|
|
|
let distX = cx - testX;
|
|
let distY = cy - testY;
|
|
let sqDistance = distX * distX + distY * distY;
|
|
|
|
if (sqDistance <= this.radius * this.radius) {
|
|
return new Point(testX, testY);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
overlappedCells(size: Size): Rect[] {
|
|
let meAsRect = new Rect(
|
|
this.center.offset(new Point(-this.radius, -this.radius)),
|
|
new Size(this.radius * 2, this.radius * 2),
|
|
);
|
|
let all: Rect[] = [];
|
|
for (let cell of meAsRect.overlappedCells(size).values()) {
|
|
if (this.getContactWithRect(cell) != null) {
|
|
all.push(cell);
|
|
}
|
|
}
|
|
return all;
|
|
}
|
|
}
|
|
|
|
export class Rect {
|
|
readonly top: Point;
|
|
readonly size: Size;
|
|
constructor(top: Point, size: Size) {
|
|
this.top = top;
|
|
this.size = size;
|
|
}
|
|
|
|
toString(): string {
|
|
return `Rect(${this.top},${this.size})`;
|
|
}
|
|
|
|
offset(offset: Point) {
|
|
return new Rect(this.top.offset(offset), this.size);
|
|
}
|
|
|
|
contains(other: Point) {
|
|
return (
|
|
other.x >= this.top.x &&
|
|
other.y >= this.top.y &&
|
|
other.x < this.top.x + this.size.w &&
|
|
other.y < this.top.y + this.size.h
|
|
);
|
|
}
|
|
|
|
overlappedCells(size: Size) {
|
|
let x0 = this.top.x;
|
|
let y0 = this.top.y;
|
|
let x1 = x0 + this.size.w;
|
|
let y1 = y0 + this.size.w;
|
|
|
|
let cx0 = Math.floor(x0 / size.w);
|
|
let cy0 = Math.floor(y0 / size.h);
|
|
let cx1 = Math.ceil(x1 / size.w);
|
|
let cy1 = Math.ceil(y1 / size.h);
|
|
|
|
let cells = [];
|
|
for (let cy = cy0; cy < cy1; cy++) {
|
|
for (let cx = cx0; cx < cx1; cx++) {
|
|
let px0 = cx * size.w;
|
|
let py0 = cy * size.h;
|
|
cells.push(new Rect(new Point(px0, py0), size));
|
|
}
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
overlaps(other: Rect) {
|
|
let ax0 = this.top.x;
|
|
let ay0 = this.top.y;
|
|
let ax1 = ax0 + this.size.w;
|
|
let ay1 = ay0 + this.size.h;
|
|
let bx0 = other.top.x;
|
|
let by0 = other.top.y;
|
|
let bx1 = bx0 + other.size.w;
|
|
let by1 = by0 + other.size.h;
|
|
|
|
let noOverlap = ax0 > bx1 || bx0 > ax1 || ay0 > by1 || by0 > ay1;
|
|
return !noOverlap;
|
|
}
|
|
}
|
|
|
|
export class Grid<T> {
|
|
readonly size: Size;
|
|
#data: T[][];
|
|
|
|
constructor(size: Size, cbDefault: (xy: Point) => T) {
|
|
this.size = size;
|
|
this.#data = [];
|
|
|
|
for (let y = 0; y < size.h; y++) {
|
|
let row = [];
|
|
for (let x = 0; x < size.w; x++) {
|
|
row.push(cbDefault(new Point(x, y)));
|
|
}
|
|
this.#data.push(row);
|
|
}
|
|
}
|
|
|
|
static createGridFromMultilineString(multiline: string): Grid<string> {
|
|
let lines = [];
|
|
for (let line of multiline.split("\n")) {
|
|
let trimmedLine = line.trim();
|
|
if (trimmedLine == "") {
|
|
continue;
|
|
}
|
|
lines.push(trimmedLine);
|
|
}
|
|
return this.createGridFromStringArray(lines);
|
|
}
|
|
|
|
static createGridFromStringArray(ary: Array<string>): Grid<string> {
|
|
let w = 0;
|
|
let h = ary.length;
|
|
for (let i = 0; i < h - 1; i++) {
|
|
let w1 = ary[i].length;
|
|
let w2 = ary[i + 1].length;
|
|
if (w1 != w2) {
|
|
throw `createGridFromStringArray: must be grid-shaped, got ${ary}`;
|
|
}
|
|
w = w1;
|
|
}
|
|
|
|
return new Grid(new Size(w, h), (xy) => {
|
|
return ary[xy.y].charAt(xy.x);
|
|
});
|
|
}
|
|
|
|
static createGridFromJaggedArray<T>(ary: Array<Array<T>>): Grid<T> {
|
|
let w = 0;
|
|
let h = ary.length;
|
|
for (let i = 0; i < h - 1; i++) {
|
|
let w1 = ary[i].length;
|
|
let w2 = ary[i + 1].length;
|
|
if (w1 != w2) {
|
|
throw `createGridFromJaggedArray: must be grid-shaped, got ${ary}`;
|
|
}
|
|
w = w1;
|
|
}
|
|
|
|
return new Grid(new Size(w, h), (xy) => {
|
|
return ary[xy.y][xy.x];
|
|
});
|
|
}
|
|
|
|
map<T2>(cbCell: (content: T, position: Point) => T2) {
|
|
return new Grid(this.size, (xy) => cbCell(this.get(xy), xy));
|
|
}
|
|
|
|
#invalidPosition(position: Point): boolean {
|
|
return (
|
|
position.x < 0 ||
|
|
position.x >= this.size.w ||
|
|
Math.floor(position.x) != position.x ||
|
|
position.y < 0 ||
|
|
position.y >= this.size.h ||
|
|
Math.floor(position.y) != position.y
|
|
);
|
|
}
|
|
#checkPosition(position: Point) {
|
|
if (this.#invalidPosition(position)) {
|
|
throw new Error(`invalid position for ${this.size}: ${position}`);
|
|
}
|
|
}
|
|
|
|
maybeGet(position: Point): T | null {
|
|
if (this.#invalidPosition(position)) {
|
|
return null;
|
|
}
|
|
return this.#data[position.y][position.x];
|
|
}
|
|
|
|
get(position: Point): T {
|
|
this.#checkPosition(position);
|
|
return this.#data[position.y][position.x];
|
|
}
|
|
|
|
set(position: Point, value: T) {
|
|
this.#checkPosition(position);
|
|
this.#data[position.y][position.x] = value;
|
|
}
|
|
}
|
|
|
|
export enum AlignX {
|
|
Left = 0,
|
|
Center = 1,
|
|
Right = 2,
|
|
}
|
|
|
|
export enum AlignY {
|
|
Top = 0,
|
|
Middle = 1,
|
|
Bottom = 2,
|
|
}
|
|
|
|
export function lerp(amt: number, lo: number, hi: number) {
|
|
if (amt <= 0) {
|
|
return lo;
|
|
}
|
|
if (amt >= 1) {
|
|
return hi;
|
|
}
|
|
return lo + (hi - lo) * amt;
|
|
}
|