Engine refactors 1

This commit is contained in:
2025-02-01 20:30:03 -08:00
parent 786a1d2e8d
commit 501d0e4dff
19 changed files with 515 additions and 363 deletions

147
src/engine/datatypes.ts Normal file
View File

@ -0,0 +1,147 @@
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;
}
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);
}
}
export class Size {
readonly w: number;
readonly h: number;
constructor(w: number, h: number) {
this.w = w;
this.h = h;
}
}
export class Grid<T> {
data: T[][];
constructor({size, cbDefault}: {size: Size, cbDefault: (xy: Point) => T}) {
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 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({
size: new Size(w, h),
cbDefault: (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({
size: new Size(w, h),
cbDefault: (xy) => {
return ary[xy.y][xy.x];
}
})
}
}
export enum AlignX {
Left = 0,
Center = 1,
Right = 2
}
export enum AlignY {
Top = 0,
Middle = 1,
Right = 2,
}

View File

View File

@ -0,0 +1,41 @@
class Assets {
#images: Record<string, HTMLImageElement>;
constructor() {
this.#images = {};
}
isLoaded(): boolean {
// you could use this, if so inclined, to check if a certain
// list of assets had been loaded prior to game start
//
// (to do so, you would call getImage() for each desired asset
// and then wait for isLoaded to return true)
for (let filename in this.#images) {
if (!this.#images[filename].complete) {
return false
}
}
return true;
}
getImage(filename: string): HTMLImageElement {
let element: HTMLImageElement;
if (this.#images[filename]) {
element = this.#images[filename];
} else {
element = document.createElement("img");
element.src = filename;
this.#images[filename] = element;
}
return element
}
}
let active: Assets = new Assets();
export function getAssets(): Assets {
return active;
}

View File

@ -0,0 +1,44 @@
const MAX_UPDATES_BANKED: number = 20.0;
// always run physics at 240 hz
const UPDATES_PER_MS: number = 1/(1000.0/240.0);
class Clock {
#lastTimestamp: number | undefined;
#updatesBanked: number
constructor() {
this.#lastTimestamp = undefined;
this.#updatesBanked = 0.0
}
recordTimestamp(timestamp: number) {
if (this.#lastTimestamp) {
let delta = timestamp - this.#lastTimestamp;
this.#bankDelta(delta);
}
this.#lastTimestamp = timestamp;
}
popUpdate() {
// return true if we should do an update right now
// and remove one draw from the bank
if (this.#updatesBanked > 1) {
this.#updatesBanked -= 1;
return true
}
return false;
}
#bankDelta(delta: number) {
this.#updatesBanked = Math.min(delta * UPDATES_PER_MS, MAX_UPDATES_BANKED);
}
}
let active: Clock = new Clock();
export function getClock(): Clock {
return active;
}

View File

@ -0,0 +1,88 @@
import {getScreen} from "./screen.ts";
import {AlignX, AlignY, Color, Point, Size} from "../datatypes.ts";
import {mainFont} from "./font.ts";
import {Sprite} from "./sprite.ts";
class Drawing {
camera: Point;
constructor() {
this.camera = new Point(0, 0);
}
get size() { return getScreen().size; }
invertRect(position: Point, size: Size) {
position = this.camera.negate().offset(position);
let ctx = getScreen().unsafeMakeContext();
ctx.globalCompositeOperation = "difference";
ctx.fillStyle = "#fff";
ctx.fillRect(
Math.floor(position.x),
Math.floor(position.y),
Math.floor(size.w),
Math.floor(size.h)
)
}
fillRect(position: Point, size: Size, color: Color) {
position = this.camera.negate().offset(position);
let ctx = getScreen().unsafeMakeContext();
ctx.fillStyle = color.toStyle();
ctx.fillRect(
Math.floor(position.x),
Math.floor(position.y),
Math.floor(size.w),
Math.floor(size.h)
);
}
drawRect(position: Point, size: Size, color: Color) {
position = this.camera.negate().offset(position);
let ctx = getScreen().unsafeMakeContext();
ctx.strokeStyle = color.toStyle();
ctx.strokeRect(
Math.floor(position.x) + 0.5,
Math.floor(position.y) + 0.5,
Math.floor(size.w),
Math.floor(size.h)
)
}
drawText(text: string, position: Point, color: Color, options?: {alignX?: AlignX, alignY?: AlignY, forceWidth?: number}) {
position = this.camera.negate().offset(position);
let ctx = getScreen().unsafeMakeContext();
mainFont.internalDrawText({
ctx,
text,
position,
alignX: options?.alignX,
alignY: options?.alignY,
forceWidth: options?.forceWidth,
color
})
}
measureText(text: string, forceWidth?: number): Size {
return mainFont.measureText({text, forceWidth})
}
drawSprite(sprite: Sprite, position: Point, ix?: number, options?: {xScale?: number, yScale: number, angle: number}) {
position = this.camera.negate().offset(position);
let ctx = getScreen().unsafeMakeContext();
sprite.internalDraw(ctx, {position, ix, xScale: options?.xScale, yScale: options?.yScale, angle: options?.angle})
}
}
let active: Drawing = new Drawing();
export function getDrawing(): Drawing {
return active;
}

133
src/engine/internal/font.ts Normal file
View File

@ -0,0 +1,133 @@
import {getAssets} from "./assets.ts";
import fontSheet from '../../art/fonts/vga_8x16.png';
import {AlignX, AlignY, Color, Point, Size} from "../datatypes.ts";
class Font {
#filename: string;
#cellsPerSheet: Size;
#pixelsPerCell: Size;
#tintingCanvas: HTMLCanvasElement;
#tintedVersions: Record<string, HTMLImageElement>;
constructor(filename: string, cellsPerSheet: Size, pixelsPerCell: Size) {
this.#filename = filename;
this.#cellsPerSheet = cellsPerSheet;
this.#pixelsPerCell = pixelsPerCell;
this.#tintingCanvas = document.createElement("canvas");
this.#tintedVersions = {}
}
get #cx(): number { return this.#cellsPerSheet.w }
get #cy(): number { return this.#cellsPerSheet.h }
get #px(): number { return this.#pixelsPerCell.w }
get #py(): number { return this.#pixelsPerCell.h }
#getTintedImage(color: string): HTMLImageElement | null {
let image = getAssets().getImage(this.#filename);
if (!image.complete) { return null; }
let tintedVersion = this.#tintedVersions[color];
if (tintedVersion != undefined) {
return tintedVersion;
}
let w = image.width;
let h = image.height;
if (!(w == this.#cx * this.#px && h == this.#cy * this.#py)) {
throw `unexpected image dimensions for font ${this.#filename}: ${w} x ${h}`
}
this.#tintingCanvas.width = w;
this.#tintingCanvas.height = h;
let ctx = this.#tintingCanvas.getContext("2d")!;
ctx.clearRect(0, 0, w, h);
ctx.drawImage(image, 0, 0);
ctx.globalCompositeOperation = "source-in";
ctx.fillStyle = color;
ctx.rect(0, 0, w, h);
ctx.fill();
let result = new Image();
result.src = this.#tintingCanvas.toDataURL();
this.#tintedVersions[color] = result;
return result;
}
internalDrawText({ctx, text, position, alignX, alignY, forceWidth, color}: {
ctx: CanvasRenderingContext2D,
text: string,
position: Point, alignX?: AlignX, alignY?: AlignY,
forceWidth?: number, color: Color
}) {
alignX = alignX == undefined ? AlignX.Left : alignX;
alignY = alignY == undefined ? AlignY.Top : alignY;
forceWidth = forceWidth == undefined ? 65535 : forceWidth;
let image = this.#getTintedImage(color.toStyle())
if (image == null) {
return;
}
let sz = this.#glyphwise(text, forceWidth, () => {});
let offsetX = position.x;
let offsetY = position.y;
offsetX += (alignX == AlignX.Left ? 0 : alignX == AlignX.Center ? -sz.w / 2 : - sz.w)
offsetY += (alignY == AlignY.Top ? 0 : alignY == AlignY.Middle ? -sz.h / 2 : - sz.h)
this.#glyphwise(text, forceWidth, (cx, cy, char) => {
let srcIx = char.charCodeAt(0);
this.#drawGlyph({ctx: ctx, image: image, ix: srcIx, x: offsetX + cx * this.#px, y: offsetY + cy * this.#py});
})
}
#drawGlyph({ctx, image, ix, x, y}: {ctx: CanvasRenderingContext2D, image: HTMLImageElement, ix: number, x: number, y: number}) {
let srcCx = ix % this.#cx;
let srcCy = Math.floor(ix / this.#cx);
let srcPx = srcCx * this.#px;
let srcPy = srcCy * this.#py;
ctx.drawImage(
image,
srcPx, srcPy, this.#px, this.#py,
Math.floor(x), Math.floor(y), this.#px, this.#py
);
}
measureText({text, forceWidth}: {text: string, forceWidth?: number}): Size {
return this.#glyphwise(text, forceWidth, () => {});
}
#glyphwise(text: string, forceWidth: number | undefined, callback: (x: number, y: number, char: string) => void): {w: number, h: number} {
let cx = 0;
let cy = 0;
let cw = 0;
let ch = 0;
let wcx = forceWidth == undefined ? undefined : Math.floor(forceWidth / this.#px);
for (let i = 0; i < text.length; i++) {
let char = text[i]
if (char == '\n') {
cx = 0;
cy += 1;
ch = cy + 1;
} else {
// console.log("cx, cy, char", [cx, cy, char])
if (wcx != undefined && cx >= wcx) {
cx = 0;
cy += 1;
ch = cy + 1;
}
callback(cx, cy, char)
cx += 1;
cw = Math.max(cw, cx);
ch = cy + 1;
}
}
return { w: cw * this.#px, h: ch * this.#py };
}
}
export let mainFont = new Font(fontSheet, new Size(32, 8), new Size(8, 16));

View File

@ -0,0 +1,34 @@
import './style.css'
import {pollAndTouch} from "./screen.ts";
import {getClock} from "./clock.ts";
import {getInput, setupInput} from "./input.ts";
import {IGame} from "../datatypes.ts";
export function hostGame(game: IGame) {
let gameCanvas = document.getElementById("game") as HTMLCanvasElement;
setupInput(gameCanvas);
onFrame(game, undefined); // start on-frame draw loop, set up screen
}
function onFrame(game: IGame, timestamp: number | undefined) {
let gameCanvas = document.getElementById("game") as HTMLCanvasElement;
requestAnimationFrame((timestamp: number) => onFrame(game, timestamp));
if (timestamp) {
getClock().recordTimestamp(timestamp);
}
onFrameFixScreen(gameCanvas);
while (getClock().popUpdate()) {
game.update();
getInput().update();
}
game.draw();
}
function onFrameFixScreen(canvas: HTMLCanvasElement) {
pollAndTouch(canvas);
}

View File

@ -0,0 +1,120 @@
import {getScreen} from "./screen.ts";
import {Point} from "../datatypes.ts";
function handleKey(e: KeyboardEvent, down: boolean) {
active.handleKeyDown(e.key, down);
}
function handleMouseOut() {
active.handleMouseMove(-1, -1);
}
function handleMouseMove(canvas: HTMLCanvasElement, m: MouseEvent) {
if (canvas.offsetWidth == 0 || canvas.offsetHeight == 0) {
return;
}
active.handleMouseMove(m.offsetX / canvas.offsetWidth, m.offsetY / canvas.offsetHeight);
}
function handleMouseButton(canvas: HTMLCanvasElement, m: MouseEvent, down: boolean) {
if (canvas.offsetWidth == 0 || canvas.offsetHeight == 0) {
return;
}
active.handleMouseMove(m.offsetX / canvas.offsetWidth, m.offsetY / canvas.offsetHeight);
let button: MouseButton | null = (
m.button == 0 ? "leftMouse" :
m.button == 1 ? "rightMouse" :
null
)
if (button != null) {
active.handleMouseDown(button, down);
}
}
export function setupInput(canvas: HTMLCanvasElement) {
canvas.addEventListener("keyup", (k) => handleKey(k, false));
document.addEventListener("keyup", (k) => handleKey(k, false));
canvas.addEventListener("keydown", (k) => handleKey(k, true));
document.addEventListener("keydown", (k) => handleKey(k, true));
canvas.addEventListener("mouseout", (_) => handleMouseOut());
canvas.addEventListener("mousemove", (m) => handleMouseMove(canvas, m));
canvas.addEventListener("mousedown", (m) => handleMouseButton(canvas, m, true));
canvas.addEventListener("mouseup", (m) => handleMouseButton(canvas, m, false));
}
export type MouseButton = "leftMouse" | "rightMouse";
class Input {
#keyDown: Record<string, boolean>;
#previousKeyDown: Record<string, boolean>;
#mouseDown: Record<string, boolean>;
#previousMouseDown: Record<string, boolean>;
#mousePosition: Point | null;
constructor() {
this.#keyDown = {};
this.#previousKeyDown = {};
this.#mouseDown = {};
this.#previousMouseDown = {};
this.#mousePosition = null;
}
update() {
this.#previousKeyDown = {...this.#keyDown};
this.#previousMouseDown = {...this.#mouseDown};
}
handleMouseDown(name: string, down: boolean) {
this.#mouseDown[name] = down;
}
handleKeyDown(name: string, down: boolean) {
this.#keyDown[name] = down;
}
handleMouseMove(x: number, y: number) {
let screen = getScreen();
if (x < 0.0 || x >= 1.0) { this.#mousePosition = null; }
if (y < 0.0 || y >= 1.0) { this.#mousePosition = null; }
let w = screen.size.w;
let h = screen.size.h;
this.#mousePosition = new Point(
Math.floor(x * w),
Math.floor(y * h),
)
}
isMouseDown(btn: MouseButton) : boolean {
return this.#mouseDown[btn];
}
isMouseClicked(btn: MouseButton) : boolean {
return this.#mouseDown[btn] && !this.#previousMouseDown[btn];
}
isMouseReleased(btn: MouseButton) : boolean {
return !this.#mouseDown[btn] && this.#previousMouseDown[btn];
}
get mousePosition(): Point | null {
return this.#mousePosition
}
isKeyDown(key: string) : boolean {
return this.#keyDown[key];
}
isKeyPressed(key: string) : boolean {
return this.#keyDown[key] && !this.#previousKeyDown[key];
}
isKeyReleased(key: string) : boolean {
return !this.#keyDown[key] && this.#previousKeyDown[key];
}
}
let active = new Input();
export function getInput(): Input {
return active;
}

View File

@ -0,0 +1,66 @@
import {Size} from "../datatypes.ts";
// TODO: Just switch to the same pattern as everywhere else
// (without repeatedly reassigning the variable)
class Screen {
#canvas: HTMLCanvasElement
size: Size
constructor(canvas: HTMLCanvasElement, size: Size) {
this.#canvas = canvas;
this.size = size
}
unsafeMakeContext(): CanvasRenderingContext2D {
let ctx = this.#canvas.getContext("2d")!;
// TODO: Other stuff to do here?
ctx.globalCompositeOperation = "source-over";
ctx.filter = "none";
ctx.strokeStyle = "#000";
ctx.fillStyle = "#000";
ctx.resetTransform();
ctx.imageSmoothingEnabled = false;
return ctx;
}
}
let active: Screen | undefined = undefined
export let desiredWidth = 384;
export let desiredHeight = 384;
export function pollAndTouch(canvas: HTMLCanvasElement) {
let realWidth = canvas.offsetWidth;
let realHeight = canvas.offsetHeight;
let divisors = [0.25, 0.5, 1, 2, 3, 4, 5, 6];
for (let i = 0; i < divisors.length; i++) {
// TODO: Is this necessary?
divisors[i] /= window.devicePixelRatio;
}
let div = 0;
while (
(div < divisors.length - 1) &&
(realWidth / divisors[div + 1] >= desiredWidth) &&
(realHeight / divisors[div + 1] >= desiredHeight)
) {
div += 1;
}
realWidth = Math.floor(canvas.offsetWidth / divisors[div]);
realHeight = Math.floor(canvas.offsetHeight / divisors[div]);
canvas.width = realWidth;
canvas.height = realHeight;
active = new Screen(canvas, new Size(realWidth, realHeight));
}
export function getScreen(): Screen {
if (active === undefined) {
throw `screen should have been defined: ${active}`
}
return active;
}

View File

@ -0,0 +1,47 @@
import {getAssets} from "./assets.ts";
import {Point, Size} from "../datatypes.ts";
export class Sprite {
readonly imageSet: string;
// spritesheet params
readonly pixelsPerSubimage: Size;
readonly origin: Point;
readonly cellsPerSheet: Size;
// number of frames
readonly nFrames: number;
constructor(imageSet: string, pixelsPerSubimage: Size, origin: Point, cellsPerSheet: Size, nFrames: number) {
this.imageSet = imageSet;
this.pixelsPerSubimage = pixelsPerSubimage;
this.origin = origin;
this.cellsPerSheet = cellsPerSheet;
this.nFrames = nFrames;
let nPossibleFrames = this.cellsPerSheet.w * this.cellsPerSheet.h;
if (this.nFrames > nPossibleFrames) {
throw `can't have ${this.nFrames} with a spritesheet dimension of ${nPossibleFrames}`;
}
}
internalDraw(ctx: CanvasRenderingContext2D, {position, ix, xScale, yScale, angle}: {position: Point, ix?: number, xScale?: number, yScale?: number, angle?: number}) {
ix = ix == undefined ? 0 : ix;
xScale = xScale == undefined ? 1.0 : xScale;
yScale = yScale == undefined ? 1.0 : yScale;
angle = angle == undefined ? 0.0 : angle;
// ctx.translate(Math.floor(x), Math.floor(y));
ctx.translate(position.x, position.y);
ctx.rotate(angle * Math.PI / 180);
ctx.scale(xScale, yScale);
ctx.translate(-this.origin.x, -this.origin.y);
let me = getAssets().getImage(this.imageSet);
let srcCx = ix % this.cellsPerSheet.w;
let srcCy = Math.floor(ix / this.cellsPerSheet.w);
let srcPx = srcCx * this.pixelsPerSubimage.w;
let srcPy = srcCy * this.pixelsPerSubimage.h;
console.log(`src px and py ${srcPx} ${srcPy}`)
ctx.drawImage(me, srcPx, srcPy, this.pixelsPerSubimage.w, this.pixelsPerSubimage.h, 0, 0, this.pixelsPerSubimage.w, this.pixelsPerSubimage.h);
}
}

View File

@ -0,0 +1,12 @@
html, body {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#game {
image-rendering: pixelated;
width: 100%;
height: 100%;
}

8
src/engine/public.ts Normal file
View File

@ -0,0 +1,8 @@
import {getInput} from "./internal/input.ts";
import {getDrawing} from "./internal/drawing.ts";
// input reexports
export let I = getInput();
// drawing reexports
export let D = getDrawing();