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 { 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 { 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): Grid { 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(ary: Array>): Grid { 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(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; }