Skip to content

Instantly share code, notes, and snippets.

@oddgoo
Last active June 16, 2024 11:51
Show Gist options
  • Save oddgoo/b0403d63bedbef1e2c0abd607474e450 to your computer and use it in GitHub Desktop.
Save oddgoo/b0403d63bedbef1e2c0abd607474e450 to your computer and use it in GitHub Desktop.
Places from My Dreams (bipsi playable)
<!DOCTYPE html><html lang="en" data-remix-generation="1" data-editor-live="https://kool.tools/bipsi" style="--tile-px: 8px; --tile-select-zoom: 5; --tileset-background-size: 640px 80px;" data-app-mode="player"><head>
<title>bipsi</title>
<meta charset="utf-8">
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAAxQTFRFAAAAAxmR8+/s3i5SinG4LAAAAAR0Uk5TAP///7MtQIgAAABGSURBVBiVlY9BDgAgCMPY/P+fhQlo9GQTknXxMM0CCivYpA+PcQCXu8RVcxeeBav5K6CC6RDsVajCtiB3HJ5TL99vnv8qTuMLAYu9aKwhAAAAAElFTkSuQmCC">
<!-- scripts-->
<script>const wrap = {};
/**
* @param {Function} first
* @param {Function} second
*/
wrap.sequenced = function(first, second) {
return function(...args) {
const intermediate = first.call(this, ...args);
// if the first returned a promise, return promise of second chained
// after first. otherwise just return the result of second (which may
// or may not be a promise)
if (intermediate?.then) {
return intermediate.then(() => second.call(this, ...args));
} else {
return second.call(this, ...args);
}
}
}
wrap.before = function(object, method, callback) {
const original = object[method];
object[method] = wrap.sequenced(callback, original);
}
wrap.after = function(object, method, callback) {
const original = object[method];
object[method] = wrap.sequenced(original, callback);
}
wrap.replace = function(object, method, callback) {
object[method] = callback;
}
wrap.splice = function(object, method, callback) {
const original = object[method];
object[method] = function (...args) {
return callback.call(this, original, ...args);
};
}
</script>
<script>"strict"
const maker = {};
const ui = {};
/**
* @template TProject
* @typedef {(project: TProject) => string[]} maker.ManifestFunction
*/
/**
* @typedef {Object} ResourceData
* @property {string} type
* @property {any} data
*/
/**
* @typedef {Object.<string, ResourceData>} maker.ResourceBundle
*/
/**
* @template TProject
* @typedef {Object} maker.ProjectBundle
* @property {TProject} project
* @property {maker.ResourceBundle} resources
*/
/**
* @template TData
* @template TInstance
* @typedef {Object} maker.ResourceHandler
* @property {(data: TData) => Promise<TInstance>} load
* @property {(instance: TInstance) => Promise<TInstance>} copy
* @property {(instance: TInstance) => Promise<TData>} save
*/
/** @type {Map<string, maker.ResourceHandler<any, any>>} */
maker.resourceHandlers = new Map();
// add a resource type called "canvas-datauri" that describes how to load a
// canvas rendering context from a datauri, how to copy one, and how to convert
// one back into a datauri
maker.resourceHandlers.set("canvas-datauri", {
load: async (data) => imageToRendering2D(await loadImage(data)),
copy: async (instance) => copyRendering2D(instance),
save: async (instance) => instance.canvas.toDataURL("image/png", 1),
});
maker.resourceHandlers.set("file-datauri", {
load: async (data) => new File([await fetch(data.uri).then((r) => r.blob())], data.name, { type: data.type }),
copy: async (instance) => new File([await instance.arrayBuffer()], instance.name, { type: instance.type }),
save: async (instance) => ({
name: instance.name,
uri: await maker.dataURIFromFile(instance),
type: instance.type,
}),
});
maker.ResourceManager = class {
constructor() {
this.lastId = 0;
/** @type {Map<string, { type: string, instance: any }>} */
this.resources = new Map();
}
/**
* Generate a new unique id for a resource.
* @returns {string}
*/
generateId() {
this.lastId += 1;
// just next lowest unused number
while (this.resources.has(this.lastId.toString())) {
this.lastId += 1;
}
return this.lastId.toString();
}
/**
* Clear all resources.
*/
clear() {
this.resources.clear();
}
/**
* Get the resource instance with the given id.
* @param {string} id
* @returns {any}
*/
get(id) {
return this.resources.get(id)?.instance;
}
/**
* Add a resource instance at a specific id.
* @param {string} id
* @param {any} instance
* @param {string} type
*/
set(id, instance, type) {
this.resources.set(id, { type, instance });
}
/**
* Add an instance as a new resource and return its new id.
* @param {any} instance
* @param {string} type
* @returns {string}
*/
add(instance, type) {
const id = this.generateId();
this.set(id, instance, type);
return id;
}
/**
* Copy the existing resource with the given id and add it as a new resource.
* @param {string} id
* @returns
*/
async fork(id) {
const source = this.resources.get(id);
const forkId = this.generateId();
const instance = await maker.resourceHandlers.get(source.type).copy(source.instance);
this.set(forkId, instance, source.type);
return { id: forkId, instance };
}
/**
* Discard all resources except those at the ids given.
* @param {Iterable<string>} keepIds
*/
prune(keepIds) {
const ids = new Set(keepIds);
this.resources.forEach((_, id) => {
if (!ids.has(id)) this.resources.delete(id);
});
}
/**
* Copy all resources from another resource manager.
* @param {maker.ResourceManager} other
*/
async copyFrom(other) {
const tasks = [];
Array.from(other.resources).forEach(([id, { type, instance }]) => {
const task = maker.resourceHandlers.get(type)
.copy(instance)
.then((copy) => this.set(id, copy, type));
tasks.push(task);
});
return Promise.all(tasks);
}
/**
* Save all resources in an object mapping id to type and save data.
* @param {Iterable<string>} ids
* @returns {Promise<maker.ResourceBundle>}
*/
async save(ids) {
/** @type {maker.ResourceBundle} */
const bundle = {};
const resourceIds = new Set(ids);
const relevant = Array.from(this.resources)
.filter(([id]) => resourceIds.has(id));
const tasks = [];
Array.from(relevant).forEach(([id, { type, instance }]) => {
const task = maker.resourceHandlers.get(type)
.save(instance)
.then((data) => bundle[id] = { type, data });
tasks.push(task);
});
await Promise.all(tasks);
return bundle;
}
/**
* Load all resources from the given bundle.
* @param {maker.ResourceBundle} bundle
*/
async load(bundle) {
const tasks = [];
Object.entries(bundle).forEach(([id, { type, data }]) => {
const task = maker.resourceHandlers.get(type)
.load(data)
.then((instance) => this.set(id, instance, type));
tasks.push(task);
});
return Promise.all(tasks);
}
}
/**
*
* @template TState
*/
maker.StateManager = class extends EventTarget {
/**
* Create a state manager, optionally providing a function that describes
* how to determine resource dependencies of a given state.
* @param {maker.ManifestFunction<TState>} getManifest
*/
constructor(getManifest = undefined) {
super();
/** @type {maker.ManifestFunction<TState>} */
this.getManifest = getManifest || (() => []);
this.resources = new maker.ResourceManager();
/** @type {TState[]} */
this.history = [];
this.index = -1;
this.historyLimit = 20;
}
/**
* The present state in history.
*/
get present() {
return this.history[this.index];
}
/**
* Is there any edit history to undo to?
*/
get canUndo() {
return this.index > 0;
}
/**
* Are there any undone edits to redo?
*/
get canRedo() {
return this.index < this.history.length - 1;
}
/**
* Replace all state with the project and resources in the given project
* bundle.
* @param {maker.ProjectBundle<TState>} bundle
*/
async loadBundle(bundle) {
this.history.length = 0;
this.history.push(bundle.project);
this.index = 0;
this.resources.clear();
await this.resources.load(bundle.resources);
this.changed();
}
/**
* Replace all state by copying from another state manager.
* @param {maker.StateManager<TState>} other
*/
async copyFrom(other) {
this.history = COPY(other.history);
this.index = other.index;
this.resources.clear();
await this.resources.copyFrom(other.resources);
this.changed();
}
/**
* Replace all state by copying just the present and dependent resources
* from another state manager.
* @param {maker.StateManager<TState>} other
*/
async copyPresentFrom(other) {
this.history = [COPY(other.present)];
this.index = 0;
this.resources.clear();
// TODO: only copy what's not going to be pruned..
await this.resources.copyFrom(other.resources);
this.pruneResources();
this.changed();
}
/**
* Copy the present state and dependent resources into a project bundle.
* @returns {Promise<maker.ProjectBundle<TState>>}
*/
async makeBundle() {
const project = COPY(this.present);
const resources = await this.resources.save(this.getManifest(this.present));
return { project, resources };
}
/**
* Save the current state as a checkpoint in history that can be returned to
* with undo/redo.
*/
makeCheckpoint() {
this.history.length = this.index + 1;
const currentData = this.present;
this.history[this.index] = COPY(currentData);
this.history.push(currentData);
if (this.index < this.historyLimit) {
this.index += 1;
} else {
// delete earliest history
this.history.splice(0, 1);
this.pruneResources();
}
}
/**
* Dispatch the change event signalling that the present state has been
* updated.
*/
changed() {
this.dispatchEvent(new CustomEvent("change"));
}
/**
* Discard all resources that are no longer required accord to the manifest
* function.
*/
pruneResources() {
this.resources.prune(this.history.flatMap(this.getManifest));
}
/**
* Make a history checkpoint, replace the current state with a forked
* version via callback, and then dispatch the change event.
* @param {(data: TState) => Promise} action
*/
async makeChange(action) {
this.makeCheckpoint();
await action(this.present);
this.changed();
}
/**
* Revert the state to the previous checkpoint in history.
*/
undo() {
if (!this.canUndo) return;
this.index -= 1;
this.changed();
}
/**
* Return the state to the most recently undone checkpoint in history.
*/
redo() {
if (!this.canRedo) return;
this.index += 1;
this.changed();
}
};
/**
* Ask the browser to download the given blob as a file with the given name.
* @param {Blob} blob
* @param {string} name
*/
maker.saveAs = function(blob, name) {
const element = document.createElement("a");
const url = window.URL.createObjectURL(blob);
element.href = url;
element.download = name;
element.click();
window.URL.revokeObjectURL(url);
};
/**
* Open the browser file picker, optionally restricted to files of a given file
* type pattern and optionally accepting multiple files.
* @param {string} accept
* @param {boolean} multiple
* @returns {Promise<File[]>}
*/
maker.pickFiles = async function(accept = "*", multiple = false) {
return new Promise((resolve) => {
const fileInput = html("input", { type: "file", accept, multiple, style: "visibility: collapse" });
document.body.append(fileInput);
function done(files) {
fileInput.remove();
resolve(files);
}
fileInput.addEventListener("change", () => done(Array.from(fileInput.files)));
fileInput.addEventListener("cancel", () => done([]));
fileInput.click();
});
}
/**
* Read plain text from a file.
* @param {File} file
* @return {Promise<string>}
*/
maker.textFromFile = async function(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => resolve(/** @type {string} */ (reader.result));
reader.readAsText(file);
});
}
/**
* Read image from a file.
* @param {File} file
* @return {Promise<string>}
*/
maker.dataURIFromFile = async function(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = reject;
reader.onload = () => resolve(/** @type {string} */ (reader.result));
reader.readAsDataURL(file);
});
}
/**
* Create a DOM for an html page from html source code
* @param {string} source
* @returns
*/
maker.htmlFromText = function(source) {
const template = document.createElement('template');
template.innerHTML = source;
return template.content;
}
/**
* @param {string} text
*/
maker.textToBlob = function(text, type = "text/plain") {
return new Blob([text], { type });
}
/**
*
* @param {ParentNode} html
*/
maker.bundleFromHTML = function(html, query="#bundle-embed") {
const json = ONE(query, html)?.textContent;
const bundle = json ? JSON.parse(json) : undefined;
return bundle;
}
class RadioGroupWrapper extends EventTarget {
/** @param {HTMLInputElement[]} inputs */
constructor(inputs) {
super();
const group = this;
this.onRadioChange = function() {
if (!this.checked) return;
group.dispatchEvent(new CustomEvent("change"));
}
this.inputs = [];
this.replaceInputs(inputs);
}
get selectedIndex() {
return this.inputs.findIndex((button) => button.checked);
}
set selectedIndex(value) {
this.inputs[value].click();
}
get selectedInput() {
return this.inputs[this.selectedIndex];
}
get value() {
return this.selectedInput?.value;
}
get valueAsNumber() {
return parseInt(this.selectedInput?.value ?? "-1", 10);
}
setSelectedIndexSilent(value) {
this.inputs.forEach((input, index) => input.checked = index === value);
}
setValueSilent(value) {
value = value.toString();
this.inputs.forEach((input) => input.checked = input.value === value);
}
/**
* @param {HTMLElement} element
* @param {...string} values
*/
tab(element, ...values) {
this.addEventListener("change", () => element.hidden = !values.includes(this.value));
}
/**
* @param {HTMLInputElement} radioElement
*/
add(radioElement) {
this.inputs.push(radioElement);
radioElement.addEventListener("change", this.onRadioChange);
}
/**
* @param {HTMLInputElement} radioElement
*/
remove(radioElement) {
arrayDiscard(this.inputs, radioElement);
radioElement.removeEventListener("change", this.onRadioChange);
}
removeAll() {
this.inputs.forEach((element) => element.removeEventListener("change", this.onRadioChange));
this.inputs.length = 0;
}
replaceInputs(inputs) {
this.removeAll();
inputs.forEach((input) => this.add(input));
}
}
class CheckboxWrapper extends EventTarget {
/** @param {HTMLInputElement[]} inputs */
constructor(inputs) {
super();
this.inputs = inputs;
inputs.forEach((input) => {
input.addEventListener("change", () => {
this.setCheckedSilent(input.checked);
this.dispatchEvent(new CustomEvent("change"));
});
});
}
get checked() {
return this.inputs[0].checked;
}
set checked(value) {
if (this.checked !== value) this.inputs[0].click();
}
setCheckedSilent(value) {
this.inputs.forEach((input) => input.checked = value);
}
}
class ButtonAction extends EventTarget {
/** @param {HTMLButtonElement[]} buttons */
constructor(buttons) {
super();
this.buttons = buttons;
this.disabled = false;
this.clickListener = () => this.invoke();
this.buttons.forEach((button) => {
button.addEventListener("click", this.clickListener);
});
}
get disabled() {
return this._disabled;
}
set disabled(value) {
this._disabled = value;
this.buttons.forEach((button) => button.disabled = value);
}
invoke(force = false) {
if (!force && this.disabled) return;
this.dispatchEvent(new CustomEvent("invoke"));
}
detach() {
this.buttons.forEach((button) => {
button.removeEventListener("click", this.clickListener);
});
this.disabled = false;
this.buttons = [];
}
}
/**
* Get a wrapper for the radio input elements sharing the given name.
* @param {string} name
* @returns {RadioGroupWrapper}
*/
ui.radio = (name) => new RadioGroupWrapper(ALL(`input[type="radio"][name="${name}"]`));
ui.toggle = (name) => new CheckboxWrapper(ALL(`input[type="checkbox"][name="${name}"]`));
/**
* @param {string} name
* @returns {HTMLInputElement}
*/
ui.slider = (name) => ONE(`input[type="range"][name=${name}]`);
/**
* @param {string} name
* @returns {HTMLInputElement | HTMLTextAreaElement}
*/
ui.text = (name) => ONE(`[name=${name}]`);
/** @type {Map<string, ButtonAction>} */
ui.actions = new Map();
/**
* Get an action linked to all button elements sharing the given name.
* Optionally provide a default listener for the action.
* @param {string} name
* @param {() => void} listener
* @returns {ButtonAction}
*/
ui.action = function (name, listener=undefined) {
const action = new ButtonAction(ALL(`button[name="${name}"]`));
ui.actions.set(name, action);
if (listener) action.addEventListener("invoke", listener);
return action;
}
/**
* Get the html select element with the given name.
* @param {string} name
* @returns {HTMLSelectElement}
*/
ui.select = (name) => ONE(`select[name="${name}"]`);
/**
* Get a child element matching CSS selector.
* @param {string} query
* @param {ParentNode} element
* @returns {HTMLElement}
*/
const ONE = (query, element = undefined) => (element || document).querySelector(query);
/**
* Get all children elements matching CSS selector.
* @param {string} query
* @param {HTMLElement | Document} element
* @returns {HTMLElement[]}
*/
const ALL = (query, element = undefined) => Array.from((element || document).querySelectorAll(query));
/**
* @template {any} T
* @param {T[]} array
* @param {T} value
* @returns {boolean}
*/
function arrayDiscard(array, value) {
const index = array.indexOf(value);
if (index >= 0) array.splice(index, 1);
return index >= 0;
}
ui.PointerDrag = class extends EventTarget {
/**
* @param {MouseEvent} event
*/
constructor(event, { clickMovementLimit = 5 } = {}) {
super();
this.pointerId = event["pointerId"];
this.clickMovementLimit = 5;
this.totalMovement = 0;
this.downEvent = event;
this.lastEvent = event;
this.listeners = {
"pointerup": (event) => {
if (event.pointerId !== this.pointerId) return;
this.lastEvent = event;
this.unlisten();
this.dispatchEvent(new CustomEvent("up", { detail: event }));
if (this.totalMovement <= clickMovementLimit) {
this.dispatchEvent(new CustomEvent("click", { detail: event }));
}
},
"pointermove": (event) => {
if (event.pointerId !== this.pointerId) return;
this.lastEvent = event;
this.totalMovement += Math.abs(event.movementX);
this.totalMovement += Math.abs(event.movementY);
this.dispatchEvent(new CustomEvent("move", { detail: event }));
}
}
document.addEventListener("pointerup", this.listeners.pointerup);
document.addEventListener("pointermove", this.listeners.pointermove);
}
unlisten() {
document.removeEventListener("pointerup", this.listeners.pointerup);
document.removeEventListener("pointermove", this.listeners.pointermove);
}
}
/**
* Wrap a pointer down event and track its subsequent movement until release.
* @param {PointerEvent} event
* @returns {ui.PointerDrag}
*/
ui.drag = (event) => new ui.PointerDrag(event);
/**
* @param {HTMLCanvasElement} canvas
* @param {MouseEvent} event
*/
function mouseEventToCanvasPixelCoords(canvas, event) {
const bounds = canvas.getBoundingClientRect();
const [mx, my] = [event.clientX - bounds.x, event.clientY - bounds.y];
const scale = canvas.width / canvas.clientWidth;
const [px, py] = [Math.floor(mx * scale), Math.floor(my * scale)];
return { x: px, y: py };
}
/**
* @param {HTMLCanvasElement} canvas
* @param {ui.PointerDrag} drag
*/
function trackCanvasStroke(canvas, drag) {
const positions = [mouseEventToCanvasPixelCoords(canvas, drag.downEvent)];
const update = (event) => positions.push(mouseEventToCanvasPixelCoords(canvas, event.detail));
drag.addEventListener("up", update);
drag.addEventListener("move", update);
return positions;
}
// from https://github.com/ai/nanoid/blob/master/non-secure/index.js
const urlAlphabet = 'ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW';
function nanoid(size = 21) {
let id = '';
let i = size;
while (i--) id += urlAlphabet[(Math.random() * 64) | 0];
return id
}
/**
* Deep copy an object by serializing it to json and parsing it again.
* @template T
* @param {T} object
* @returns {T}
*/
const COPY = (object) => JSON.parse(JSON.stringify(object));
/**
* Create an array of zeroes to the given length.
* @param {number} length
* @returns {number[]}
*/
const ZEROES = (length) => Array(length).fill(0);
/**
* Create an array of a value repeated to the given length.
* @template T
* @param {number} length
* @param {T} value
* @returns {T[]}
*/
const REPEAT = (length, value) => Array(length).fill(value);
/**
* Create an html element with the given attributes and children.
* @template {keyof HTMLElementTagNameMap} K
* @param {K} tagName
* @param {*} attributes
* @param {...(Node | string)} children
* @returns {HTMLElementTagNameMap[K]}
*/
function html(tagName, attributes = {}, ...children) {
const element = /** @type {HTMLElementTagNameMap[K]} */ (document.createElement(tagName));
Object.entries(attributes).forEach(([name, value]) => element.setAttribute(name, value));
children.forEach((child) => element.append(child));
return element;
}
/** @param {number} milliseconds */
function sleep(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
/**
* @template T
* @param {IDBRequest<T>} request
* @returns {Promise<T>}
*/
function promisfyRequest(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* @param {IDBTransaction} transaction
* @returns {Promise}
*/
function promisfyTransaction(transaction) {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.onerror = () => reject(transaction.error);
});
}
maker.ProjectStorage = class {
constructor(appID, generateMeta=undefined) {
this.appID = appID;
this.generateMeta = generateMeta;
this.error = undefined;
this.openDatabase().then(
(request) => request.close(),
(reason) => this.error = reason,
);
}
get available() {
return this.error === undefined;
}
async openDatabase() {
const request = indexedDB.open(this.appID);
request.addEventListener("upgradeneeded", () => {
request.result.createObjectStore("projects");
request.result.createObjectStore("projects-meta");
});
return promisfyRequest(request);
}
async stores(mode) {
const db = await this.openDatabase();
const transaction = db.transaction(["projects", "projects-meta"], mode);
const projects = transaction.objectStore("projects");
const meta = transaction.objectStore("projects-meta");
return { transaction, projects, meta };
}
/**
* @returns {Promise<any[]>}
*/
async list() {
const stores = await this.stores("readonly");
return promisfyRequest(stores.meta.getAll());
}
/**
* @param {any} projectData
* @returns {Promise}
*/
async save(projectData, key) {
const meta = { date: (new Date()).toISOString() };
if (this.generateMeta) Object.assign(meta, this.generateMeta(projectData));
const stores = await this.stores("readwrite");
stores.projects.put(projectData, key);
stores.meta.put(meta, key);
return promisfyTransaction(stores.transaction);
}
/**
* @param {string} key
* @returns {Promise<any>}
*/
async load(key) {
const stores = await this.stores("readonly");
return promisfyRequest(stores.projects.get(key));
}
/**
* @param {string} key
*/
async delete(key) {
const stores = await this.stores("readwrite");
stores.projects.delete(key);
stores.meta.delete(key);
return promisfyTransaction(stores.transaction);
}
}
</script>
<script>/**
* @param {number} width
* @param {number} height
* @returns {CanvasRenderingContext2D}
*/
function createRendering2D(width, height) {
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false;
return context;
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {string | CanvasGradient | CanvasPattern | undefined} fillStyle
*/
function fillRendering2D(rendering, fillStyle = undefined) {
if (fillStyle !== undefined) {
const prevStyle = rendering.fillStyle;
rendering.fillStyle = fillStyle;
rendering.fillRect(0, 0, rendering.canvas.width, rendering.canvas.height);
rendering.fillStyle = prevStyle;
} else {
rendering.clearRect(0, 0, rendering.canvas.width, rendering.canvas.height);
}
}
/**
* @param {CanvasRenderingContext2D} source
* @param {CanvasRenderingContext2D} destination
* @param {{ x: number, y: number, w: number, h: number }} rect
*/
function copyRendering2D(
source,
destination = undefined,
rect = undefined,
) {
rect = rect ?? { x: 0, y: 0, w: source.canvas.width, h: source.canvas.height };
destination = destination || createRendering2D(rect.w, rect.h);
destination.canvas.width = rect.w;
destination.canvas.height = rect.h;
destination.drawImage(
source.canvas,
rect.x, rect.y, rect.w, rect.h,
0, 0, rect.w, rect.h,
);
return destination;
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {number} width
* @param {number} height
*/
function resizeRendering2D(rendering, width, height) {
const copy = copyRendering2D(rendering);
rendering.canvas.width = width;
rendering.canvas.height = height;
rendering.drawImage(copy.canvas, 0, 0);
}
/**
* @param {CanvasRenderingContext2D} rendering
*/
function invertMask(rendering) {
withPixels(rendering, (pixels) => {
for (let i = 0; i < pixels.length; ++i) {
pixels[i] = 0xFFFFFFFF - pixels[i];
}
});
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {number} dx
* @param {number} dy
*/
function cycleRendering2D(rendering, dx, dy) {
const { width, height } = rendering.canvas;
const sx = -Math.sign(dx);
const sy = -Math.sign(dy);
const temp = copyRendering2D(rendering);
fillRendering2D(rendering);
rendering.drawImage(temp.canvas, dx, dy );
rendering.drawImage(temp.canvas, dx + width*sx, dy );
rendering.drawImage(temp.canvas, dx + width*sx, dy + height*sy);
rendering.drawImage(temp.canvas, dx, dy + height*sy);
}
/**
* @param {CanvasRenderingContext2D} rendering
*/
function mirrorRendering2D(rendering) {
const prevComposite = rendering.globalCompositeOperation;
rendering.globalCompositeOperation = "copy";
rendering.scale(-1, 1);
rendering.drawImage(rendering.canvas, -rendering.canvas.width, 0);
rendering.globalCompositeOperation = prevComposite;
}
/**
* @param {CanvasRenderingContext2D} rendering
*/
function flipRendering2D(rendering) {
const prevComposite = rendering.globalCompositeOperation;
rendering.globalCompositeOperation = "copy";
rendering.scale(1, -1);
rendering.drawImage(rendering.canvas, 0, -rendering.canvas.height);
rendering.globalCompositeOperation = prevComposite;
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {number} turns
*/
function turnRendering2D(rendering, turns=1) {
const { width, height } = rendering.canvas;
const prevComposite = rendering.globalCompositeOperation;
rendering.globalCompositeOperation = "copy";
rendering.setTransform(1, 0, 0, 1, width/2, height/2);
rendering.rotate(turns * Math.PI / 2);
rendering.drawImage(rendering.canvas, -width/2, -height/2);
rendering.globalCompositeOperation = prevComposite;
}
/**
* @callback pixelsAction
* @param {Uint32Array} pixels
*/
/**
* @param {CanvasRenderingContext2D} rendering
* @param {pixelsAction} action
*/
function withPixels(rendering, action) {
const imageData = rendering.getImageData(0, 0, rendering.canvas.width, rendering.canvas.height);
action(new Uint32Array(imageData.data.buffer));
rendering.putImageData(imageData, 0, 0);
}
/**
* @param {CanvasRenderingContext2D} mask
* @param {string} style
* @param {CanvasRenderingContext2D} destination
*/
function recolorMask(mask, style, destination = undefined) {
const recolored = copyRendering2D(mask, destination);
recolored.globalCompositeOperation = "source-in";
fillRendering2D(recolored, style);
return recolored;
}
/**
* @param {number} x0
* @param {number} y0
* @param {number} x1
* @param {number} y1
* @param {(x: number, y: number) => void} plot
*/
// adapted from https://stackoverflow.com/a/34267311
function lineplot(x0, y0, x1, y1, plot) {
x0 |= 0; y0 |= 0; x1 |= 0; y1 |= 0;
const steep = Math.abs(y1 - y0) > Math.abs(x1 - x0);
if (steep) [x0, y0, x1, y1] = [y0, x0, y1, x1];
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const ystep = Math.sign(y1 - y0);
const xstep = Math.sign(x1 - x0);
let err = Math.floor(dx / 2);
let y = y0;
if (dx === 0 && dy === 0) {
plot(x0, y0);
}
for (let x = x0; x != (x1 + xstep); x += xstep) {
plot(steep ? y : x, steep ? x : y);
err -= dy;
if (err < 0) {
y += ystep;
err += dx;
}
}
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {number} x
* @param {number} y
* @param {number} color
*/
function floodfill(rendering, x, y, color, tolerance = 5) {
const [width, height] = [rendering.canvas.width, rendering.canvas.height];
withPixels(rendering, pixels => {
const queue = [[x, y]];
const done = new Array(width * height);
const initial = pixels[y * width + x];
const ir = initial >>> 0 & 0xFF;
const ig = initial >>> 8 & 0xFF;
const ib = initial >>> 16 & 0xFF;
function enqueue(x, y) {
const within = x >= 0 && y >= 0 && x < width && y < height;
if (within && !done[y * width + x]) {
const pixel = pixels[y * width + x];
const pr = pixel >>> 0 & 0xFF;
const pg = pixel >>> 8 & 0xFF;
const pb = pixel >>> 16 & 0xFF;
const dist = Math.abs(pr - ir) + Math.abs(pg - ig) + Math.abs(pb - ib);
if (dist <= tolerance) queue.push([x, y]);
}
}
while (queue.length > 0) {
const [x, y] = queue.pop();
pixels[y * width + x] = color;
done[y * width + x] = true;
enqueue(x - 1, y);
enqueue(x + 1, y);
enqueue(x, y - 1);
enqueue(x, y + 1);
}
});
};
/**
* @param {CanvasRenderingContext2D} rendering
* @param {number} x
* @param {number} y
* @param {number} color
* @returns {CanvasRenderingContext2D}
*/
function floodfillOutput(rendering, x, y, color) {
const [width, height] = [rendering.canvas.width, rendering.canvas.height];
const output = createRendering2D(width, height);
withPixels(rendering, srcPixels =>
withPixels(output, dstPixels => {
const queue = [[x, y]];
const done = new Array(width * height);
const initial = srcPixels[y * width + x];
function enqueue(x, y) {
const within = x >= 0 && y >= 0 && x < width && y < height;
if (within && srcPixels[y * width + x] === initial && !done[y * width + x]) {
queue.push([x, y]);
}
}
while (queue.length > 0) {
const [x, y] = queue.pop();
dstPixels[y * width + x] = color;
done[y * width + x] = true;
enqueue(x - 1, y);
enqueue(x + 1, y);
enqueue(x, y - 1);
enqueue(x, y + 1);
}
}));
return output;
};
/**
* @param {{r:number,g:number,b:number}} rgb
*/
function rgbToHex(rgb) {
const packed = (0xFF000000 + (rgb.r << 16) + (rgb.g << 8) + (rgb.b << 0));
return "#" + packed.toString(16).substr(-6);
}
/**
* @param {string} hex
* @param {number} alpha
*/
function hexToUint32(hex, alpha = undefined) {
if (hex.charAt(0) === '#') hex = hex.substring(1);
if (alpha === undefined && hex.length === 8) alpha = parseInt(hex.substr(6, 2), 16);
if (alpha === undefined) alpha = 255;
hex = hex.substr(4, 2) + hex.substr(2, 2) + hex.substr(0, 2);
return (parseInt(hex, 16) | (alpha << 24)) >>> 0;
}
/**
* @param {number} number
* @param {string} prefix
*/
function numberToHex(number, prefix = '#') {
number = (number | 0xff000000) >>> 0;
let hex = number.toString(16).substring(2, 8);
hex = hex.substr(4, 2) + hex.substr(2, 2) + hex.substr(0, 2);
return prefix + hex;
}
const MASK_PALETTE = {
'_': hexToUint32('#000000', 0),
default: hexToUint32('#FFFFFF', 255),
};
/**
* @param {string} text
* @param {Record<string, number>} palette
* @returns {CanvasRenderingContext2D}
*/
function textToRendering2D(text, palette = MASK_PALETTE) {
text = text.trim();
const lines = text.split('\n').map((line) => [...line.trim()]);
const width = lines[0].length;
const height = lines.length;
const rendering = createRendering2D(width, height);
withPixels(rendering, (pixels) => {
lines.forEach((line, y) => line.forEach((char, x) => {
const color = palette[char];
pixels[y * width + x] = color !== undefined ? color : palette.default;
}));
});
return rendering;
}
/**
* @param {{ h: number, s: number, v: number }} hsv
*/
function HSVToRGB(hsv) {
const { h, s, v } = hsv;
let r, g, b;
const i = Math.floor(h * 6);
const f = h * 6 - i;
const p = (1 - s);
const q = (1 - f * s);
const t = (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = 1, g = t, b = p; break;
case 1: r = q, g = 1, b = p; break;
case 2: r = p, g = 1, b = t; break;
case 3: r = p, g = q, b = 1; break;
case 4: r = t, g = p, b = 1; break;
case 5: r = 1, g = p, b = q; break;
}
r *= v * 255;
g *= v * 255;
b *= v * 255;
return { r, g, b };
}
/**
* @param {{ r: number, g: number, b: number }} rgb
*/
function RGBToHSV(rgb) {
const { r, g, b } = rgb;
var max = Math.max(r, g, b), min = Math.min(r, g, b),
d = max - min,
h,
s = (max === 0 ? 0 : d / max),
v = max / 255;
switch (max) {
case min: h = 0; break;
case r: h = (g - b) + d * (g < b ? 6: 0); h /= 6 * d; break;
case g: h = (b - r) + d * 2; h /= 6 * d; break;
case b: h = (r - g) + d * 4; h /= 6 * d; break;
}
return { h, s, v };
}
function HSVToCone(hsv) {
const a = Math.PI * hsv.h;
const r = hsv.s * .5 * hsv.v;
const x = Math.cos(a) * r;
const y = Math.sin(a) * r;
return { x, y, z: hsv.v };
}
function uint32ToRGB(uint32) {
return {
r: uint32 >>> 0 & 0xFF,
g: uint32 >>> 8 & 0xFF,
b: uint32 >>> 16 & 0xFF,
uint32,
};
}
function hexToRGB(hex) {
if (hex.charAt(0) === '#') hex = hex.substring(1);
return {
b: parseInt(hex.substr(4, 2), 16),
g: parseInt(hex.substr(2, 2), 16),
r: parseInt(hex.substr(0, 2), 16),
uint32: hexToUint32(hex),
};
}
function RGBToUint32(rgb) {
return rgb.r | rgb.g << 8 | rgb.b << 16 | 0xFF << 24;
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {string[]} palette
*/
function recolorToPalette(rendering, palette) {
const paletteConverted = palette.map((hex) => {
const cone = HSVToCone(RGBToHSV(hexToRGB(hex)));
const uint32 = hexToUint32(hex);
return { ...cone, uint32 };
});
const mapping = new Map();
function chooseColor(uint32) {
const alpha = (uint32 >>> 24) < 16;
if (alpha) return 0;
const existing = mapping.get(uint32);
if (existing) return existing;
const actual = HSVToCone(RGBToHSV(uint32ToRGB(uint32)));
let bestSqrDistance = Infinity;
let best = paletteConverted[0];
for (let candidate of paletteConverted) {
const dx = Math.abs(actual.x - candidate.x);
const dy = Math.abs(actual.y - candidate.y);
const dz = Math.abs(actual.z - candidate.z);
const sqrDistance = dx*dx + dy*dy + dz*dz;
if (sqrDistance < bestSqrDistance) {
bestSqrDistance = sqrDistance;
best = candidate;
}
}
mapping.set(uint32, best.uint32);
return best.uint32;
}
withPixels(rendering, (pixels) => {
for (let i = 0; i < pixels.length; ++i) {
pixels[i] = chooseColor(pixels[i]);
}
});
}
/**
* Copy image contents to a new canvas rendering context.
* @param {HTMLImageElement} image
*/
function imageToRendering2D(image) {
const rendering = createRendering2D(image.naturalWidth, image.naturalHeight);
rendering.drawImage(image, 0, 0);
return rendering;
}
/**
* Create an html image from a given src (probably a datauri).
* @param {string} src
* @returns {Promise<HTMLImageElement>}
*/
async function loadImage(src) {
return imageLoadWaiter(loadImageLazy(src));
}
/**
* Create an html image from a given src (probably a datauri).
* @param {string} src
* @returns {HTMLImageElement}
*/
function loadImageLazy(src) {
const image = document.createElement("img");
image.src = src;
return image;
}
/**
* Await any pending loading of an html image.
* @param {HTMLImageElement} image
* @returns {Promise<HTMLImageElement>}
*/
async function imageLoadWaiter(image) {
if (image.complete) {
return Promise.resolve(image);
} else {
return new Promise((resolve, reject) => {
image.addEventListener("load", () => resolve(image));
image.addEventListener("error", reject);
});
}
}
/**
* In the given rendering, replace every instance of a color in the prev palette
* with the corresponding color in the next palette, ignoring colors that don't
* appear. This is broken in firefox because colors are not stored exactly.
* @param {CanvasRenderingContext2D} rendering
* @param {string[]} prev
* @param {string[]} next
*/
function swapPalette(rendering, prev, next) {
const mapping = new Map();
prev.forEach((pixel, index) => mapping.set(prev[index], next[index]));
withPixels(rendering, (pixels) => {
for (let i = 0; i < pixels.length; ++i) {
pixels[i] = mapping.get(pixels[i]) || pixels[i];
}
});
}
/**
* Replace every color in the given rendering. Each existing color is matched
* to the closest color in the prev palette and replaced with the corresponding
* color in the next palette.
* @param {CanvasRenderingContext2D} rendering
* @param {number[]} prev
* @param {number[]} next
*/
function swapPaletteSafe(rendering, prev, next) {
const mapping = new Map();
for (let i = 0; i < prev.length; ++i) {
mapping.set(prev[i], next[i % next.length]);
}
function addMissing(prevPixel) {
let bestDistance = Infinity;
let bestNextPixel = next[0];
const pr = prevPixel >>> 0 & 0xFF;
const pg = prevPixel >>> 8 & 0xFF;
const pb = prevPixel >>> 16 & 0xFF;
for (let i = 0; i < prev.length; ++i) {
const target = prev[i];
const tr = target >>> 0 & 0xFF;
const tg = target >>> 8 & 0xFF;
const tb = target >>> 16 & 0xFF;
const dist = Math.abs(pr - tr)
+ Math.abs(pg - tg)
+ Math.abs(pb - tb);
if (dist < bestDistance) {
bestDistance = dist;
bestNextPixel = next[i];
}
}
mapping.set(prevPixel, bestNextPixel);
return bestNextPixel;
}
withPixels(rendering, (pixels) => {
for (let i = 0; i < pixels.length; ++i) {
const prev = pixels[i];
pixels[i] = mapping.get(prev) ?? addMissing(prev);
}
});
}
/**
* @param {HTMLCanvasElement} canvas
*/
async function canvasToBlob(canvas) {
return new Promise((resolve) => canvas.toBlob(resolve));
}
</script>
<script>/**
* @typedef {Object} Vector2
* @property {number} x
* @property {number} y
*/
/**
* @typedef {Object} Rect
* @property {number} x
* @property {number} y
* @property {number} width
* @property {number} height
*/
/**
* @param {number} min
* @param {number} max
*/
function range(min, max) {
return Array.from(new Array(max-min+1), (x, i) => i + min);
}
/**
* @typedef {Object} BlitsyFontCharacter
* @property {number} codepoint
* @property {CanvasImageSource} image
* @property {Rect} rect
* @property {number} spacing
* @property {Vector2?} offset
*/
/**
* @typedef {Object} BipsiDataFont
* @property {string} name
* @property {number} charWidth
* @property {number} charHeight
* @property {number[][]} runs
* @property {Object.<number, { spacing: number, offset: Vector2, size: Vector2 }>} special
* @property {string} atlas
*/
/**
* @typedef {Object} BlitsyFont
* @property {string} name
* @property {number} lineHeight
* @property {Map<number, BlitsyFontCharacter>} characters
*/
/**
* @typedef {Object} BlitsyGlyph
* @property {HTMLCanvasElement} image
* @property {Rect} rect
* @property {Vector2} position
* @property {Vector2} offset
* @property {boolean} hidden
* @property {string} fillStyle
* @property {Map<string, any>} styles
*/
/**
* @typedef {Object} BlitsyTextRenderOptions
* @property {BlitsyFont} font
* @property {number} lineCount
* @property {number} lineWidth
* @property {number} lineGap
*/
/** @typedef {BlitsyGlyph[]} BlitsyPage */
/**
* @param {BipsiDataFont} data
*/
async function loadBipsiFont(data) {
const font = {
name: data.name,
lineHeight: data.charHeight,
characters: new Map(),
}
const atlas = await loadImage(data.atlas);
const cols = atlas.naturalWidth / data.charWidth;
const indexes = data.runs.flatMap(([start, end]) => range(start, end ?? start));
indexes.forEach((codepoint, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const special = data.special?.[codepoint];
const size = special?.size;
const rect = {
x: col * data.charWidth,
y: row * data.charHeight,
width: size?.x ?? data.charWidth,
height: size?.y ?? data.charHeight,
};
const spacing = special?.spacing ?? rect.width;
const offset = special?.offset;
font.characters.set(codepoint, { codepoint, image: atlas, rect, spacing, offset });
});
return font;
}
/** @param {HTMLScriptElement} script */
async function loadBasicFont(script) {
const atlasdata = script.innerHTML;
const charWidth = parseInt(script.getAttribute("data-char-width"), 10);
const charHeight = parseInt(script.getAttribute("data-char-height"), 10);
const indexes = parseRuns(script.getAttribute("data-runs"));
const atlas = await loadImage(atlasdata);
const cols = atlas.naturalWidth / charWidth;
const font = {
name: "font",
lineHeight: charHeight,
characters: new Map(),
};
indexes.forEach((codepoint, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
const rect = {
x: col * charWidth,
y: row * charHeight,
width: charWidth,
height: charHeight,
};
font.characters.set(codepoint, { codepoint, image: atlas, rect, spacing: charWidth, offset: { x: 0, y: 0 } });
});
return font;
}
/** @param {string} data */
function parseRuns(data) {
const runs = data.split(",").map((run) => {
const [start, end] = run.split("-").map((index) => parseInt(index, 10));
return [ start, end ?? start ];
});
const indexes = [];
runs.forEach(([min, max]) => indexes.push(...range(min, max)));
return indexes;
}
/**
* @param {BlitsyFont} font
* @param {string} char
*/
function getFontChar(font, char) {
const codepoint = char.codePointAt(0);
return font.characters.get(codepoint);
}
/**
* @param {BlitsyPage} page
* @param {number} width
* @param {number} height
* @param {number} ox
* @param {number} oy
*/
function renderPage(page, width, height, ox = 0, oy = 0)
{
const result = createRendering2D(width, height);
const buffer = createRendering2D(width, height);
for (const glyph of page)
{
if (glyph.hidden) continue;
// padding + position + offset
const x = ox + glyph.position.x + glyph.offset.x;
const y = oy + glyph.position.y + glyph.offset.y;
const {
x: glyphX,
y: glyphY,
width: glyphWidth,
height: glyphHeight,
} = glyph.rect;
// draw tint layer
result.fillStyle = glyph.fillStyle;
result.fillRect(x, y, glyphWidth, glyphHeight);
// draw text layer
buffer.drawImage(glyph.image, glyphX, glyphY, glyphWidth, glyphHeight, x, y, glyphWidth, glyphHeight);
}
// draw text layer in tint color
result.globalCompositeOperation = 'destination-in';
result.drawImage(buffer.canvas, 0, 0);
return result;
}
const defaultStyleHandler = (styles, style) => {
if (style.substr(0, 1) === "+") {
styles.set(style.substring(1), true);
} else if (style.substr(0, 1) === "-") {
styles.delete(style.substring(1));
} else if (style.includes("=")) {
const [key, val] = style.split(/\s*=\s*/);
styles.set(key, val);
}
}
/**
* @param {string} script
* @param {BlitsyTextRenderOptions} options
* @param {*} styleHandler
* @returns {BlitsyPage[]}
*/
function scriptToPages(script, options, styleHandler = defaultStyleHandler) {
const tokens = tokeniseScript(script);
const commands = tokensToCommands(tokens);
return commandsToPages(commands, options, styleHandler);
}
function tokeniseScript(script) {
const tokens = [];
let buffer = "";
let braceDepth = 0;
function openBrace() {
if (braceDepth === 0) flushBuffer();
braceDepth += 1;
}
function closeBrace() {
if (braceDepth === 1) flushBuffer();
braceDepth -= 1;
}
function newLine() {
flushBuffer();
tokens.push(["markup", "br"]);
}
function flushBuffer() {
if (buffer.length === 0) return;
const type = braceDepth > 0 ? "markup" : "text";
tokens.push([type, buffer]);
buffer = "";
}
const actions = {
"{": openBrace,
"}": closeBrace,
"\n": newLine,
}
for (const char of script) {
if (char in actions)
actions[char]();
else
buffer += char;
}
flushBuffer();
return tokens;
}
function textBufferToCommands(buffer) {
const chars = Array.from(buffer);
return chars.map((char) => ({ type: "glyph", char, breakable: char === " " }));
}
function markupBufferToCommands(buffer) {
if (buffer === "pg") return [{ type: "break", target: "page" }];
if (buffer === "br") return [{ type: "break", target: "line" }];
else return [{ type: "style", style: buffer }];
}
/** @param {any[]} tokens */
function tokensToCommands(tokens) {
const handlers = {
"text": textBufferToCommands,
"markup": markupBufferToCommands,
};
const tokenToCommands = ([type, buffer]) => handlers[type](buffer);
return tokens.flatMap(tokenToCommands);
}
/**
* @param {*} commands
* @param {BlitsyTextRenderOptions} options
* @param {*} styleHandler
*/
function commandsToPages(commands, options, styleHandler) {
commandsBreakLongSpans(commands, options);
const styles = new Map();
const pages = [];
let page = [];
let currLine = 0;
function newPage() {
pages.push(page);
page = [];
currLine = 0;
}
function endPage() {
do { endLine(); } while (currLine % options.lineCount !== 0)
}
function endLine() {
currLine += 1;
if (currLine === options.lineCount) newPage();
}
function doBreak(target) {
if (target === "line") endLine();
else if (target === "page") endPage();
}
function findNextBreakIndex() {
let width = 0;
for (let i = 0; i < commands.length; ++i) {
const command = commands[i];
if (command.type === "break") return i;
if (command.type === "style") continue;
width += computeLineWidth(options.font, command.char);
// if we overshot, look backward for last possible breakable glyph
if (width > options.lineWidth) {
const result = find(commands, i, -1, command => command.type === "glyph" && command.breakable);
if (result) return result[1];
}
};
}
function addGlyph(command, offset) {
const char = getFontChar(options.font, command.char) ?? getFontChar(options.font, "?");
const x = offset + (char.offset?.x ?? 0);
const y = currLine * (options.font.lineHeight + options.lineGap) + (char.offset?.y ?? 0);
const glyph = {
char: command.char,
image: char.image,
rect: char.rect,
position: { x, y },
offset: { x: 0, y: 0 },
hidden: true,
fillStyle: "white",
styles: new Map(styles.entries()),
};
page.push(glyph);
return char.spacing;
}
function generateGlyphLine(commands) {
let offset = 0;
for (const command of commands) {
if (command.type === "glyph") {
offset += addGlyph(command, offset);
} else if (command.type === "style") {
styleHandler(styles, command.style);
}
}
}
let index;
while ((index = findNextBreakIndex()) !== undefined) {
generateGlyphLine(commands.slice(0, index));
commands = commands.slice(index);
const command = commands[0];
if (command.type === "break") {
doBreak(command.target);
commands.shift();
} else {
if (command.type === "glyph" && command.char === " ") {
commands.shift();
}
endLine();
}
}
generateGlyphLine(commands);
endPage();
return pages;
}
/**
* Find spans of unbreakable commands that are too long to fit within a page
* width and amend those spans so that breaking permitted in all positions.
* @param {*} commands
* @param {BlitsyTextRenderOptions} options
*/
function commandsBreakLongSpans(commands, options) {
const canBreak = (command) => command.type === "break"
|| (command.type === "glyph" && command.breakable);
const spans = filterToSpans(commands, canBreak);
for (const span of spans) {
const glyphs = span.filter(command => command.type === "glyph");
const charWidths = glyphs.map(command => computeLineWidth(options.font, command.char));
const spanWidth = charWidths.reduce((x, y) => x + y, 0);
if (spanWidth > options.lineWidth) {
for (const command of glyphs) command.breakable = true;
}
}
}
/**
* @param {BlitsyFont} font
* @param {string} line
*/
function computeLineWidth(font, line) {
const chars = Array.from(line).map((char) => getFontChar(font, char));
const widths = chars.map((char) => char ? char.spacing : 0);
return widths.reduce((a, b) => a + b);
}
/**
* Segment the given array into contiguous runs of elements that are not
* considered breakable.
*/
function filterToSpans(array, breakable) {
const spans = [];
let buffer = [];
array.forEach((element, index) => {
if (!breakable(element, index)) {
buffer.push(element);
} else if (buffer.length > 0) {
spans.push(buffer);
buffer = [];
}
});
if (buffer.length > 0) {
spans.push(buffer);
}
return spans;
}
function find(array, start, step, predicate) {
for (let i = start; 0 <= i && i < array.length; i += step) {
if (predicate(array[i], i)) return [array[i], i];
}
}
</script>
<script>/**
* @typedef {Object} DialoguePage
* @property {BlitsyPage} glyphs
* @property {Partial<DialogueOptions>} options
*/
/**
* @typedef {Object} DialogueOptions
* @property {*} font
* @property {number} anchorX
* @property {number} anchorY
* @property {number} lines
* @property {number} lineGap
* @property {number} padding
* @property {number} glyphRevealDelay
* @property {string} backgroundColor
* @property {string} panelColor
* @property {string} textColor
*/
const DIALOGUE_DEFAULTS = {
anchorX: 0.5,
anchorY: 0.5,
lines: 2,
lineGap: 4,
padding: 8,
glyphRevealDelay: .05,
backgroundColor: undefined,
panelColor: "#000000",
textColor: "#FFFFFF",
};
const CONT_ICON_DATA = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAADNJREFUCJmNzrENACAMA0E/++/8NAhRBEg6yyc5SePUoNqwDICnWP04ww1tWOHfUqqf1UwGcw4T9WFhtgAAAABJRU5ErkJggg==";
const STOP_ICON_DATA = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAGCAYAAAD68A/GAAAAAXNSR0IArs4c6QAAACJJREFUCJljZICC/////2fAAhgZGRn////PwIRNEhsYCgoBIkQHCf7H+yAAAAAASUVORK5CYII="
class DialoguePlayback extends EventTarget {
constructor(width, height) {
super();
this.dialogueRendering = createRendering2D(width, height);
/** @type {DialoguePage[]} */
this.queuedPages = [];
this.pagesSeen = 0;
this.options = {};
// an awaitable that generates a new promise that resolves once no dialogue is active
/** @type {PromiseLike<void>} */
this.waiter = {
then: (resolve, reject) => {
if (this.empty) {
resolve();
} else {
return wait(this, "empty").then(resolve, reject);
}
},
}
this.clear();
}
get empty() {
return this.currentPage === undefined;
}
async load() {
this.contIcon = imageToRendering2D(await loadImage(CONT_ICON_DATA));
this.stopIcon = imageToRendering2D(await loadImage(STOP_ICON_DATA));
}
clear() {
this.queuedPages = [];
this.pagesSeen = 0;
this.setPage(undefined);
}
/** @param {DialoguePage} page */
setPage(page) {
const prev = this.currentPage;
this.currentPage = page;
this.pageTime = 0;
this.showGlyphCount = 0;
this.showGlyphElapsed = 0;
this.pageGlyphCount = page ? page.glyphs.length : 0;
this.dispatchEvent(new CustomEvent("next-page", { detail: { prev, next: page } }));
if (page === undefined) {
this.dispatchEvent(new CustomEvent("empty"));
}
}
/**
* @param {string} script
* @param {Partial<DialogueOptions>} options
* @returns {Promise}
*/
async queue(script, options={}) {
const { font, lines, lineGap } = this.getOptions(options);
const lineWidth = 192;
script = parseFakedown(script);
const glyphPages = scriptToPages(script, { font, lineWidth, lineCount: lines, lineGap });
const pages = glyphPages.map((glyphs) => ({ glyphs, options }));
this.queuedPages.push(...pages);
if (this.empty) this.moveToNextPage();
const last = pages[pages.length - 1];
return new Promise((resolve) => {
const onNextPage = (event) => {
const { prev, next } = event.detail;
if (prev === last) {
this.removeEventListener("next-page", onNextPage);
resolve();
}
};
this.addEventListener("next-page", onNextPage);
});
}
/** @param {number} dt */
update(dt) {
if (this.empty) return;
this.pageTime += dt;
this.showGlyphElapsed += dt;
this.applyStyle();
const options = this.getOptions(this.currentPage.options);
while (this.showGlyphElapsed > options.glyphRevealDelay && this.showGlyphCount < this.pageGlyphCount) {
this.showGlyphElapsed -= options.glyphRevealDelay;
this.revealNextChar();
this.applyStyle();
}
}
render() {
const options = this.getOptions(this.currentPage.options);
const height = options.padding * 2
+ (options.font.lineHeight + options.lineGap) * options.lines;
const width = 208;
fillRendering2D(this.dialogueRendering);
fillRendering2D(this.dialogueRendering, options.backgroundColor || "transparent");
const { width: displayWidth, height: displayHeight } = this.dialogueRendering.canvas;
const spaceX = displayWidth - width;
const spaceY = displayHeight - height;
const margin = options.noMargin ? 0 : Math.ceil(Math.min(spaceX, spaceY) / 2);
const minX = margin;
const maxX = displayWidth - margin;
const minY = margin;
const maxY = displayHeight - margin;
const x = Math.floor(minX + (maxX - minX - width ) * options.anchorX);
const y = Math.floor(minY + (maxY - minY - height) * options.anchorY);
this.dialogueRendering.fillStyle = options.panelColor;
this.dialogueRendering.fillRect(x, y, width, height);
this.applyStyle();
const render = renderPage(
this.currentPage.glyphs,
width, height,
options.padding, options.padding,
);
this.dialogueRendering.drawImage(render.canvas, x, y);
if (this.showGlyphCount === this.pageGlyphCount) {
const prompt = this.queuedPages.length > 0
? this.contIcon
: this.stopIcon;
this.dialogueRendering.drawImage(
recolorMask(prompt, options.textColor).canvas,
x+width-options.padding-prompt.canvas.width,
y+height-options.lineGap-prompt.canvas.height,
);
}
}
getOptions(options) {
return Object.assign({}, DIALOGUE_DEFAULTS, this.options, options);
}
revealNextChar() {
if (this.empty) return;
this.showGlyphCount = Math.min(this.showGlyphCount + 1, this.pageGlyphCount);
this.currentPage.glyphs.forEach((glyph, i) => {
if (i < this.showGlyphCount) glyph.hidden = false;
});
}
revealAll() {
if (this.empty) return;
this.showGlyphCount = this.currentPage.glyphs.length;
this.revealNextChar();
}
cancel() {
this.queuedPages.length = 0;
this.currentPage = undefined;
}
skip() {
if (this.empty) return;
if (this.showGlyphCount === this.pageGlyphCount) {
this.moveToNextPage();
} else {
this.showGlyphCount = this.pageGlyphCount;
this.currentPage.glyphs.forEach((glyph) => glyph.hidden = false);
}
}
moveToNextPage() {
const nextPage = this.queuedPages.shift();
this.pagesSeen += 1;
this.setPage(nextPage);
}
applyStyle() {
if (this.empty) return;
const currentGlyph = this.currentPage.glyphs[this.showGlyphCount];
const options = this.getOptions(this.currentPage.options);
if (currentGlyph) {
if (currentGlyph.styles.has("delay")) {
this.showCharTime = parseFloat(currentGlyph.styles.get("delay"));
} else {
this.showCharTime = this.currentPage.options.glyphRevealDelay;
}
}
this.currentPage.glyphs.forEach((glyph, i) => {
glyph.fillStyle = glyph.styles.get("clr") ?? options.textColor;
if (glyph.styles.has("r"))
glyph.hidden = false;
if (glyph.styles.has("shk"))
glyph.offset = { x: getRandomInt(-1, 2), y: getRandomInt(-1, 2) };
if (glyph.styles.has("wvy"))
glyph.offset.y = (Math.sin(i + this.pageTime * 5) * 3) | 0;
if (glyph.styles.has("rbw")) {
const h = Math.abs(Math.sin(performance.now() / 600 - i / 8));
glyph.fillStyle = rgbToHex(HSVToRGB({ h, s: 1, v: 1 }));
}
});
}
}
/**
* @param {EventTarget} target
* @param {string} event
* @returns
*/
async function wait(target, event) {
return new Promise((resolve) => {
target.addEventListener(event, resolve, { once: true });
});
}
/**
* Return a random integer at least min and below max. Why is that the normal
* way to do random ints? I have no idea.
* @param {number} min
* @param {number} max
* @returns {number}
*/
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
}
function getRandomFloat(min, max) {
return Math.random() * (max - min) + min;
}
</script>
<script>const URL_PARAMS = new URLSearchParams(window.location.search);
const BIPSI_HD = URL_PARAMS.get("hd") === "true" || document.documentElement.dataset.hd;
let SAVE_SLOT = URL_PARAMS.get("save") ?? "slot0";
// browser saves will be stored under the id "bipsi"
let storage = new maker.ProjectStorage(BIPSI_HD ? "bipsi-hd" : "bipsi");
// type definitions for the structure of bipsi project data. useful for the
// code editor, ignored by the browser
/**
* @typedef {Object} BipsiDataSettings
* @property {string} title
*/
/**
* @typedef {Object} BipsiDataEventField
* @property {string} key
* @property {string} type
* @property {any} data
*/
/**
* @typedef {Object} BipsiDataEvent
* @property {number} id
* @property {number[]} position
* @property {BipsiDataEventField[]} fields
*/
/**
* @typedef {Object} BipsiDataRoom
* @property {number} id
* @property {number} palette
* @property {number[][]} tilemap
* @property {number[][]} backmap
* @property {number[][]} foremap
* @property {number[][]} wallmap
* @property {BipsiDataEvent[]} events
*/
/**
* @typedef {Object} BipsiDataTile
* @property {number} id
* @property {number[]} frames
*/
/**
* @typedef {Object} BipsiDataPalette
* @property {number} id
* @property {string[]} colors
*/
/**
* @typedef {Object} BipsiDataProject
* @property {BipsiDataRoom[]} rooms
* @property {BipsiDataPalette[]} palettes
* @property {string} tileset
* @property {BipsiDataTile[]} tiles
*/
/**
* @typedef {Object} BipsiDataLocation
* @property {number} room
* @property {number[]} position
*/
/**
* Return a list of resource ids that a particular bipsi project depends on.
* @param {BipsiDataProject} data
* @returns {string[]}
*/
function getManifest(data) {
// all embedded files
const files = allEvents(data)
.flatMap((event) => event.fields)
.filter((field) => field.type === "file")
.map((field) => field.data);
// + tileset
return [data.tileset, ...files];
}
// change these at your own risk
let TILE_PX = BIPSI_HD ? 16 : 8;
let ROOM_SIZE = 16;
let SCREEN_ZOOM = 2;
let ROOM_PX = TILE_PX * ROOM_SIZE;
let SCREEN_PX = ROOM_PX * SCREEN_ZOOM;
const constants = {
frameInterval: 400,
tileset: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAAjUlEQVR42u3XMQ4AEBAEwPv/p2kUIo5ScmYqQWU3QsSkDbu5TFBHVoDTfqemAFQKfy3BOs7WKBT+HLQCfBB+dgPcHnoKULAIp7ECfFoA30AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOCFDjCu5xlD93/uAAAAAElFTkSuQmCC",
wallTile: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAAlQTFRFAAAA////AAAAc8aDcQAAAAN0Uk5TAP//RFDWIQAAADlJREFUGJVlj0EOACAIw2D/f7QmLAa7XeyaKFgVkfSjum1M9xhDeN24+pjdbVYPwSt8lGMDcnV+DjlaUACpjVBfxAAAAABJRU5ErkJggg==",
eventTile: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAAlQTFRFAAAA////AAAAc8aDcQAAAAN0Uk5TAP//RFDWIQAAACVJREFUGJVjYMAATCgAJMCIBCACCHmYAFz3AAugOwzd6eieQwMAdfAA3XvBXggAAAAASUVORK5CYII=",
startTile: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAAXNSR0IArs4c6QAAAAZQTFRFAAAA////pdmf3QAAAAJ0Uk5TAP9bkSK1AAAAJUlEQVQYlWNgwACMKAC7ALJqnALIqkEETD8lAhiGEnIHIb+gAQBFEACBGFbz9wAAAABJRU5ErkJggg==",
pluginTile: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAQAAAC1+jfqAAAAAXNSR0IArs4c6QAAAFJJREFUKJGVkVEOwCAIQ4vp/a/MPkwUisOtXxBfaQTgIgPg3TvREAaAu1RNG3Ob3QmIUyI8dKxLoABVzK2VCAHqh/9EnNe1gNOqAvB+DnbuT3oAhXsLLn/W2IoAAAAASUVORK5CYII=",
colorwheelMargin: 12,
}
const TEMP_ROOM = createRendering2D(ROOM_PX, ROOM_PX);
const TEMP_SCREEN = createRendering2D(SCREEN_PX, SCREEN_PX);
/**
* @param {HTMLCanvasElement} tileset
* @param {number} index
*/
function getTileCoords(tileset, index) {
const cols = tileset.width / TILE_PX;
return {
x: TILE_PX * (index % cols),
y: TILE_PX * Math.floor(index / cols),
size: TILE_PX,
}
}
/**
* @param {CanvasRenderingContext2D} tileset
* @param {number} tileIndex
* @param {CanvasRenderingContext2D} destination
* @returns {CanvasRenderingContext2D}
*/
function copyTile(tileset, tileIndex, destination = undefined) {
const { x, y, size } = getTileCoords(tileset.canvas, tileIndex);
const tile = copyRendering2D(tileset, destination, { x, y, w: size, h: size });
return tile;
}
/**
* @param {CanvasRenderingContext2D} tileset
* @param {number} tileIndex
* @param {CanvasRenderingContext2D} tile
*/
function drawTile(tileset, tileIndex, tile) {
const { x, y, size } = getTileCoords(tileset.canvas, tileIndex);
tileset.clearRect(x, y, size, size);
tileset.drawImage(tile.canvas, x, y);
}
/**
* @param {BipsiDataTile[]} tiles
* @param {number} frame
* @returns {Map<number, number>}
*/
function makeTileToFrameMap(tiles, frame) {
/** @type {[number, number][]} */
return new Map(tiles.map((tile) => [
tile.id,
tile.frames[frame % tile.frames.length],
]));
}
/**
* @param {CanvasRenderingContext2D} destination
* @param {CanvasRenderingContext2D} tileset
* @param {Map<number, number>} tileToFrame
* @param {BipsiDataPalette} palette
* @param {{ tilemap: number[][], backmap: number[][], foremap: number[][] }} layer
*/
function drawTilemapLayer(destination, tileset, tileToFrame, palette, { tilemap, backmap, foremap }) {
drawRecolorLayer(destination, (backg, color, tiles) => {
for (let ty = 0; ty < ROOM_SIZE; ++ty) {
for (let tx = 0; tx < ROOM_SIZE; ++tx) {
let back = backmap[ty][tx];
let fore = foremap[ty][tx];
let tileIndex = tilemap[ty][tx];
if (tileIndex === 0) {
fore = back;
tileIndex = 1;
}
const frameIndex = tileToFrame.get(tileIndex);
const { x, y, size } = getTileCoords(tileset.canvas, frameIndex);
if (back > 0) {
backg.fillStyle = palette.colors[back];
backg.fillRect(tx * size, ty * size, size, size);
}
if (fore > 0) {
color.fillStyle = palette.colors[fore];
color.fillRect(tx * size, ty * size, size, size);
}
tiles.drawImage(
tileset.canvas,
x, y, size, size,
tx * size, ty * size, size, size,
);
}
}
});
}
/**
* @param {CanvasRenderingContext2D} destination
* @param {CanvasRenderingContext2D} tileset
* @param {Map<number, number>} tileToFrame
* @param {BipsiDataPalette} palette
* @param {BipsiDataEvent[]} events
*/
function drawEventLayer(destination, tileset, tileToFrame, palette, events) {
drawRecolorLayer(destination, (backg, color, tiles) => {
events.forEach((event) => {
const [tx, ty] = event.position;
const graphicField = oneField(event, "graphic", "tile");
if (graphicField) {
let { fg, bg } = FIELD(event, "colors", "colors") ?? { bg: 1, fg: 3 };
const frameIndex = tileToFrame.get(graphicField.data) ?? 0;
const { x, y, size } = getTileCoords(tileset.canvas, frameIndex);
if (eventIsTagged(event, "transparent")) {
bg = 0;
}
if (bg > 0) {
backg.fillStyle = palette.colors[bg];
backg.fillRect(tx * size, ty * size, size, size);
}
if (fg > 0) {
color.fillStyle = palette.colors[fg];
color.fillRect(tx * size, ty * size, size, size);
}
tiles.drawImage(
tileset.canvas,
x, y, size, size,
tx * size, ty * size, size, size,
);
}
});
});
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {BipsiDataPalette} palette
* @param {BipsiDataRoom} room
*/
function drawRoomThumbnail(rendering, palette, room) {
rendering.canvas.width = ROOM_SIZE;
rendering.canvas.height = ROOM_SIZE;
const [, background, foreground, highlight] = palette.colors;
for (let y = 0; y < ROOM_SIZE; ++y) {
for (let x = 0; x < ROOM_SIZE; ++x) {
const foreground = palette.colors[room.foremap[y][x]];
const background = palette.colors[room.backmap[y][x]];
const color = room.wallmap[y][x] === 1 ? foreground : background;
rendering.fillStyle = color;
rendering.fillRect(x, y, 1, 1);
}
}
rendering.fillStyle = highlight;
room.events.forEach((event) => {
const [x, y] = event.position;
rendering.fillRect(x, y, 1, 1);
});
}
/**
* @param {CanvasRenderingContext2D} rendering
* @param {BipsiDataPalette} palette
*/
function drawPaletteThumbnail(rendering, palette) {
for (let y = 0; y < 2; ++y) {
for (let x = 0; x < 4; ++x) {
rendering.fillStyle = palette.colors[y * 4 + x];
rendering.fillRect(x, y, 1, 1);
}
}
rendering.clearRect(0, 0, 1, 1);
}
/**
* @param {any[][]} map
* @param {number} dx
* @param {number} dy
*/
function cycleMap(map, dx, dy) {
const x = dx > 0 ? dx : ROOM_SIZE + dx;
const y = dy > 0 ? dy : ROOM_SIZE + dy;
map.push(...map.splice(0, y));
map.forEach((row) => {
row.push(...row.splice(0, x));
});
}
/**
* @param {BipsiDataEvent[]} events
* @param {number} dx
* @param {number} dy
*/
function cycleEvents(events, dx, dy) {
events.forEach((event) => {
event.position[0] = (event.position[0] + ROOM_SIZE + dx) % ROOM_SIZE;
event.position[1] = (event.position[1] + ROOM_SIZE + dy) % ROOM_SIZE;
});
}
/**
* @param {BipsiDataEvent[]} events
* @param {number} x
* @param {number} y
*/
function getEventsAt(events, x, y, ignore=undefined) {
return events.filter((event) => event.position[0] === x
&& event.position[1] === y
&& event !== ignore);
}
/**
* @template {{id: number}} T
* @param {T[]} items
* @param {number} id
* @returns {T}
*/
function getById(items, id) {
return items.find((item) => item.id === id);
}
/**
* @param {BipsiDataProject} data
* @param {number} id
* @returns {BipsiDataRoom}
*/
function getRoomById(data, id) {
return getById(data.rooms, id);
}
/**
* @param {BipsiDataProject} data
* @param {number} id
* @returns {BipsiDataPalette}
*/
function getPaletteById(data, id) {
return getById(data.palettes, id);
}
/**
* @param {BipsiDataProject} data
* @param {number} id
* @returns {BipsiDataEvent}
*/
function getEventById(data, id) {
return getById(allEvents(data), id);
}
/**
* @param {BipsiDataProject} data
* @param {number} id
* @returns {BipsiDataTile}
*/
function getTileById(data, id) {
return getById(data.tiles, id);
}
/**
* @param {BipsiDataTile[]} tiles
*/
function findFreeFrame(tiles) {
const frames = new Set(tiles.flatMap((tile) => tile.frames));
const max = Math.max(...frames);
for (let i = 0; i < max; ++i) {
if (!frames.has(i)) return i;
}
return max + 1;
}
/**
* @param {{id: number}[]} items
* @returns {number}
*/
function nextId(items) {
const max = Math.max(0, ...items.map((item) => item.id ?? 0));
return max + 1;
}
/** @param {BipsiDataProject} data */
const nextRoomId = (data) => nextId(data.rooms);
/** @param {BipsiDataProject} data */
const nextTileId = (data) => nextId(data.tiles);
/** @param {BipsiDataProject} data */
const nextEventId = (data) => nextId(data.rooms.flatMap((room) => room.events));
/** @param {BipsiDataProject} data */
const nextPaletteId = (data) => nextId(data.palettes);
/**
* @param {CanvasRenderingContext2D} tileset
* @param {BipsiDataTile[]} tiles
*/
function resizeTileset(tileset, tiles) {
const maxFrame = Math.max(...tiles.flatMap((tile) => tile.frames));
const cols = 16;
const rows = Math.ceil((maxFrame + 1) / cols);
resizeRendering2D(tileset, cols * TILE_PX, rows * TILE_PX);
}
</script>
<script>/**
* Use inline style to resize canvas to fit its parent, preserving the aspect
* ratio of its internal dimensions.
* @param {HTMLCanvasElement} canvas
*/
function fitCanvasToParent(canvas) {
const [tw, th] = [canvas.parentElement.clientWidth, canvas.parentElement.clientHeight];
const [sw, sh] = [tw / canvas.width, th / canvas.height];
let scale = Math.min(sw, sh);
if (canvas.width * scale > 512) scale = Math.floor(scale);
canvas.style.setProperty("width", `${canvas.width * scale}px`);
canvas.style.setProperty("height", `${canvas.height * scale}px`);
}
/**
* @param {HTMLElement} element
*/
function scaleElementToParent(element, margin=0) {
const parent = element.parentElement;
const [tw, th] = [parent.clientWidth-margin*2, parent.clientHeight-margin*2];
const [sw, sh] = [tw / element.clientWidth, th / element.clientHeight];
let scale = Math.min(sw, sh);
if (scale > 1) scale = Math.floor(scale);
element.style.setProperty("transform", `translate(-50%, -50%) scale(${scale})`);
return scale;
}
// async equivalent of Function constructor
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor
/**
* @param {any} message
* @param {string} origin
*/
function postMessageParent(message, origin) {
const target = window.parent ?? window.opener;
target?.postMessage(message, origin);
}
/**
* @param {BipsiDataEvent} event
* @param {string} key
*/
function eventIsTagged(event, key) {
return oneField(event, key, "tag") !== undefined;
}
/**
* @param {BipsiDataRoom} room
* @param {number} x
* @param {number} y
*/
function cellIsSolid(room, x, y) {
const wall = room.wallmap[y][x] > 0;
const solid = getEventsAt(room.events, x, y).some((event) => eventIsTagged(event, "solid"));
return solid || wall;
}
/**
*
* @param {BipsiDataEvent} event
* @param {string} name
* @param {string} type
*/
function allFields(event, name, type=undefined) {
return event.fields.filter((field) => field.key === name && field.type === (type ?? field.type));
}
/**
*
* @param {BipsiDataEvent} event
* @param {string} name
* @param {string} type
*/
function oneField(event, name, type=undefined) {
return event.fields.find((field) => field.key === name && field.type === (type ?? field.type));
}
/**
* @param {BipsiDataProject} data
*/
function allEvents(data) {
return data.rooms.flatMap((room) => room.events);
}
/**
* @param {BipsiDataProject} data
* @param {BipsiDataEvent} event
*/
function roomFromEvent(data, event) {
return data.rooms.find((room) => room.events.includes(event));
}
/**
* @param {BipsiDataProject} data
* @param {BipsiDataLocation} location
* @returns {BipsiDataEvent?}
*/
function getEventAtLocation(data, location) {
const room = findRoomById(data, location.room);
const [x, y] = location.position;
const [event] = getEventsAt(room.events, x, y);
return event;
}
/**
* @param {BipsiDataProject} data
* @param {BipsiDataLocation} location
* @returns {BipsiDataEvent[]}
*/
function getEventsAtLocation(data, location) {
const room = findRoomById(data, location.room);
const [x, y] = location.position;
const events = getEventsAt(room.events, x, y);
return events;
}
/**
* @param {BipsiDataProject} data
* @param {BipsiDataEvent} event
* @returns {BipsiDataLocation}
*/
function getLocationOfEvent(data, event) {
const room = roomFromEvent(data, event);
return { room: room.id, position: [...event.position] };
}
/**
* @param {BipsiDataProject} data
* @param {BipsiDataEvent} event
* @param {BipsiDataLocation} location
*/
function moveEvent(data, event, location) {
const room = findRoomById(data, location.room);
if (!room) throw Error("NO ROOM WITH ID " + location.room);
removeEvent(data, event);
room.events.push(event);
event.position = [...location.position];
}
/**
* @param {BipsiDataProject} data
* @param {number} eventId
* @param {BipsiDataLocation} location
*/
function moveEventById(data, eventId, location) {
const event = findEventById(data, eventId);
moveEvent(data, event, location);
}
/**
* @param {BipsiDataProject} data
* @param {BipsiDataEvent} event
*/
function removeEvent(data, event) {
const prevRoom = roomFromEvent(data, event);
arrayDiscard(prevRoom.events, event);
}
function shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
/**
* @param {BipsiDataProject} data
* @param {number} roomId
*/
function findRoomById(data, roomId) {
return data.rooms.find((room) => room.id === roomId);
}
/**
* @param {BipsiDataProject} data
* @param {number} eventId
*/
function findEventById(data, eventId) {
return allEvents(data).filter((event) => event.id === eventId)[0];
}
function findEventsByTag(data, tag) {
return allEvents(data).filter((event) => eventIsTagged(event, tag));
}
function findEventByTag(data, tag) {
return allEvents(data).filter((event) => eventIsTagged(event, tag))[0];
}
/**
* @param {BipsiDataEvent} event
*/
function allEventTags(event) {
return event.fields.filter((field) => field.type === "tag").map((field) => field.key);
}
const ERROR_STYLE = {
glyphRevealDelay: 0,
lines: 8,
panelColor: "#FF0000",
textColor: "#FFFFFF",
anchorX: .5, anchorY: .5,
}
const BEHAVIOUR_BEFORE = `
let script = $FIELD("before", "javascript");
if (script) {
await RUN_JS(script);
}
`;
const BEHAVIOUR_AFTER = `
let script = $FIELD("after", "javascript");
if (script) {
await RUN_JS(script);
}
`;
const BEHAVIOUR_PAGE_COLOR = `
let color = FIELD(EVENT, "page-color", "text");
if (color) {
SET_CSS("--page-color", color);
}
`;
const BEHAVIOUR_IMAGES = `
let backgrounds = FIELDS_OR_LIBRARY("background");
if (backgrounds.length > 0) {
SHOW_IMAGE("BACKGROUND", backgrounds, 0, 0, 0);
} else if (IS_TAGGED(EVENT, "clear-background")) {
HIDE_IMAGE("BACKGROUND");
}
let midgrounds = FIELDS_OR_LIBRARY("midground");
if (midgrounds.length > 0) {
SHOW_IMAGE("MIDGROUND", midgrounds, 1, 0, 0);
} else if (IS_TAGGED(EVENT, "clear-midground")) {
HIDE_IMAGE("MIDGROUND");
}
let foregrounds = FIELDS_OR_LIBRARY("foreground");
if (foregrounds.length > 0) {
SHOW_IMAGE("FOREGROUND", foregrounds, 2, 0, 0);
} else if (IS_TAGGED(EVENT, "clear-foreground")) {
HIDE_IMAGE("FOREGROUND");
}
let overlays = FIELDS_OR_LIBRARY("overlay");
if (overlays.length > 0) {
SHOW_IMAGE("OVERLAY", overlays, 3, 0, 0);
} else if (IS_TAGGED(EVENT, "clear-overlay")) {
HIDE_IMAGE("OVERLAY");
}
`;
const BEHAVIOUR_MUSIC = `
let music = FIELD_OR_LIBRARY("music");
if (music) {
PLAY_MUSIC(music);
} else if (IS_TAGGED(EVENT, "stop-music")) {
STOP_MUSIC();
}
`;
const BEHAVIOUR_TITLE = `
let title = FIELD(EVENT, "title", "dialogue");
if (title) {
await TITLE(title, FIELD(EVENT, "say-style", "json"));
}
`;
const BEHAVIOUR_DIALOGUE = `
let id = FIELD(EVENT, "say-shared-id", "text") ?? "SAY-ITERATORS/" + EVENT_ID(EVENT);
let mode = FIELD(EVENT, "say-mode", "text") ?? "cycle";
let say = SAMPLE(id, mode, FIELDS(EVENT, "say", "dialogue"));
if (say) {
await SAY(say, FIELD(EVENT, "say-style", "json"));
} else if (say === undefined) {
let nosays = FIELD(EVENT, "no-says", "javascript");
if (nosays) {
await RUN_JS(nosays);
}
}
`;
const BEHAVIOUR_EXIT = `
let destination = FIELD(EVENT, "exit", "location");
if (destination) {
MOVE(AVATAR, destination);
}
`;
const BEHAVIOUR_REMOVE = `
if (IS_TAGGED(EVENT, "one-time")) {
REMOVE(EVENT);
}
`;
const BEHAVIOUR_ENDING = `
let ending = FIELD(EVENT, "ending", "dialogue");
if (ending !== undefined) {
if (ending.length > 0) {
await TITLE(ending, FIELD(EVENT, "say-style", "json"));
}
RESTART();
}
`;
const BEHAVIOUR_SET_AVATAR = `
let graphic = FIELD(EVENT, "set-avatar", "tile");
if (graphic) {
SET_GRAPHIC(AVATAR, graphic);
}
`;
const BEHAVIOUR_TOUCH_LOCATION = `
let location = FIELD(EVENT, "touch-location", "location");
let events = location ? EVENTS_AT(location) : [];
for (const event of events) {
await TOUCH(event);
}
`;
const BEHAVIOUR_ADD_BEHAVIOUR = `
ADD_BEHAVIOURS(...FIELDS(EVENT, "add-behaviour", "javascript"));
ADD_BEHAVIOURS(...FIELDS(EVENT, "add-behavior", "javascript"));
`;
const STANDARD_SCRIPTS = [
BEHAVIOUR_PAGE_COLOR,
BEHAVIOUR_IMAGES,
BEHAVIOUR_MUSIC,
BEHAVIOUR_TITLE,
BEHAVIOUR_DIALOGUE,
BEHAVIOUR_EXIT,
BEHAVIOUR_REMOVE,
BEHAVIOUR_ENDING,
BEHAVIOUR_SET_AVATAR,
BEHAVIOUR_TOUCH_LOCATION,
BEHAVIOUR_ADD_BEHAVIOUR,
];
const BACKG_PAGE = createRendering2D(ROOM_PX, ROOM_PX);
const COLOR_PAGE = createRendering2D(ROOM_PX, ROOM_PX);
const TILES_PAGE = createRendering2D(ROOM_PX, ROOM_PX);
function drawRecolorLayer(destination, render) {
fillRendering2D(BACKG_PAGE);
fillRendering2D(COLOR_PAGE);
fillRendering2D(TILES_PAGE);
render(BACKG_PAGE, COLOR_PAGE, TILES_PAGE);
BACKG_PAGE.globalCompositeOperation = "destination-out";
BACKG_PAGE.drawImage(TILES_PAGE.canvas, 0, 0);
BACKG_PAGE.globalCompositeOperation = "source-over";
COLOR_PAGE.globalCompositeOperation = "destination-in";
COLOR_PAGE.drawImage(TILES_PAGE.canvas, 0, 0);
COLOR_PAGE.globalCompositeOperation = "source-over";
destination.drawImage(BACKG_PAGE.canvas, 0, 0);
destination.drawImage(COLOR_PAGE.canvas, 0, 0);
}
const BACKG_PAGE_D = createRendering2D(555, 555);
const COLOR_PAGE_D = createRendering2D(555, 555);
const TILES_PAGE_D = createRendering2D(555, 555);
function drawRecolorLayerDynamic(destination, render) {
const { width, height } = destination.canvas;
resizeRendering2D(BACKG_PAGE_D, width, height);
resizeRendering2D(COLOR_PAGE_D, width, height);
resizeRendering2D(TILES_PAGE_D, width, height);
fillRendering2D(BACKG_PAGE_D);
fillRendering2D(COLOR_PAGE_D);
fillRendering2D(TILES_PAGE_D);
render(BACKG_PAGE_D, COLOR_PAGE_D, TILES_PAGE_D);
BACKG_PAGE_D.globalCompositeOperation = "destination-out";
BACKG_PAGE_D.drawImage(TILES_PAGE_D.canvas, 0, 0);
BACKG_PAGE_D.globalCompositeOperation = "source-over";
COLOR_PAGE_D.globalCompositeOperation = "destination-in";
COLOR_PAGE_D.drawImage(TILES_PAGE_D.canvas, 0, 0);
COLOR_PAGE_D.globalCompositeOperation = "source-over";
destination.drawImage(BACKG_PAGE_D.canvas, 0, 0);
destination.drawImage(COLOR_PAGE_D.canvas, 0, 0);
}
class BipsiPlayback extends EventTarget {
constructor(font) {
super();
// home for data of the project we're playing
this.stateManager = new maker.StateManager(getManifest);
this.stateBackup = new maker.StateManager(getManifest);
// final composite of any graphics
this.rendering = createRendering2D(256, 256);
this.font = font;
this.dialoguePlayback = new DialoguePlayback(256, 256);
this.dialoguePlayback.options.font = font;
this.time = 0;
this.frameCount = 0;
this.frameDelay = .400;
this.ready = false;
this.busy = false;
this.error = false;
this.objectURLs = new Map();
this.imageElements = new Map();
this.visibleImagesLoadedWaiter = { then: (resolve, reject) => this.visibleImagesLoaded().then(resolve, reject) };
this.proceedWaiter = { then: (resolve) => this.addEventListener("proceed", resolve, { once: true }) };
this.music = document.createElement("audio");
this.music.loop = true;
this.autoplay = false;
this.variables = new Map();
this.images = new Map();
this.extra_behaviours = [];
}
async init() {
await this.dialoguePlayback.load();
}
/** @type {BipsiDataProject} */
get data() {
return this.stateManager.present;
}
async backup() {
await this.stateBackup.copyFrom(this.stateManager);
}
/**
* @param {maker.StateManager<BipsiDataProject>} stateManager
*/
async copyFrom(stateManager) {
this.clear();
await this.stateManager.copyFrom(stateManager);
await this.backup();
}
/**
* @param {maker.ProjectBundle<BipsiDataProject>} bundle
*/
async loadBundle(bundle) {
this.clear();
await this.stateManager.loadBundle(bundle);
await this.backup();
}
clear() {
this.time = 0;
this.frameCount = 0;
this.frameDelay = .400;
this.ready = false;
this.error = false;
this.ended = false;
this.dialoguePlayback.clear();
this.variables.clear();
this.music.removeAttribute("src");
this.music.pause();
this.images.clear();
this.extra_behaviours.length = 0;
this.imageElements.clear();
this.objectURLs.forEach((url) => URL.revokeObjectURL(url));
this.objectURLs.clear();
}
getFileObjectURL(id) {
const url = this.objectURLs.get(id)
?? URL.createObjectURL(this.stateManager.resources.get(id));
this.objectURLs.set(id, url);
return url;
}
getFileImageElement(id) {
const image = this.imageElements.get(id) ?? loadImageLazy(this.getFileObjectURL(id));
this.imageElements.set(id, image);
return image;
}
async restart() {
this.clear();
await this.stateManager.copyFrom(this.stateBackup);
this.start();
}
async start() {
// player avatar is event tagged "is-player" at the beginning of the game
const avatar = findEventByTag(this.data, "is-player");
if (avatar === undefined) {
this.showError("NO EVENT WITH is-player TAG FOUND");
return;
}
// move avatar to last event (render on top)
const room = roomFromEvent(this.data, avatar);
moveEvent(this.data, avatar, { room: room.id, position: [...avatar.position] });
this.avatarId = avatar.id;
this.libraryId = findEventByTag(this.data, "is-library")?.id;
this.ready = true;
const setup = findEventByTag(this.data, "is-setup");
if (setup) await this.touch(setup);
// game starts by running the touch behaviour of the player avatar
await this.touch(avatar);
}
update(dt) {
if (!this.ready) return;
// tile animation
this.time += dt;
while (this.time >= this.frameDelay) {
this.frameCount += 1;
this.time -= this.frameDelay;
}
// dialogue animation
this.dialoguePlayback.update(dt);
// rerender
this.render();
}
addRoomToScene(scene, dest, frame) {
// find avatar, current room, current palette
const avatar = getEventById(this.data, this.avatarId);
const room = roomFromEvent(this.data, avatar);
const palette = this.getActivePalette();
const tileset = this.stateManager.resources.get(this.data.tileset);
// find current animation frame for each tile
const tileToFrame = makeTileToFrameMap(this.data.tiles, frame);
function upscaler(func) {
return () => {
fillRendering2D(TEMP_ROOM);
func();
dest.drawImage(TEMP_ROOM.canvas, 0, 0, 256, 256);
};
}
scene.push({ layer: 1, func: upscaler(() => drawTilemapLayer(TEMP_ROOM, tileset, tileToFrame, palette, room)) });
scene.push({ layer: 2, func: upscaler(() => drawEventLayer(TEMP_ROOM, tileset, tileToFrame, palette, room.events)) });
}
addImagesToScene(scene, dest, frame) {
function drawImage({ image, x, y }) {
dest.drawImage(image[frame % image.length], x, y);
}
const images = [...this.images.values()];
const draws = images.map((image) => ({ layer: image.layer, func: () => drawImage(image) }));
scene.push(...draws);
}
addDialogueToScene(scene, dest, frame) {
if (this.dialoguePlayback.empty)
return;
// change default dialogue position based on avatar position
const avatar = getEventById(this.data, this.avatarId);
const top = avatar.position[1] >= 8;
this.dialoguePlayback.options.anchorY = top ? 0 : 1;
// redraw dialogue and copy to display area
this.dialoguePlayback.render();
scene.push({ layer: 3, func: () => dest.drawImage(this.dialoguePlayback.dialogueRendering.canvas, 0, 0) });
}
addLayersToScene(scene, dest, frame) {
if (!this.ended) {
this.addRoomToScene(scene, dest, frame);
this.addDialogueToScene(scene, dest, frame);
this.addImagesToScene(scene, dest, frame);
}
}
render(frame=undefined) {
frame = frame ?? this.frameCount;
const scene = [];
// add visual layers to scene
this.addLayersToScene(scene, this.rendering, frame);
// sort visual layers
scene.sort((a, b) => a.layer - b.layer);
// clear and draw layers
fillRendering2D(this.rendering);
scene.forEach(({ func }) => func());
// signal, to anyone listening, that rendering happened
this.dispatchEvent(new CustomEvent("render"));
}
end() {
this.ended = true;
}
log(...data) {
this.dispatchEvent(new CustomEvent("log", { detail: data }));
window.parent.postMessage({ type: "log", data });
}
setVariable(key, value) {
this.variables.set(key, value);
this.sendVariables();
}
sendVariables() {
const variables = new Map();
this.variables.forEach((value, key) => {
try {
variables.set(key, JSON.parse(JSON.stringify(value)));
} catch (e) {
variables.set(key, "[COMPLEX VALUE]");
}
});
window.parent.postMessage({ type: "variables", data: variables });
}
get canMove() {
return this.ready
&& this.dialoguePlayback.empty
&& !this.busy
&& !this.ended;
}
async proceed() {
if (!this.ready) return;
if (this.ended) {
this.restart();
}
this.dispatchEvent(new CustomEvent("proceed"));
this.dialoguePlayback.skip();
if (this.autoplay) {
this.music.play();
this.autoplay = false;
}
}
async say(script, options={}) {
this.log(`> SAYING "${script}"`);
script = replaceVariables(script, this.variables);
await this.dialoguePlayback.queue(script, options);
}
async move(dx, dy) {
if (this.ended) this.proceed();
if (!this.canMove) return;
this.busy = true;
const avatar = getEventById(this.data, this.avatarId);
const room = roomFromEvent(this.data, avatar);
// determine move destination
const [px, py] = avatar.position;
const [tx, ty] = [px+dx, py+dy];
// is the movement stopped by the room edge or solid cells?
const bounded = tx < 0 || tx >= ROOM_SIZE || ty < 0 || ty >= ROOM_SIZE;
const blocked = bounded ? false : cellIsSolid(room, tx, ty);
// if not, then update avatar position
if (!blocked && !bounded) avatar.position = [tx, ty];
// find if there are events that should be touched. prefer events at
// the cell the avatar tried to move into but settle for events at
// the cell they're already standing on otherwise
const [fx, fy] = avatar.position;
const events0 = getEventsAt(room.events, tx, ty, avatar);
const events1 = getEventsAt(room.events, fx, fy, avatar);
const events = events0.length ? events0 : events1;
// if there were such events, touch them
for (const event of events) {
await this.touch(event);
}
this.busy = false;
}
eventDebugInfo(event) {
const tags = allEventTags(event).join(", ");
const info = tags.length > 0 ? `(tags: ${tags}) ` : "";
return `${info}@ ${event.position}`;
}
/**
* @param {BipsiDataEvent} event
*/
async touch(event) {
this.log(`> TOUCHING EVENT ${this.eventDebugInfo(event)}`);
const touch = oneField(event, "touch", "javascript")?.data;
if (touch !== undefined) {
await this.runJS(event, touch);
} else {
await this.runJS(event, BEHAVIOUR_BEFORE);
await standardEventTouch(this, event);
await this.runJS(event, BEHAVIOUR_AFTER);
}
}
async runJS(event, js) {
const defines = this.makeScriptingDefines(event);
const names = Object.keys(defines).join(", ");
const preamble = `const { ${names} } = this;\n`;
try {
const script = new AsyncFunction("", preamble + js);
return await script.call(defines);
} catch (e) {
const long = `> SCRIPT ERROR "${e}"\n---\n${js}\n---`;
this.log(long);
const error = `SCRIPT ERROR:\n${e}`;
this.showError(error);
}
return undefined;
}
makeScriptingDefines(event) {
const defines = bindScriptingDefines(SCRIPTING_FUNCTIONS);
addScriptingConstants(defines, this, event);
return defines;
}
playMusic(src) {
this.music.src = src;
this.autoplay = true;
this.music.play();
}
stopMusic() {
this.music.pause();
this.music.removeAttribute("src");
this.autoplay = false;
}
setBackground(image) {
this.background = image;
}
async showImage(imageID, fileIDs, layer, x, y) {
if (typeof fileIDs === "string") {
fileIDs = [fileIDs];
}
if (fileIDs.length === 0) {
this.hideImage(imageID);
} else {
const images = fileIDs.map((fileID) => this.getFileImageElement(fileID));
this.images.set(imageID, { image: images, layer, x, y });
return Promise.all(images.map(imageLoadWaiter));
}
}
hideImage(imageID) {
this.images.delete(imageID);
}
async visibleImagesLoaded() {
for (const { image } of this.images.values())
for (const frame of image)
await imageLoadWaiter(frame);
}
showError(text) {
this.error = true;
this.dialoguePlayback.clear();
this.dialoguePlayback.queue(text, ERROR_STYLE);
this.dialoguePlayback.skip();
this.dialoguePlayback.render();
this.rendering.drawImage(this.dialoguePlayback.dialogueRendering.canvas, 0, 0);
this.dispatchEvent(new CustomEvent("render"));
}
getActivePalette() {
const avatar = getEventById(this.data, this.avatarId);
const room = roomFromEvent(this.data, avatar);
const palette = getPaletteById(this.data, room.palette);
return palette;
}
}
/**
* @param {BipsiPlayback} playback
* @param {BipsiDataEvent} event
* @returns {Promise}
*/
async function standardEventTouch(playback, event) {
for (let script of STANDARD_SCRIPTS) {
await playback.runJS(event, script);
}
for (let script of playback.extra_behaviours) {
await playback.runJS(event, script);
}
}
function sample(playback, id, type, values) {
let iterator = playback.variables.get(id);
if (!iterator?.next) {
iterator = ITERATOR_FUNCS[type](values);
playback.variables.set(id, iterator);
}
return iterator.next()?.value;
}
const ITERATOR_FUNCS = {
"shuffle": makeShuffleIterator,
"cycle": makeCycleIterator,
"sequence": makeSequenceIterator,
"sequence-once": makeSequenceOnceIterator,
}
function* makeShuffleIterator(values) {
values = [...values];
while (values.length > 0) {
shuffleArray(values);
for (let value of values) {
yield value;
}
}
}
function* makeCycleIterator(values) {
values = [...values];
while (values.length > 0) {
for (let value of values) {
yield value;
}
}
}
function* makeSequenceIterator(values) {
values = [...values];
for (let value of values) {
yield value;
}
while (values.length > 0) {
yield values[values.length - 1];
}
}
function* makeSequenceOnceIterator(values) {
values = [...values];
for (let value of values) {
yield value;
}
}
/**
* @param {BipsiPlayback} playback
* @param {BipsiDataEvent} event
* @returns {Promise}
*/
async function runEventRemove(playback, event) {
if (eventIsTagged(event, "one-time")) {
removeEvent(playback.data, event);
}
}
function fakedownToTag(text, fd, tag) {
const pattern = new RegExp(`${fd}([^${fd}]+)${fd}`, 'g');
return text.replace(pattern, `{+${tag}}$1{-${tag}}`);
}
function parseFakedown(text) {
text = fakedownToTag(text, '##', 'shk');
text = fakedownToTag(text, '~~', 'wvy');
text = fakedownToTag(text, '==', 'rbw');
text = fakedownToTag(text, '__', 'r');
return text;
}
/**
* @param {BipsiDataEvent} event
* @param {string} name
* @param {string?} type
*/
function clearFields(event, name, type=undefined) {
const fields = allFields(event, name, type);
fields.forEach((field) => arrayDiscard(event.fields, field));
}
/**
* @param {BipsiDataEvent} event
* @param {string} name
* @param {string} type
* @param {any[]} values
*/
function replaceFields(event, name, type, ...values) {
clearFields(event, name, type);
values.forEach((value) => {
event.fields.push({
key: name,
type,
data: value,
});
});
}
function replace(format) {
const values = Array.prototype.slice.call(arguments, 1);
return format.replace(/\[\s*(\d+)\s*\]/g, (match, index) => values[index] ?? match);
};
function replaceVariables(text, variables) {
return text.replace(/\[\[([^\]]+)\]\]/g, (match, key) => variables.get(key) ?? match);
}
const WALK_DIRECTIONS = {
"L": [-1, 0],
"R": [ 1, 0],
"U": [ 0, -1],
"D": [ 0, 1],
"<": [-1, 0],
">": [ 1, 0],
"^": [ 0, -1],
"v": [ 0, 1],
}
function bindScriptingDefines(defines) {
const bound = {};
for (const [name, func] of Object.entries(defines)) {
bound[name] = func.bind(bound);
}
return bound;
}
const FIELD = (event, name, type=undefined) => oneField(event, name, type)?.data;
const FIELDS = (event, name, type=undefined) => allFields(event, name, type).map((field) => field.data);
const IS_TAGGED = (event, name) => eventIsTagged(event, name);
const SCRIPTING_FUNCTIONS = {
SAY(dialogue, options) {
return this.PLAYBACK.say(dialogue, options);
},
SAY_FIELD(name, options=undefined, event=this.EVENT) {
const text = this.FIELD(event, name, "dialogue") ?? `[FIELD MISSING: ${name}]`;
return this.SAY(text, options);
},
TITLE(dialogue, options) {
const [, background] = this.PALETTE.colors;
options = { anchorY: .5, backgroundColor: background, ...options };
return this.PLAYBACK.say(dialogue, options);
},
TOUCH(event) {
return this.PLAYBACK.touch(event);
},
EVENT_AT(location) {
return getEventAtLocation(this.PLAYBACK.data, location);
},
EVENTS_AT(location) {
return getEventsAtLocation(this.PLAYBACK.data, location);
},
LOCATION_OF(event) {
return getLocationOfEvent(this.PLAYBACK.data, event);
},
FIND_EVENTS(tag) {
return findEventsByTag(this.PLAYBACK.data, tag);
},
FIND_EVENT(tag) {
return findEventByTag(this.PLAYBACK.data, tag);
},
PLAY_MUSIC(file) {
this.PLAYBACK.playMusic(this.PLAYBACK.getFileObjectURL(file));
},
STOP_MUSIC() {
this.PLAYBACK.stopMusic();
},
SHOW_IMAGE(id, files, layer, x, y) {
return this.PLAYBACK.showImage(id, files, layer, x, y);
},
HIDE_IMAGE(id) {
this.PLAYBACK.hideImage(id);
},
FILE_TEXT(file) {
return this.PLAYBACK.stateManager.resources.get(file).text();
},
FIELD_OR_LIBRARY(field, event=this.EVENT) {
let file = FIELD(event, field, "file");
let name = FIELD(event, field, "text");
if (!file && name && this.LIBRARY) {
file = FIELD(this.LIBRARY, name, "file");
} else if (!file && this.LIBRARY) {
file = FIELD(this.LIBRARY, field, "file");
}
return file;
},
FIELDS_OR_LIBRARY(field, event=this.EVENT) {
let files = FIELDS(event, field, "file");
let names = FIELDS(event, field, "text");
if (files.length === 0 && names.length > 0 && this.LIBRARY) {
files = names.map((name) => FIELD(this.LIBRARY, name, "file"));
} else if (files.length === 0 && this.LIBRARY) {
files = FIELDS(this.LIBRARY, field, "file");
}
return files;
},
DO_STANDARD() {
return standardEventTouch(this.PLAYBACK, this.EVENT);
},
MOVE(event, location) {
moveEvent(this.PLAYBACK.data, event, location);
},
FIELD,
FIELDS,
SET_FIELDS(event, name, type, ...values) {
replaceFields(event, name, type, ...values);
},
$FIELD(name, type=undefined, event=this.EVENT) {
return this.FIELD(event, name, type);
},
$FIELDS(name, type=undefined, event=this.EVENT) {
return this.FIELDS(event, name, type);
},
$SET_FIELDS(name, type=undefined, ...values) {
return this.SET_FIELDS(this.EVENT, name, type, ...values);
},
IS_TAGGED,
TAG(event, name) {
replaceFields(event, name, "tag", true);
},
UNTAG(event, name) {
clearFields(event, name, "tag");
},
$IS_TAGGED(name, event=this.EVENT) {
return this.IS_TAGGED(event, name);
},
$TAG(name, event=this.EVENT) {
this.TAG(event, name);
},
$UNTAG(name, event=this.EVENT) {
this.UNTAG(event, name);
},
REMOVE(event=this.EVENT) {
removeEvent(this.PLAYBACK.data, event);
},
$REMOVE(event=this.EVENT) {
this.REMOVE(event);
},
SET_GRAPHIC(event, tile) {
replaceFields(event, "graphic", "tile", tile);
},
$SET_GRAPHIC(tile, event=this.EVENT) {
this.SET_GRAPHIC(event, tile);
},
async WALK(event, sequence, delay=.4, wait=.4) {
const dirs = Array.from(sequence);
for (const dir of dirs) {
if (dir === ".") {
await sleep(wait * 1000);
} else {
let [x, y] = event.position;
const [dx, dy] = WALK_DIRECTIONS[dir];
x = Math.max(0, Math.min(ROOM_SIZE - 1, x + dx));
y = Math.max(0, Math.min(ROOM_SIZE - 1, y + dy));
event.position = [x, y];
await sleep(delay * 1000);
}
}
},
async $WALK(sequence, delay=.4, wait=.4, event=this.EVENT) {
return this.WALK(event, sequence, delay, wait);
},
GET(key, fallback=undefined, target=undefined) {
key = target ? `${this.EVENT_ID(target)}/${key}` : key;
return this.PLAYBACK.variables.get(key) ?? fallback;
},
SET(key, value, target=undefined) {
key = target ? `${this.EVENT_ID(target)}/${key}` : key;
this.PLAYBACK.setVariable(key, value);
},
$GET(key, fallback=undefined, target=this.EVENT) {
return this.GET(key, fallback, target);
},
$SET(key, value, target=this.EVENT) {
this.SET(key, value, target);
},
EVENT_ID(event) {
return event.id;
},
TEXT_REPLACE(text, ...values) {
return replace(text, ...values);
},
LOG(...data) {
this.PLAYBACK.log(...data);
},
DELAY(seconds) {
return sleep(seconds * 1000);
},
RESTART() {
this.PLAYBACK.end();
},
SAMPLE(id, type, ...values) {
return sample(this.PLAYBACK, id, type, ...values);
},
SET_CSS(name, value) {
ONE(":root").style.setProperty(name, value);
},
RUN_JS(script, event=this.EVENT) {
return this.PLAYBACK.runJS(event, script);
},
ADD_BEHAVIOURS(...scripts) {
this.PLAYBACK.extra_behaviours.push(...scripts);
},
POST(message, origin="*") {
postMessageParent(message, origin);
},
}
/**
* @param {BipsiPlayback} playback
* @param {BipsiDataEvent} event
*/
function addScriptingConstants(defines, playback, event) {
// edit here to add new scripting functions
defines.PLAYBACK = playback;
defines.AVATAR = getEventById(playback.data, playback.avatarId);
defines.LIBRARY = getEventById(playback.data, playback.libraryId);
defines.EVENT = event;
defines.PALETTE = playback.getActivePalette();
defines.DIALOGUE = playback.dialoguePlayback.waiter;
defines.DIALOG = defines.DIALOGUE;
defines.INPUT = playback.proceedWaiter;
defines.VISIBLE_IMAGES_LOADED = playback.visibleImagesLoadedWaiter;
// don't use these. retained for backwards compatibility
defines.WAIT_INPUT = () => defines.INPUT;
}
</script>
<script>/**
* @param {CanvasRenderingContext2D} destination
* @param {CanvasRenderingContext2D} tileset
* @param {Map<number, number>} tileToFrame
* @param {BipsiDataPalette} palette
* @param {BipsiDataRoom} room
*/
function drawRoomPreview(destination, tileset, tileToFrame, palette, room) {
const [background] = palette.colors;
fillRendering2D(destination, background);
drawTilemapLayer(destination, tileset, tileToFrame, palette, room);
drawEventLayer(destination, tileset, tileToFrame, palette, room.events);
room.events.forEach((event) => {
const [x, y] = event.position;
destination.fillStyle = "white";
destination.globalAlpha = .5;
destination.fillRect(
x * TILE_PX + 1,
y * TILE_PX + 1,
TILE_PX - 2,
TILE_PX - 2,
);
});
destination.globalAlpha = 1;
}
/**
* @param {CanvasRenderingContext2D} destination
* @param {BipsiPlayback} playback
* @param {number} roomId
*/
function drawRoomPreviewPlayback(destination, playback, roomId) {
const tileset = playback.stateManager.resources.get(playback.data.tileset);
const room = getRoomById(playback.data, roomId);
const palette = getPaletteById(playback.data, room.palette);
const tileToFrame = makeTileToFrameMap(playback.data.tiles, 0);
drawRoomPreview(destination, tileset, tileToFrame, palette, room);
}
/**
* @param {CanvasRenderingContext2D} destination
* @param {BipsiPlayback} playback
* @param {number} roomId
*/
function drawRoomThumbPlayback(destination, playback, roomId) {
const room = getRoomById(playback.data, roomId);
const palette = getPaletteById(playback.data, room.palette);
drawRoomThumbnail(destination, palette, room);
}
async function generateRoomPreviewURL(destination, playback, roomId) {
drawRoomPreviewPlayback(destination, playback, roomId);
URL.createObjectURL(await canvasToBlob(destination.canvas));
}
/**
* @param {BipsiPlayback} playback
* @returns {Promise<[string, number][]>}
*/
async function recordFrames(playback) {
const frames = [];
const temp = createRendering2D(512, 512);
await playback.render(0);
temp.drawImage(playback.rendering.canvas, 0, 0, 512, 512);
frames.push([temp.canvas.toDataURL(), Math.floor(playback.frameDelay * 1000)]);
await playback.render(1);
temp.drawImage(playback.rendering.canvas, 0, 0, 512, 512);
frames.push([temp.canvas.toDataURL(), Math.floor(playback.frameDelay * 1000)]);
return frames;
}
</script>
<script>async function preload() {
const canvas = ONE("#player-canvas");
const rendering = canvas.getContext("2d");
rendering.drawImage(ONE("#loading-splash"), 0, 0);
scaleElementToParent(canvas.parentElement);
}
</script>
<script>async function startEditor(font) {
const editor = new BipsiEditor(font);
await editor.init();
// used to show/hide elements in css
document.documentElement.setAttribute("data-app-mode", "editor");
// no embedded project, start editor with save or editor embed
const save = await storage.load(SAVE_SLOT).catch(() => undefined);
const fallback = BIPSI_HD ? makeBlankBundle() : maker.bundleFromHTML(document, "#editor-embed");
const bundle = save || fallback;
// load bundle and enter editor mode
await editor.loadBundle(bundle);
// Auto-select the pointed-to event (upper-left corner)
editor.selectPointedEvent();
// unsaved changes warning
window.addEventListener("beforeunload", (event) => {
if (!editor.unsavedChanges) return;
event.preventDefault();
return event.returnValue = "Are you sure you want to exit?";
});
return editor;
}
async function makePlayback(font, bundle) {
const playback = new BipsiPlayback(font);
await playback.init();
const playCanvas = /** @type {HTMLCanvasElement} */ (ONE("#player-canvas"));
const playRendering = /** @type {CanvasRenderingContext2D} */ (playCanvas.getContext("2d"));
// need to override touch events for mobile
document.body.style.touchAction = "none";
// update the canvas size every render just in case..
playback.addEventListener("render", () => {
fillRendering2D(playRendering);
playRendering.drawImage(playback.rendering.canvas, 0, 0);
scaleElementToParent(playCanvas.parentElement);
document.documentElement.style.setProperty('--vh', `${window.innerHeight / 100}px`);
});
// update the canvas size whenever the browser window resizes
window.addEventListener("resize", () => scaleElementToParent(playCanvas.parentElement));
// update the canvas size initially
scaleElementToParent(playCanvas.parentElement);
let moveCooldown = 0;
const heldKeys = new Set();
const keys = new Map();
keys.set("ArrowLeft", () => playback.move(-1, 0));
keys.set("ArrowRight", () => playback.move( 1, 0));
keys.set("ArrowUp", () => playback.move( 0, -1));
keys.set("ArrowDown", () => playback.move( 0, 1));
const keyToCode = new Map();
keyToCode.set("ArrowUp", "KeyW");
keyToCode.set("ArrowLeft", "KeyA");
keyToCode.set("ArrowDown", "KeyS");
keyToCode.set("ArrowRight", "KeyD");
function doMove(key) {
const move = keys.get(key);
if (move) {
move();
moveCooldown = .2;
}
}
let prev;
const timer = (next) => {
prev = prev ?? Date.now();
next = next ?? Date.now();
const dt = Math.max(0, (next - prev) / 1000.);
moveCooldown = Math.max(moveCooldown - dt, 0);
prev = next;
window.requestAnimationFrame(timer);
if (moveCooldown === 0) {
const key = Array.from(keys.keys()).find((key) => heldKeys.has(key) || heldKeys.has(keyToCode.get(key)));
if (key) doMove(key);
}
playback.update(dt);
}
timer();
function down(key, code) {
if (!playback.canMove) {
playback.proceed();
} else {
heldKeys.add(key);
heldKeys.add(code);
doMove(key);
}
}
function up(key, code) {
heldKeys.delete(key);
heldKeys.delete(code);
}
const turnToKey = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp"];
let ignoreMouse = false;
window.onblur = () => setTimeout(() => ignoreMouse = true, 0);
window.onfocus = () => setTimeout(() => ignoreMouse = false, 0);
document.addEventListener("keydown", (event) => {
if (!event.repeat) down(event.key, event.code);
if (keys.has(event.key)) {
event.stopPropagation();
event.preventDefault();
}
}, { capture: true });
document.addEventListener("keyup", (event) => up(event.key, event.code));
document.addEventListener("pointerdown", (event) => {
if (ignoreMouse) return;
const threshold = playCanvas.getBoundingClientRect().width / ROOM_SIZE * 2;
const drag = ui.drag(event);
let [x0, y0] = [drag.downEvent.clientX, drag.downEvent.clientY];
playback.proceed();
drag.addEventListener("move", () => {
const [x1, y1] = [drag.lastEvent.clientX, drag.lastEvent.clientY];
const [dx, dy] = [x1 - x0, y1 - y0];
const dist = Math.max(Math.abs(dx), Math.abs(dy));
const angle = Math.atan2(dy, dx) + Math.PI * 2;
const turns = Math.round(angle / (Math.PI * .5)) % 4;
const nextKey = turnToKey[turns];
if (dist >= threshold) {
doMove(nextKey);
x0 = x1;
y0 = y1;
}
});
});
async function captureGif() {
const giffer = window.open(
"https://kool.tools/tools/gif/",
"gif maker",
"left=10,top=10,width=512,height=512,resizable=no,location=no",
);
const frames = await recordFrames(playback);
sleep(500).then(() => giffer.postMessage({ name: "bipsi", frames }, "https://kool.tools"));
}
function getRoomListing() {
const current = getLocationOfEvent(playback.data, getEventById(playback.data, playback.avatarId));
const rooms = [];
const thumb = createRendering2D(ROOM_SIZE, ROOM_SIZE);
const preview = createRendering2D(ROOM_PX, ROOM_PX);
playback.data.rooms.forEach((room) => {
drawRoomPreviewPlayback(preview, playback, room.id);
drawRoomThumbPlayback(thumb, playback, room.id);
rooms.push({ id: room.id, thumb: thumb.canvas.toDataURL(), preview: preview.canvas.toDataURL() });
});
postMessageParent({ type: "room-listing", rooms, current }, "*");
}
/** @type {Map<string, (any) => void>} */
const debugHandlers = new Map();
debugHandlers.set("move-to", (message) => moveEventById(playback.data, playback.avatarId, message.destination));
debugHandlers.set("key-down", (message) => down(message.key, message.code));
debugHandlers.set("key-up", (message) => up(message.key, message.code));
debugHandlers.set("capture-gif", (message) => captureGif());
debugHandlers.set("get-room-listing", (message) => getRoomListing());
debugHandlers.set("touch-location", (message) => playback.touch(getEventAtLocation(playback.data, message.location)));
debugHandlers.set("touch-tagged", (message) => playback.touch(findEventByTag(playback.data, message.tag)));
// only allow these when playtesting from editor
if (document.documentElement.getAttribute("data-debug")) {
// if the game runs javascript from variables then this would be a
// vector to run arbitrary javascript on the game's origin giving
// read/write access to storage for that origin and the power to e.g
// erase all game saves etc
debugHandlers.set("set-variable", (message) => playback.setVariable(message.key, message.value));
}
window.addEventListener("message", (event) => {
debugHandlers.get(event.data.type)?.call(this, event.data);
});
document.documentElement.setAttribute("data-app-mode", "player");
await playback.loadBundle(bundle);
playback.start();
return playback;
}
let PLAYBACK;
let EDITOR;
async function start() {
const font = await loadBipsiFont(JSON.parse(ONE("#font-embed").textContent));
if (BIPSI_HD) document.documentElement.dataset.hd = "true";
// determine if there is a project bundle embedded in this page
const bundle = maker.bundleFromHTML(document);
if (bundle) {
PLAYBACK = await makePlayback(font, bundle);
window.PLAYBACK = PLAYBACK;
} else {
EDITOR = await startEditor(font);
window.EDITOR = EDITOR;
// Run EDITOR code for all plugins
const editorCode = EDITOR.gatherPluginsJavascript([ "EDITOR" ]);
(new Function(editorCode))();
}
}
</script>
<!-- styles-->
<style>
/* prevent weird touch highlights https://stackoverflow.com/questions/21003535/ */
.no-select {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* hidden elements should be hidden regardless of their display style */
[hidden] { display: none !important; }
/* default to width/height including padding and border */
* { box-sizing: border-box; }
/* used dynamically to prevent or cancel smooth transitions */
.skip-transition { transition: none !important; }
/* make buttons and select inherit font */
button, select { font-family: inherit; font-size: inherit; }
/* clickable things should use this cursor imo */
button, a, input[type="radio"], input[type="checkbox"], select, option, label, summary { cursor: pointer; }
/* crisp pixelart for images and backgrounds */
* {
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-crisp-edges;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
/* prevent pull down to refresh page (and lose data rip) */
body {
overscroll-behavior-y: contain;
}
html, body {
margin: 0; padding: 0;
width: 100%;
min-height: calc(var(--vh, 1vh) * 100);
overflow: hidden auto;
overscroll-behavior-y: contain;
}
body {
display: flex; flex-direction: column;
justify-content: center;
align-items: center;
color: white;
}
</style>
<style>body {
background: var(--page-color, black);
}
#player {
width: 256px;
height: 256px;
position: absolute;
left: 50%; top: 50%;
transform: translate(-50%, -50%);
overflow: hidden;
}
</style>
</head>
<body class="no-select" onload="start()">
<div id="player">
<canvas id="player-canvas" width="256" height="256"></canvas>
</div>
<img id="loading-splash" hidden="hidden" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEABAMAAACuXLVVAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAGUExURQAAAP/YAFIewjUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAACXSURBVHja7dJBCgMhDEDReIN4/8sOkba4mEULogN9DxcusvhEAwAAAAAAAAAAAAAAAABmfRMBAgQ8PCA/czndDwX0QwHRxonaQdT85oCx+szXra1/iW8CIs4G5LSBeoJ6iHF2BLw/QP2BVgG1kI0BN/Otr/VrwHICBAgQIECAAAECBAh4bgAAAAAAAAAAAAAAAAAAfyjiAkM9EgGHYOwCAAAAAElFTkSuQmCC">
<script id="plugins"></script>
<script>preload();</script>
<script id="bundle-embed" type="application/json">{"project":{"rooms":[{"id":1,"palette":0,"tilemap":[[9,9,0,8,0,9,3,11,8,0,3,11,0,0,9,0],[8,0,0,0,0,3,3,3,8,0,3,0,9,0,0,0],[0,0,8,0,8,0,3,11,0,3,0,0,0,0,0,0],[9,0,0,0,0,8,9,3,0,0,0,0,0,0,0,0],[0,9,0,0,0,3,3,11,11,0,0,0,0,0,0,0],[0,0,9,0,9,0,3,3,0,0,0,0,0,0,0,0],[9,0,0,8,0,0,3,3,11,0,11,0,0,0,11,0],[0,0,0,0,0,3,3,11,0,0,0,0,0,0,0,0],[10,9,0,0,9,3,3,11,0,0,0,11,0,0,0,0],[0,10,10,0,0,3,3,11,11,0,0,3,0,0,11,0],[10,10,10,10,10,3,3,11,0,0,13,0,13,11,0,0],[0,12,5,2,2,3,3,3,11,13,0,0,0,13,3,11],[12,10,12,5,2,2,3,3,3,3,14,0,15,3,3,3],[0,12,0,10,5,2,2,2,2,2,2,7,2,2,2,2],[0,0,0,0,12,5,2,2,2,2,2,7,2,2,2,2],[0,0,0,12,0,0,5,2,2,2,17,0,5,2,2,2]],"backmap":[[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]],"foremap":[[3,3,2,5,2,5,4,3,5,4,4,3,4,2,5,4],[5,2,2,2,2,4,4,3,5,2,4,2,5,2,2,2],[2,2,5,2,5,2,4,3,2,4,4,2,2,2,2,2],[5,2,2,2,2,3,3,4,2,2,2,2,2,2,2,2],[2,3,2,2,2,4,4,3,3,4,2,2,2,2,2,2],[2,3,3,2,5,2,4,4,2,2,2,2,2,2,2,2],[3,2,2,3,2,2,4,4,3,2,3,2,2,2,3,2],[2,2,2,2,2,4,3,3,2,2,2,2,2,2,2,2],[4,3,4,4,3,4,4,3,4,4,4,3,4,4,4,4],[3,4,4,4,4,4,3,3,3,4,4,4,4,4,3,4],[4,4,4,4,4,4,3,3,4,4,4,4,4,3,4,4],[2,3,3,3,3,4,4,4,3,4,4,6,4,4,3,3],[3,4,3,3,3,3,4,4,4,4,3,6,3,4,4,4],[2,3,4,4,3,3,3,3,3,3,3,3,3,3,3,3],[2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3],[2,2,2,3,2,2,3,3,3,3,3,2,3,3,3,3]],"wallmap":[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0],[1,1,1,1,1,0,0,0,0,0,1,0,1,0,0,0],[0,0,0,0,0,1,0,0,0,1,0,0,0,1,0,0],[0,0,0,0,0,0,1,1,1,1,0,0,0,1,1,1],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]],"events":[{"id":5,"position":[2,14],"fields":[{"key":"is-player","type":"tag","data":true},{"key":"graphic","type":"tile","data":4},{"key":"colors","type":"colors","data":{"bg":0,"fg":5}},{"key":"title","type":"dialogue","data":"Places from my dreams"},{"key":"page-color","type":"text","data":"black\n"},{"key":"say-style","type":"json","data":{"anchorX":0.5,"lines":2,"lineGap":4,"padding":8,"glyphRevealDelay":0.05,"backgroundColor":null,"panelColor":"#2f2041","textColor":"#FFFFFF"}}]},{"id":6,"position":[11,10],"fields":[{"key":"exit","type":"location","data":{"room":2,"position":[8,14]}},{"key":"colors","type":"colors","data":{"fg":6,"bg":1}},{"key":"graphic","type":"tile","data":16}]}]},{"id":2,"palette":0,"tilemap":[[0,0,0,0,18,0,0,0,0,18,18,0,0,18,18,18],[0,18,0,18,0,0,18,18,18,0,18,18,18,18,0,0],[0,0,0,0,18,0,0,0,0,0,18,0,18,0,0,0],[18,0,18,18,18,18,18,18,18,18,0,21,18,0,0,0],[0,0,18,21,0,0,0,0,0,20,0,0,18,0,0,0],[18,0,18,0,0,0,21,0,0,0,0,18,18,0,0,0],[0,18,18,0,20,0,0,0,18,18,18,0,0,0,0,19],[0,0,18,0,0,0,0,0,18,0,0,0,0,0,0,19],[0,0,18,18,18,18,18,18,18,0,0,0,0,0,0,19],[18,18,18,18,0,0,0,0,0,0,0,0,0,0,0,19],[18,18,18,18,0,0,0,0,0,0,0,0,0,0,0,19],[0,0,0,0,0,0,0,0,0,0,0,0,23,0,19,19],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,19,19],[0,0,0,0,0,0,0,0,0,0,0,0,19,19,19,19],[19,19,19,19,19,19,6,0,0,0,19,19,19,18,19,0],[19,0,19,18,18,18,18,0,0,0,19,0,19,0,19,0]],"backmap":[[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]],"foremap":[[2,2,2,2,2,2,2,2,2,2,3,4,4,3,4,4],[2,2,2,3,2,2,2,3,2,2,3,3,3,4,2,2],[2,2,2,2,3,2,2,2,2,2,3,2,4,2,2,2],[2,2,3,3,3,3,3,3,3,3,2,5,4,2,2,2],[2,2,3,5,2,2,2,2,2,5,2,2,4,2,2,2],[2,2,3,4,2,2,5,2,2,2,2,4,4,2,2,2],[2,3,3,4,5,2,5,2,4,4,4,2,2,2,2,4],[2,2,3,4,2,2,2,2,4,2,2,2,2,2,2,4],[2,2,3,4,4,4,4,4,4,2,2,2,2,2,2,4],[3,3,3,4,2,6,2,2,2,2,2,2,2,2,2,4],[4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,4],[4,4,4,4,2,2,2,2,2,2,2,2,3,2,4,4],[4,4,4,4,4,2,2,2,2,2,2,2,2,6,4,2],[4,4,4,4,2,2,2,2,2,2,2,2,4,4,4,2],[4,4,4,4,4,4,4,2,2,2,4,4,4,3,2,2],[3,2,3,3,3,3,4,2,2,2,4,2,4,2,3,2]],"wallmap":[[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1],[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],[0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0],[0,0,0,0,0,0,0,0,1,1,1,0,0,0,0,1],[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1],[0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,1],[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1],[1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,1],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],[0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0],[1,1,1,1,1,1,0,0,0,0,1,1,1,0,0,0],[0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0]],"events":[{"id":7,"position":[5,9],"fields":[{"key":"graphic","type":"tile","data":22},{"key":"colors","type":"colors","data":{"bg":1,"fg":6}},{"key":"solid","type":"tag","data":true},{"key":"say","type":"dialogue","data":"1993. The entrance of a concrete gallery. There is a giant hole where ghosts are floating around."}]},{"id":8,"position":[8,15],"fields":[{"key":"exit","type":"location","data":{"room":1,"position":[11,13]}},{"key":"graphic","type":"tile","data":16},{"key":"colors","type":"colors","data":{"fg":6,"bg":1}}]}]}],"palettes":[{"id":0,"colors":["#000000","#2f2041","#093a54","#0a7e8b","#bed7e9","#fce4a8","#ff585f","#c8024f"]}],"tileset":"238","tiles":[{"id":6,"frames":[5]},{"id":4,"frames":[3,6]},{"id":20,"frames":[21,22]},{"id":21,"frames":[23,24]},{"id":16,"frames":[16,17]},{"id":22,"frames":[25]},{"id":23,"frames":[26]},{"id":1,"frames":[0]},{"id":2,"frames":[1]},{"id":7,"frames":[7]},{"id":5,"frames":[4]},{"id":17,"frames":[18]},{"id":14,"frames":[14]},{"id":15,"frames":[15]},{"id":12,"frames":[12]},{"id":3,"frames":[2]},{"id":13,"frames":[13]},{"id":11,"frames":[11]},{"id":8,"frames":[8]},{"id":9,"frames":[9]},{"id":10,"frames":[10]},{"id":18,"frames":[19]},{"id":19,"frames":[20]}]},"resources":{"238":{"type":"canvas-datauri","data":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAAAQCAYAAADeWHeIAAAB40lEQVRoQ+VY7RLCMAhz7//QandXDxFIGHR++dNRoCFAtu0Cftf7D9nI59v9l7EftlGMI/5k/Olb+/H+z+beYS/v7+U541Tx0PnuxfIKwAQbZxm7DqD+0QfCFz2PMNvPIgIwE4AhgEzUS9qKxfhGl9TdY3VcBUiGmIz/d+QFCYAux1xs+lg5djV5qsRBpMr6z+CkV5gX6+idn4jWMQHY/cQSINqJqDDjuQTsLA2ACpwpFmuLYqLm3bFCBEBO2CSYFYBioeLPwrM5VeJZU82KW81F4yabzVvP7MSgCNChAY4CkwHPI1jkI9Ii2REvCTHOZnL3iIgmZkeM9Cublaw1slDyHd336T5mgTrxYRoyEr0aszIBooRQF3kAySSRjzNJgEjd0ZFn3udpBVgfIFaDjwDVYETiMHrm+cl8HGI0zNcSgGFdtGNfxgr5NbBCAE/tR28kiCSRJrAwst42dF4Mtu+0Ka+ASvJZAljK29Mk1vRCgtArHiNiEbkqOK08WyZA5eJHCIDGrOWTiYPIgVZkBYeVBUa+aQIwOxAFY3cxetOItInXrUhYWkpdT5xo1UlBu1o7ZXGO7B8fghDDPSeoI6PgTGeO8x12TIcyNgiHCh6dhWV90ROAdeh1uSeivg2wXyPADew4OB8VzfL7AAAAAElFTkSuQmCC"}}}</script>
<script id="font-embed" type="text">{"name":"ibm437-small","charWidth":6,"charHeight":8,"runs":[[0],[9786,9787],[9829,9830],[9827],[9824],[8226],[9688],[9675],[9689],[9794],[9792],[9834,9835],[9788],[9658],[9668],[8597],[8252],[182],[167],[9644],[8616],[8593],[8595],[8594],[8592],[8735],[8596],[9650],[9660],[32,126],[8962],[199],[252],[233],[226],[228],[224],[229],[231],[234],[235],[232],[239],[238],[236],[196,197],[201],[230],[198],[244],[246],[242],[251],[249],[255],[214],[220],[162,163],[165],[8359],[402],[225],[237],[243],[250],[241],[209],[170],[186],[191],[8976],[172],[189],[188],[161],[171],[187],[9617,9619],[9474],[9508],[9569,9570],[9558],[9557],[9571],[9553],[9559],[9565],[9564],[9563],[9488],[9492],[9524],[9516],[9500],[9472],[9532],[9566,9567],[9562],[9556],[9577],[9574],[9568],[9552],[9580],[9575,9576],[9572,9573],[9561],[9560],[9554,9555],[9579],[9578],[9496],[9484],[9608],[9604],[9612],[9616],[9600],[945],[223],[915],[960],[931],[963],[181],[964],[934],[920],[937],[948],[8734],[966],[949],[8745],[8801],[177],[8805],[8804],[8992],[8993],[247],[8776],[176],[8729],[183],[8730],[8319],[178],[9632],[160]],"atlas":"data:image/webp;base64,UklGRloFAABXRUJQVlA4WAoAAAAYAAAAXwAAfwAAVlA4TF4EAAAvX8AfEA8w//M///Mf8KD9bL/iNh7hN8fT+sSytlOr3FH2eGwlU7dSnGWwEpdtL7nq3V4xkye5XJqOApMpW8vr0GyZ6YrD5S7jVdleTLlhS8eTUkT/Hbht40g0oL3e7xM4LICg8pJLJi49txj13DmlZ5O+dWDCYeHQ5A4lUa3nBuWzD+7+eZfc8aceGsj0cgk6l6faXIlDkxOZlzfjsB8dQuWlB80X4VzlJRcAsSYZcTNKAL3P27NoE03JBkNtn5xdHHL1VRjl6Nb21ydnjWH07KDxncajju0zV5aTCwFvj7Ahp9F6xok1STXrpwQVcC4F515y1cTDVHB4fXFmY1ic4wd0lPaTNZLYwUVBkt45EM8O6uMYJ7USRySgb8UsgV/sbaM85glCBXjbcIuJgNyhrVrZL7shRVNKwPoyqtsUVDcfIManLGofCdeMWxIytHTWL38XJnipZI5eofoUChF+RTFXcV1hcyXkbLV0eLzcGZaiDKMmATxaU2+14rYMtcpWLZuZCkkY71p7VY/NqEJCxiZrU67Izv5VQaYZ6tK9ZTymooQM1TJUyyHBZXJaU9kl5XDDQbUKt1xxVjuoWnZOQz1s1TI5Op7ME2zVY6qbPjUlMUiachpH50tUhBFawk1QJIkLWmZso6JITASZWUXrk8pZDW7cckNeBqYLhBRRoCAKAkoBmtUiCy/dZ8TNCykU3niDj8wwQw0o89gLTR+Zyuk80UuFqA3o5RfrvpL4l6o26nfVhlvuBTrXD0AEAImuSRVv15qCVvr8eCr+bsc8NZw4lXpNO5aqCW5a1iVRkdM/XdDvK+X6intTtDbpO/sidYFQANAFUCAjVk/oFOTuJJ2tKQCiGoAJSBpdmny4QT7sh5EyggajHltV6Jw/7POXI98tbcr6qWJlSkxbrTIYo2cHaTdPQD784pQSywsAYTgWbqv+tH3qp/OEL9+IogUA4GLBtS0H14e9gXg6qIk2I38/lQ5yRU3va03bY06iHTDsWHrNm5mgtqbMX7H9Qe/cRbSTPaHMgGNjAUCaUvfihO5yCkoIAFgkAcR8gNwRQ9tTZn6mZ06CKfEVwcQyVD2krI4o8XT3gwas0MKEoryZHgsb0cfciPQk010fyDjNCG69Tc/7aQt0G5aB6PUA950dWXoFn21SzDWgNjMdWbkn8p0967urEX6xuUY1bRVgPOa3oxQubrlVboESBkP1tMDT4jRz76H8wKG8MwZAgL78ufzKfw6O9PJAL0QnhdvcGx6ozj9IeypybJzDpTznTFu+IJ8fBJB3gA1g6FYu5W+hLX8r+dHuPy1MAGRXGEBzoB+A2VWEjvLmYFIuW5QJ+iaUxpoDyZbgDLtU2fR3nGd/0J+Zp20KxTjlHDPnxSccjvMzEcbklx8EQTMAhlFjwJyPvisXPxVixR0A+ZADGYJ7uLKjEF8h8mPKx1z6XBzslRIHX3i42F3T6jAt/BROj2HthYsnXBKAoUo47tbmAkVYSUbWAAAASUkqAAgAAAAGABIBAwABAAAAAQAAABoBBQABAAAAVgAAABsBBQABAAAAXgAAACgBAwABAAAAAgAAADEBAgAQAAAAZgAAAGmHBAABAAAAdgAAAAAAAABgAAAAAQAAAGAAAAABAAAAcGFpbnQubmV0IDUuMC42AAUAAJAHAAQAAAAwMjMwAaADAAEAAAABAAAAAqAEAAEAAABgAAAAA6AEAAEAAACAAAAABaAEAAEAAAC4AAAAAAAAAAIAAQACAAQAAABSOTgAAgAHAAQAAAAwMTAwAAAAAA=="}</script>
</body></html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment