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