This commit is contained in:
Kim Ravn Hansen
2025-09-19 16:04:11 +02:00
parent b1d667d7cb
commit 3a9185ca94
20 changed files with 2520 additions and 360 deletions

View File

@@ -1,11 +0,0 @@
export const Direction = Object.freeze({
NW: 0,
N: 1,
NE: 2,
E: 3,
C: 4,
W: 5,
SW: 6,
S: 7,
SE: 8,
});

View File

@@ -0,0 +1,657 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple 3D Dungeon Crawler</title>
<style>
body {
margin: 0;
overflow: hidden;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans",
"Helvetica Neue", sans-serif;
background-color: #111;
color: #fff;
display: flex;
flex-direction: column;
align-items: center;
}
#info-container {
position: absolute;
top: 10px;
left: 10px;
background-color: rgba(0, 0, 0, 0.7);
padding: 15px;
border-radius: 10px;
max-width: 350px;
border: 1px solid #444;
}
h1 {
margin-top: 0;
font-size: 1.2em;
}
p,
li {
font-size: 0.9em;
line-height: 1.5;
}
ul {
padding-left: 20px;
}
#crosshair {
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 4px;
background-color: white;
border-radius: 50%;
transform: translate(-50%, -50%);
pointer-events: none; /* So it doesn't interfere with mouse lock */
}
#game-canvas {
display: block;
}
</style>
</head>
<body>
<div id="info-container">
<h1>First-Person Dungeon Crawler</h1>
<p>Create a <strong>map.txt</strong> file to load your own dungeon:</p>
<ul>
<li><code>#</code> = Wall</li>
<li><code>&nbsp;</code> (space) = Floor</li>
<li><code>P</code> = Player Start</li>
</ul>
<p><strong>Controls:</strong></p>
<ul>
<li><strong>Click Screen:</strong> Lock mouse for camera control</li>
<li><strong>W / S:</strong> Move Forward / Backward</li>
<li><strong>A / D:</strong> Strafe Left / Right</li>
<li><strong>Mouse:</strong> Look Around</li>
<li><strong>ESC:</strong> Unlock mouse</li>
</ul>
<input type="file" id="map-upload" accept=".txt" />
</div>
<div id="crosshair"></div>
<canvas id="game-canvas"></canvas>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
/**
* A class that creates an ASCII effect.
*
* The ASCII generation is based on [jsascii]{@link https://github.com/hassadee/jsascii/blob/master/jsascii.js}.
*
* @three_import import { AsciiEffect } from 'three/addons/effects/AsciiEffect.js';
*/
class AsciiEffect {
/**
* Constructs a new ASCII effect.
*
* @param {WebGLRenderer} renderer - The renderer.
* @param {string} [charSet=' .:-=+*#%@'] - The char set.
* @param {AsciiEffect~Options} [options] - The configuration parameter.
*/
constructor(renderer, charSet = " .:-=+*#%@", options = {}) {
// ' .,:;=|iI+hHOE#`$';
// darker bolder character set from https://github.com/saw/Canvas-ASCII-Art/
// ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$'.split('');
// Some ASCII settings
const fResolution = options["resolution"] || 0.15;
const iScale = options["scale"] || 1;
const bColor = options["color"] || false;
const bAlpha = options["alpha"] || false;
const bBlock = options["block"] || false;
const bInvert = options["invert"] || false;
const strResolution = options["strResolution"] || "low";
let width, height;
const domElement = document.createElement("div");
domElement.style.cursor = "default";
const oAscii = document.createElement("table");
domElement.appendChild(oAscii);
let iWidth, iHeight;
let oImg;
/**
* Resizes the effect.
*
* @param {number} w - The width of the effect in logical pixels.
* @param {number} h - The height of the effect in logical pixels.
*/
this.setSize = function (w, h) {
width = w;
height = h;
renderer.setSize(w, h);
initAsciiSize();
};
/**
* When using this effect, this method should be called instead of the
* default {@link WebGLRenderer#render}.
*
* @param {Object3D} scene - The scene to render.
* @param {Camera} camera - The camera.
*/
this.render = function (scene, camera) {
renderer.render(scene, camera);
asciifyImage(oAscii);
};
/**
* The DOM element of the effect. This element must be used instead of the
* default {@link WebGLRenderer#domElement}.
*
* @type {HTMLDivElement}
*/
this.domElement = domElement;
// Throw in ascii library from https://github.com/hassadee/jsascii/blob/master/jsascii.js (MIT License)
function initAsciiSize() {
iWidth = Math.floor(width * fResolution);
iHeight = Math.floor(height * fResolution);
oCanvas.width = iWidth;
oCanvas.height = iHeight;
// oCanvas.style.display = "none";
// oCanvas.style.width = iWidth;
// oCanvas.style.height = iHeight;
oImg = renderer.domElement;
if (oImg.style.backgroundColor) {
oAscii.rows[0].cells[0].style.backgroundColor = oImg.style.backgroundColor;
oAscii.rows[0].cells[0].style.color = oImg.style.color;
}
oAscii.cellSpacing = "0";
oAscii.cellPadding = "0";
const oStyle = oAscii.style;
oStyle.whiteSpace = "pre";
oStyle.margin = "0px";
oStyle.padding = "0px";
oStyle.letterSpacing = fLetterSpacing + "px";
oStyle.fontFamily = strFont;
oStyle.fontSize = fFontSize + "px";
oStyle.lineHeight = fLineHeight + "px";
oStyle.textAlign = "left";
oStyle.textDecoration = "none";
}
const strFont = "courier new, monospace";
const oCanvasImg = renderer.domElement;
const oCanvas = document.createElement("canvas");
if (!oCanvas.getContext) {
return;
}
const oCtx = oCanvas.getContext("2d");
if (!oCtx.getImageData) {
return;
}
let aCharList;
if (charSet) {
aCharList = charSet.split("");
} else {
const aDefaultCharList = " .,:;i1tfLCG08@".split("");
const aDefaultColorCharList = " CGO08@".split("");
aCharList = bColor ? aDefaultColorCharList : aDefaultCharList;
}
// Setup dom
const fFontSize = (2 / fResolution) * iScale;
const fLineHeight = (2 / fResolution) * iScale;
// adjust letter-spacing for all combinations of scale and resolution to get it to fit the image width.
let fLetterSpacing = 0;
if (strResolution == "low") {
switch (iScale) {
case 1:
fLetterSpacing = -1;
break;
case 2:
case 3:
fLetterSpacing = -2.1;
break;
case 4:
fLetterSpacing = -3.1;
break;
case 5:
fLetterSpacing = -4.15;
break;
}
}
if (strResolution == "medium") {
switch (iScale) {
case 1:
fLetterSpacing = 0;
break;
case 2:
fLetterSpacing = -1;
break;
case 3:
fLetterSpacing = -1.04;
break;
case 4:
case 5:
fLetterSpacing = -2.1;
break;
}
}
if (strResolution == "high") {
switch (iScale) {
case 1:
case 2:
fLetterSpacing = 0;
break;
case 3:
case 4:
case 5:
fLetterSpacing = -1;
break;
}
}
// can't get a span or div to flow like an img element, but a table works?
// convert img element to ascii
function asciifyImage(oAscii) {
oCtx.clearRect(0, 0, iWidth, iHeight);
oCtx.drawImage(oCanvasImg, 0, 0, iWidth, iHeight);
const oImgData = oCtx.getImageData(0, 0, iWidth, iHeight).data;
// Coloring loop starts now
let strChars = "";
// console.time('rendering');
for (let y = 0; y < iHeight; y += 2) {
for (let x = 0; x < iWidth; x++) {
const iOffset = (y * iWidth + x) * 4;
const iRed = oImgData[iOffset];
const iGreen = oImgData[iOffset + 1];
const iBlue = oImgData[iOffset + 2];
const iAlpha = oImgData[iOffset + 3];
let iCharIdx;
let fBrightness;
fBrightness = (0.3 * iRed + 0.59 * iGreen + 0.11 * iBlue) / 255;
// fBrightness = (0.3*iRed + 0.5*iGreen + 0.3*iBlue) / 255;
if (iAlpha == 0) {
// should calculate alpha instead, but quick hack :)
//fBrightness *= (iAlpha / 255);
fBrightness = 1;
}
iCharIdx = Math.floor((1 - fBrightness) * (aCharList.length - 1));
if (bInvert) {
iCharIdx = aCharList.length - iCharIdx - 1;
}
// good for debugging
//fBrightness = Math.floor(fBrightness * 10);
//strThisChar = fBrightness;
let strThisChar = aCharList[iCharIdx];
if (strThisChar === undefined || strThisChar == " ") strThisChar = "&nbsp;";
if (bColor) {
strChars +=
"<span style='" +
"color:rgb(" +
iRed +
"," +
iGreen +
"," +
iBlue +
");" +
(bBlock
? "background-color:rgb(" + iRed + "," + iGreen + "," + iBlue + ");"
: "") +
(bAlpha ? "opacity:" + iAlpha / 255 + ";" : "") +
"'>" +
strThisChar +
"</span>";
} else {
strChars += strThisChar;
}
}
strChars += "<br/>";
}
oAscii.innerHTML = `<tr><td style="display:block;width:${width}px;height:${height}px;overflow:hidden">${strChars}</td></tr>`;
// console.timeEnd('rendering');
// return oAscii;
}
}
}
/**
* This type represents configuration settings of `AsciiEffect`.
*
* @typedef {Object} AsciiEffect~Options
* @property {number} [resolution=0.15] - A higher value leads to more details.
* @property {number} [scale=1] - The scale of the effect.
* @property {boolean} [color=false] - Whether colors should be enabled or not. Better quality but slows down rendering.
* @property {boolean} [alpha=false] - Whether transparency should be enabled or not.
* @property {boolean} [block=false] - Whether blocked characters should be enabled or not.
* @property {boolean} [invert=false] - Whether colors should be inverted or not.
* @property {('low'|'medium'|'high')} [strResolution='low'] - The string resolution.
**/
// --- Basic Setup ---
let scene, camera, renderer;
let mapData = [],
mapWidth,
mapHeight;
const TILE_SIZE = 5; // Size of each grid square in the world
const WALL_HEIGHT = 5;
// --- Player State ---
const player = {
height: WALL_HEIGHT / 2,
speed: 0.15,
turnSpeed: 0.05,
velocity: new THREE.Vector3(),
controls: {
moveForward: false,
moveBackward: false,
moveLeft: false,
moveRight: false,
},
};
// --- Initialization ---
function init() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);
scene.fog = new THREE.Fog(0x1a1a1a, 10, 50);
// Camera (First-person view)
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.y = player.height;
// Renderer
renderer = new THREE.WebGLRenderer({ canvas: document.getElementById("game-canvas"), antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// Lighting
const ambientLight = new THREE.AmbientLight(0x404040, 2);
scene.add(ambientLight);
const pointLight = new THREE.PointLight(0xffffff, 1.5, 100);
pointLight.position.set(0, WALL_HEIGHT * 1.5, 0); // Light is attached to player
camera.add(pointLight); // Attach light to camera
scene.add(camera); // Add camera to scene to ensure light is added
// Event Listeners
document.getElementById("map-upload").addEventListener("change", handleMapUpload);
window.addEventListener("resize", onWindowResize);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keyup", onKeyUp);
// Mouse Look Controls
setupPointerLock();
// Load a default map
loadMap(getDefaultMap());
// Start the game loop
animate();
}
// --- Map Handling ---
function getDefaultMap() {
return [
"##########",
"#P # #",
"# # ### #",
"#### # # #",
"# # #",
"# ###### #",
"# # #",
"# # ######",
"# # #",
"##########",
].join("\n");
}
function handleMapUpload(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
loadMap(e.target.result);
};
reader.readAsText(file);
}
function loadMap(data) {
// Clear existing map objects from scene
const objectsToRemove = [];
scene.children.forEach((child) => {
if (child.userData.isMapTile) {
objectsToRemove.push(child);
}
});
objectsToRemove.forEach((obj) => scene.remove(obj));
// Parse new map data
mapData = data.split("\n").map((row) => row.split(""));
mapHeight = mapData.length;
mapWidth = mapData[0].length;
// Create geometry and materials once
const wallGeometry = new THREE.BoxGeometry(TILE_SIZE, WALL_HEIGHT, TILE_SIZE);
const wallMaterial = new THREE.MeshStandardMaterial({ color: 0x888888, roughness: 0.8 });
const floorGeometry = new THREE.PlaneGeometry(TILE_SIZE, TILE_SIZE);
const floorMaterial = new THREE.MeshStandardMaterial({ color: 0x444444, side: THREE.DoubleSide });
// Build the scene from the map data
for (let y = 0; y < mapHeight; y++) {
for (let x = 0; x < mapWidth; x++) {
const char = mapData[y][x];
const worldX = (x - mapWidth / 2) * TILE_SIZE;
const worldZ = (y - mapHeight / 2) * TILE_SIZE;
if (char === "#") {
const wall = new THREE.Mesh(wallGeometry, wallMaterial);
wall.position.set(worldX, WALL_HEIGHT / 2, worldZ);
wall.userData.isMapTile = true;
scene.add(wall);
} else {
// Add floor for every non-wall tile
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.position.set(worldX, 0, worldZ);
floor.userData.isMapTile = true;
scene.add(floor);
}
if (char === "@") {
camera.position.x = worldX;
camera.position.z = worldZ;
}
}
}
// Add a ceiling
const ceilingGeometry = new THREE.PlaneGeometry(mapWidth * TILE_SIZE, mapHeight * TILE_SIZE);
const ceilingMaterial = new THREE.MeshStandardMaterial({ color: 0x555555, side: THREE.DoubleSide });
const ceiling = new THREE.Mesh(ceilingGeometry, ceilingMaterial);
ceiling.rotation.x = Math.PI / 2;
ceiling.position.y = WALL_HEIGHT;
ceiling.userData.isMapTile = true;
scene.add(ceiling);
}
function isWall(x, z) {
const mapX = Math.floor(x / TILE_SIZE + mapWidth / 2);
const mapY = Math.floor(z / TILE_SIZE + mapHeight / 2);
if (mapX < 0 || mapX >= mapWidth || mapY < 0 || mapY >= mapHeight) {
return true; // Treat out of bounds as a wall
}
return mapData[mapY][mapX] === "#";
}
// --- Controls & Movement ---
function setupPointerLock() {
const canvas = renderer.domElement;
canvas.addEventListener("click", () => {
canvas.requestPointerLock();
});
document.addEventListener("pointerlockchange", () => {
if (document.pointerLockElement === canvas) {
document.addEventListener("mousemove", onMouseMove);
} else {
document.removeEventListener("mousemove", onMouseMove);
}
});
}
function onMouseMove(event) {
if (document.pointerLockElement !== renderer.domElement) return;
const movementX = event.movementX || 0;
camera.rotation.y -= movementX * 0.002;
}
function onKeyDown(event) {
switch (event.code) {
case "KeyW":
case "ArrowUp":
player.controls.moveForward = true;
break;
case "KeyS":
case "ArrowDown":
player.controls.moveBackward = true;
break;
case "KeyA":
case "ArrowLeft":
player.controls.moveLeft = true;
break;
case "KeyD":
case "ArrowRight":
player.controls.moveRight = true;
break;
}
}
function onKeyUp(event) {
switch (event.code) {
case "KeyW":
case "ArrowUp":
player.controls.moveForward = false;
break;
case "KeyS":
case "ArrowDown":
player.controls.moveBackward = false;
break;
case "KeyA":
case "ArrowLeft":
player.controls.moveLeft = false;
break;
case "KeyD":
case "ArrowRight":
player.controls.moveRight = false;
break;
}
}
function updatePlayerPosition() {
const direction = new THREE.Vector3();
camera.getWorldDirection(direction);
const right = new THREE.Vector3();
right.crossVectors(camera.up, direction).normalize();
player.velocity.set(0, 0, 0);
if (player.controls.moveForward) {
player.velocity.add(direction);
}
if (player.controls.moveBackward) {
player.velocity.sub(direction);
}
if (player.controls.moveLeft) {
player.velocity.add(right);
}
if (player.controls.moveRight) {
player.velocity.sub(right);
}
if (player.velocity.length() > 0) {
player.velocity.normalize().multiplyScalar(player.speed);
}
// Collision detection
const collisionMargin = TILE_SIZE / 4;
let moveX = true,
moveZ = true;
if (isWall(camera.position.x + player.velocity.x * collisionMargin, camera.position.z)) {
moveX = false;
}
if (isWall(camera.position.x, camera.position.z + player.velocity.z * collisionMargin)) {
moveZ = false;
}
if (moveX) camera.position.x += player.velocity.x;
if (moveZ) camera.position.z += player.velocity.z;
}
// --- Main Loop ---
function animate() {
requestAnimationFrame(animate);
updatePlayerPosition();
renderer.render(scene, camera);
}
// --- Utility ---
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// --- Start everything ---
init();
</script>
</body>
</html>

View File

@@ -3,13 +3,17 @@ import { Direction } from "./WfcConstants.js";
/**
* Represents a 3x3 grid of values (sub-cells), that are used as building blocks for procedurally
* generated grids. In reality, only the center value will be included in the outputted WfcGrid;
* the 8 surrounding colors are there to establish which TrainingCells can live next to each other.
* the 8 surrounding colors are there to establish which SourceCells can live next to each other.
*/
export class TrainingCell {
/** @param {Uint8Array} values The 9 sub cells that make up this TrainingCell */
constructor() {
this.values = new Uint8Array(9);
export class SourceCell {
/** @param {Uint8Array?} values The 9 sub cells that make up this SourceCell */
constructor(values) {
if (values === undefined) {
this.values = new Uint8Array(9);
return;
}
this.values = values;
}
/** @returns {string} The actual value of this Trainin gCell is represented by its center value */
@@ -18,7 +22,18 @@ export class TrainingCell {
}
/**
* @param {TrainingCell} other
* @param {uint8} value
* Set the default value of this source cell */
set value(value) {
this.values[Direction.C] = value;
}
clone() {
return new SourceCell(this.values.slice());
}
/**
* @param {SourceCell} other
* @param {number} direction
*
* @returns {boolean}
@@ -26,10 +41,9 @@ export class TrainingCell {
potentialNeighbours(other, direction) {
// sadly, we're not allowed to be friends with ourselves.
if (this === other) {
console.log("WTF were checking to be friends with ourselves!", this, other, direction);
return false;
console.log("WTF were checking to be friends with ourselves!", { _this: this, other, direction });
// throw new Error("WTF were checking to be friends with ourselves!", { _this: this, other, direction });
}
return (
//
// if they want to live to my east,
@@ -69,7 +83,7 @@ export class TrainingCell {
// my north row must match their middle row
this.values[Direction.NW] === other.values[Direction.W] &&
this.values[Direction.N] === other.values[Direction.C] &&
this.values[Direction.Direction.NE] === other.values[Direction.E]) ||
this.values[Direction.NE] === other.values[Direction.E]) ||
//
// if they want to live to my south,
// their two northern rows must match
@@ -78,7 +92,7 @@ export class TrainingCell {
// my middle row must match their north row
this.values[Direction.W] === other.values[Direction.NW] &&
this.values[Direction.C] === other.values[Direction.N] &&
this.values[Direction.E] === other.values[Direction.SE] &&
this.values[Direction.E] === other.values[Direction.NE] &&
// my south row must match their middle row
this.values[Direction.SW] === other.values[Direction.W] &&
this.values[Direction.S] === other.values[Direction.C] &&

42
frontend/SourceGrid.js Executable file
View File

@@ -0,0 +1,42 @@
import { SourceCell } from "./SourceCell.js";
export class SourceGrid {
/**
* @type {SourceCell[]} cells The cells that make up this source grid.
*/
cells;
/**
* @type {number} the width and/or height of the source grid
*/
dim;
/**
* @param {SourceCell[]} cells
*/
constructor(cells) {
if (cells[0] === undefined) {
throw new Error("cells must be a non empty array");
}
if (!(cells[0] instanceof SourceCell)) {
throw new Error("cells arg must be an array of SourceCell, but it isn't");
}
this.cells = cells;
this.dim = Math.round(Math.sqrt(cells.length));
if (this.dim ** 2 !== cells.length) {
throw new Error("Source grid must be quadratic (height === width), but it isn't");
}
}
toString() {
return this.cells.map((cell) => cell.value).join(", ");
}
clone() {
return new SourceGrid(this.cells.map((sgCell) => sgCell.clone()));
}
}

View File

@@ -1,30 +0,0 @@
import { TrainingCell } from "./TrainingCell.js";
export class TrainingGrid {
/**
* @param {TrainingCell[]} cells
*/
constructor(cells) {
if (cells[0] === undefined) {
throw new Error("cells must be a non empty array");
}
if (!(cells[0] instanceof TrainingCell)) {
throw new Error("cells arg must be an array of TrainingCell, but it isn't");
}
/** @type {TrainingCell[]} cells*/
this.cells = cells;
/** @type {number} the width and/or height of the training grid */
this.dim = Math.round(Math.sqrt(cells.length));
if (this.dim ** 2 !== cells.length) {
throw new Error("Training grid must be quadratic (height === width), but it isn't");
}
}
clone() {
return new TrainingGrid(this.cells.slice());
}
}

View File

@@ -1,4 +1,4 @@
import { TrainingCell } from "./TrainingCell";
import { SourceCell } from "./SourceCell";
/**
* Represents a single cell in a WfcGrid
@@ -9,7 +9,7 @@ export class WfcCell {
* @param {number} i index in the cell-array of this cell
* @param {number} x x-coordinate of cell
* @param {number} y y-coordinate of cell
* @param {TrainingCell[]} options - A list of training cells that could potentially live here.
* @param {SourceCell[]} options - A list of source cells that could potentially live here.
*/
constructor(i, x, y, options) {
if (!options.length) {
@@ -17,7 +17,7 @@ export class WfcCell {
throw Error("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options);
}
if (!(options[0] instanceof TrainingCell)) {
if (!(options[0] instanceof SourceCell)) {
console.log("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options);
throw Error("Bad >>options<< arg in WfcCell constructor. Must be array of WfcCells, but wasn't.", options);
}
@@ -29,22 +29,14 @@ export class WfcCell {
}
getEntropy() {
const result = this.options.length;
return result;
return this.options.length;
}
get lockedIn() {
return this.getEntropy() === 1;
}
get valid() {
return this.options.length > 0;
return this.options.length === 1;
}
get value() {
if (this.options[0] === undefined) {
throw new Error("Bad! I do not have any options, and therefore no color");
}
return this.options[0].value;
return this.options.length === 1 ? this.options[0].value : 0;
}
}

21
frontend/WfcConstants.js Executable file
View File

@@ -0,0 +1,21 @@
export const Direction = Object.freeze({
NW: 0,
N: 1,
NE: 2,
E: 3,
C: 4,
W: 5,
SW: 6,
S: 7,
SE: 8,
0: "North west",
1: "North",
2: "North east",
3: "East",
4: "[center]",
5: "West",
6: "South west",
7: "South",
8: "South east",
});

View File

@@ -1,5 +1,7 @@
import { Direction } from "./WfcConstants.js";
import { WfcCell } from "./WfcCell.js";
import { SourceGrid } from "./SourceGrid.js";
import { Xorshift32 } from "../utils/random.js";
/**
* A WfcGrid represents the output of a Wave Function Collapse operation.
@@ -8,18 +10,26 @@ export class WfcGrid {
/**
* @param {number} w width (in cells)
* @param {number} h height (in cells)
* @param {TrainingGrid} trainingGrid the training grid that will be the source from which we populate this grid.
* @param {SourceGrid} sourceGrid the source grid that will be the source from which we populate this grid.
* @type {Xorshift32} pre-seeded pseudo random number generator
*/
constructor(w, h, trainingGrid, rng) {
constructor(w, h, sourceGrid, rng) {
/** @type {number} */
this.width = w;
/** @type {number} */
this.height = h;
this.trainingGrid = trainingGrid;
/** @type {SourceGrid} */
this.sourceGrid = sourceGrid;
/** @type {number[]} */
this.lowEntropyCellIdCache = sourceGrid.cells.keys;
/** @type {number} */
this.lowestEntropy = sourceGrid.dim ** 2;
/** @type {Xorshift32} */
this.rng = rng;
//
// Populate the cells so each has all available options
// For now, this means *copying* all TrainingCell options into each cell
/** @type {WfcCell[]} */
this.cells = [];
this.reset();
}
@@ -27,139 +37,165 @@ export class WfcGrid {
console.log("Resetting Cells");
const [w, h] = [this.width, this.height];
const len = w * h;
this.cells = new Array(len);
this.cells = [];
for (let i = 0; i < len; i++) {
const x = i % w;
const y = Math.floor(i / w);
this.cells[i] = new WfcCell(i, x, y, this.trainingGrid.clone().pixels);
this.cells.push(new WfcCell(i, x, y, this.sourceGrid.clone().cells));
}
console.log("Done");
}
/**
* Get the cells that currently have the lowest entropy
* @returns {number[]}
*/
cellsIdsWithLowestEntropy() {
console.log("Finding cells with lowest entopy");
let result = [];
refreshLowEntropyCellIdCache() {
this.lowEntropyCellIdCache = [];
// set lowestEntropy to the highest possible entropy,
// and let's search for lower entropy in the cells
let lowestEntropy = this.trainingGrid.dim ** 2;
this.lowestEntropy = this.sourceGrid.dim ** 2;
this.cells.forEach((cell, idx) => {
console.log("\t checking cell %d (entropy: %d)", idx, cell.getEntropy());
const entropy = cell.getEntropy();
// Cell is locked in, and should not be included
if (entropy <= 1) {
return;
}
//
// Have we found cells with low entropy?
if (cell.getEntropy() < lowestEntropy) {
if (entropy < this.lowestEntropy) {
// we've found a cell with lower entropy that the ones we've been looking
// at so far Clear the search results and start over with this cell's
// entropy as our target
result = [idx];
lowestEntropy = cell.getEntropy();
this.lowEntropyCellIdCache = [idx];
this.lowestEntropy = entropy;
return;
}
//
// Cell matches current entropy level, add it to search results.
if (cell.getEntropy() === lowestEntropy) {
if (entropy === this.lowestEntropy) {
// Cell matches our current level of entropy, so we add it to our search results.
// at so far! Clear the results and start over.
result.push(idx);
this.lowEntropyCellIdCache.push(idx);
return;
}
});
if (result.length <= 0) {
console.log("Found zero lowest-entropy cells.", { lowestEntropy });
if (this.lowEntropyCellIdCache.length === 0) {
console.log("Found zero lowest-entropy cells.", { entropy: this.lowestEntropy });
}
return result;
}
collapse() {
console.log("Starting collaps()");
let count = this.cells.length;
while (count > 0) {
count--;
// Get a list of possible target cells
const lowEntropyCellIds = this.cellIdsWithLowestEntropy();
/**
* Collapse the grid by one iteration by locking in a random option for the given cell.
*
* If no cell given, a random cell will be chosen from the cache of lowest-entropy cells.
*
* @param {number?} The index of the cell that is to be collapsed around
*/
collapse(cellId = undefined) {
if (this.lowEntropyCellIdCache.length === 0) {
this.refreshLowEntropyCellIdCache();
}
//
// We've hit a dead end
// No appropriate target cells found.
if (lowEntropyCellIds.length === 0) {
console.log("Found no lowest-entropy cells. This should not happen");
return count;
}
const rCellId = this.rng.randomElement(lowEntropyCellIds);
const rCell = this.cells[rCellId];
/** @type {TrainingCell} a randomly chosen option that was available to rCell */
const rOption = this.rng.randomElement(rCell.options);
// Lock in the choice for this cell
rCell.options = [rOption];
// _____ ____ _ _
// | ____|_ __ _ __ ___ _ __| __ ) ___| | _____ _| |
// | _| | '__| '__/ _ \| '__| _ \ / _ \ |/ _ \ \ /\ / / |
// | |___| | | | | (_) | | | |_) | __/ | (_) \ V V /|_|
// |_____|_| |_| \___/|_| |____/ \___|_|\___/ \_/\_/ (_)
// Locking in this cell has changed the grid.
// We must look at the cell's cardinal neighbours and update their options.
for (let nArr of this.getNeighboursFor(rCell)) {
/** @type {number} direction of the neighbour */
const neighbourDirection = nArr[0];
/** @type {WfcCell} the neighbouring cell */
const neighbourCell = nArr[1];
// Clear the neighbour's options, and
// repopulate with valid options.
const newOptions = [];
for (let neighbourOption of neighbourCell.options) {
if (neighbourOption.potentialNeighbours(rOption, neighbourDirection)) {
newOptions.push(neighbourOption);
}
}
// We've collapsed too deep.
if (newOptions.length === 0) {
console.error("We've removed all options from a neighbour!", {
rCell,
rOption,
neighbourCell,
neighbourDirection,
newOptions,
});
return false;
}
neighbourCell.options = newOptions;
if (cellId === undefined) {
cellId = this.rng.randomElement(this.lowEntropyCellIdCache);
if (cellId === undefined) {
throw new Error("Could not find a valid cell to start the collapse");
}
}
console.log("Done");
return 0;
const targetCell = this.cells[cellId];
if (!targetCell) {
throw new Error(`Could not find cell with index ${cellId}`);
}
/** @type {SourceCell} a randomly chosen option that was available to targetCell */
const targetOption = this.rng.randomElement(targetCell.options);
// Lock in the choice for this cell
targetCell.options = [targetOption];
// _____ ____ _ _
// | ____|_ __ _ __ ___ _ __| __ ) ___| | _____ _| |
// | _| | '__| '__/ _ \| '__| _ \ / _ \ |/ _ \ \ /\ / / |
// | |___| | | | | (_) | | | |_) | __/ | (_) \ V V /|_|
// |_____|_| |_| \___/|_| |____/ \___|_|\___/ \_/\_/ (_)
// Locking in this cell has changed the grid.
// We must look at the cell's cardinal neighbours and update their options.
for (let nArr of this.neighbourCells(targetCell)) {
/** @type {number} direction of the neighbour */
const neighbourDirection = nArr[0];
/** @type {WfcCell} the neighbouring cell */
const neighbourCell = nArr[1];
// Clear the neighbour's options, and
// repopulate with valid options.
const newOptions = [];
for (let neighbourOption of neighbourCell.options) {
if (neighbourOption.potentialNeighbours(targetOption, neighbourDirection)) {
newOptions.push(neighbourOption);
}
}
const newEntropyLevel = newOptions.length;
// We've collapsed too deep.
if (newOptions.length === 0) {
const oldOptions = neighbourCell.options;
neighbourCell.options = newOptions;
console.error("We've removed all options from a neighbour!", {
targetCell,
targetOption,
neighbourCell,
oldOptions,
Direction: Direction[neighbourDirection],
});
return false;
}
neighbourCell.options = newOptions;
if (newEntropyLevel < this.lowestEntropy) {
this.lowestEntropy = newEntropyLevel;
this.lowEntropyCellIdCache = [];
}
if (newEntropyLevel === this.lowestEntropy) {
if (!this.lowEntropyCellIdCache.includes(neighbourCell.i)) {
this.lowEntropyCellIdCache.push(neighbourCell.i);
}
}
}
return true;
}
/**
* Get the neighbours of a cell.
* @param {WfcCell} cell
*/
getNeighboursFor(cell) {
neighbourCells(cell) {
const result = [];
//
// Northern neighbour
//
const yNorth = cell.y - 1;
if (yNorth >= 0) {
if (yNorth > 0) {
const xNorth = cell.x;
const idx = this.width * yNorth + xNorth;
result.push([Direction.N, this.cells[idx]]);
}
//
// Southern neighbour
//
const ySouth = cell.y + 1;
if (ySouth < this.height) {
const xSouth = cell.x;
@@ -167,6 +203,9 @@ export class WfcGrid {
result.push([Direction.S, this.cells[idx]]);
}
//
// Eastern neighbour
//
const xEast = cell.x + 1;
if (xEast < this.width) {
const yEast = cell.y;
@@ -174,6 +213,9 @@ export class WfcGrid {
result.push([Direction.E, this.cells[idx]]);
}
//
// Western neighbour
//
const xWest = cell.x - 1;
if (xWest >= 0) {
const yWest = cell.y;

View File

@@ -0,0 +1,482 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Dungeon Crawler</title>
<style>
body {
margin: 0;
padding: 20px;
background-color: #000;
color: #ccc;
font-family: "Courier New", monospace;
overflow: hidden;
}
#gameContainer {
text-align: center;
}
#viewport {
font-size: 8px;
line-height: 8px;
white-space: pre;
border: 2px solid #0f0;
display: inline-block;
background-color: #000;
padding: 10px;
overflor: ignore;
}
#controls {
margin-top: 20px;
color: #0f0;
}
#mapInput {
margin-top: 20px;
}
textarea {
background-color: #001100;
color: #ccc;
border: 1px solid #0f0;
font-family: "Courier New", monospace;
padding: 10px;
}
button {
background-color: #001100;
color: #0f0;
border: 1px solid #0f0;
padding: 5px 10px;
font-family: "Courier New", monospace;
cursor: pointer;
}
button:hover {
background-color: #002200;
}
</style>
</head>
<body>
<div id="gameContainer">
<div id="viewport"></div>
<div id="controls">
<div>Use WASD or Arrow Keys to move and arrow keys to turn</div>
</div>
<div id="mapInput">
<div>Load your map (# = walls, space = floor):</div>
<br />
<textarea id="mapText" rows="10" cols="50">
####################
# # #
# # ### #
# # # #
# #### # #
# # #
# # # #
# # #### # #
# # # #
# ### ### #
# #
####################
</textarea
>
<br /><br />
<button onclick="loadMap()">Load Map</button>
</div>
</div>
<script>
class Vec2 {
constructor(x, y) {
if (!(Number.isFinite(x) && Number.isFinite(y))) {
throw new Error("Invalid x, y");
}
this.x = x;
this.y = y;
}
length() {
/// HYPOT!!!!
return Math.sqrt(this.x * this.x + this.y * this.y);
}
angle() {
const res = Math.atan2(this.y, this.x);
// breakpoint
return res;
}
angleBetween(other) {
const dot = this.x * other.x + this.y * other.y;
const magA = Math.hypot(this.x, this.y);
const magB = Math.hypot(other.x, other.y);
return Math.acos(dot / (magA * magB)); // radians
}
normalized() {
const factor = 1 / this.length();
return new Vec2(this.x * factor, this.y * factor);
}
turnedLeft() {
return new Vec2(this.y, -this.x);
}
turnedRight() {
return new Vec2(-this.y, this.x);
}
rotated(angle) {
const a = this.angle() + angle;
const l = this.length();
return new Vec2(Math.cos(a) * l, Math.sin(a) * l);
}
minus(otherVec2) {
return new Vec2(this.x - otherVec2.x, this.y - otherVec2.y);
}
plus(otherVec2) {
return new Vec2(this.x + otherVec2.x, this.y + otherVec2.y);
}
scaled(factor) {
return new Vec2(this.x * factor, this.y * factor);
}
increased(distance) {
return this.normalized().scaled(this.length() + distance);
}
// round the components of the vector to the nearest multiple of factor
sanitized(factor = 0.5) {
// hack
return this;
return new Vec2(Math.round(this.x / factor) * factor, Math.round(this.y / factor) * factor);
}
distanceTo(target) {
const v2 = new Vec2(target.x - this.x, target.y - this.y);
return v2.length;
}
clone() {
return new Vec2(this.x, this.y);
}
}
class RotationAnimation {
static execute(game, targetView) {
const anim = new RotationAnimation(game, targetView);
anim.start();
}
constructor(game, angle) {
const ticks = Math.floor(game.animationTime * game.fps);
const anglePerTick = angle / ticks;
this.game = game;
this.frames = [];
for (let i = 1; i < ticks; i++) {
this.frames.push(game.player.view.rotated(i * anglePerTick));
}
this.frames.push(game.player.view.rotated(angle));
}
step() {
if (this.frames.length === 0) {
return true;
}
const newView = this.frames.shift();
this.game.player.view = newView;
}
start() {
this.startedAt = Date.now();
this.game.animation = setInterval(() => {
const done = this.step();
// requestAnimationFrame(() => this.game.render());
this.game.render();
if (done) {
clearInterval(this.game.animation);
this.game.animation = null;
console.log("Animation done in %f seconds", (Date.now() - this.startedAt) / 1000);
}
}, 1000 / this.game.fps);
}
}
class TranslationAnimation {
static execute(game, targetPos) {
const anim = new TranslationAnimation(game, targetPos);
anim.start();
}
constructor(game, targetPos) {
const directionVec = targetPos.minus(game.player.pos);
const ticks = Math.floor(game.animationTime * game.fps);
this.game = game;
this.frames = [];
for (let i = 1; i < ticks; i++) {
this.frames.push(game.player.pos.plus(directionVec.scaled(i / ticks)));
}
this.frames.push(targetPos);
console.log(
"Current Player Location [%f, %f]",
game.player.pos.x,
game.player.pos.y,
this.frames.length,
);
console.log(
"Created animation to translate player to new position [%f, %f]",
targetPos.x,
targetPos.y,
this.frames.length,
);
}
step() {
if (this.frames.length === 0) {
return true;
}
const newPos = this.frames.shift();
console.log("Moving player to new position [%f, %f]", newPos.x, newPos.y, this.frames.length);
this.game.player.pos = newPos;
}
start() {
this.startedAt = Date.now();
this.game.animation = setInterval(() => {
const done = this.step();
// requestAnimationFrame(() => this.game.render());
this.game.render();
if (done) {
clearInterval(this.game.animation);
this.game.animation = null;
console.log("Animation done in %f seconds", (Date.now() - this.startedAt) / 1000);
}
}, 1000 / this.game.fps);
}
}
class DungeonCrawler {
constructor() {
this.viewport = document.getElementById("viewport");
/** @type {number} Screen width */
this.width = 120;
/** @type {number} Screen height */
this.height = 40;
/** @type {number} Number of frames per second used in animations */
this.fps = 30;
/** @type {number} Number of seconds a default animation takes */
this.animationTime = 1.0;
/** handle from setInterval */
this.animation = null;
this.player = {
pos: new Vec2(10, 10),
view: new Vec2(0, 1),
};
// Player position and orientation
this.fov = Math.PI / 3; // 60 degrees
// Map
this.map = [];
this.mapWidth = 0;
this.mapHeight = 0;
// Raycasting settings
this.maxDepth = 20;
this.setupControls();
this.loadDefaultMap();
this.render();
// this.gameLoop();
}
loadDefaultMap() {
const defaultMap = document.getElementById("mapText").value;
this.parseMap(defaultMap);
}
parseMap(mapString) {
// _____ ___ ____ ___
// |_ _/ _ \| _ \ / _ \
// | || | | | | | | | | |
// | || |_| | |_| | |_| |
// |_| \___/|____/ \___/
//-------------------------
// Map is one of:
// - Uint8Array
// - Uint16Array
// - Uint8Array[]
// - Uint16Array[]
// Info should include walkability, texture info, tile type (monster, chest, door, etc.)
const lines = mapString.trim().split("\n");
this.mapHeight = lines.length;
this.mapWidth = Math.max(...lines.map((line) => line.length));
this.map = [];
for (let y = 0; y < this.mapHeight; y++) {
this.map[y] = [];
const line = lines[y] || "";
for (let x = 0; x < this.mapWidth; x++) {
this.map[y][x] = line[x] === "#" ? 1 : 0;
}
}
// Find a starting position (first open space)
for (let y = 1; y < this.mapHeight - 1; y++) {
for (let x = 1; x < this.mapWidth - 1; x++) {
if (this.map[y][x] === 0) {
this.player.pos.x = x + 0.5;
this.player.pos.y = y + 0.5;
return;
}
}
}
}
setupControls() {
const keys = new Set();
const moveSpeed = 1.0;
const rotSpeed = 0.05;
document.addEventListener("keydown", (e) => {
keys.add(e.key.toLowerCase());
});
document.addEventListener("keyup", (e) => {
keys.delete(e.key.toLowerCase());
});
// Movement
setInterval(() => {
// Don't listen to inputs while we're in the middle of an animation
if (this.animation !== null) {
return;
}
if (keys.has("w")) {
TranslationAnimation.execute(this, this.player.pos.plus(this.player.view).sanitized());
return;
}
if (keys.has("s")) {
TranslationAnimation.execute(this, this.player.pos.minus(this.player.view).sanitized());
return;
}
if (keys.has("a")) {
TranslationAnimation.execute(
this,
this.player.pos.plus(this.player.view.turnedLeft()).sanitized(),
);
return;
}
if (keys.has("d")) {
TranslationAnimation.execute(
this,
this.player.pos.plus(this.player.view.turnedRight()).sanitized(),
);
return;
}
if (keys.has("arrowleft")) {
// this.player.view.angle -= rotSpeed;
return;
}
if (keys.has("arrowright")) {
// this.player.view.angle += rotSpeed;
return;
}
}, 1000 / this.fps);
}
isWall(x, y) {
const mapX = Math.floor(x);
const mapY = Math.floor(y);
if (mapX < 0 || mapX >= this.mapWidth || mapY < 0 || mapY >= this.mapHeight) {
return true;
}
return this.map[mapY][mapX] === 1;
}
castRay(angle) {
const rayX = Math.cos(angle);
const rayY = Math.sin(angle);
let distance = 0;
const step = 0.02;
let testX = this.player.pos.x + rayX * distance;
let testY = this.player.pos.y + rayY * distance;
while (distance < this.maxDepth) {
if (this.isWall(testX, testY)) {
return [distance, testX, testY];
}
distance += step;
testX = this.player.pos.x + rayX * distance;
testY = this.player.pos.y + rayY * distance;
}
return [distance, maxX, maxY];
}
render() {
let screen = "";
const halfHeight = this.height / 2;
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const pa = this.player.view.angle();
const rayAngle = pa - this.fov / 2 + (x / this.width) * this.fov;
const [distance, rayX, rayY] = this.castRay(rayAngle);
// Calculate wall height
const wallH = halfHeight / distance;
const ceiling = y < halfHeight - wallH;
const floor = y > halfHeight + wallH;
if (ceiling) {
screen += " ";
continue;
}
if (floor) {
screen += ".";
continue;
}
// Wall
let char = ".";
if (distance < 12) char = "░";
if (distance < 8) char = "▒";
if (distance < 4) char = "▓";
if (distance < 2) char = "█";
screen += char;
}
screen += "\n";
}
this.viewport.textContent = screen;
}
// gameLoop() {
// this.render();
// requestAnimationFrame(() => this.gameLoop());
// }
}
function loadMap() {
game.parseMap(document.getElementById("mapText").value);
}
// Start the game
const game = new DungeonCrawler();
</script>
</body>
</html>

View File

@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cellular Automata Map Generator</title>
<style>
body {
font-family: 'Arial', sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #2c3e50, #3498db);
min-height: 100vh;
color: white;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 15px;
padding: 30px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
h1 {
text-align: center;
margin-bottom: 30px;
font-size: 2.5em;
background: linear-gradient(45deg, #f39c12, #e74c3c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
padding: 20px;
background: rgba(255, 255, 255, 0.05);
border-radius: 10px;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-weight: bold;
color: #ecf0f1;
font-size: 14px;
}
input, select, button {
padding: 10px;
border: none;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
}
input, select {
background: rgba(255, 255, 255, 0.9);
color: #2c3e50;
}
input:focus, select:focus {
outline: none;
box-shadow: 0 0 10px rgba(52, 152, 219, 0.5);
transform: translateY(-2px);
}
button {
background: linear-gradient(45deg, #e74c3c, #f39c12);
color: white;
cursor: pointer;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.3s ease;
}
button:hover {
transform: translateY(-3px);
box-shadow: 0 6px 20px rgba(231, 76, 60, 0.4);
}
button:active {
transform: translateY(0);
}
.canvas-container {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
canvas {
border: 3px solid rgba(255, 255, 255, 0.2);
border-radius: 10px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease;
}
canvas:hover {
transform: scale(1.02);
}
.info {
text-align: center;
margin-top: 20px;
padding: 15px;
background: rgba(46, 204, 113, 0.1);
border-radius: 8px;
border-left: 4px solid #2ecc71;
}
.legend {
display: flex;
justify-content: center;
gap: 30px;
margin-top: 15px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
border: 2px solid rgba(255, 255, 255, 0.3);
}
@keyframes generate {
0% { transform: scale(1); }
50% { transform: scale(0.95); }
100% { transform: scale(1); }
}
.generating {
animation: generate 0.5s ease-in-out;
}
</style>
</head>
<body>
<div class="container">
<h1>Cellular Automata Map Generator</h1>
<div class="controls">
<div class="control-group">
<label for="width">Width:</label>
<input type="number" id="width" value="100" min="20" max="200">
</div>
<div class="control-group">
<label for="height">Height:</label>
<input type="number" id="height" value="80" min="20" max="150">
</div>
<div class="control-group">
<label for="fillPercent">Fill Percent:</label>
<input type="range" id="fillPercent" value="45" min="20" max="80">
<span id="fillValue">45%</span>
</div>
<div class="control-group">
<label for="iterations">Iterations:</label>
<input type="number" id="iterations" value="5" min="1" max="20">
</div>
<div class="control-group">
<label for="wallThreshold">Wall Threshold:</label>
<input type="number" id="wallThreshold" value="4" min="1" max="8">
</div>
<div class="control-group">
<label for="mapType">Map Type:</label>
<select id="mapType">
<option value="cave">Cave System</option>
<option value="island">Island Terrain</option>
<option value="maze">Maze-like</option>
</select>
</div>
<div class="control-group">
<button onclick="generateMap()">Generate Map</button>
</div>
<div class="control-group">
<button onclick="animateGeneration()">Animate Process</button>
</div>
</div>
<div class="canvas-container">
<canvas id="mapCanvas" width="800" height="640"></canvas>
</div>
<div class="legend">
<div class="legend-item">
<div class="legend-color" style="background: #34495e;"></div>
<span>Wall</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #ecf0f1;"></div>
<span>Floor</span>
</div>
<div class="legend-item">
<div class="legend-color" style="background: #3498db;"></div>
<span>Water (Island mode)</span>
</div>
</div>
<div class="info">
<strong>How it works:</strong> Cellular automata uses simple rules applied iteratively. Each cell becomes a wall or floor based on its neighbors.
This creates organic, cave-like structures perfect for game maps!
</div>
</div>
<script>
class CellularAutomataGenerator {
constructor(width, height) {
this.width = width;
this.height = height;
this.map = [];
this.canvas = document.getElementById('mapCanvas');
this.ctx = this.canvas.getContext('2d');
this.cellSize = Math.min(800 / width, 640 / height);
// Adjust canvas size
this.canvas.width = width * this.cellSize;
this.canvas.height = height * this.cellSize;
}
initializeMap(fillPercent, mapType) {
this.map = [];
for (let x = 0; x < this.width; x++) {
this.map[x] = [];
for (let y = 0; y < this.height; y++) {
if (x === 0 || x === this.width - 1 || y === 0 || y === this.height - 1) {
this.map[x][y] = 1; // Border walls
} else {
let fillChance = fillPercent / 100;
if (mapType === 'island') {
// Distance from center affects probability
let centerX = this.width / 2;
let centerY = this.height / 2;
let distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
let maxDistance = Math.sqrt(centerX ** 2 + centerY ** 2);
fillChance *= (1 - distance / maxDistance) * 1.5;
} else if (mapType === 'maze') {
fillChance *= 0.6; // Lower fill for maze-like structures
}
this.map[x][y] = Math.random() < fillChance ? 1 : 0;
}
}
}
}
smoothMap(wallThreshold) {
let newMap = [];
for (let x = 0; x < this.width; x++) {
newMap[x] = [];
for (let y = 0; y < this.height; y++) {
let neighborWalls = this.getNeighborWallCount(x, y);
if (neighborWalls > wallThreshold) {
newMap[x][y] = 1;
} else if (neighborWalls < wallThreshold) {
newMap[x][y] = 0;
} else {
newMap[x][y] = this.map[x][y];
}
}
}
this.map = newMap;
}
getNeighborWallCount(gridX, gridY) {
let wallCount = 0;
for (let neighborX = gridX - 1; neighborX <= gridX + 1; neighborX++) {
for (let neighborY = gridY - 1; neighborY <= gridY + 1; neighborY++) {
if (neighborX >= 0 && neighborX < this.width && neighborY >= 0 && neighborY < this.height) {
if (neighborX !== gridX || neighborY !== gridY) {
wallCount += this.map[neighborX][neighborY];
}
} else {
wallCount++; // Out of bounds counts as wall
}
}
}
return wallCount;
}
drawMap(mapType) {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
for (let x = 0; x < this.width; x++) {
for (let y = 0; y < this.height; y++) {
let color;
if (this.map[x][y] === 1) {
color = '#34495e'; // Wall
} else {
if (mapType === 'island' && this.isNearEdge(x, y, 5)) {
color = '#3498db'; // Water for island edges
} else {
color = '#ecf0f1'; // Floor
}
}
this.ctx.fillStyle = color;
this.ctx.fillRect(x * this.cellSize, y * this.cellSize, this.cellSize, this.cellSize);
// Add subtle border for better visibility
this.ctx.strokeStyle = 'rgba(0,0,0,0.1)';
this.ctx.lineWidth = 0.5;
this.ctx.strokeRect(x * this.cellSize, y * this.cellSize, this.cellSize, this.cellSize);
}
}
}
isNearEdge(x, y, distance) {
return x < distance || x >= this.width - distance ||
y < distance || y >= this.height - distance;
}
async generateWithAnimation(fillPercent, iterations, wallThreshold, mapType) {
this.initializeMap(fillPercent, mapType);
this.drawMap(mapType);
await this.delay(500);
for (let i = 0; i < iterations; i++) {
this.smoothMap(wallThreshold);
this.drawMap(mapType);
await this.delay(300);
}
}
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
let generator;
// Update fill percent display
document.getElementById('fillPercent').addEventListener('input', function() {
document.getElementById('fillValue').textContent = this.value + '%';
});
function generateMap() {
const width = parseInt(document.getElementById('width').value);
const height = parseInt(document.getElementById('height').value);
const fillPercent = parseInt(document.getElementById('fillPercent').value);
const iterations = parseInt(document.getElementById('iterations').value);
const wallThreshold = parseInt(document.getElementById('wallThreshold').value);
const mapType = document.getElementById('mapType').value;
generator = new CellularAutomataGenerator(width, height);
generator.initializeMap(fillPercent, mapType);
for (let i = 0; i < iterations; i++) {
generator.smoothMap(wallThreshold);
}
generator.drawMap(mapType);
// Add generation animation
document.getElementById('mapCanvas').classList.add('generating');
setTimeout(() => {
document.getElementById('mapCanvas').classList.remove('generating');
}, 500);
}
async function animateGeneration() {
const width = parseInt(document.getElementById('width').value);
const height = parseInt(document.getElementById('height').value);
const fillPercent = parseInt(document.getElementById('fillPercent').value);
const iterations = parseInt(document.getElementById('iterations').value);
const wallThreshold = parseInt(document.getElementById('wallThreshold').value);
const mapType = document.getElementById('mapType').value;
generator = new CellularAutomataGenerator(width, height);
await generator.generateWithAnimation(fillPercent, iterations, wallThreshold, mapType);
}
// Generate initial map
window.addEventListener('load', generateMap);
</script>
</body>
</html>

View File

@@ -1,7 +1,6 @@
import { crackdown } from "../utils/crackdown.js";
import { parseArgs } from "../utils/parseArgs.js";
import { MessageType } from "../utils/messages.js";
import { sprintf } from "sprintf-js";
/** Regex to validate if a :help [topic] command i entered correctly */
const helpRegex = /^:help(?:\s+(.*))?$/;
@@ -107,6 +106,7 @@ class MUDClient {
};
this.websocket.onerror = (error) => {
console.log("Websocket error", error);
this.updateStatus("Connection Error", "error");
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
};
@@ -203,7 +203,7 @@ class MUDClient {
let help = helpRegex.exec(inputText);
if (help) {
console.log("here");
help[1] ? this.send(MshType.HELP, help[1].trim()) : this.send(MshType.HELP);
help[1] ? this.send(MessageType.HELP, help[1].trim()) : this.send(MessageType.HELP);
this.echo(inputText);
return;
}

View File

@@ -0,0 +1,573 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ASCII Dungeon Generator</title>
<style>
body {
font-family: "Courier New", monospace;
background-color: #1a1a1a;
color: #00ff00;
margin: 0;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
text-align: center;
color: #00ff00;
text-shadow: 0 0 10px #00ff00;
}
.controls {
text-align: center;
margin-bottom: 20px;
}
button {
background-color: #333;
color: #00ff00;
border: 2px solid #00ff00;
padding: 10px 20px;
margin: 5px;
cursor: pointer;
font-family: "Courier New", monospace;
font-size: 14px;
transition: all 0.3s;
}
button:hover {
background-color: #00ff00;
color: #1a1a1a;
box-shadow: 0 0 10px #00ff00;
}
.dungeon-display {
background-color: #000;
border: 2px solid #00ff00;
padding: 15px;
font-size: 12px;
line-height: 1;
overflow-x: auto;
white-space: pre;
box-shadow: 0 0 20px rgba(0, 255, 0, 0.3);
}
.settings {
display: flex;
justify-content: center;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.setting {
display: flex;
align-items: center;
gap: 5px;
}
input[type="range"] {
background-color: #333;
}
label {
color: #00ff00;
font-size: 14px;
}
.legend {
margin-top: 20px;
padding: 15px;
background-color: #222;
border: 1px solid #444;
border-radius: 5px;
}
.legend h3 {
margin-top: 0;
color: #00ff00;
}
.legend-item {
margin: 5px 0;
}
</style>
</head>
<body>
<div class="container">
<h1>⚔️ ASCII DUNGEON GENERATOR ⚔️</h1>
<div class="settings">
<div class="setting">
<label for="width">Width:</label>
<input type="range" id="width" min="40" max="100" value="60" />
<span id="widthValue">60</span>
</div>
<div class="setting">
<label for="height">Height:</label>
<input type="range" id="height" min="30" max="60" value="40" />
<span id="heightValue">40</span>
</div>
<div class="setting">
<label for="roomCount">Rooms:</label>
<input type="range" id="roomCount" min="5" max="20" value="10" />
<span id="roomCountValue">10</span>
</div>
</div>
<div class="controls">
<button onclick="generateDungeon()">Generate New Dungeon</button>
<button onclick="downloadDungeon()">Download as Text</button>
</div>
<div class="dungeon-display" id="dungeonDisplay"></div>
<div class="legend">
<h3>Legend:</h3>
<div class="legend-item"><strong>#</strong> - Wall</div>
<div class="legend-item"><strong>.</strong> - Floor</div>
<div class="legend-item"><strong>+</strong> - Door</div>
<div class="legend-item"><strong>@</strong> - Player Start</div>
<div class="legend-item"><strong>$</strong> - Treasure</div>
<div class="legend-item"><strong>!</strong> - Monster</div>
<div class="legend-item"><strong>^</strong> - Trap</div>
</div>
</div>
<script>
class Tile {
static FLOOR = new Tile(" ", true);
static RESERVED = new Tile(" ", true);
static PILLAR = new Tile("◯", false);
// static TRAP = new Tile("◡", true);
static TRAP = new Tile("☠", true);
static MONSTER = new Tile("!", true);
static WALL = new Tile("#", false);
static PLAYER_START = new Tile("@", true);
static DOOR = new Tile("░", true);
/**
* @param {string} the utf-8 character that symbolizes this tile on the map
* @param {boolean} walkable can adventurers walk on this tile
*/
constructor(symbol, walkable = false) {
this.symbol = symbol;
this.walkable = walkable;
}
toString() {
return this.symbol;
}
}
class DungeonGenerator {
constructor(width, height, roomCount) {
this.width = width;
this.height = height;
this.roomCount = roomCount;
this.grid = [];
this.rooms = [];
this.corridors = [];
}
generate() {
this.initializeGrid();
this.generateRooms();
this.connectRooms();
this.addDoors();
this.addPillarsToBigRooms();
this.addFeatures();
this.checkAccessibility();
return this.gridToString();
}
initializeGrid() {
this.grid = Array(this.height)
.fill()
.map(() => Array(this.width).fill(Tile.WALL));
}
generateRooms() {
this.rooms = [];
const maxAttempts = this.roomCount * 10;
let attempts = 0;
while (this.rooms.length < this.roomCount && attempts < maxAttempts) {
const room = this.generateRoom();
if (room && !this.roomOverlaps(room)) {
this.rooms.push(room);
this.carveRoom(room);
}
attempts++;
}
}
generateRoom() {
const minSize = 4;
const maxSize = Math.min(12, Math.floor(Math.min(this.width, this.height) / 4));
const width = this.random(minSize, maxSize);
const height = this.random(minSize, maxSize);
const x = this.random(1, this.width - width - 1);
const y = this.random(1, this.height - height - 1);
return { x, y, width, height };
}
roomOverlaps(newRoom) {
return this.rooms.some(
(room) =>
newRoom.x < room.x + room.width + 2 &&
newRoom.x + newRoom.width + 2 > room.x &&
newRoom.y < room.y + room.height + 2 &&
newRoom.y + newRoom.height + 2 > room.y,
);
}
carveRoom(room) {
for (let y = room.y; y < room.y + room.height; y++) {
for (let x = room.x; x < room.x + room.width; x++) {
this.grid[y][x] = Tile.FLOOR;
}
}
}
connectRooms() {
if (this.rooms.length < 2) return;
// Connect each room to at least one other room
for (let i = 1; i < this.rooms.length; i++) {
const roomA = this.rooms[i - 1];
const roomB = this.rooms[i];
this.createCorridor(roomA, roomB);
}
// Add some extra connections for more interesting layouts
const extraConnections = Math.floor(this.rooms.length / 3);
for (let i = 0; i < extraConnections; i++) {
const roomA = this.rooms[this.random(0, this.rooms.length - 1)];
const roomB = this.rooms[this.random(0, this.rooms.length - 1)];
if (roomA !== roomB) {
this.createCorridor(roomA, roomB);
}
}
}
createCorridor(roomA, roomB) {
const startX = Math.floor(roomA.x + roomA.width / 2);
const startY = Math.floor(roomA.y + roomA.height / 2);
const endX = Math.floor(roomB.x + roomB.width / 2);
const endY = Math.floor(roomB.y + roomB.height / 2);
// Create L-shaped corridor
if (Math.random() < 0.5) {
// Horizontal first, then vertical
this.carveLine(startX, startY, endX, startY);
this.carveLine(endX, startY, endX, endY);
} else {
// Vertical first, then horizontal
this.carveLine(startX, startY, startX, endY);
this.carveLine(startX, endY, endX, endY);
}
}
carveLine(x1, y1, x2, y2) {
const dx = Math.sign(x2 - x1);
const dy = Math.sign(y2 - y1);
let x = x1;
let y = y1;
while (x !== x2 || y !== y2) {
if (x >= 0 && x < this.width && y >= 0 && y < this.height) {
this.grid[y][x] = Tile.FLOOR;
}
if (x !== x2) x += dx;
if (y !== y2 && x === x2) y += dy;
}
// Ensure endpoint is carved
if (x2 >= 0 && x2 < this.width && y2 >= 0 && y2 < this.height) {
this.grid[y2][x2] = Tile.FLOOR;
}
}
addDoors() {
this.rooms.forEach((room) => {
const doors = [];
// Check each wall of the room for potential doors
for (let x = room.x; x < room.x + room.width; x++) {
// Top wall
if (
room.y > 0 &&
this.grid[room.y - 1][x] === Tile.FLOOR &&
this.grid[room.y][x] === Tile.FLOOR
) {
doors.push({ x, y: room.y });
}
// Bottom wall
if (
room.y + room.height < this.height &&
this.grid[room.y + room.height][x] === Tile.FLOOR &&
this.grid[room.y + room.height - 1][x] === Tile.FLOOR
) {
doors.push({ x, y: room.y + room.height - 1 });
}
}
for (let y = room.y; y < room.y + room.height; y++) {
// Left wall
if (
room.x > 0 &&
this.grid[y][room.x - 1] === Tile.FLOOR &&
this.grid[y][room.x] === Tile.FLOOR
) {
doors.push({ x: room.x, y });
}
// Right wall
if (
room.x + room.width < this.width &&
this.grid[y][room.x + room.width] === Tile.FLOOR &&
this.grid[y][room.x + room.width - 1] === Tile.FLOOR
) {
doors.push({ x: room.x + room.width - 1, y });
}
}
// Add a few doors randomly
doors.forEach((door) => {
if (Math.random() < 0.3) {
this.grid[door.y][door.x] = Tile.DOOR;
}
});
});
}
addPillarsToBigRooms() {
const walkabilityCache = [];
let i = 0;
for (let y = 1; y < this.height - 1; y++) {
//
for (let x = 1; x < this.width - 1; x++) {
i++;
const cell = this.grid[y][x];
if (!cell) {
console.log("out of bounds [%d, %d] (%s)", x, y, typeof cell);
continue;
}
if (this.grid[y][x].walkable) {
walkabilityCache.push([i, x, y]);
}
}
}
const shuffle = (arr) => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // random index 0..i
[arr[i], arr[j]] = [arr[j], arr[i]]; // swap
}
return arr;
};
shuffle(walkabilityCache);
for (let [i, x, y] of walkabilityCache) {
const walkable = (offsetX, offsetY) => {
const c = this.grid[y + offsetY][x + offsetX];
return c.walkable;
};
const surroundingFloorCount =
0 +
// top row ------------|-----------
walkable(-1, -1) + // | north west
walkable(+0, -1) + // | north
walkable(+1, -1) + // | north east
// middle row ---------|-----------
walkable(-1, +0) + // | west
// | self
walkable(+1, +0) + // | east
// bottom row ---------|-----------
walkable(-1, +1) + // | south west
walkable(+0, +1) + // | south
walkable(+1, +1); // | south east
// ----------------------------|-----------
if (surroundingFloorCount === 8) {
this.grid[y][x] = Tile.PILLAR;
continue;
}
if (surroundingFloorCount >= 7) {
this.grid[y][x] = Tile.WALL;
}
}
}
// Check that all rooms are accessibly from the start
checkAccessibility() {
let playerStartIdx;
let walkableTileCount = 0;
const walkabilityCache = [];
// Create a flat linear version of the grid, consisting
// only of booleans: true of passable, false if obstacle.
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
const cell = this.grid[y][x];
const isObstacle = cell === Tile.WALL || cell === Tile.PILLAR;
if (cell === Tile.PLAYER_START) {
playerStartIdx = walkabilityCache.length;
}
if (!isObstacle) {
walkableTileCount++;
walkabilityCache.push(true);
} else {
walkabilityCache.push(false);
}
}
}
const toXy = (idx) => {
return [idx % this.width, Math.floor(idx / this.width)];
};
const stack = [playerStartIdx];
/** @type {Set} */
const visited = new Set();
while (stack.length > 0) {
const idx = stack.pop();
if (!walkabilityCache[idx]) {
continue;
}
if (visited.has(idx)) {
continue;
}
visited.add(idx);
// Add neighbors
const [x, y] = toXy(idx);
const [minX, minY] = [1, 1];
const maxX = this.width - 2;
const maxY = this.height - 2;
if (y >= minY) stack.push(idx - this.width); // up
if (y <= maxY) stack.push(idx + this.width); // down
if (x >= minX) stack.push(idx - 1); // left
if (x <= maxX) stack.push(idx + 1); // right
}
if (visited.size !== walkableTileCount) {
console.log(
"unpassable! There are %d floor tiles, but the player can only visit %d of them",
walkableTileCount,
visited.size,
);
}
}
//
//
addFeatures() {
const floorTiles = [];
for (let y = 0; y < this.height; y++) {
for (let x = 0; x < this.width; x++) {
if (this.grid[y][x] === Tile.FLOOR) {
floorTiles.push({ x, y });
}
}
}
if (floorTiles.length === 0) return;
// Add player start
const playerStart = floorTiles[this.random(0, floorTiles.length - 1)];
this.grid[playerStart.y][playerStart.x] = "@";
// Add treasures
const treasureCount = Math.min(3, Math.floor(this.rooms.length / 2));
for (let i = 0; i < treasureCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.grid[pos.y][pos.x] === Tile.FLOOR) {
this.grid[pos.y][pos.x] = "$";
}
}
// Add monsters
const monsterCount = Math.min(5, this.rooms.length);
for (let i = 0; i < monsterCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.grid[pos.y][pos.x] === Tile.FLOOR) {
this.grid[pos.y][pos.x] = Tile.MONSTER;
}
}
// Add traps
const trapCount = Math.floor(floorTiles.length / 30);
for (let i = 0; i < trapCount; i++) {
const pos = floorTiles[this.random(0, floorTiles.length - 1)];
if (this.grid[pos.y][pos.x] === Tile.FLOOR) {
this.grid[pos.y][pos.x] = Tile.TRAP;
}
}
}
gridToString() {
return this.grid.map((row) => row.join("")).join("\n");
}
random(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
}
let currentDungeon = "";
function generateDungeon() {
const width = parseInt(document.getElementById("width").value);
const height = parseInt(document.getElementById("height").value);
const roomCount = parseInt(document.getElementById("roomCount").value);
const generator = new DungeonGenerator(width, height, roomCount);
currentDungeon = generator.generate();
document.getElementById("dungeonDisplay").textContent = currentDungeon;
}
function downloadDungeon() {
if (!currentDungeon) {
generateDungeon();
}
const blob = new Blob([currentDungeon], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "dungeon_map.txt";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// Update slider value displays
document.getElementById("width").addEventListener("input", function () {
document.getElementById("widthValue").textContent = this.value;
});
document.getElementById("height").addEventListener("input", function () {
document.getElementById("heightValue").textContent = this.value;
});
document.getElementById("roomCount").addEventListener("input", function () {
document.getElementById("roomCountValue").textContent = this.value;
});
// Generate initial dungeon
generateDungeon();
</script>
</body>
</html>

View File

@@ -28,11 +28,10 @@
<h3>Tools</h3>
<button id="drawBtn" class="active" onclick="painter.setTool('draw')">Draw</button>
<button id="fillBtn" onclick="painter.setTool('fill')">Fill</button>
<button onclick="painter.toggleDrawingMode()" id="drawModeBtn">Toggle Drawing Mode</button>
</div>
<div class="tool-group">
<button onclick="painter.clearCanvas()">Clear All</button>
<button onclick="painter.reset()">Reset</button>
<button onclick="painter.randomFill()">Random Fill</button>
<button onclick="painter.invertColors()">Invert</button>
</div>

View File

@@ -1,18 +1,17 @@
import { sprintf } from "sprintf-js";
import { Xorshift32 } from "../utils/random.js";
import { WfcGrid } from "./WfcGrid.js";
import { TrainingCell } from "./TrainingCell.js";
import { TrainingGrid } from "./TrainingGrid.js";
import { SourceCell } from "./SourceCell.js";
import { SourceGrid } from "./SourceGrid.js";
class PainApp {
/** @type {string} */
activeColor = "#000";
/** @type {string} */
currentTool = "draw";
class PainterApp {
/** @type {number} The index of the color we're currently painting with */
toolPaletteIndex = 0;
/** @type {string} Mode. Draw or fill */
mode = "draw";
/** @type {boolean} */
isDrawing = false;
/** @type {boolean} */
drawingMode = false;
/**@param {string[]} pal */
constructor(dim, pal, gridElement, paletteElement, previewElement) {
@@ -20,67 +19,65 @@ class PainApp {
this.dim = dim;
/** @type {string[]} Default color palette */
this.palette = pal;
/** @type {string[]} */
this.samplePixels = new Array(dim ** 2).fill(pal[pal.length - 1]);
/** @type {HTMLElement} */
this.gridElement = gridElement;
/** @type {HTMLElement} */
this.previewElement = previewElement;
/** @type {HTMLElement} */
this.paletteElement = paletteElement;
/** @type {HTMLInputElement} */
this.trainingImage = new TrainingGrid(
this.samplePixels.map(() => {
return new TrainingCell();
}),
);
this.createGrid();
this.createColorPalette();
this.updatePreview();
this.setActiveColor(pal[0]);
this.updateTrainingGrid();
this.reset();
}
createGrid() {
reset() {
// Assume the "background" color is always the last color in the palette.
const fillWith = 0;
this.sourceGrid = new SourceGrid(
Array(this.dim ** 2)
.fill(null)
.map(() => new SourceCell(new Uint8Array(9).fill(fillWith))),
);
this.createGridHtmlElements();
this.createPaletteSwatch();
this.updatePreview();
this.setToolPaletteIndex(1);
this.updateSourceGrid();
}
createGridHtmlElements() {
this.gridElement.innerHTML = "";
for (let i = 0; i < this.dim ** 2; i++) {
const pixel = document.createElement("div");
pixel.className = "pixel";
pixel.setAttribute("data-index", i);
pixel.style.backgroundColor = this.samplePixels[i];
pixel.className = `pal-idx-${this.getCell(i)}`;
pixel.setAttribute("id", "cell-idx-" + i);
pixel.addEventListener("mousedown", (e) => this.startDrawing(e, i));
pixel.addEventListener("mouseenter", (e) => this.continueDrawing(e, i));
pixel.addEventListener("mouseup", () => this.stopDrawing());
pixel.addEventListener("mousedown", (e) => this.mouseDown(e, i));
this.gridElement.appendChild(pixel);
}
// Prevent context menu and handle mouse events
this.gridElement.addEventListener("contextmenu", (e) => e.preventDefault());
document.addEventListener("mouseup", () => this.stopDrawing());
}
createColorPalette() {
createPaletteSwatch() {
this.paletteElement.innerHTML = "";
this.palette.forEach((color, paletteIndex) => {
const swatch = document.createElement("div");
swatch.classList.add("color-swatch");
swatch.classList.add(`pal-idx-${paletteIndex}`);
swatch.classList.add(`pal-color-${color}`);
swatch.style.backgroundColor = color;
swatch.onclick = () => this.setActiveColor(paletteIndex);
swatch.onclick = () => this.setToolPaletteIndex(paletteIndex);
this.paletteElement.appendChild(swatch);
});
}
setActiveColor(paletteIndex) {
setToolPaletteIndex(paletteIndex) {
//
this.activeColor = this.palette[paletteIndex];
this.toolPaletteIndex = paletteIndex;
const colorSwatches = this.paletteElement.querySelectorAll(".color-swatch");
colorSwatches.forEach((swatch) => {
@@ -90,7 +87,7 @@ class PainApp {
}
setTool(tool) {
this.currentTool = tool;
this.mode = tool;
document.querySelectorAll(".tools button").forEach((btn) => btn.classList.remove("active"));
document.getElementById(tool + "Btn").classList.add("active");
@@ -105,71 +102,56 @@ class PainApp {
}
}
toggleDrawingMode() {
this.drawingMode = !this.drawingMode;
const btn = document.getElementById("drawModeBtn");
if (this.drawingMode) {
btn.textContent = "Drawing Mode: ON";
btn.classList.add("drawing-mode");
document.getElementById("status").textContent = "Drawing mode ON - Click and drag to paint";
} else {
btn.textContent = "Drawing Mode: OFF";
btn.classList.remove("drawing-mode");
document.getElementById("status").textContent = "Drawing mode OFF - Click individual pixels";
}
}
startDrawing(e, index) {
mouseDown(e, index) {
e.preventDefault();
this.isDrawing = true;
this.applyTool(index);
}
/** @param {MouseEvent} e */
continueDrawing(e, index) {
if (this.isDrawing && this.drawingMode) {
this.applyTool(index);
return;
getCell(idx, y = undefined) {
if (y === undefined) {
return this.sourceGrid.cells[idx].value;
}
this.updateTrainingCell(index);
this.updatePreview(index);
}
stopDrawing() {
this.isDrawing = false;
// Treat idx as an x-coordinate, and calculate an index
return this.sourceGrid.cells[y * this.dim + idx].value;
}
applyTool(index) {
switch (this.currentTool) {
switch (this.mode) {
case "draw":
this.setPixel(index, this.activeColor);
this.setPixel(index, this.toolPaletteIndex);
break;
case "fill":
this.floodFill(index, this.samplePixels[index], this.activeColor);
this.floodFill(index, this.toolPaletteIndex);
break;
}
}
setPixel(index, color) {
this.samplePixels[index] = color;
const pixel = document.querySelector(`[data-index="${index}"]`);
pixel.style.backgroundColor = color;
setPixel(cellIdx, palIdx) {
const pixEl = document.getElementById("cell-idx-" + cellIdx);
this.sourceGrid.cells[cellIdx].value = palIdx;
pixEl.className = "pal-idx-" + palIdx;
this.updateSourceCell(cellIdx);
this.updatePreview();
}
floodFill(startIndex, targetColor, fillColor) {
if (targetColor === fillColor) return;
floodFill(startIndex, fillColorPalIdx) {
const targetPalIdx = this.getCell(startIndex);
if (targetPalIdx === fillColorPalIdx) {
return;
}
const stack = [startIndex];
const visited = new Set();
while (stack.length > 0) {
const index = stack.pop();
if (visited.has(index) || this.samplePixels[index] !== targetColor) continue;
if (visited.has(index) || this.getCell(index) !== targetPalIdx) continue;
visited.add(index);
this.setPixel(index, fillColor);
this.setPixel(index, fillColorPalIdx);
// Add neighbors
const row = Math.floor(index / this.dim);
@@ -182,113 +164,80 @@ class PainApp {
}
}
clearCanvas() {
this.samplePixels.fill("#fff");
this.createGrid();
this.updatePreview();
}
randomFill() {
for (let i = 0; i < this.dim ** 2; i++) {
const randomColor = this.palette[Math.floor(Math.random() * this.palette.length)];
this.setPixel(i, randomColor);
this.setPixel(i, Math.floor(Math.random() * this.palette.length));
}
}
invertColors() {
for (let i = 0; i < this.dim ** 2; i++) {
const color = this.samplePixels[i];
const r = 15 - parseInt(color.substr(1, 1), 16);
const g = 15 - parseInt(color.substr(2, 1), 16);
const b = 15 - parseInt(color.substr(3, 1), 16);
const inverted =
"#" +
r.toString(16) + // red
g.toString(16) + // green
b.toString(16); // blue
const cell = this.getCell(i);
const inverted = cell % 2 === 0 ? cell + 1 : cell - 1;
this.setPixel(i, inverted);
if (i % 10 === 0) {
console.log("invertion", {
color,
r,
g,
b,
inverted,
});
}
}
this.setToolPaletteIndex(
this.toolPaletteIndex % 2 === 0 ? this.toolPaletteIndex + 1 : this.toolPaletteIndex - 1,
);
}
updatePreview(subImageIdx = undefined) {
updatePreview() {
const canvas = document.createElement("canvas");
if (subImageIdx === undefined) {
canvas.width = this.dim;
canvas.height = this.dim;
const ctx = canvas.getContext("2d");
canvas.width = this.dim;
canvas.height = this.dim;
const ctx = canvas.getContext("2d");
for (let i = 0; i < this.dim ** 2; i++) {
const x = i % this.dim;
const y = Math.floor(i / this.dim);
ctx.fillStyle = this.samplePixels[i];
ctx.fillRect(x, y, 1, 1);
}
} else {
canvas.width = 3;
canvas.height = 3;
const ctx = canvas.getContext("2d");
for (let i = 0; i < 3 * 3; i++) {
//
const x = i % 3;
const y = Math.floor(i / 3);
ctx.fillStyle = this.trainingImage.pixels[subImageIdx].subPixels[i];
ctx.fillRect(x, y, 1, 1);
}
for (let i = 0; i < this.dim ** 2; i++) {
const x = i % this.dim;
const y = Math.floor(i / this.dim);
ctx.fillStyle = this.palette[this.getCell(i)];
ctx.fillRect(x, y, 1, 1);
}
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
this.previewElement.style.backgroundSize = "100%";
return;
}
updateTrainingGrid() {
for (let i = 0; i < this.samplePixels.length; i++) {
this.updateTrainingCell(i);
updateSourceGrid() {
for (let i = 0; i < this.dim ** 2; i++) {
this.updateSourceCell(i);
}
}
updateTrainingCell(i) {
/** @param {number} i */
updateSourceCell(idx) {
const dim = this.dim;
const x = i % dim;
const y = Math.floor(i / dim);
const x = idx % dim;
const y = Math.floor(idx / dim);
const colorAt = (dX, dY) => {
const valueAt = (dX, dY) => {
const _x = (x + dim + dX) % dim; // add dim before modulo because JS modulo allows negative results
const _y = (y + dim + dY) % dim;
return this.samplePixels[_y * dim + _x];
return this.getCell(_y * dim + _x);
};
this.trainingImage.pixels[i] = new TrainingCell([
this.sourceGrid.cells[idx].values = new Uint8Array([
// | neighbour
// ---------------------|-----------
colorAt(-1, -1), // | northwest
colorAt(0, -1), // | north
colorAt(1, -1), // | northeast
valueAt(-1, -1), // | northwest
valueAt(0, -1), // | north
valueAt(1, -1), // | northeast
colorAt(-1, 0), // | east
this.samplePixels[i], //| -- self --
colorAt(1, 0), // | west
valueAt(-1, 0), // | east
this.getCell(idx), // | -- self --
valueAt(1, 0), // | west
colorAt(-1, 1), // | southwest
colorAt(0, 1), // | south
colorAt(1, 1), // | southeast
valueAt(-1, 1), // | southwest
valueAt(0, 1), // | south
valueAt(1, 1), // | southeast
]);
}
exportAsImage() {
const canvas = document.createElement("canvas");
canvas.width = this.dim; // 9x upscale
canvas.width = this.dim;
canvas.height = this.dim;
const ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = false;
@@ -296,7 +245,7 @@ class PainApp {
for (let i = 0; i < this.dim ** 2; i++) {
const x = i % this.dim;
const y = Math.floor(i / this.dim);
ctx.fillStyle = this.samplePixels[i];
ctx.fillStyle = this.palette[this.getCell(i)];
ctx.fillRect(x, y, 1, 1);
}
@@ -307,7 +256,7 @@ class PainApp {
}
exportAsData() {
const data = JSON.stringify(this.samplePixels);
const data = Array.from({ length: this.dim ** 2 }, (_, i) => this.getCell(i));
const blob = new Blob([data], { type: "application/json" });
const link = document.createElement("a");
link.download = "pixel-art-data.json";
@@ -326,13 +275,15 @@ class PainApp {
reader.onload = (event) => {
try {
const data = JSON.parse(event.target.result);
if (Array.isArray(data) && data.length === this.dim ** 2) {
this.samplePixels = data;
this.createGrid();
this.updatePreview();
} else {
if (!Array.isArray(data) && data.length === this.dim ** 2) {
alert("Invalid data format!");
}
data.forEach((v, k) => {
this.setPixel(k, v);
});
this.createGridHtmlElements();
this.updatePreview();
this.updateSourceGrid();
} catch (error) {
alert("Error reading file!" + error);
}
@@ -344,50 +295,50 @@ class PainApp {
}
waveFunction() {
this.updateTrainingGrid();
this.updateSourceGrid();
const wfcImg = new WfcGrid(
// this.previewElement.clientWidth,
// this.previewElement.clientHeight,
30,
30,
this.trainingImage.clone(),
10,
10,
this.sourceGrid.clone(),
new Xorshift32(Date.now()),
);
// Could not "collapse" the image.
// We should reset and try again?
let its = wfcImg.collapse();
let running = true;
let count = 0;
const maxCount = 1000;
if (its > 0) {
throw new Error(`Function Collapse failed with ${its} iterations left to go`);
}
const collapseFunc = () => {
running = wfcImg.collapse();
const canvas = document.createElement("canvas");
canvas.width = wfcImg.width;
canvas.height = wfcImg.height;
const canvas = document.createElement("canvas");
canvas.width = wfcImg.width;
canvas.height = wfcImg.height;
// debug values
canvas.width = 30;
canvas.height = 30;
//
const ctx = canvas.getContext("2d");
let i = 0;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
console.log("pix");
const cell = wfcImg.cells[i++];
if (cell.valid) {
ctx.fillStyle = "magenta";
const ctx = canvas.getContext("2d");
let i = 0;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const cell = wfcImg.cells[i++];
ctx.fillStyle = cell.value;
ctx.fillRect(x, y, 1, 1);
}
}
}
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
this.previewElement.style.backgroundSize = "100%";
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
this.previewElement.style.backgroundSize = "100%";
if (running && ++count < maxCount) {
setTimeout(collapseFunc, 1);
}
};
collapseFunc();
}
}
const base_palette = [
"#000",
"#FFF",
"#007",
"#00F",
"#070",
@@ -403,9 +354,9 @@ const base_palette = [
"#FF0",
];
const palette = new Array(base_palette.length * 2);
const palette = new Array();
base_palette.forEach((color, idx) => {
base_palette.forEach((color) => {
//
// Calc inverted color
const invR = 15 - Number.parseInt(color.substr(1, 1), 16);
@@ -414,11 +365,11 @@ base_palette.forEach((color, idx) => {
const invColor = sprintf("#%x%x%x", invR, invG, invB);
// populate the palette
palette[idx] = color;
palette[7 * 4 - 1 - idx] = invColor;
palette.push(color);
palette.push(invColor);
});
window.painter = new PainApp(
window.painter = new PainterApp(
9,
palette,
document.getElementById("gridContainer"), //
@@ -426,5 +377,33 @@ window.painter = new PainApp(
document.getElementById("preview"), //
);
// share window.dim with the HTML and CSS
// ____ ____ ____
// / ___/ ___/ ___|
// | | \___ \___ \
// | |___ ___) |__) |
// \____|____/____/
//--------------------
//
// share the dimensions of the SourceGrid with CSS/HTML
document.getElementsByTagName("body")[0].style.setProperty("--dim", window.painter.dim);
//
// --------------------------------------
// Add the palette colors as CSS classes
// --------------------------------------
const styleElement = document.createElement("style");
styleElement.type = "text/css";
let cssRules = "";
palette.forEach((color, index) => {
const className = `pal-idx-${index}`;
cssRules += `.${className} { background-color: ${color} !important; }\n`;
});
// Add the CSS to the style element
styleElement.innerHTML = cssRules;
// Append to head
document.head.appendChild(styleElement);

View File

@@ -7,7 +7,6 @@
* Serializing this object effectively saves the game.
*/
import { Config } from "../config.js";
import { isIdSane, miniUid } from "../utils/id.js";
import { Xorshift32 } from "../utils/random.js";
import { Character } from "./character.js";

View File

@@ -1,13 +1,13 @@
{
"hash": "b248c40f",
"hash": "a9cc42de",
"configHash": "86a557ed",
"lockfileHash": "772b6e1c",
"browserHash": "37f1288b",
"lockfileHash": "56518f4e",
"browserHash": "f6412460",
"optimized": {
"sprintf-js": {
"src": "../../sprintf-js/src/sprintf.js",
"file": "sprintf-js.js",
"fileHash": "039885aa",
"fileHash": "41b15421",
"needsInterop": true
}
},

View File

@@ -30,13 +30,8 @@ export class AuthenticationScene extends Scene {
this.session.player = this.player;
this.session.sendText(["= Success!", "((but I don't know what to do now...))"]);
return;
if (this.player.admin) {
this.session.setScene("new AdminJustLoggedInScene");
} else {
this.session.setScene("new JustLoggedInScene");
}
this.session.setScene("new JustLoggedInScene");
}
/**

View File

@@ -38,7 +38,7 @@ export class PasswordPrompt extends Prompt {
//
// Block users who enter bad passwords too many times.
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
this.blockedUntil = Date.now() + Config.accountLockoutSeconds;
this.blockedUntil = Date.now() + Config.accountLockoutSeconds * 1000;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}
@@ -49,7 +49,7 @@ export class PasswordPrompt extends Prompt {
if (this.player.blockedUntil > Date.now()) {
//
// Try to re-login too soon, and your lockout lasts longer.
this.blockedUntil += Config.accountLockoutSeconds;
this.blockedUntil += Config.accountLockoutSeconds * 1000;
this.calamity("You have been locked out for too many failed password attempts, come back later");
return;
}

View File

@@ -1,4 +1,3 @@
import { ItemBlueprint } from "../models/item.js";
import { gGame } from "../models/globals.js";
//
@@ -12,7 +11,6 @@ import { gGame } from "../models/globals.js";
// Seed the Game.ItemBlueprint store
export class ItemSeeder {
seed() {
//
// __ __
// \ \ / /__ __ _ _ __ ___ _ __ ___
// \ \ /\ / / _ \/ _` | '_ \ / _ \| '_ \/ __|
@@ -54,7 +52,6 @@ export class ItemSeeder {
specialEffect: "TBD",
});
//
// _
// / \ _ __ _ __ ___ ___ _ __ ___
// / _ \ | '__| '_ ` _ \ / _ \| '__/ __|
@@ -76,7 +73,6 @@ export class ItemSeeder {
armorHitPoints: 6,
});
//
// _ ___ _
// | |/ (_) |_ ___
// | ' /| | __/ __|

View File

@@ -64,6 +64,9 @@ export class Xorshift32 {
* @returns {T} One element from the array. * @return {<T>}
*/
randomElement(arr) {
if (arr instanceof Set) {
arr = [...arr];
}
const idx = this.lowerThan(arr.length);
return arr[idx];