Compare commits

...

7 Commits

25
dist/index.html vendored

@ -7,12 +7,14 @@
<style>
* {
box-sizing: border-box;
font-family: sans-serif;
}
html, body {
margin: 0;
overflow: hidden;
background-color: black;
color: #ccc;
}
#render {
@ -21,9 +23,32 @@
left: 0;
background-color: black;
}
noscript {
display: block;
margin-top: 60px;
text-align: center;
line-height: 1.5rem;
}
a {
color: #cb54c2;
}
</style>
</head>
<body>
<noscript>
<h1>JavaScript Disabled</h1>
<p>
It appears that JavaScript is disabled in your browser. Excellent!<br>
However, by nature of being an interactive game, this site requires it.<br>
Please enable JavaScript in your browser's settings to play this game.
</p>
<p>
Alternatively, you may also obtain the source code and run it locally:<br>
<a href="https://git.bsd.gay/fef/wfcjs">https://git.bsd.gay/fef/wfcjs</a>
</p>
</noscript>
<canvas id="render"></canvas>
<script type="text/javascript" src="bundle.js"></script>
</body>

52
dist/palette.json vendored

@ -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
]
}
]
}

BIN
dist/sprites/01.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

BIN
dist/sprites/02.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 674 B

BIN
dist/sprites/03.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 677 B

BIN
dist/sprites/04.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 B

BIN
dist/sprites/05.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 B

@ -1,13 +1,25 @@
import {fetchPalette} from "./sprite";
import {Chunk} from "./chunk";
export class App {
private canvas: HTMLCanvasElement;
private readonly context: CanvasRenderingContext2D;
private lastTime: DOMHighResTimeStamp = 0;
private isRunning: boolean = false;
private chunk?: Chunk;
public constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.updateSize(window.innerWidth, window.innerHeight);
const context = canvas.getContext("2d");
if (context === null) {
throw new Error("Failed to get 2D context");
}
context.imageSmoothingEnabled = false; // pixelart
this.context = context;
document.onresize = () => this.updateSize(window.innerWidth, window.innerHeight);
this.updateSize(window.innerWidth, window.innerHeight);
window.onresize = () => this.updateSize(window.innerWidth, window.innerHeight);
fetchPalette("").then(palette => this.chunk = new Chunk(palette));
}
public run() {
@ -20,7 +32,10 @@ export class App {
}
private draw(time: DOMHighResTimeStamp) {
// TODO: actually draw stuff
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
if (this.chunk) {
this.chunk.render(this.context);
}
this.lastTime = time;
if (this.isRunning) {
@ -35,4 +50,5 @@ export class App {
}
const app = new App(document.getElementById("render") as HTMLCanvasElement);
(window as any)._app = app;
app.run();

@ -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));
}

@ -6,8 +6,7 @@
"removeComments": true,
"sourceMap": true,
"outDir": "./dist",
"strict": true,
"resolveJsonModule": true,
"strict": true
},
"files": [
"src/app.ts"

Loading…
Cancel
Save