diff --git a/engine/.gitignore b/engine/.gitignore new file mode 100644 index 000000000..9e4ee9fa2 --- /dev/null +++ b/engine/.gitignore @@ -0,0 +1,131 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +.DS_Store + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/engine/LICENSE b/engine/LICENSE new file mode 100644 index 000000000..630b498f4 --- /dev/null +++ b/engine/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Hack Club + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/engine/README.md b/engine/README.md new file mode 100644 index 000000000..8ab15fc5a --- /dev/null +++ b/engine/README.md @@ -0,0 +1,415 @@ +# 👾 Sprig Engine 👾 + +[Sprig](https://github.com/hackclub/sprig/) is an open source game console, web-based game editor, and hardware development kit. + +This is the standalone engine as used in the web editor. It's published as a package on [NPM](https://docs.npmjs.com/about-npm) so you can run Sprig games in your own projects and websites. + +If you're into that kind of thing, this also comes with full TypeScript types included. + +[Check out the docs for the core engine functions in the main repository.](https://github.com/hackclub/sprig/blob/main/docs/docs.md) + +## Table of contents + + + +- [Quickstart (web)](#quickstart-web) + - [Node.js and bundlers](#nodejs-and-bundlers) + - [Using Sprig functions](#using-sprig-functions) +- [Common use cases](#common-use-cases) + - [Running a game from a string](#running-a-game-from-a-string) + - [Stopping/reloading games](#stoppingreloading-games) +- [Advanced use cases](#advanced-use-cases) + - [Base engine](#base-engine) + - [ImageData engine](#imagedata-engine) + - [Get the Sprig palette](#get-the-sprig-palette) + - [Read game state](#read-game-state) + - [TypeScript](#typescript) + - [SSR Support](#ssr-support) +- [API Reference](#api-reference) + - [sprig](#sprig) + - [sprig/base](#sprigbase) + - [sprig/image-data](#sprigimage-data) + - [sprig/web](#sprigweb) +- [Contributing](#contributing) + + + +## Quickstart (web) + +```html + + + + +``` + +(See [examples/simple-game.html](https://github.com/hackclub/sprig-engine/blob/main/examples/simple-game.html) for a full example. [See this deployed live!](https://hackclub.github.io/sprig-engine/examples/simple-game.html)) + +### Node.js and bundlers + +If you're using Node.js or a bundler, you will want to install Sprig using a package manager: + +``` +$ npm install sprig +``` + +And import it: + +```js +import { webEngine } from "sprig/web" +``` + +### Using Sprig functions + +In the quickstart example, Sprig functions like `setLegend`, `playTune`, etc. are passed to the `runGame` function in one large object. They can be accessed like so: + +```js +function runGame(api) { + api.playTune(api.tune`...`) +} +``` + +Or you can "destructure" everything you need at the top of `runGame`, allowing you to write Sprig games like you might in the editor: + +```js +function runGame(api) { + const { setLegend, playTune, tune } = api + + playTune(tune`...`) +} +``` + +## Common use cases + +### Running a game from a string + +You might want to run a game stored in a string. For example, you might be building your own Sprig editor, or you might want to read games from a URL. + +We recommend you create a function with a parameter for each Sprig function. This will allow the functions to be treated as global within the code. + +This is what the Sprig editor does: + +```js +const code = "playTune(tune`...`)" // For example. + +const game = webEngine(canvas) +const fn = new Function(...Object.keys(game.api), code) +fn(...Object.values(game.api)) +``` + +The [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/fetch) is a great way to load games from a file hosted on the web: + +```js +const response = await fetch("https://example.com/sprig-game.js") +const code = await response.text() + +// Run the game as before. +``` + +### Stopping/reloading games + +You might want to stop a game, perhaps to run new code. The web engine exposes a cleanup function for this purpose: + +```js +const game = webEngine(canvas) +runGame(game.api) +// ... +game.cleanup() +``` + +This will: + +- Clear the screen +- Stop drawing anything to the screen +- Stop listening for key presses +- End any tunes that are playing + +**It will *not* cancel any timers.** `setTimeout` and `setInterval` are functions provided by the browser and Sprig doesn't mess with them by default. If you want to cancel timers on cleanup, you can implement your own timer functions as game functions and use those in your game instead: + +```js +const game = webEngine(canvas) + +const timeouts = [] +const intervals = [] +const customApi = { + ...game.api, + setTimeout: (fn, ms) => { + const timer = setTimeout(fn, ms) + timeouts.push(timer) + return timer + }, + setInterval: (fn, ms) => { + const timer = setInterval(fn, ms) + intervals.push(timer) + return timer + } +} + +function customCleanup() { + game.cleanup() + timeouts.forEach(clearTimeout) + intervals.forEach(clearInterval) +} + +runGame(customApi) +// ... +customCleanup() +``` + +## Advanced use cases + +### Base engine + +All examples up to this point have been based on the web engine, a wrapper on top of Sprig's core engine that implements Canvas rendering, input handling, and tunes. + +This package does include the base engine which can run in more environments and you can build upon. Because it's missing many implementations provided by the web engine, the base engine's returned API only contains a subset of engine functions. + +```js +import { baseEngine } from "sprig/base" + +const game = baseEngine() +runGame(game.api) +``` + +If you want to build on top of the base engine, looking at the [web engine source code](https://github.com/hackclub/sprig-engine/blob/main/src/web/index.ts) is a great place to start! + +### ImageData engine + +This includes an alternate engine layered on top of the base engine which is able to render games to static [ImageData](https://developer.mozilla.org/en-US/docs/Web/API/ImageData) objects in a headless capacity. This is used to implement the 3D interactive game console on the [Sprig homepage](https://sprig.hackclub.com/). + +To use this engine: + +```js +const { imageDataEngine } from "sprig/image-data" + +const game = imageDataEngine() +runGame(game.api) +game.button("w") // Press W key. + +const imageData = game.render() +``` + +### Get the Sprig palette + +You can access the palette colors by importing it from the base engine: + +```js +import { palette } from "sprig/base" +``` + +The palette is provided as an array of RGBA colors. Each palette item is a 2-element array: + +- Single char color key as a string. (Example: `"3"` for red) +- 4-element array representing an RGBA color. (Example: `[0, 0, 0, 255]` for black) + +> **Why an array and not an object?** +> +> The palette is an array because there's generally a defined order for the pallete items. This allows you to iterate over the palette and, for example, render a color picker. +> +> This unfortunately makes it harder to look up a color by key. + +### Read game state + +The base, web, and ImageData engines all return a `game.state` field which can be used to read the current game state. + +```js +const game = webEngine(canvas) +runGame(game.api) + +// ... + +const screenWidth = game.state.dimensions.width +``` + +You can see all state fields in the `GameState` interface in [src/base/index.ts](https://github.com/hackclub/sprig-engine/blob/main/src/base/index.ts). + +### TypeScript + +This package has full TypeScript types included. + +The standard API required for interop with all existing Sprig games is exported from the main package: + +```ts +import type { FullSprigAPI } from "sprig" +``` + +You can use this type to verify API compatibility. The packaged web and base engines each only export certain subsets of this; for example, the web engine does not implement its own `setInterval` function because this is expected to be provided by the browser. + +Most other types are exported from the base package. + +### SSR Support + +This whole package, including `sprig/web`, should be SSR-compatible. This means you can import it in a Node.js environment without errors. Please report an issue if this is not the case! + +SSR-compatibility does *not* mean that all functions will work properly on the server. For example, the tune engine waits until `playTune` is called to initialize the `AudioContext`. This means the package can be *imported* on the server, but `playTune` will error in non-browser environments. + +## API Reference + + + +### sprig + +```ts +const VALID_INPUTS: readonly ["w", "s", "a", "d", "i", "j", "k", "l"] +type InputKey = typeof VALID_INPUTS[number] +interface AddTextOptions { + x?: number; + y?: number; + color?: string; +} +class SpriteType { + type: string; + x: number; + y: number; + readonly dx: number; + readonly dy: number; + remove(): void; +} +type Rgba = [ + number, + number, + number, + number +] +interface TextElement { + x: number; + y: number; + color: Rgba; + content: string; +} +interface GameState { + legend: [ + string, + string + ][]; + texts: TextElement[]; + dimensions: { + width: number; + height: number; + }; + sprites: SpriteType[]; + solids: string[]; + pushable: Record; + background: string | null; +} +interface PlayTuneRes { + end(): void; + isPlaying(): boolean; +} +const tones: Record +const instruments: readonly ["sine", "triangle", "square", "sawtooth"] +type InstrumentType = typeof instruments[number] +const instrumentKey: Record +const reverseInstrumentKey: Record<"sine" | "triangle" | "square" | "sawtooth", string> +type Tune = [ + number, + ...(InstrumentType | number | string)[] +][] +interface FullSprigAPI { + map(template: TemplateStringsArray, ...params: string[]): string; + bitmap(template: TemplateStringsArray, ...params: string[]): string; + color(template: TemplateStringsArray, ...params: string[]): string; + tune(template: TemplateStringsArray, ...params: string[]): string; + setMap(string: string): void; + addText(str: string, opts?: AddTextOptions): void; + clearText(): void; + addSprite(x: number, y: number, type: string): void; + getGrid(): SpriteType[][]; + getTile(x: number, y: number): SpriteType[]; + tilesWith(...matchingTypes: string[]): SpriteType[][]; + clearTile(x: number, y: number): void; + setSolids(types: string[]): void; + setPushables(map: Record): void; + setBackground(type: string): void; + getFirst(type: string): SpriteType | undefined; + getAll(type: string): SpriteType[]; + width(): number; + height(): number; + setLegend(...bitmaps: [ + string, + string + ][]): void; + onInput(key: InputKey, fn: () => void): void; + afterInput(fn: () => void): void; + playTune(text: string, n?: number): PlayTuneRes; + setTimeout(fn: TimerHandler, ms: number): number; + setInterval(fn: TimerHandler, ms: number): number; + clearTimeout(id: number): void; + clearInterval(id: number): void; +} +``` + +### sprig/base + +```ts +const baseEngine: () => { api: BaseEngineAPI; state: GameState; } +type BaseEngineAPI = Pick +const font: number[] +type PaletteItem = [ + string, + Rgba +] +const palette: PaletteItem[] +const transparent: PaletteItem +const hexToRgba: (hex: string) => Rgba +const rgbaToHex: (rgba: Rgba) => string +const composeText: (texts: TextElement[]) => { char: string; color: Rgba; }[][] +const textToTune: (text: string) => Tune +const tuneToText: (tune: Tune) => string +``` + +### sprig/image-data + +```ts +type ImageDataEngineAPI = BaseEngineAPI & Pick +const imageDataEngine: () => { api: ImageDataEngineAPI; render(): ImageData; button(key: "w" | "s" | "a" | "d" | "i" | "j" | "k" | "l"): void; cleanup(): void; state: GameState; } +const bitmapTextToImageData: (key: string, text: string) => ImageData +``` + +### sprig/web + +```ts +const webEngine: (canvas: HTMLCanvasElement) => { api: WebEngineAPI; state: GameState; cleanup(): void; } +type WebEngineAPI = BaseEngineAPI & Pick & { + getState(): GameState; // For weird backwards-compatibility reasons, not part of API +} +const getTextImg: (texts: TextElement[]) => CanvasImageSource +const playFrequency: (frequency: number, duration: number, instrument: "sine" | "triangle" | "square" | "sawtooth", ctx: AudioContext, dest: AudioNode) => void +const playTuneHelper: (tune: Tune, number: number, playingRef: { playing: boolean; }, ctx: AudioContext, dest: AudioNode) => Promise +const playTune: (tune: Tune, number?: number) => PlayTuneRes +``` + + + +## Contributing + +Please make a pull request with any changes, or feel free to create an issue with questions or suggestions! + +In a terminal, clone the repo and install packages: + +``` +$ git clone https://github.com/hackclub/sprig-engine/ +$ cd sprig-engine +$ yarn install +``` + +Run the TypeScript build in watch mode: + +``` +$ yarn dev +``` + +Generate this Markdown file's table of contents: + +``` +$ yarn toc +``` \ No newline at end of file diff --git a/engine/base.d.ts b/engine/base.d.ts new file mode 100644 index 000000000..49c5aef72 --- /dev/null +++ b/engine/base.d.ts @@ -0,0 +1 @@ +export * from './dist/base' \ No newline at end of file diff --git a/engine/base.js b/engine/base.js new file mode 100644 index 000000000..82e62e908 --- /dev/null +++ b/engine/base.js @@ -0,0 +1 @@ +module.exports = require('./dist/base') \ No newline at end of file diff --git a/engine/examples/simple-game.html b/engine/examples/simple-game.html new file mode 100644 index 000000000..f22f4a350 --- /dev/null +++ b/engine/examples/simple-game.html @@ -0,0 +1,78 @@ + + + + + Sprig Web Example + + + + + + + + \ No newline at end of file diff --git a/engine/image-data.d.ts b/engine/image-data.d.ts new file mode 100644 index 000000000..b46e2d1a9 --- /dev/null +++ b/engine/image-data.d.ts @@ -0,0 +1 @@ +export * from './dist/image-data' \ No newline at end of file diff --git a/engine/image-data.js b/engine/image-data.js new file mode 100644 index 000000000..98c636611 --- /dev/null +++ b/engine/image-data.js @@ -0,0 +1 @@ +module.exports = require('./dist/image-data') \ No newline at end of file diff --git a/engine/package.json b/engine/package.json new file mode 100644 index 000000000..190c80205 --- /dev/null +++ b/engine/package.json @@ -0,0 +1,52 @@ +{ + "name": "sprig", + "version": "1.0.3", + "description": "The standalone Sprig game engine", + "main": "dist/api.js", + "types": "dist/api.d.ts", + "scripts": { + "build": "tsc", + "api-reference": "node scripts/api-reference.js", + "toc": "markdown-toc -i --bullets '-' README.md", + "prepublishOnly": "yarn build && yarn api-reference && yarn toc", + "dev": "tsc --watch" + }, + "exports": { + ".": { + "import": "./dist/api.js", + "types": "./dist/api.d.ts" + }, + "./base": { + "import": "./dist/base/index.js", + "types": "./dist/base/index.d.ts" + }, + "./image-data": { + "import": "./dist/image-data/index.js", + "types": "./dist/image-data/index.d.ts" + }, + "./web": { + "import": "./dist/web/index.js", + "types": "./dist/web/index.d.ts" + } + }, + "repository": "https://github.com/hackclub/sprig/", + "license": "MIT", + "private": false, + "type": "module", + "devDependencies": { + "markdown-toc": "^1.2.0", + "typescript": "^5.1.3" + }, + "files": [ + "README.md", + "LICENSE", + "dist/", + "src/", + "base.js", + "base.d.ts", + "image-data.js", + "image-data.d.ts", + "web.js", + "web.d.ts" + ] +} diff --git a/engine/scripts/api-reference.js b/engine/scripts/api-reference.js new file mode 100644 index 000000000..a2f4ecc11 --- /dev/null +++ b/engine/scripts/api-reference.js @@ -0,0 +1,94 @@ +import * as _ts from 'typescript' +import fs from 'fs' + +/** + * @type {_ts} + */ +const ts = _ts.default + +const tsconfig = JSON.parse(fs.readFileSync('tsconfig.json', 'utf-8').toString()) + +const formatFakeDeclaration = (source) => { + const program = ts.createProgram([ source ], { + ...tsconfig, + noEmit: true, + }) + const sourceFile = program.getSourceFile(source) + const checker = program.getTypeChecker() + const sourceFileSymbol = checker.getSymbolAtLocation(sourceFile) + const exports = checker.getExportsOfModule(sourceFileSymbol) + const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }) + + let output = '' + for (const node of exports) { + const declaration = node.valueDeclaration || node.declarations[0] + + // Get the type of the node. + let type + if (ts.isTypeAliasDeclaration(declaration)) { + type = printer.printNode(ts.EmitHint.Unspecified, declaration.type, sourceFile) + } else if (ts.isInterfaceDeclaration(declaration) || ts.isClassDeclaration(declaration)) { + const members = declaration.members.map(member => ( + printer.printNode(ts.EmitHint.Unspecified, member, sourceFile) + )).join('\n').split('\n').map(line => ' ' + line).join('\n') + type = '{\n' + members + '\n}' + } else { + type = checker.typeToString(checker.getTypeAtLocation(declaration)) + } + if (type === 'any') { + throw new Error(`Node "${node.name}" has type any!`) + } + + // Format the node. + if (ts.isFunctionDeclaration(declaration) || ts.isVariableDeclaration(declaration)) { + output += `const ${node.name}: ${type}` + } else if (ts.isTypeAliasDeclaration(declaration)) { + output += `type ${node.name} = ${type}` + } else if (ts.isInterfaceDeclaration(declaration)) { + output += `interface ${node.name} ${type}` + } else if (ts.isClassDeclaration(declaration)) { + output += `class ${node.name} ${type}` + } else { + throw new Error(`Node "${node.name}" is of an unknown type`) + } + output += '\n' + } + output = output.trim() + return output +} + +const generateMarkdown = () => { + return [ + '', + '', + '### sprig', + '', + '```ts', + formatFakeDeclaration('./src/api.ts'), + '```', + '', + '### sprig/base', + '', + '```ts', + formatFakeDeclaration('./src/base/index.ts'), + '```', + '', + '### sprig/image-data', + '', + '```ts', + formatFakeDeclaration('./src/image-data/index.ts'), + '```', + '', + '### sprig/web', + '', + '```ts', + formatFakeDeclaration('./src/web/index.ts'), + '```', + '', + '' + ].join('\n') +} + +const readme = fs.readFileSync('README.md', 'utf-8').toString() +const newReadme = readme.replace(/[\s\S]*/, generateMarkdown()) +fs.writeFileSync('README.md', newReadme) \ No newline at end of file diff --git a/engine/src/api.ts b/engine/src/api.ts new file mode 100644 index 000000000..31c062c7c --- /dev/null +++ b/engine/src/api.ts @@ -0,0 +1,184 @@ +export const VALID_INPUTS = [ 'w', 's', 'a', 'd', 'i', 'j', 'k', 'l' ] as const +export type InputKey = typeof VALID_INPUTS[number] + +export interface AddTextOptions { + x?: number + y?: number + color?: string +} + +export declare class SpriteType { + type: string + x: number + y: number + readonly dx: number + readonly dy: number + remove(): void +} + +export type Rgba = [number, number, number, number] + +export interface TextElement { + x: number + y: number + color: Rgba + content: string +} + +export interface GameState { + legend: [string, string][] + texts: TextElement[] + dimensions: { + width: number + height: number + } + sprites: SpriteType[] + solids: string[] + pushable: Record + background: string | null +} + +export interface PlayTuneRes { + end(): void + isPlaying(): boolean +} + +export const tones: Record = { + 'B0': 31, + 'C1': 33, + 'C#1': 35, + 'D1': 37, + 'D#1': 39, + 'E1': 41, + 'F1': 44, + 'F#1': 46, + 'G1': 49, + 'G#1': 52, + 'A1': 55, + 'A#1': 58, + 'B1': 62, + 'C2': 65, + 'C#2': 69, + 'D2': 73, + 'D#2': 78, + 'E2': 82, + 'F2': 87, + 'F#2': 93, + 'G2': 98, + 'G#2': 104, + 'A2': 110, + 'A#2': 117, + 'B2': 123, + 'C3': 131, + 'C#3': 139, + 'D3': 147, + 'D#3': 156, + 'E3': 165, + 'F3': 175, + 'F#3': 185, + 'G3': 196, + 'G#3': 208, + 'A3': 220, + 'A#3': 233, + 'B3': 247, + 'C4': 262, + 'C#4': 277, + 'D4': 294, + 'D#4': 311, + 'E4': 330, + 'F4': 349, + 'F#4': 370, + 'G4': 392, + 'G#4': 415, + 'A4': 440, + 'A#4': 466, + 'B4': 494, + 'C5': 523, + 'C#5': 554, + 'D5': 587, + 'D#5': 622, + 'E5': 659, + 'F5': 698, + 'F#5': 740, + 'G5': 784, + 'G#5': 831, + 'A5': 880, + 'A#5': 932, + 'B5': 988, + 'C6': 1047, + 'C#6': 1109, + 'D6': 1175, + 'D#6': 1245, + 'E6': 1319, + 'F6': 1397, + 'F#6': 1480, + 'G6': 1568, + 'G#6': 1661, + 'A6': 1760, + 'A#6': 1865, + 'B6': 1976, + 'C7': 2093, + 'C#7': 2217, + 'D7': 2349, + 'D#7': 2489, + 'E7': 2637, + 'F7': 2794, + 'F#7': 2960, + 'G7': 3136, + 'G#7': 3322, + 'A7': 3520, + 'A#7': 3729, + 'B7': 3951, + 'C8': 4186, + 'C#8': 4435, + 'D8': 4699, + 'D#8': 4978 +} + +export const instruments = [ 'sine', 'triangle', 'square', 'sawtooth' ] as const +export type InstrumentType = typeof instruments[number] + +export const instrumentKey: Record = { + '~': 'sine', + '-': 'square', + '^': 'triangle', + '/': 'sawtooth' +} +export const reverseInstrumentKey = Object.fromEntries( + Object.entries(instrumentKey).map(([ k, v ]) => [ v, k ]) +) as Record + +export type Tune = [number, ...(InstrumentType | number | string)[]][] + +export interface FullSprigAPI { + map(template: TemplateStringsArray, ...params: string[]): string + bitmap(template: TemplateStringsArray, ...params: string[]): string + color(template: TemplateStringsArray, ...params: string[]): string + tune(template: TemplateStringsArray, ...params: string[]): string + + setMap(string: string): void + addText(str: string, opts?: AddTextOptions): void + clearText(): void + addSprite(x: number, y: number, type: string): void + getGrid(): SpriteType[][] + getTile(x: number, y: number): SpriteType[] + tilesWith(...matchingTypes: string[]): SpriteType[][] + clearTile(x: number, y: number): void + setSolids(types: string[]): void + setPushables(map: Record): void + setBackground(type: string): void + getFirst(type: string): SpriteType | undefined + getAll(type: string): SpriteType[] + width(): number + height(): number + + setLegend(...bitmaps: [string, string][]): void + onInput(key: InputKey, fn: () => void): void + afterInput(fn: () => void): void + playTune(text: string, n?: number): PlayTuneRes + + setTimeout(fn: TimerHandler, ms: number): number + setInterval(fn: TimerHandler, ms: number): number + clearTimeout(id: number): void + clearInterval(id: number): void +} \ No newline at end of file diff --git a/engine/src/base/font.ts b/engine/src/base/font.ts new file mode 100644 index 000000000..f6b305dba --- /dev/null +++ b/engine/src/base/font.ts @@ -0,0 +1,258 @@ +export const font = [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x81, 0xFF, // 00 + 0x00, 0x00, 0x22, 0x72, 0x22, 0x3E, 0x00, 0x00, // 01 + 0x00, 0x00, 0x12, 0x32, 0x7E, 0x32, 0x12, 0x00, // 02 + 0x7E, 0x81, 0xB9, 0xA5, 0xB9, 0xA5, 0xB9, 0x81, // 03 + 0x55, 0xFF, 0x55, 0xFF, 0x55, 0xFF, 0x55, 0xFF, // 04 + 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, // 05 + 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, // 06 + 0x00, 0x00, 0x3C, 0x42, 0x42, 0x7E, 0x00, 0x00, // 07 + 0x00, 0x10, 0x30, 0x7E, 0x30, 0x10, 0x00, 0x00, // 08 + 0x00, 0x08, 0x0C, 0x7E, 0x0C, 0x08, 0x00, 0x00, // 09 + 0x00, 0x10, 0x10, 0x10, 0x7C, 0x38, 0x10, 0x00, // 0A + 0x08, 0x1C, 0x3E, 0x08, 0x08, 0x08, 0x08, 0x00, // 0B + 0x38, 0x30, 0x28, 0x08, 0x08, 0x08, 0x3E, 0x00, // 0C + 0x00, 0x00, 0x12, 0x32, 0x7E, 0x30, 0x10, 0x00, // 0D + 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, // 0E + 0x3E, 0x7C, 0x7C, 0x3E, 0x3E, 0x7C, 0xF8, 0xF8, // 0F + 0x38, 0x30, 0x28, 0x04, 0x04, 0x04, 0x04, 0x00, // 10 + 0x7F, 0x08, 0x1C, 0x2A, 0x08, 0x08, 0x08, 0x00, // 11 + 0x00, 0x08, 0x08, 0x08, 0x2A, 0x1C, 0x08, 0x7F, // 12 + 0x7E, 0x81, 0x9D, 0xA1, 0xB9, 0x85, 0x85, 0xB9, // 13 + 0x00, 0x3C, 0x42, 0x5A, 0x5A, 0x42, 0x3C, 0x00, // 14 + 0x88, 0x44, 0x22, 0x11, 0x88, 0x44, 0x22, 0x11, // 15 + 0x00, 0x7F, 0x22, 0x72, 0x27, 0x22, 0x7F, 0x00, // 16 + 0x11, 0x22, 0x44, 0x88, 0x11, 0x22, 0x44, 0x88, // 17 + 0x00, 0x01, 0x09, 0x0D, 0x7F, 0x0D, 0x09, 0x01, // 18 + 0x00, 0x90, 0xB0, 0xFE, 0xB0, 0x90, 0x00, 0x00, // 19 + 0x00, 0x08, 0x7C, 0x06, 0x7C, 0x08, 0x00, 0x00, // 1A + 0xCC, 0xCC, 0x33, 0x33, 0xCC, 0xCC, 0x33, 0x33, // 1B + 0x7E, 0x81, 0xA1, 0xA1, 0xA1, 0xA1, 0xBD, 0x81, // 1C + 0x7E, 0x81, 0xB9, 0xA5, 0xB9, 0xA5, 0xA5, 0x81, // 1D + 0x7E, 0x81, 0x99, 0xA1, 0xA1, 0xA1, 0x99, 0x81, // 1E + 0x00, 0x10, 0x3E, 0x60, 0x3E, 0x10, 0x00, 0x00, // 1F + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // 20 + 0x30, 0x30, 0x30, 0x30, 0x30, 0x00, 0x30, 0x00, // 21 + 0x77, 0x33, 0x66, 0x00, 0x00, 0x00, 0x00, 0x00, // 22 + 0x36, 0x36, 0xFE, 0x6C, 0xFE, 0xD8, 0xD8, 0x00, // 23 + 0x18, 0x3E, 0x6C, 0x3E, 0x1B, 0x1B, 0x7E, 0x18, // 24 + 0x00, 0xC6, 0xCC, 0x18, 0x30, 0x66, 0xC6, 0x00, // 25 + 0x38, 0x6C, 0x38, 0x76, 0xDC, 0xCC, 0x76, 0x00, // 26 + 0x1C, 0x0C, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, // 27 + 0x18, 0x30, 0x60, 0x60, 0x60, 0x30, 0x18, 0x00, // 28 + 0x60, 0x30, 0x18, 0x18, 0x18, 0x30, 0x60, 0x00, // 29 + 0x00, 0x66, 0x3C, 0xFF, 0x3C, 0x66, 0x00, 0x00, // 2A + 0x00, 0x30, 0x30, 0xFC, 0x30, 0x30, 0x00, 0x00, // 2B + 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x0C, 0x18, // 2C + 0x00, 0x00, 0x00, 0xFE, 0x00, 0x00, 0x00, 0x00, // 2D + 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00, // 2E + 0x06, 0x0C, 0x18, 0x30, 0x60, 0xC0, 0x80, 0x00, // 2F + 0x7C, 0xC6, 0xCE, 0xDE, 0xF6, 0xE6, 0x7C, 0x00, // 30 + 0x30, 0x70, 0x30, 0x30, 0x30, 0x30, 0xFC, 0x00, // 31 + 0x78, 0xCC, 0x0C, 0x38, 0x60, 0xCC, 0xFC, 0x00, // 32 + 0xFC, 0x18, 0x30, 0x78, 0x0C, 0xCC, 0x78, 0x00, // 33 + 0x1C, 0x3C, 0x6C, 0xCC, 0xFE, 0x0C, 0x1E, 0x00, // 34 + 0xFC, 0xC0, 0xF8, 0x0C, 0x0C, 0xCC, 0x78, 0x00, // 35 + 0x38, 0x60, 0xC0, 0xF8, 0xCC, 0xCC, 0x78, 0x00, // 36 + 0xFC, 0xCC, 0x0C, 0x18, 0x30, 0x30, 0x30, 0x00, // 37 + 0x78, 0xCC, 0xCC, 0x78, 0xCC, 0xCC, 0x78, 0x00, // 38 + 0x78, 0xCC, 0xCC, 0x7C, 0x0C, 0x18, 0x70, 0x00, // 39 + 0x00, 0x00, 0x30, 0x30, 0x00, 0x30, 0x30, 0x00, // 3A + 0x00, 0x00, 0x30, 0x30, 0x00, 0x30, 0x30, 0x60, // 3B + 0x18, 0x30, 0x60, 0xC0, 0x60, 0x30, 0x18, 0x00, // 3C + 0x00, 0x00, 0xFC, 0x00, 0xFC, 0x00, 0x00, 0x00, // 3D + 0x60, 0x30, 0x18, 0x0C, 0x18, 0x30, 0x60, 0x00, // 3E + 0x78, 0xCC, 0x0C, 0x18, 0x30, 0x00, 0x30, 0x00, // 3F + 0x7C, 0xC6, 0xDE, 0xDE, 0xDE, 0xC0, 0x78, 0x00, // 40 + 0x30, 0x78, 0xCC, 0xCC, 0xFC, 0xCC, 0xCC, 0x00, // 41 + 0xFC, 0x66, 0x66, 0x7C, 0x66, 0x66, 0xFC, 0x00, // 42 + 0x3C, 0x66, 0xC0, 0xC0, 0xC0, 0x66, 0x3C, 0x00, // 43 + 0xF8, 0x6C, 0x66, 0x66, 0x66, 0x6C, 0xF8, 0x00, // 44 + 0xFE, 0x62, 0x68, 0x78, 0x68, 0x62, 0xFE, 0x00, // 45 + 0xFE, 0x62, 0x68, 0x78, 0x68, 0x60, 0xF0, 0x00, // 46 + 0x3C, 0x66, 0xC0, 0xC0, 0xCE, 0x66, 0x3C, 0x00, // 47 + 0xCC, 0xCC, 0xCC, 0xFC, 0xCC, 0xCC, 0xCC, 0x00, // 48 + 0x78, 0x30, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00, // 49 + 0x1E, 0x0C, 0x0C, 0x0C, 0xCC, 0xCC, 0x78, 0x00, // 4A + 0xE6, 0x66, 0x6C, 0x70, 0x6C, 0x66, 0xE6, 0x00, // 4B + 0xF0, 0x60, 0x60, 0x60, 0x62, 0x66, 0xFE, 0x00, // 4C + 0xC6, 0xEE, 0xFE, 0xD6, 0xC6, 0xC6, 0xC6, 0x00, // 4D + 0xC6, 0xE6, 0xF6, 0xDE, 0xCE, 0xC6, 0xC6, 0x00, // 4E + 0x38, 0x6C, 0xC6, 0xC6, 0xC6, 0x6C, 0x38, 0x00, // 4F + 0xFC, 0x66, 0x66, 0x7C, 0x60, 0x60, 0xF0, 0x00, // 50 + 0x78, 0xCC, 0xCC, 0xCC, 0xDC, 0x78, 0x1C, 0x00, // 51 + 0xFC, 0x66, 0x66, 0x7C, 0x6C, 0x66, 0xE6, 0x00, // 52 + 0x7C, 0xC6, 0xF0, 0x3C, 0x0E, 0xC6, 0x7C, 0x00, // 53 + 0xFC, 0xB4, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00, // 54 + 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0x78, 0x00, // 55 + 0xCC, 0xCC, 0xCC, 0x78, 0x78, 0x30, 0x30, 0x00, // 56 + 0xC6, 0xC6, 0xC6, 0xD6, 0xFE, 0xEE, 0xC6, 0x00, // 57 + 0xC6, 0xC6, 0x6C, 0x38, 0x6C, 0xC6, 0xC6, 0x00, // 58 + 0xCC, 0xCC, 0xCC, 0x78, 0x30, 0x30, 0x78, 0x00, // 59 + 0xFE, 0xC6, 0x8C, 0x18, 0x32, 0x66, 0xFE, 0x00, // 5A + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // 5B + 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x00, // 5C + 0x00, 0xFE, 0x06, 0x06, 0x00, 0x00, 0x00, 0x00, // 5D + 0x10, 0x38, 0x6C, 0xC6, 0x00, 0x00, 0x00, 0x00, // 5E + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, // 5F + 0x3C, 0x42, 0x99, 0xA1, 0xA1, 0x99, 0x42, 0x3C, // 60 + 0x00, 0x00, 0x78, 0x0C, 0x7C, 0xCC, 0x76, 0x00, // 61 + 0xE0, 0x60, 0x7C, 0x66, 0x66, 0x66, 0xDC, 0x00, // 62 + 0x00, 0x00, 0x78, 0xCC, 0xC0, 0xCC, 0x78, 0x00, // 63 + 0x1C, 0x0C, 0x7C, 0xCC, 0xCC, 0xCC, 0x76, 0x00, // 64 + 0x00, 0x00, 0x78, 0xCC, 0xFC, 0xC0, 0x78, 0x00, // 65 + 0x38, 0x6C, 0x60, 0xF0, 0x60, 0x60, 0xF0, 0x00, // 66 + 0x00, 0x00, 0x76, 0xCC, 0xCC, 0x7C, 0x0C, 0xF8, // 67 + 0xE0, 0x60, 0x6C, 0x76, 0x66, 0x66, 0xE6, 0x00, // 68 + 0x30, 0x00, 0x70, 0x30, 0x30, 0x30, 0xFC, 0x00, // 69 + 0x0C, 0x00, 0x1C, 0x0C, 0x0C, 0xCC, 0xCC, 0x78, // 6A + 0xE0, 0x60, 0x66, 0x6C, 0x78, 0x6C, 0xE6, 0x00, // 6B + 0x70, 0x30, 0x30, 0x30, 0x30, 0x30, 0xFC, 0x00, // 6C + 0x00, 0x00, 0xCC, 0xFE, 0xFE, 0xD6, 0xC6, 0x00, // 6D + 0x00, 0x00, 0xF8, 0xCC, 0xCC, 0xCC, 0xCC, 0x00, // 6E + 0x00, 0x00, 0x78, 0xCC, 0xCC, 0xCC, 0x78, 0x00, // 6F + 0x00, 0x00, 0xDC, 0x66, 0x66, 0x7C, 0x60, 0xF0, // 70 + 0x00, 0x00, 0x76, 0xCC, 0xCC, 0x7C, 0x0C, 0x1E, // 71 + 0x00, 0x00, 0xDC, 0x76, 0x66, 0x60, 0xF0, 0x00, // 72 + 0x00, 0x00, 0x7C, 0xC0, 0x78, 0x0C, 0xF8, 0x00, // 73 + 0x10, 0x30, 0x7C, 0x30, 0x30, 0x34, 0x18, 0x00, // 74 + 0x00, 0x00, 0xCC, 0xCC, 0xCC, 0xCC, 0x76, 0x00, // 75 + 0x00, 0x00, 0xCC, 0xCC, 0xCC, 0x78, 0x30, 0x00, // 76 + 0x00, 0x00, 0xC6, 0xD6, 0xFE, 0xFE, 0x6C, 0x00, // 77 + 0x00, 0x00, 0xC6, 0x6C, 0x38, 0x6C, 0xC6, 0x00, // 78 + 0x00, 0x00, 0xCC, 0xCC, 0xCC, 0x7C, 0x0C, 0xF8, // 79 + 0x00, 0x00, 0xFC, 0x98, 0x30, 0x64, 0xFC, 0x00, // 7A + 0x6C, 0x00, 0x78, 0x0C, 0x7C, 0xCC, 0x76, 0x00, // 7B + 0xCC, 0x00, 0x78, 0xCC, 0xCC, 0xCC, 0x78, 0x00, // 7C + 0xCC, 0x00, 0xCC, 0xCC, 0xCC, 0xCC, 0x76, 0x00, // 7D + 0x3C, 0x66, 0x66, 0x6C, 0x66, 0x66, 0x6C, 0xF0, // 7E + 0xFF, 0x81, 0x81, 0x81, 0x81, 0x81, 0x81, 0xFF, // 7F + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7E, 0x00, // 80 + 0xFF, 0xFF, 0xDD, 0x8D, 0xDD, 0xC1, 0xFF, 0xFF, // 81 + 0xFF, 0xFF, 0xED, 0xCD, 0x81, 0xCD, 0xED, 0xFF, // 82 + 0x81, 0x7E, 0x46, 0x5A, 0x46, 0x5A, 0x46, 0x7E, // 83 + 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00, 0xAA, 0x00, // 84 + 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, // 85 + 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, 0x00, 0xFF, // 86 + 0xFF, 0xFF, 0xC3, 0xBD, 0xBD, 0x81, 0xFF, 0xFF, // 87 + 0xFF, 0xEF, 0xCF, 0x81, 0xCF, 0xEF, 0xFF, 0xFF, // 88 + 0xFF, 0xF7, 0xF3, 0x81, 0xF3, 0xF7, 0xFF, 0xFF, // 89 + 0xFF, 0xEF, 0xEF, 0xEF, 0x83, 0xC7, 0xEF, 0xFF, // 8A + 0xF7, 0xE3, 0xC1, 0xF7, 0xF7, 0xF7, 0xF7, 0xFF, // 8B + 0xC7, 0xCF, 0xD7, 0xF7, 0xF7, 0xF7, 0xC1, 0xFF, // 8C + 0xFF, 0xFF, 0xED, 0xCD, 0x81, 0xCF, 0xEF, 0xFF, // 8D + 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, // 8E + 0xC1, 0x83, 0x83, 0xC1, 0xC1, 0x83, 0x07, 0x07, // 8F + 0xC7, 0xCF, 0xD7, 0xFB, 0xFB, 0xFB, 0xFB, 0xFF, // 90 + 0x80, 0xF7, 0xE3, 0xD5, 0xF7, 0xF7, 0xF7, 0xFF, // 91 + 0xFF, 0xF7, 0xF7, 0xF7, 0xD5, 0xE3, 0xF7, 0x80, // 92 + 0x81, 0x7E, 0x62, 0x5E, 0x46, 0x7A, 0x7A, 0x46, // 93 + 0xFF, 0xC3, 0xBD, 0xA5, 0xA5, 0xBD, 0xC3, 0xFF, // 94 + 0x77, 0xBB, 0xDD, 0xEE, 0x77, 0xBB, 0xDD, 0xEE, // 95 + 0xFF, 0x80, 0xDD, 0x8D, 0xD8, 0xDD, 0x80, 0xFF, // 96 + 0xEE, 0xDD, 0xBB, 0x77, 0xEE, 0xDD, 0xBB, 0x77, // 97 + 0xFF, 0xFE, 0xF6, 0xF2, 0x80, 0xF2, 0xF6, 0xFE, // 98 + 0xFF, 0x6F, 0x4F, 0x01, 0x4F, 0x6F, 0xFF, 0xFF, // 99 + 0xFF, 0xF7, 0x83, 0xF9, 0x83, 0xF7, 0xFF, 0xFF, // 9A + 0x33, 0x33, 0xCC, 0xCC, 0x33, 0x33, 0xCC, 0xCC, // 9B + 0x81, 0x7E, 0x5E, 0x5E, 0x5E, 0x5E, 0x42, 0x7E, // 9C + 0x81, 0x7E, 0x46, 0x5A, 0x46, 0x5A, 0x5A, 0x7E, // 9D + 0x81, 0x7E, 0x66, 0x5E, 0x5E, 0x5E, 0x66, 0x7E, // 9E + 0xFF, 0xEF, 0xC1, 0x9F, 0xC1, 0xEF, 0xFF, 0xFF, // 9F + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // A0 + 0xCF, 0xCF, 0xCF, 0xCF, 0xCF, 0xFF, 0xCF, 0xFF, // A1 + 0x88, 0xCC, 0x99, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // A2 + 0xC9, 0xC9, 0x01, 0x93, 0x01, 0x27, 0x27, 0xFF, // A3 + 0xE7, 0xC1, 0x93, 0xC1, 0xE4, 0xE4, 0x81, 0xE7, // A4 + 0xFF, 0x39, 0x33, 0xE7, 0xCF, 0x99, 0x39, 0xFF, // A5 + 0xC7, 0x93, 0xC7, 0x89, 0x23, 0x33, 0x89, 0xFF, // A6 + 0xE3, 0xF3, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, // A7 + 0xE7, 0xCF, 0x9F, 0x9F, 0x9F, 0xCF, 0xE7, 0xFF, // A8 + 0x9F, 0xCF, 0xE7, 0xE7, 0xE7, 0xCF, 0x9F, 0xFF, // A9 + 0xFF, 0x99, 0xC3, 0x00, 0xC3, 0x99, 0xFF, 0xFF, // AA + 0xFF, 0xCF, 0xCF, 0x03, 0xCF, 0xCF, 0xFF, 0xFF, // AB + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE3, 0xF3, 0xE7, // AC + 0xFF, 0xFF, 0xFF, 0x01, 0xFF, 0xFF, 0xFF, 0xFF, // AD + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xCF, 0xCF, 0xFF, // AE + 0xF9, 0xF3, 0xE7, 0xCF, 0x9F, 0x3F, 0x7F, 0xFF, // AF + 0x83, 0x39, 0x31, 0x21, 0x09, 0x19, 0x83, 0xFF, // B0 + 0xCF, 0x8F, 0xCF, 0xCF, 0xCF, 0xCF, 0x03, 0xFF, // B1 + 0x87, 0x33, 0xF3, 0xC7, 0x9F, 0x33, 0x03, 0xFF, // B2 + 0x03, 0xE7, 0xCF, 0x87, 0xF3, 0x33, 0x87, 0xFF, // B3 + 0xE3, 0xC3, 0x93, 0x33, 0x01, 0xF3, 0xE1, 0xFF, // B4 + 0x03, 0x3F, 0x07, 0xF3, 0xF3, 0x33, 0x87, 0xFF, // B5 + 0xC7, 0x9F, 0x3F, 0x07, 0x33, 0x33, 0x87, 0xFF, // B6 + 0x03, 0x33, 0xF3, 0xE7, 0xCF, 0xCF, 0xCF, 0xFF, // B7 + 0x87, 0x33, 0x33, 0x87, 0x33, 0x33, 0x87, 0xFF, // B8 + 0x87, 0x33, 0x33, 0x83, 0xF3, 0xE7, 0x8F, 0xFF, // B9 + 0xFF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xCF, 0xFF, // BA + 0xFF, 0xFF, 0xCF, 0xCF, 0xFF, 0xCF, 0xCF, 0x9F, // BB + 0xE7, 0xCF, 0x9F, 0x3F, 0x9F, 0xCF, 0xE7, 0xFF, // BC + 0xFF, 0xFF, 0x03, 0xFF, 0x03, 0xFF, 0xFF, 0xFF, // BD + 0x9F, 0xCF, 0xE7, 0xF3, 0xE7, 0xCF, 0x9F, 0xFF, // BE + 0x87, 0x33, 0xF3, 0xE7, 0xCF, 0xFF, 0xCF, 0xFF, // BF + 0x83, 0x39, 0x21, 0x21, 0x21, 0x3F, 0x87, 0xFF, // C0 + 0xCF, 0x87, 0x33, 0x33, 0x03, 0x33, 0x33, 0xFF, // C1 + 0x03, 0x99, 0x99, 0x83, 0x99, 0x99, 0x03, 0xFF, // C2 + 0xC3, 0x99, 0x3F, 0x3F, 0x3F, 0x99, 0xC3, 0xFF, // C3 + 0x07, 0x93, 0x99, 0x99, 0x99, 0x93, 0x07, 0xFF, // C4 + 0x01, 0x9D, 0x97, 0x87, 0x97, 0x9D, 0x01, 0xFF, // C5 + 0x01, 0x9D, 0x97, 0x87, 0x97, 0x9F, 0x0F, 0xFF, // C6 + 0xC3, 0x99, 0x3F, 0x3F, 0x31, 0x99, 0xC3, 0xFF, // C7 + 0x33, 0x33, 0x33, 0x03, 0x33, 0x33, 0x33, 0xFF, // C8 + 0x87, 0xCF, 0xCF, 0xCF, 0xCF, 0xCF, 0x87, 0xFF, // C9 + 0xE1, 0xF3, 0xF3, 0xF3, 0x33, 0x33, 0x87, 0xFF, // CA + 0x19, 0x99, 0x93, 0x8F, 0x93, 0x99, 0x19, 0xFF, // CB + 0x0F, 0x9F, 0x9F, 0x9F, 0x9D, 0x99, 0x01, 0xFF, // CC + 0x39, 0x11, 0x01, 0x29, 0x39, 0x39, 0x39, 0xFF, // CD + 0x39, 0x19, 0x09, 0x21, 0x31, 0x39, 0x39, 0xFF, // CE + 0xC7, 0x93, 0x39, 0x39, 0x39, 0x93, 0xC7, 0xFF, // CF + 0x03, 0x99, 0x99, 0x83, 0x9F, 0x9F, 0x0F, 0xFF, // D0 + 0x87, 0x33, 0x33, 0x33, 0x23, 0x87, 0xE3, 0xFF, // D1 + 0x03, 0x99, 0x99, 0x83, 0x93, 0x99, 0x19, 0xFF, // D2 + 0x83, 0x39, 0x0F, 0xC3, 0xF1, 0x39, 0x83, 0xFF, // D3 + 0x03, 0x4B, 0xCF, 0xCF, 0xCF, 0xCF, 0x87, 0xFF, // D4 + 0x33, 0x33, 0x33, 0x33, 0x33, 0x33, 0x87, 0xFF, // D5 + 0x33, 0x33, 0x33, 0x87, 0x87, 0xCF, 0xCF, 0xFF, // D6 + 0x39, 0x39, 0x39, 0x29, 0x01, 0x11, 0x39, 0xFF, // D7 + 0x39, 0x39, 0x93, 0xC7, 0x93, 0x39, 0x39, 0xFF, // D8 + 0x33, 0x33, 0x33, 0x87, 0xCF, 0xCF, 0x87, 0xFF, // D9 + 0x01, 0x39, 0x73, 0xE7, 0xCD, 0x99, 0x01, 0xFF, // DA + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // DB + 0xE7, 0xE7, 0xE7, 0xE7, 0xE7, 0xE7, 0xE7, 0xFF, // DC + 0xFF, 0x01, 0xF9, 0xF9, 0xFF, 0xFF, 0xFF, 0xFF, // DD + 0xEF, 0xC7, 0x93, 0x39, 0xFF, 0xFF, 0xFF, 0xFF, // DE + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, // DF + 0xC3, 0xBD, 0x66, 0x5E, 0x5E, 0x66, 0xBD, 0xC3, // E0 + 0xFF, 0xFF, 0x87, 0xF3, 0x83, 0x33, 0x89, 0xFF, // E1 + 0x1F, 0x9F, 0x83, 0x99, 0x99, 0x99, 0x23, 0xFF, // E2 + 0xFF, 0xFF, 0x87, 0x33, 0x3F, 0x33, 0x87, 0xFF, // E3 + 0xE3, 0xF3, 0x83, 0x33, 0x33, 0x33, 0x89, 0xFF, // E4 + 0xFF, 0xFF, 0x87, 0x33, 0x03, 0x3F, 0x87, 0xFF, // E5 + 0xC7, 0x93, 0x9F, 0x0F, 0x9F, 0x9F, 0x0F, 0xFF, // E6 + 0xFF, 0xFF, 0x89, 0x33, 0x33, 0x83, 0xF3, 0x07, // E7 + 0x1F, 0x9F, 0x93, 0x89, 0x99, 0x99, 0x19, 0xFF, // E8 + 0xCF, 0xFF, 0x8F, 0xCF, 0xCF, 0xCF, 0x03, 0xFF, // E9 + 0xF3, 0xFF, 0xE3, 0xF3, 0xF3, 0x33, 0x33, 0x87, // EA + 0x1F, 0x9F, 0x99, 0x93, 0x87, 0x93, 0x19, 0xFF, // EB + 0x8F, 0xCF, 0xCF, 0xCF, 0xCF, 0xCF, 0x03, 0xFF, // EC + 0xFF, 0xFF, 0x33, 0x01, 0x01, 0x29, 0x39, 0xFF, // ED + 0xFF, 0xFF, 0x07, 0x33, 0x33, 0x33, 0x33, 0xFF, // EE + 0xFF, 0xFF, 0x87, 0x33, 0x33, 0x33, 0x87, 0xFF, // EF + 0xFF, 0xFF, 0x23, 0x99, 0x99, 0x83, 0x9F, 0x0F, // F0 + 0xFF, 0xFF, 0x89, 0x33, 0x33, 0x83, 0xF3, 0xE1, // F1 + 0xFF, 0xFF, 0x23, 0x89, 0x99, 0x9F, 0x0F, 0xFF, // F2 + 0xFF, 0xFF, 0x83, 0x3F, 0x87, 0xF3, 0x07, 0xFF, // F3 + 0xEF, 0xCF, 0x83, 0xCF, 0xCF, 0xCB, 0xE7, 0xFF, // F4 + 0xFF, 0xFF, 0x33, 0x33, 0x33, 0x33, 0x89, 0xFF, // F5 + 0xFF, 0xFF, 0x33, 0x33, 0x33, 0x87, 0xCF, 0xFF, // F6 + 0xFF, 0xFF, 0x39, 0x29, 0x01, 0x01, 0x93, 0xFF, // F7 + 0xFF, 0xFF, 0x39, 0x93, 0xC7, 0x93, 0x39, 0xFF, // F8 + 0xFF, 0xFF, 0x33, 0x33, 0x33, 0x83, 0xF3, 0x07, // F9 + 0xFF, 0xFF, 0x03, 0x67, 0xCF, 0x9B, 0x03, 0xFF, // FA + 0x93, 0xFF, 0x87, 0xF3, 0x83, 0x33, 0x89, 0xFF, // FB + 0x33, 0xFF, 0x87, 0x33, 0x33, 0x33, 0x87, 0xFF, // FC + 0x33, 0xFF, 0x33, 0x33, 0x33, 0x33, 0x89, 0xFF, // FD + 0xC3, 0x99, 0x99, 0x93, 0x99, 0x99, 0x93, 0x0F, // FE + 0x00, 0x7E, 0x7E, 0x7E, 0x7E, 0x7E, 0x7E, 0x00, // FF +] \ No newline at end of file diff --git a/engine/src/base/index.ts b/engine/src/base/index.ts new file mode 100644 index 000000000..f53025f4c --- /dev/null +++ b/engine/src/base/index.ts @@ -0,0 +1,306 @@ +import type { AddTextOptions, FullSprigAPI, GameState, SpriteType } from '../api.js' +import { palette } from './palette.js' +import { composeText } from './text.js' + +export * from './font.js' +export * from './palette.js' +export * from './text.js' +export * from './tune.js' + +// Tagged template literal factory go brrr +const _makeTag = (cb: (string: string) => T) => { + return (strings: TemplateStringsArray, ...interps: string[]) => { + if (typeof strings === 'string') { + throw new Error('Tagged template literal must be used like name`text`, instead of name(`text`)') + } + const string = strings.reduce((p, c, i) => p + c + (interps[i] ?? ''), '') + return cb(string) + } +} + +export type BaseEngineAPI = Pick< + FullSprigAPI, + | 'setMap' + | 'addText' + | 'clearText' + | 'addSprite' + | 'getGrid' + | 'getTile' + | 'tilesWith' + | 'clearTile' + | 'setSolids' + | 'setPushables' + | 'setBackground' + | 'map' + | 'bitmap' + | 'color' + | 'tune' + | 'getFirst' + | 'getAll' + | 'width' + | 'height' +> +export function baseEngine(): { api: BaseEngineAPI, state: GameState } { + const gameState: GameState = { + legend: [], + texts: [], + dimensions: { + width: 0, + height: 0, + }, + sprites: [], + solids: [], + pushable: {}, + background: null + } + + class Sprite implements SpriteType { + _type: string + _x: number + _y: number + dx: number + dy: number + + constructor(type: string, x: number, y: number) { + this._type = type + this._x = x + this._y = y + this.dx = 0 + this.dy = 0 + } + + set type(newType) { + const legendDict = Object.fromEntries(gameState.legend) + if (!(newType in legendDict)) throw new Error(`"${newType}" isn\'t in the legend.`) + this.remove() + addSprite(this._x, this._y, newType) + } + + get type() { + return this._type + } + + set x(newX) { + const dx = newX - this.x + if (_canMoveToPush(this, dx, 0)) this.dx = dx + } + + get x() { + return this._x + } + + set y(newY) { + const dy = newY - this.y + if (_canMoveToPush(this, 0, dy)) this.dy = dy + } + + get y() { + return this._y + } + + remove() { + gameState.sprites = gameState.sprites.filter(s => s !== this) + return this + } + } + + const _canMoveToPush = (sprite: Sprite, dx: number, dy: number): boolean => { + const { x, y, type } = sprite + const { width, height } = gameState.dimensions + const i = (x+dx)+(y+dy)*width + + const inBounds = (x+dx < width && x+dx >= 0 && y+dy < height && y+dy >= 0) + if (!inBounds) return false + + const grid = getGrid() + + const notSolid = !gameState.solids.includes(type) + const noMovement = dx === 0 && dy === 0 + const movingToEmpty = i < grid.length && grid[i]!.length === 0 + + if (notSolid || noMovement || movingToEmpty) { + sprite._x += dx + sprite._y += dy + return true + } + + let canMove = true + + const { pushable } = gameState + + grid[i]!.forEach(sprite => { + const isSolid = gameState.solids.includes(sprite.type) + const isPushable = (type in pushable) && pushable[type]!.includes(sprite.type) + + if (isSolid && !isPushable) + canMove = false + + if (isSolid && isPushable) { + canMove = canMove && _canMoveToPush(sprite as Sprite, dx, dy) + } + }) + + if (canMove) { + sprite._x += dx + sprite._y += dy + } + + return canMove + } + + const getGrid = (): SpriteType[][] => { + const { width, height } = gameState.dimensions + + const grid: SpriteType[][] = new Array(width*height).fill(0).map(_ => []) + gameState.sprites.forEach(s => { + const i = s.x+s.y*width + grid[i]!.push(s) + }) + + const legendIndex = (t: SpriteType) => gameState.legend.findIndex(l => l[0] == t.type) + for (const tile of grid) tile.sort((a, b) => legendIndex(a) - legendIndex(b)) + + return grid + } + + const _checkBounds = (x: number, y: number): void => { + const { width, height } = gameState.dimensions + if (x >= width || x < 0 || y < 0 || y >= height) throw new Error(`Sprite out of bounds.`) + } + + const _checkLegend = (type: string): void => { + if (!(type in Object.fromEntries(gameState.legend))) + throw new Error(`Unknown sprite type: ${type}`) + } + + const addSprite = (x: number, y: number, type: string): void => { + if (type === '.') return + + _checkBounds(x, y) + _checkLegend(type) + + const s = new Sprite(type, x, y) + gameState.sprites.push(s) + } + + const _allEqual = (arr: T[]): boolean => arr.every(val => val === arr[0]) + + const setMap = (string: string): void => { + if (!string) throw new Error('Tried to set empty map.') + + if (string.constructor == Object) throw new Error('setMap() takes a string, not a dict.') // https://stackoverflow.com/a/51285298 + if (Array.isArray(string)) throw new Error('It looks like you passed an array into setMap(). Did you mean to use something like setMap(levels[level]) instead of setMap(levels)?') + + const rows = string.trim().split("\n").map(x => x.trim()) + const rowLengths = rows.map(x => x.length) + const isRect = _allEqual(rowLengths) + if (!isRect) throw new Error('Level must be rectangular.') + const w = rows[0]?.length ?? 0 + const h = rows.length + gameState.dimensions.width = w + gameState.dimensions.height = h + + gameState.sprites = [] + + const nonSpace = string.split("").filter(x => x !== " " && x !== "\n") // \S regex was too slow + for (let i = 0; i < w*h; i++) { + const type = nonSpace[i]! + if (type === '.') continue + + const x = i%w + const y = Math.floor(i/w) + + addSprite(x, y, type) + } + } + + const clearTile = (x: number, y: number): void => { + gameState.sprites = gameState.sprites.filter(s => s.x !== x || s.y !== y) + } + + const addText = (str: string, opts: AddTextOptions = {}): void => { + const CHARS_MAX_X = 21 + const padLeft = Math.floor((CHARS_MAX_X - str.length)/2) + + if (Array.isArray(opts.color)) throw new Error('addText no longer takes an RGBA color. Please use a Sprig color instead with \"{ color: color`` }\"') + const [, rgba ] = palette.find(([key]) => key === opts.color) ?? palette.find(([key]) => key === 'L')! + + const textOptions = { + x: opts.x ?? padLeft, + y: opts.y ?? 0, + color: rgba, + content: str + }; + + composeText([textOptions]); + + gameState.texts.push(textOptions); + } + + const clearText = (): void => { gameState.texts = [] } + + const getTile = (x: number, y: number): SpriteType[] => { + if (y < 0) return [] + if (x < 0) return [] + if (y >= gameState.dimensions.height) return [] + if (x >= gameState.dimensions.width) return [] + + return getGrid()[gameState.dimensions.width*y+x] ?? [] + } + + const _hasDuplicates = (array: T[]): boolean => (new Set(array)).size !== array.length + + const tilesWith = (...matchingTypes: string[]): SpriteType[][] => { + const { width, height } = gameState.dimensions + const tiles: SpriteType[][] = [] + const grid = getGrid() + for (let x = 0; x < width; x++) { + for (let y = 0; y < height; y++) { + const tile = grid[width*y+x] || [] + const matchIndices = matchingTypes.map(type => { + return tile.map(s => s.type).indexOf(type) + }) + if (!_hasDuplicates(matchIndices) && !matchIndices.includes(-1)) tiles.push(tile) + } + } + return tiles + } + + const setSolids = (arr: string[]): void => { + if (!Array.isArray(arr)) throw new Error('The sprites passed into setSolids() need to be an array.') + gameState.solids = arr + } + const setPushables = (map: Record): void => { + for (const key in map) { + if(key.length != 1) { + throw new Error('Your sprite name must be wrapped in [] brackets here.'); + } + _checkLegend(key) + } + gameState.pushable = map + } + + const api: BaseEngineAPI = { + setMap, + addText, + clearText, + addSprite, + getGrid, + getTile, + tilesWith, + clearTile, + setSolids, + setPushables, + setBackground: (type: string) => { gameState.background = type }, + map: _makeTag(text => text), + bitmap: _makeTag(text => text), + color: _makeTag(text => text), + tune: _makeTag(text => text), + getFirst: (type: string): SpriteType | undefined => gameState.sprites.find(t => t.type === type), // ** + getAll: (type: string): SpriteType[] => type ? gameState.sprites.filter(t => t.type === type) : gameState.sprites, // ** + width: () => gameState.dimensions.width, + height: () => gameState.dimensions.height + } + + return { api, state: gameState } +} diff --git a/engine/src/base/palette.ts b/engine/src/base/palette.ts new file mode 100644 index 000000000..69e584ce2 --- /dev/null +++ b/engine/src/base/palette.ts @@ -0,0 +1,46 @@ +import type { Rgba } from '../api.js' + +export type PaletteItem = [string, Rgba] +export const palette: PaletteItem[] = [ + // Grey + ['0', [0, 0, 0, 255]], + ['L', [73, 80, 87, 255]], + ['1', [145, 151, 156, 255]], + ['2', [248, 249, 250, 255]], + + // Red + ['3', [235, 44, 71, 255]], + ['C', [139, 65, 46, 255]], + + // Blue + ['7', [25, 177, 248, 255]], + ['5', [19, 21, 224, 255]], + + // Yellow + ['6', [254, 230, 16, 255]], + ['F', [149, 140, 50, 255]], + + // Green + ['4', [45, 225, 62, 255]], + ['D', [29, 148, 16, 255]], + + // Pink and purple + ['8', [245, 109, 187, 255]], + ['H', [170, 58, 197, 255]], + + // Orange + ['9', [245, 113, 23, 255]], + + // Transparent + ['.', [0, 0, 0, 0]] +] +export const transparent: PaletteItem = palette.at(-1)! + +export const hexToRgba = (hex: string): Rgba => { + const [ r, g, b, a ] = hex.match(/\w\w/g)?.map((x) => parseInt(x, 16)) ?? [] + return [ r!, g!, b!, a ?? 255 ] +} + +export const rgbaToHex = (rgba: Rgba): string => { + return '#' + rgba.map(n => n.toString(16).padStart(2, '0')).join('') +} \ No newline at end of file diff --git a/engine/src/base/text.ts b/engine/src/base/text.ts new file mode 100644 index 000000000..6825e189c --- /dev/null +++ b/engine/src/base/text.ts @@ -0,0 +1,27 @@ +import type { Rgba, TextElement } from '../api.js' + +export function composeText(texts: TextElement[]): { char: string, color: Rgba }[][] { + const emptyCell = () => ({ char: ' ', color: [0, 0, 0, 0] as Rgba }) + const range = (length: number, fn: () => T): T[] => Array.from({ length }, fn) + const gridFromSize = (w: number, h: number) => range(h, () => range(w, emptyCell)) + const CHARS_MAX_X = 20 + const CHARS_MAX_Y = 16 + + const grid = gridFromSize(CHARS_MAX_X, CHARS_MAX_Y) + + for (const { x: sx, y: sy, content, color } of texts) { + let y = sy + for (const line of content.split('\n')) { + let x = sx + for (const char of line.split('')) { + if (" !\"#%&\'()*+,./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ\\^_-`abcdefghijklmnopqrstuvwxyz|~¦§¨©¬®¯°±´¶·¸ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ÙÚÛÜÝÞßàáâãäåæçèéêëìíîïñòóôõö÷ùúûüýþÿĀāĂ㥹ĆćĊċČčĎĐđĒēĖėĘęĚěĞğĠġĦħĪīĮįİıŃńŇňŌōŒœŞşŨũŪūŮůŲųŴŵŶŷŸǍǎǏǐǑǒǓǔˆˇ˘˙˚˛˜˝ẀẁẂẃẄẅỲỳ†‡•…‰⁄™∂∅∏∑−√∞∫≈≠≤≥◊".indexOf(char) === -1) + throw new Error(`Character ${char} is not in the font. It will be rendered incorrectly.`) + if (x <= CHARS_MAX_X && y < CHARS_MAX_Y) + grid[y]![x++] = {color: color, char} + } + y++ + } + } + + return grid +} diff --git a/engine/src/base/tune.ts b/engine/src/base/tune.ts new file mode 100644 index 000000000..9d90ff903 --- /dev/null +++ b/engine/src/base/tune.ts @@ -0,0 +1,63 @@ +/* +song form + +[ + [duration, instrument, pitch, duration, ...], +] + +Syntax: +500: 64.4~500 + c5~1000 +[500, 'sine', 64.4, 500, 'sine', 'c5', 1000] +Comma between each tune element. Whitespace ignored. +*/ + +import { type Tune, instrumentKey, InstrumentType, reverseInstrumentKey } from '../api.js' + +export const textToTune = (text: string): Tune => { + const elements = text.replace(/\s/g, '').split(',') + const tune = [] + + for (const element of elements) { + if (!element) continue + const [durationRaw, notesRaw] = element.split(':') + const duration = Math.round(parseInt(durationRaw ?? '0', 10)) + const notes = (notesRaw || '').split('+').map((noteRaw) => { + if (!noteRaw) return [] + const [, pitchRaw, instrumentRaw, durationRaw] = noteRaw.match(/^(.+)([~\-^\/])(.+)$/)! + return [ + instrumentKey[instrumentRaw!] ?? 'sine', + isNaN(parseInt(pitchRaw ?? '', 10)) ? pitchRaw! : parseInt(pitchRaw!, 10), + parseInt(durationRaw ?? '0', 10) + ] + }) + tune.push([duration, ...notes].flat()) + } + + return tune as Tune +} + +export const tuneToText = (tune: Tune): string => { + const groupNotes = (notes: (number | string)[]) => { + const groups = [] + for (let i = 0; i < notes.length; i++) { + if (i % 3 === 0) { + groups.push([notes[i]!]) + } else { + groups[groups.length-1]!.push(notes[i]!) + } + } + return groups + } + + const notesToString = ([duration, ...notes]: Tune[number]) => ( + notes.length === 0 + ? duration + : `${duration}: ${groupNotes(notes).map(notesToStringHelper).join(' + ')}` + ) + + const notesToStringHelper = ([instrument, duration, note]: (number | string)[]) => ( + `${duration}${reverseInstrumentKey[instrument as InstrumentType]}${note}` + ) + + return tune.map(notesToString).join(',\n') +} \ No newline at end of file diff --git a/engine/src/image-data/bitmap.ts b/engine/src/image-data/bitmap.ts new file mode 100644 index 000000000..7e936103c --- /dev/null +++ b/engine/src/image-data/bitmap.ts @@ -0,0 +1,33 @@ +import { palette } from '../base/index.js' + +// At odds with in-game behavior... doesn't enforce a size with stretching. +export const bitmapTextToImageData = (key: string, text: string): ImageData => { + const rows = text.trim().split("\n").map(x => x.trim()) + const rowLengths = rows.map(x => x.length) + const isRect = rowLengths.every(val => val === rowLengths[0]) + if (!isRect) throw new Error(`Bitmap with key ${key} is not rectangular.`); + const width = rows[0]!.length || 1 + const height = rows.length || 1 + const data = new Uint8ClampedArray(width*height*4) + + const colors = Object.fromEntries(palette) + + const nonSpace = text.split('').filter(x => x !== ' ' && x !== '\n') // \S regex led to massive perf problems + for (let i = 0; i < width*height; i++) { + const type = nonSpace[i] || "." + + if (!(type in colors)) { + const err = `in sprite string: no known color for char "${type}"` + console.error(err + '\n' + text) + throw new Error(err + ' (invalid sprite in console)') + } + + const [ r, g, b, a ] = colors[type] ?? colors['.']! + data[i*4] = r + data[i*4 + 1] = g + data[i*4 + 2] = b + data[i*4 + 3] = a + } + + return new ImageData(data, width, height) +} \ No newline at end of file diff --git a/engine/src/image-data/index.ts b/engine/src/image-data/index.ts new file mode 100644 index 000000000..64b0bec8d --- /dev/null +++ b/engine/src/image-data/index.ts @@ -0,0 +1,116 @@ +import type { FullSprigAPI, GameState, InputKey } from '../api.js' +import { type BaseEngineAPI, baseEngine } from '../base/index.js' +import { bitmapTextToImageData } from './bitmap.js' + +export * from './bitmap.js' + +export type ImageDataEngineAPI = BaseEngineAPI & Pick< + FullSprigAPI, + | 'onInput' + | 'afterInput' + | 'setLegend' + | 'setBackground' + | 'setTimeout' + | 'setInterval' + | 'playTune' +> +export const imageDataEngine = (): { + api: ImageDataEngineAPI, + render(): ImageData, + button(key: InputKey): void, + cleanup(): void, + state: GameState +} => { + const game = baseEngine() + + let legendImages: Record = {} + let background: string = '.' + + const timeouts: number[] = [] + const intervals: number[] = [] + + const keyHandlers: Record void)[]> = { + w: [], + s: [], + a: [], + d: [], + i: [], + j: [], + k: [], + l: [] + } + const afterInputs: (() => void)[] = [] + + const cleanup = () => { + timeouts.forEach(clearTimeout) + intervals.forEach(clearInterval) + } + + const api = { + ...game.api, + onInput: (key: InputKey, fn: () => void) => keyHandlers[key].push(fn), + afterInput: (fn: () => void) => afterInputs.push(fn), + setLegend: (...bitmaps: [string, string][]) => { + game.state.legend = bitmaps + legendImages = {} + for (const [ id, desc ] of bitmaps) + legendImages[id] = bitmapTextToImageData(id, desc) + }, + setBackground: (kind: string) => background = kind, + setTimeout: (fn: TimerHandler, ms: number) => { + const timer = setTimeout(fn, ms) + timeouts.push(timer) + return timer + }, + setInterval: (fn: TimerHandler, ms: number) => { + const timer = setInterval(fn, ms) + intervals.push(timer) + return timer + }, + playTune: () => ({ end() {}, isPlaying() { return false } }) + } + + return { + api, + button(key: InputKey): void { + for (const fn of keyHandlers[key]) fn() + for (const fn of afterInputs) fn() + game.state.sprites.forEach((s: any) => { + s.dx = 0 + s.dy = 0 + }) + }, + render(): ImageData { + const width = () => game.state.dimensions.width + const height = () => game.state.dimensions.height + const tSize = () => 16 + + const sw = width() * tSize() + const sh = height() * tSize() + + const out = new ImageData(sw, sh) + out.data.fill(255) + + for (const t of game.api.getGrid().flat()) { + const img = legendImages[t.type ?? background] + if (!img) continue + + for (let x = 0; x < tSize(); x++) + for (let y = 0; y < tSize(); y++) { + const tx = t.x * tSize() + x + const ty = t.y * tSize() + y + const src_alpha = img.data[(y * 16 + x) * 4 + 3] + if (!src_alpha) continue + out.data[(ty * sw + tx) * 4 + 0] = img.data[(y * 16 + x) * 4 + 0]! + out.data[(ty * sw + tx) * 4 + 1] = img.data[(y * 16 + x) * 4 + 1]! + out.data[(ty * sw + tx) * 4 + 2] = img.data[(y * 16 + x) * 4 + 2]! + out.data[(ty * sw + tx) * 4 + 3] = img.data[(y * 16 + x) * 4 + 3]! + } + } + + return out + }, + cleanup, + state: game.state + } +} \ No newline at end of file diff --git a/engine/src/web/index.ts b/engine/src/web/index.ts new file mode 100644 index 000000000..f0aa26552 --- /dev/null +++ b/engine/src/web/index.ts @@ -0,0 +1,176 @@ +import { type InputKey, type PlayTuneRes, VALID_INPUTS, type FullSprigAPI, type GameState } from '../api.js' +import { type BaseEngineAPI, baseEngine, textToTune } from '../base/index.js' +import { bitmapTextToImageData } from '../image-data/index.js' +import { getTextImg } from './text.js' +import { playTune } from './tune.js' +import { makeCanvas } from './util.js' + +export * from './text.js' +export * from './tune.js' + +export type WebEngineAPI = BaseEngineAPI & Pick< + FullSprigAPI, + | 'setLegend' + | 'onInput' + | 'afterInput' + | 'playTune' +> & { + getState(): GameState // For weird backwards-compatibility reasons, not part of API +} + +export function webEngine(canvas: HTMLCanvasElement): { + api: WebEngineAPI, + state: GameState, + cleanup(): void +} { + const { api, state } = baseEngine() + + const ctx = canvas.getContext('2d')! + const offscreenCanvas = makeCanvas(1, 1) + const offscreenCtx = offscreenCanvas.getContext('2d')! + + const _bitmaps: Record = {} + let _zOrder: string[] = [] + + ctx.imageSmoothingEnabled = false + + const _gameloop = (): void => { + const { width, height } = state.dimensions + if (width === 0 || height === 0) return + + ctx.clearRect(0, 0, canvas.width, canvas.height) + + offscreenCanvas.width = width*16 + offscreenCanvas.height = height*16 + + offscreenCtx.fillStyle = 'white' + offscreenCtx.fillRect(0, 0, width*16, height*16) + + const grid = api.getGrid() + + for (let i = 0; i < width * height; i++) { + const x = i % width + const y = Math.floor(i/width) + const sprites = grid[i]! + + if (state.background) { + const imgData = _bitmaps[state.background]! + offscreenCtx.drawImage(imgData, x*16, y*16) + } + + sprites + .sort((a, b) => _zOrder.indexOf(b.type) - _zOrder.indexOf(a.type)) + .forEach((sprite) => { + const imgData = _bitmaps[sprite.type]! + offscreenCtx.drawImage(imgData, x*16, y*16) + }) + + } + + const scale = Math.min(canvas.width/(width*16), canvas.height/(height*16)) + const actualWidth = offscreenCanvas.width*scale + const actualHeight = offscreenCanvas.height*scale + ctx.drawImage( + offscreenCanvas, + (canvas.width-actualWidth)/2, + (canvas.height-actualHeight)/2, + actualWidth, + actualHeight + ) + + const textCanvas = getTextImg(state.texts) + ctx.drawImage( + textCanvas, + 0, + 0, + canvas.width, + canvas.height + ) + + animationId = window.requestAnimationFrame(_gameloop) + } + let animationId = window.requestAnimationFrame(_gameloop) + + const setLegend = (...bitmaps: [string, string][]): void => { + if (bitmaps.length == 0) throw new Error('There needs to be at least one sprite in the legend.') + + if (!Array.isArray(bitmaps[0])) throw new Error('The sprites passed into setLegend each need to be in square brackets, like setLegend([player, bitmap`...`]).') + + bitmaps.forEach(([ key ]) => { + if (key === '.') throw new Error(`Can't reassign "." bitmap`) + if (key.length !== 1) throw new Error(`Bitmaps must have one character names`) + }) + + state.legend = bitmaps + _zOrder = bitmaps.map(x => x[0]) + + for (let i = 0; i < bitmaps.length; i++) { + const [ key, value ] = bitmaps[i]! + const imgData = bitmapTextToImageData(key, value) + const littleCanvas = makeCanvas(16, 16) + littleCanvas.getContext('2d')!.putImageData(imgData, 0, 0) + _bitmaps[key] = littleCanvas + } + } + + let tileInputs: Record void)[]> = { + w: [], + s: [], + a: [], + d: [], + i: [], + j: [], + k: [], + l: [] + } + const afterInputs: (() => void)[] = [] + + const keydown = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (!VALID_INPUTS.includes(key as any)) return + + for (const validKey of VALID_INPUTS) + if (key === validKey) tileInputs[key].forEach(fn => fn()) + + afterInputs.forEach(f => f()) + + state.sprites.forEach((s: any) => { + s.dx = 0 + s.dy = 0 + }) + + e.preventDefault() + } + canvas.addEventListener('keydown', keydown) + + const onInput = (key: InputKey, fn: () => void): void => { + if (!VALID_INPUTS.includes(key)) + throw new Error(`Unknown input key, "${key}": expected one of ${VALID_INPUTS.join(', ')}`) + tileInputs[key].push(fn) + } + const afterInput = (fn: () => void): void => { afterInputs.push(fn) } + + const tunes: PlayTuneRes[] = [] + return { + api: { + ...api, + setLegend, + onInput, + afterInput, + getState: () => state, + playTune: (text: string, n: number) => { + const tune = textToTune(text) + const playTuneRes = playTune(tune, n) + tunes.push(playTuneRes) + return playTuneRes + } + }, + state, + cleanup: () => { + ctx.clearRect(0, 0, canvas.width, canvas.height) + window.cancelAnimationFrame(animationId) + canvas.removeEventListener('keydown', keydown) + tunes.forEach(tune => tune.end()) + } + } +} diff --git a/engine/src/web/text.ts b/engine/src/web/text.ts new file mode 100644 index 000000000..2e6fd616c --- /dev/null +++ b/engine/src/web/text.ts @@ -0,0 +1,35 @@ +import type { TextElement } from '../api.js' +import { font, composeText } from '../base/index.js' +import { makeCanvas } from './util.js' + +export const getTextImg = (texts: TextElement[]): CanvasImageSource => { + const charGrid = composeText(texts) + const img = new ImageData(160, 128) + img.data.fill(0) + + for (const [i, row] of Object.entries(charGrid)) { + let xt = 0 + for (const { char, color } of row) { + const cc = char.charCodeAt(0) + + let y = Number(i)*8 + for (const bits of font.slice(cc*8, (1+cc)*8)) { + for (let x = 0; x < 8; x++) { + const val = (bits>>(7-x)) & 1 + + img.data[(y*img.width + xt + x)*4 + 0] = val*color[0] + img.data[(y*img.width + xt + x)*4 + 1] = val*color[1] + img.data[(y*img.width + xt + x)*4 + 2] = val*color[2] + img.data[(y*img.width + xt + x)*4 + 3] = val*255 + } + y++ + } + xt += 8 + } + } + + const canvas = makeCanvas(160, 128) + canvas.getContext('2d')!.putImageData(img, 0, 0) + + return canvas +} \ No newline at end of file diff --git a/engine/src/web/tune.ts b/engine/src/web/tune.ts new file mode 100644 index 000000000..c656e66b0 --- /dev/null +++ b/engine/src/web/tune.ts @@ -0,0 +1,60 @@ +import { type InstrumentType, type PlayTuneRes, type Tune, instruments, tones } from '../api.js' + +export function playFrequency(frequency: number, duration: number, instrument: InstrumentType, ctx: AudioContext, dest: AudioNode) { + const osc = ctx.createOscillator() + const rampGain = ctx.createGain() + + osc.connect(rampGain) + rampGain.connect(dest) + + osc.frequency.value = frequency + osc.type = instrument ?? 'sine' + osc.start() + + const endTime = ctx.currentTime + duration*2/1000 + osc.stop(endTime) + + rampGain.gain.setValueAtTime(0, ctx.currentTime) + rampGain.gain.linearRampToValueAtTime(.2, ctx.currentTime + duration/5/1000) + rampGain.gain.exponentialRampToValueAtTime(0.00001, ctx.currentTime + duration/1000) + rampGain.gain.linearRampToValueAtTime(0, ctx.currentTime + duration*2/1000) // does this ramp from the last ramp + + osc.onended = () => { + osc.disconnect() + rampGain.disconnect() + } +} + +const sleep = async (duration: number) => new Promise(resolve => setTimeout(resolve, duration)) +export async function playTuneHelper(tune: Tune, number: number, playingRef: { playing: boolean }, ctx: AudioContext, dest: AudioNode) { + for (let i = 0; i < tune.length*number; i++) { + const index = i%tune.length + if (!playingRef.playing) break + const noteSet = tune[index]! + const sleepTime = noteSet[0] + for (let j = 1; j < noteSet.length; j += 3) { + const instrument = noteSet[j] as InstrumentType + const note = noteSet[j+1]! + const duration = noteSet[j+2] as number + + const frequency = typeof note === 'string' + ? tones[note.toUpperCase()] + : 2**((note-69)/12)*440 + if (instruments.includes(instrument) && frequency !== undefined) playFrequency(frequency, duration, instrument, ctx, dest) + } + await sleep(sleepTime) + } +} + +let audioCtx: AudioContext | null = null +export function playTune(tune: Tune, number = 1): PlayTuneRes { + const playingRef = { playing: true } + + if (audioCtx === null) audioCtx = new AudioContext() + playTuneHelper(tune, number, playingRef, audioCtx, audioCtx.destination) + + return { + end() { playingRef.playing = false }, + isPlaying() { return playingRef.playing } + } +} \ No newline at end of file diff --git a/engine/src/web/util.ts b/engine/src/web/util.ts new file mode 100644 index 000000000..f7ee75a22 --- /dev/null +++ b/engine/src/web/util.ts @@ -0,0 +1,6 @@ +export function makeCanvas(width: number, height: number): HTMLCanvasElement { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas +} \ No newline at end of file diff --git a/engine/tsconfig.json b/engine/tsconfig.json new file mode 100644 index 000000000..9d58145c2 --- /dev/null +++ b/engine/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "incremental": true, + "target": "ES2022", + "lib": [ "ES2022", "DOM" ], + "module": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "isolatedModules": true, + "esModuleInterop": false, + "forceConsistentCasingInFileNames": true, + "strict": true + }, + "include": [ "./src/**/*" ] +} diff --git a/engine/web.d.ts b/engine/web.d.ts new file mode 100644 index 000000000..98212da2b --- /dev/null +++ b/engine/web.d.ts @@ -0,0 +1 @@ +export * from './dist/web' \ No newline at end of file diff --git a/engine/web.js b/engine/web.js new file mode 100644 index 000000000..0d66d8673 --- /dev/null +++ b/engine/web.js @@ -0,0 +1 @@ +module.exports = require('./dist/web') \ No newline at end of file diff --git a/engine/yarn.lock b/engine/yarn.lock new file mode 100644 index 000000000..bc05299a7 --- /dev/null +++ b/engine/yarn.lock @@ -0,0 +1,403 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +ansi-red@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/ansi-red/-/ansi-red-0.1.1.tgz#8c638f9d1080800a353c9c28c8a81ca4705d946c" + integrity sha512-ewaIr5y+9CUTGFwZfpECUbFlGcC0GCw1oqR9RI6h1gQCd9Aj2GxSckCnPsVJnmfMZbwFYE+leZGASgkWl06Jow== + dependencies: + ansi-wrap "0.1.0" + +ansi-wrap@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" + integrity sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw== + +argparse@^1.0.10, argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +autolinker@~0.28.0: + version "0.28.1" + resolved "https://registry.yarnpkg.com/autolinker/-/autolinker-0.28.1.tgz#0652b491881879f0775dace0cdca3233942a4e47" + integrity sha512-zQAFO1Dlsn69eXaO6+7YZc+v84aquQKbwpzCE3L0stj56ERn9hutFxPopViLjo9G+rWwjozRhgS5KJ25Xy19cQ== + dependencies: + gulp-header "^1.7.1" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +coffee-script@^1.12.4: + version "1.12.7" + resolved "https://registry.yarnpkg.com/coffee-script/-/coffee-script-1.12.7.tgz#c05dae0cb79591d05b3070a8433a98c9a89ccc53" + integrity sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw== + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +concat-with-sourcemaps@*: + version "1.1.0" + resolved "https://registry.yarnpkg.com/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz#d4ea93f05ae25790951b99e7b3b09e3908a4082e" + integrity sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg== + dependencies: + source-map "^0.6.1" + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +diacritics-map@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/diacritics-map/-/diacritics-map-0.1.0.tgz#6dfc0ff9d01000a2edf2865371cac316e94977af" + integrity sha512-3omnDTYrGigU0i4cJjvaKwD52B8aoqyX/NEIkukFFkogBemsIbhSa1O414fpTp5nuszJG6lvQ5vBvDVNCbSsaQ== + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +expand-range@^1.8.1: + version "1.8.2" + resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337" + integrity sha512-AFASGfIlnIbkKPQwX1yHaDjFvh/1gyKJODme52V6IORh69uEYgZp0o9C+qsIGNVEiuuhQU0CSSl++Rlegg1qvA== + dependencies: + fill-range "^2.1.0" + +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== + dependencies: + is-extendable "^0.1.0" + +fill-range@^2.1.0: + version "2.2.4" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565" + integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q== + dependencies: + is-number "^2.1.0" + isobject "^2.0.0" + randomatic "^3.0.0" + repeat-element "^1.1.2" + repeat-string "^1.5.2" + +for-in@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80" + integrity sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ== + +gray-matter@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-2.1.1.tgz#3042d9adec2a1ded6a7707a9ed2380f8a17a430e" + integrity sha512-vbmvP1Fe/fxuT2QuLVcqb2BfK7upGhhbLIt9/owWEvPYrZZEkelLcq2HqzxosV+PQ67dUFLaAeNpH7C4hhICAA== + dependencies: + ansi-red "^0.1.1" + coffee-script "^1.12.4" + extend-shallow "^2.0.1" + js-yaml "^3.8.1" + toml "^2.3.2" + +gulp-header@^1.7.1: + version "1.8.12" + resolved "https://registry.yarnpkg.com/gulp-header/-/gulp-header-1.8.12.tgz#ad306be0066599127281c4f8786660e705080a84" + integrity sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ== + dependencies: + concat-with-sourcemaps "*" + lodash.template "^4.4.0" + through2 "^2.0.0" + +inherits@^2.0.3, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-buffer@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" + integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== + +is-extendable@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== + +is-extendable@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-1.0.1.tgz#a7470f9e426733d81bd81e1155264e3a3507cab4" + integrity sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA== + dependencies: + is-plain-object "^2.0.4" + +is-number@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f" + integrity sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg== + dependencies: + kind-of "^3.0.2" + +is-number@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff" + integrity sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ== + +is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +isarray@1.0.0, isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +isobject@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" + integrity sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA== + dependencies: + isarray "1.0.0" + +isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== + +js-yaml@^3.8.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +kind-of@^3.0.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64" + integrity sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ== + dependencies: + is-buffer "^1.1.5" + +kind-of@^6.0.0: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +lazy-cache@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264" + integrity sha512-7vp2Acd2+Kz4XkzxGxaB1FWOi8KjWIWsgdfD5MCb86DWvlLqhRPM+d6Pro3iNEL5VT9mstz5hKAlcd+QR6H3aA== + dependencies: + set-getter "^0.1.0" + +list-item@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/list-item/-/list-item-1.1.1.tgz#0c65d00e287cb663ccb3cb3849a77e89ec268a56" + integrity sha512-S3D0WZ4J6hyM8o5SNKWaMYB1ALSacPZ2nHGEuCjmHZ+dc03gFeNZoNDcqfcnO4vDhTZmNrqrpYZCdXsRh22bzw== + dependencies: + expand-range "^1.8.1" + extend-shallow "^2.0.1" + is-number "^2.1.0" + repeat-string "^1.5.2" + +lodash._reinterpolate@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" + integrity sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA== + +lodash.template@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab" + integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A== + dependencies: + lodash._reinterpolate "^3.0.0" + lodash.templatesettings "^4.0.0" + +lodash.templatesettings@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33" + integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ== + dependencies: + lodash._reinterpolate "^3.0.0" + +markdown-link@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/markdown-link/-/markdown-link-0.1.1.tgz#32c5c65199a6457316322d1e4229d13407c8c7cf" + integrity sha512-TurLymbyLyo+kAUUAV9ggR9EPcDjP/ctlv9QAFiqUH7c+t6FlsbivPo9OKTU8xdOx9oNd2drW/Fi5RRElQbUqA== + +markdown-toc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/markdown-toc/-/markdown-toc-1.2.0.tgz#44a15606844490314afc0444483f9e7b1122c339" + integrity sha512-eOsq7EGd3asV0oBfmyqngeEIhrbkc7XVP63OwcJBIhH2EpG2PzFcbZdhy1jutXSlRBBVMNXHvMtSr5LAxSUvUg== + dependencies: + concat-stream "^1.5.2" + diacritics-map "^0.1.0" + gray-matter "^2.1.0" + lazy-cache "^2.0.2" + list-item "^1.1.1" + markdown-link "^0.1.1" + minimist "^1.2.0" + mixin-deep "^1.1.3" + object.pick "^1.2.0" + remarkable "^1.7.1" + repeat-string "^1.6.1" + strip-color "^0.1.0" + +math-random@^1.0.1: + version "1.0.4" + resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" + integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A== + +minimist@^1.2.0: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mixin-deep@^1.1.3: + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== + dependencies: + for-in "^1.0.2" + is-extendable "^1.0.1" + +object.pick@^1.2.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747" + integrity sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ== + dependencies: + isobject "^3.0.1" + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +randomatic@^3.0.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed" + integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw== + dependencies: + is-number "^4.0.0" + kind-of "^6.0.0" + math-random "^1.0.1" + +readable-stream@^2.2.2, readable-stream@~2.3.6: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +remarkable@^1.7.1: + version "1.7.4" + resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-1.7.4.tgz#19073cb960398c87a7d6546eaa5e50d2022fcd00" + integrity sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg== + dependencies: + argparse "^1.0.10" + autolinker "~0.28.0" + +repeat-element@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9" + integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ== + +repeat-string@^1.5.2, repeat-string@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" + integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +set-getter@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.1.tgz#a3110e1b461d31a9cfc8c5c9ee2e9737ad447102" + integrity sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw== + dependencies: + to-object-path "^0.3.0" + +source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +strip-color@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/strip-color/-/strip-color-0.1.0.tgz#106f65d3d3e6a2d9401cac0eb0ce8b8a702b4f7b" + integrity sha512-p9LsUieSjWNNAxVCXLeilaDlmuUOrDS5/dF9znM1nZc7EGX5+zEFC0bEevsNIaldjlks+2jns5Siz6F9iK6jwA== + +through2@^2.0.0: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + +to-object-path@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af" + integrity sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg== + dependencies: + kind-of "^3.0.2" + +toml@^2.3.2: + version "2.3.6" + resolved "https://registry.yarnpkg.com/toml/-/toml-2.3.6.tgz#25b0866483a9722474895559088b436fd11f861b" + integrity sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ== + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +typescript@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.3.tgz#8d84219244a6b40b6fb2b33cc1c062f715b9e826" + integrity sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw== + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +xtend@~4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==