Compare commits
7 Commits
ccd31710ae
...
fb6f58817c
Author | SHA1 | Date |
---|---|---|
anna | fb6f58817c | 2 years ago |
anna | 446a052832 | 2 years ago |
anna | 8447313106 | 2 years ago |
anna | f93b50e870 | 2 years ago |
anna | 3930220c3e | 2 years ago |
anna | e730e85b45 | 2 years ago |
anna | db7cbd27dd | 2 years ago |
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"baseurl": "/sprites",
|
||||||
|
"sections": 4,
|
||||||
|
"resolution": 128,
|
||||||
|
"sprites": [
|
||||||
|
{
|
||||||
|
"url": "/01.png",
|
||||||
|
"edges": [
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 1, 1, 0,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 1, 1, 0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "/02.png",
|
||||||
|
"edges": [
|
||||||
|
0, 1, 1, 0,
|
||||||
|
0, 1, 1, 0,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 1, 1, 0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "/03.png",
|
||||||
|
"edges": [
|
||||||
|
0, 1, 1, 0,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 1, 1, 0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "/04.png",
|
||||||
|
"edges": [
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "/05.png",
|
||||||
|
"edges": [
|
||||||
|
0, 1, 1, 0,
|
||||||
|
0, 1, 1, 0,
|
||||||
|
0, 1, 1, 0,
|
||||||
|
0, 1, 1, 0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 665 B |
Binary file not shown.
After Width: | Height: | Size: 674 B |
Binary file not shown.
After Width: | Height: | Size: 677 B |
Binary file not shown.
After Width: | Height: | Size: 653 B |
Binary file not shown.
After Width: | Height: | Size: 685 B |
@ -0,0 +1,161 @@
|
|||||||
|
import { IRender } from "./render";
|
||||||
|
import { Direction, Palette, Sprite } from "./sprite";
|
||||||
|
|
||||||
|
/** Either a tile (if collapsed), or a list of possible tiles (if not) */
|
||||||
|
type MaybeTile = Tile | Tile[];
|
||||||
|
|
||||||
|
/** Number of tiles per row and column of a chunk */
|
||||||
|
const CHUNK_DIM = 16 >>> 0;
|
||||||
|
|
||||||
|
export class Chunk implements IRender {
|
||||||
|
private palette: Palette;
|
||||||
|
private grid: MaybeTile[] = [];
|
||||||
|
private complete: boolean = false;
|
||||||
|
|
||||||
|
public constructor(palette: Palette) {
|
||||||
|
this.palette = palette;
|
||||||
|
|
||||||
|
// pregenerate an array with all possible tiles
|
||||||
|
let allTileVariants = [];
|
||||||
|
for (let s of palette.sprites) {
|
||||||
|
for (let dir = 0; dir < 4; dir++) {
|
||||||
|
allTileVariants.push(new Tile(s, dir));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initialize the grid with perfect entropy
|
||||||
|
for (let i = 0; i < CHUNK_DIM ** 2; i++) {
|
||||||
|
this.grid.push(allTileVariants);
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapse a single tile to generate an initial disturbance in entropy
|
||||||
|
this.collapseAndUpdate(0, 0);
|
||||||
|
|
||||||
|
window.onmousedown = () => this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(context: CanvasRenderingContext2D) {
|
||||||
|
const res = this.palette.resolution;
|
||||||
|
this.grid.forEach((tile, index) => {
|
||||||
|
const y = Math.floor(index / CHUNK_DIM);
|
||||||
|
const x = index % CHUNK_DIM;
|
||||||
|
if (!Array.isArray(tile)) {
|
||||||
|
tile.draw(context, x * res, y * res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick() {
|
||||||
|
if (this.complete) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lowest: number = Number.MAX_SAFE_INTEGER;
|
||||||
|
let lowestCoords: number[][] = [];
|
||||||
|
this.grid.forEach((tile, index) => {
|
||||||
|
if (Array.isArray(tile)) {
|
||||||
|
if (tile.length <= lowest) {
|
||||||
|
const x = index % CHUNK_DIM;
|
||||||
|
const y = Math.floor(index / CHUNK_DIM);
|
||||||
|
if (tile.length < lowest) {
|
||||||
|
lowest = tile.length;
|
||||||
|
lowestCoords = [[x, y]];
|
||||||
|
} else {
|
||||||
|
lowestCoords.push([x, y]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (lowestCoords.length > 0) {
|
||||||
|
let coords = pickRandom(lowestCoords);
|
||||||
|
this.collapseAndUpdate(coords[0], coords[1]);
|
||||||
|
} else {
|
||||||
|
this.complete = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a tile and update entropy on surrounding tiles */
|
||||||
|
private collapseAndUpdate(x: number, y: number) {
|
||||||
|
const index = y * CHUNK_DIM + x;
|
||||||
|
const candidates = this.grid[index];
|
||||||
|
if (!Array.isArray(candidates)) {
|
||||||
|
return; // tile is already collapsed (or out of bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.grid[index] = pickRandom(candidates);
|
||||||
|
|
||||||
|
if (y > 0) {
|
||||||
|
this.updateEntropy(x, y - 1); // north
|
||||||
|
}
|
||||||
|
if (x < CHUNK_DIM - 1) {
|
||||||
|
this.updateEntropy(x + 1, y); // east
|
||||||
|
}
|
||||||
|
if (y < CHUNK_DIM - 1) {
|
||||||
|
this.updateEntropy(x, y + 1); // south
|
||||||
|
}
|
||||||
|
if (x > 0) {
|
||||||
|
this.updateEntropy(x - 1, y); // west
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all candidates that are no longer possible */
|
||||||
|
private updateEntropy(x: number, y: number) {
|
||||||
|
const i = y * CHUNK_DIM + x;
|
||||||
|
const candidates = this.grid[i];
|
||||||
|
if (!Array.isArray(candidates)) {
|
||||||
|
return; // tile is already collapsed (or out of bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterTile = (tile: MaybeTile) => Array.isArray(tile) ? null : tile;
|
||||||
|
const neighs: (Tile | null)[] = [null, null, null, null];
|
||||||
|
if (y > 0) {
|
||||||
|
neighs[Direction.NORTH] = filterTile(this.grid[i - CHUNK_DIM]);
|
||||||
|
}
|
||||||
|
if (x < CHUNK_DIM - 1) {
|
||||||
|
neighs[Direction.EAST] = filterTile(this.grid[i + 1]);
|
||||||
|
}
|
||||||
|
if (y < CHUNK_DIM - 1) {
|
||||||
|
neighs[Direction.SOUTH] = filterTile(this.grid[i + CHUNK_DIM]);
|
||||||
|
}
|
||||||
|
if (x > 0) {
|
||||||
|
neighs[Direction.WEST] = filterTile(this.grid[i - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we have to be careful to create an entirely new array here
|
||||||
|
// because the constructor inserts a reference to the same array
|
||||||
|
// of MaybeTiles for every cell, so manipulating the array in-place
|
||||||
|
// would break everything
|
||||||
|
this.grid[i] = candidates.filter(c => !neighs.some((n, dir) => n && c.conflicts(dir, n)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Tile {
|
||||||
|
private readonly sprite: Sprite;
|
||||||
|
private readonly orientation: Direction;
|
||||||
|
|
||||||
|
public constructor(sprite: Sprite, orientation: Direction) {
|
||||||
|
this.sprite = sprite;
|
||||||
|
this.orientation = orientation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public matches(ownEdge: Direction, neighbor: Tile): boolean {
|
||||||
|
const ownEdgeAbs = (ownEdge + 4 - this.orientation) % 4;
|
||||||
|
const neighEdge = (ownEdge + 2) % 4;
|
||||||
|
const neighEdgeAbs = (neighEdge + 4 - neighbor.orientation) % 4;
|
||||||
|
return this.sprite.matches(ownEdgeAbs, neighbor.sprite, neighEdgeAbs);
|
||||||
|
}
|
||||||
|
|
||||||
|
public conflicts(ownEdge: Direction, neighbor: Tile): boolean {
|
||||||
|
return !this.matches(ownEdge, neighbor);
|
||||||
|
}
|
||||||
|
|
||||||
|
public draw(context: CanvasRenderingContext2D, x: number, y: number) {
|
||||||
|
this.sprite.draw(context, x, y, this.orientation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRandom<T>(a: T[]): T {
|
||||||
|
return a[Math.round(Math.random() * (a.length - 1))];
|
||||||
|
}
|
@ -0,0 +1,53 @@
|
|||||||
|
export type ObjectType = { [key: string]: Schema };
|
||||||
|
export type PrimitiveType = "null" | "boolean" | "number" | "string";
|
||||||
|
export type Schema = PrimitiveType | ObjectType | Array<Schema>;
|
||||||
|
|
||||||
|
export function validate<T>(json: any, schema: Schema): T {
|
||||||
|
if (typeof schema === "string") {
|
||||||
|
return validatePrimitive(json, schema) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(schema)) {
|
||||||
|
if (schema.length !== 1) {
|
||||||
|
throw new SyntaxError("Invalid schema: Array types must only contain one element");
|
||||||
|
}
|
||||||
|
const type = schema[0];
|
||||||
|
|
||||||
|
if (!Array.isArray(json)) {
|
||||||
|
throw new TypeError(`Expected array, got ${typeof json} instead`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = [];
|
||||||
|
for (let e of json) {
|
||||||
|
a.push(validate(e, type));
|
||||||
|
}
|
||||||
|
return a as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const o: any = {};
|
||||||
|
for (let prop in schema) {
|
||||||
|
if (schema.hasOwnProperty(prop)) {
|
||||||
|
if (!json.hasOwnProperty(prop)) {
|
||||||
|
throw new TypeError(`Property ${schema[prop]} is missing`);
|
||||||
|
}
|
||||||
|
o[prop] = validate(json[prop], schema[prop]) as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePrimitive<T extends PrimitiveType>(json: any, type: T):
|
||||||
|
PrimitiveToJSType<T> {
|
||||||
|
if (typeof json === type) {
|
||||||
|
return json;
|
||||||
|
} else {
|
||||||
|
throw new TypeError(`Expected type ${type}, got ${typeof json} instead`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PrimitiveToJSType<T extends PrimitiveType> =
|
||||||
|
T extends "null" ? null :
|
||||||
|
T extends "boolean" ? boolean :
|
||||||
|
T extends "number" ? number :
|
||||||
|
T extends "string" ? string :
|
||||||
|
never;
|
@ -0,0 +1,3 @@
|
|||||||
|
export interface IRender {
|
||||||
|
render(context: CanvasRenderingContext2D): void;
|
||||||
|
}
|
@ -0,0 +1,172 @@
|
|||||||
|
import { Schema as JSONSchema, validate as validateJSON } from "./json";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON schema for a single sprite.
|
||||||
|
*/
|
||||||
|
interface ISpriteData {
|
||||||
|
/** URL to this tile image, appended to `IPaletteData.baseurl`. */
|
||||||
|
url: string,
|
||||||
|
/**
|
||||||
|
* All edge sections and their material IDs.
|
||||||
|
* Edges are divided into `IPaletteData.sections` sections,
|
||||||
|
* and their individual materials are stored here in clockwise order.
|
||||||
|
*
|
||||||
|
* The illustration below shows the indices of material IDs for
|
||||||
|
* 3-section edges.
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* 0 1 2
|
||||||
|
* + - - - +
|
||||||
|
* 11 | | 3
|
||||||
|
* 10 | tile | 4
|
||||||
|
* 9 | | 5
|
||||||
|
* + - - - +
|
||||||
|
* 8 7 6
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
edges: number[],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON schema for `/dist/sprites.json`
|
||||||
|
*/
|
||||||
|
export interface IPaletteData {
|
||||||
|
/**
|
||||||
|
* Absolute base URL prepended to all tile URLs.
|
||||||
|
* Must not end with a trailing slash.
|
||||||
|
*/
|
||||||
|
baseurl: string,
|
||||||
|
/**
|
||||||
|
* Number of sections per edge.
|
||||||
|
* The `ISprite.edges` array must be exactly 4 times as long as this value
|
||||||
|
* (as in the four edges of a quadratic tile).
|
||||||
|
*/
|
||||||
|
sections: number,
|
||||||
|
/** horizontal and vertical resolution of all sprites, in pixels */
|
||||||
|
resolution: number,
|
||||||
|
/** Array of all tile prototypes. */
|
||||||
|
sprites: ISpriteData[],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores and renders a single type of tile.
|
||||||
|
* Actual `Tile`s refer to instances of this class for their data.
|
||||||
|
*/
|
||||||
|
export class Sprite {
|
||||||
|
private readonly sections: number;
|
||||||
|
private readonly edges: number[];
|
||||||
|
private bitmap?: ImageBitmap;
|
||||||
|
|
||||||
|
public constructor(sections: number, baseurl: string, proto: ISpriteData) {
|
||||||
|
this.sections = sections;
|
||||||
|
if (proto.edges.length !== sections * 4) {
|
||||||
|
throw new TypeError("Invalid edge count");
|
||||||
|
}
|
||||||
|
this.edges = proto.edges;
|
||||||
|
this.fetchBitmapAsync(baseurl + proto.url)
|
||||||
|
.then(i => this.bitmap = i)
|
||||||
|
.catch(e => console.error(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
public matches(ownEdge: Direction, other: Sprite, otherEdge: Direction): boolean {
|
||||||
|
const sections = this.sections;
|
||||||
|
if (other.sections !== sections) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStart = (edge: Direction) => {
|
||||||
|
let start = edge * sections;
|
||||||
|
if (edge >= 2) {
|
||||||
|
start += sections - 1;
|
||||||
|
}
|
||||||
|
return start;
|
||||||
|
};
|
||||||
|
let ownPos = getStart(ownEdge);
|
||||||
|
let otherPos = getStart(otherEdge);
|
||||||
|
|
||||||
|
const ownDir = ownEdge < 2 ? 1 : -1;
|
||||||
|
const otherDir = otherEdge < 2 ? 1 : -1;
|
||||||
|
for (let n = 0; n < sections; n++) {
|
||||||
|
if (this.edges[ownPos] !== other.edges[otherPos]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ownPos += ownDir;
|
||||||
|
otherPos += otherDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw this sprite to the screen at the specified position and rotation.
|
||||||
|
*
|
||||||
|
* @param context The context to draw the sprite in
|
||||||
|
* @param dx horizontal offset in pixels
|
||||||
|
* @param dy vertical offset in pixels
|
||||||
|
* @param orientation If specified, rotate the sprite such that the north
|
||||||
|
* facing edge points to the specified direction.
|
||||||
|
*/
|
||||||
|
public draw(context: CanvasRenderingContext2D, dx: number, dy: number, orientation?: Direction) {
|
||||||
|
if (this.bitmap !== undefined) {
|
||||||
|
if (orientation === undefined || orientation === Direction.NORTH) {
|
||||||
|
context.drawImage(this.bitmap, dx, dy);
|
||||||
|
} else {
|
||||||
|
context.save();
|
||||||
|
const halfWidth = this.bitmap.width / 2;
|
||||||
|
const halfHeight = this.bitmap.height / 2;
|
||||||
|
context.translate(dx + halfWidth, dy + halfHeight);
|
||||||
|
context.rotate(Math.PI * (orientation / 2));
|
||||||
|
context.drawImage(this.bitmap, -halfWidth, -halfHeight);
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchBitmapAsync(url: string): Promise<ImageBitmap> {
|
||||||
|
console.debug(`fetching bitmap ${url}`);
|
||||||
|
const response = await fetch(url);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const image = await createImageBitmap(blob);
|
||||||
|
console.debug(`fetched and parsed bitmap ${url}`);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum Direction {
|
||||||
|
NORTH = 0,
|
||||||
|
EAST = 1,
|
||||||
|
SOUTH = 2,
|
||||||
|
WEST = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Palette {
|
||||||
|
private readonly sections: number;
|
||||||
|
public readonly sprites: Sprite[];
|
||||||
|
public readonly resolution: number;
|
||||||
|
|
||||||
|
public constructor(proto: IPaletteData) {
|
||||||
|
this.sections = proto.sections;
|
||||||
|
const sprites = [];
|
||||||
|
for (let s of proto.sprites) {
|
||||||
|
sprites.push(new Sprite(proto.sections, proto.baseurl, s));
|
||||||
|
}
|
||||||
|
this.sprites = sprites;
|
||||||
|
this.resolution = proto.resolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PALETTE_SCHEMA: JSONSchema = {
|
||||||
|
baseurl: "string",
|
||||||
|
sections: "number",
|
||||||
|
resolution: "number",
|
||||||
|
sprites: [{
|
||||||
|
url: "string",
|
||||||
|
edges: ["number"],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchPalette(rootURL: string): Promise<Palette> {
|
||||||
|
const response = await fetch(rootURL + "/palette.json");
|
||||||
|
const json = await response.json();
|
||||||
|
return new Palette(validateJSON(json, PALETTE_SCHEMA));
|
||||||
|
}
|
Loading…
Reference in New Issue