vyy
This commit is contained in:
@@ -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,
|
|
||||||
});
|
|
||||||
657
frontend/3d_dungeon_crawler.html
Normal file
657
frontend/3d_dungeon_crawler.html
Normal 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> </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 = " ";
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -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
|
* 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;
|
* 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 {
|
export class SourceCell {
|
||||||
/** @param {Uint8Array} values The 9 sub cells that make up this TrainingCell */
|
/** @param {Uint8Array?} values The 9 sub cells that make up this SourceCell */
|
||||||
constructor() {
|
constructor(values) {
|
||||||
|
if (values === undefined) {
|
||||||
this.values = new Uint8Array(9);
|
this.values = new Uint8Array(9);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.values = values;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {string} The actual value of this Trainin gCell is represented by its center value */
|
/** @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
|
* @param {number} direction
|
||||||
*
|
*
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
@@ -26,10 +41,9 @@ export class TrainingCell {
|
|||||||
potentialNeighbours(other, direction) {
|
potentialNeighbours(other, direction) {
|
||||||
// sadly, we're not allowed to be friends with ourselves.
|
// sadly, we're not allowed to be friends with ourselves.
|
||||||
if (this === other) {
|
if (this === other) {
|
||||||
console.log("WTF were checking to be friends with ourselves!", this, other, direction);
|
console.log("WTF were checking to be friends with ourselves!", { _this: this, other, direction });
|
||||||
return false;
|
// throw new Error("WTF were checking to be friends with ourselves!", { _this: this, other, direction });
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
//
|
//
|
||||||
// if they want to live to my east,
|
// if they want to live to my east,
|
||||||
@@ -69,7 +83,7 @@ export class TrainingCell {
|
|||||||
// my north row must match their middle row
|
// my north row must match their middle row
|
||||||
this.values[Direction.NW] === other.values[Direction.W] &&
|
this.values[Direction.NW] === other.values[Direction.W] &&
|
||||||
this.values[Direction.N] === other.values[Direction.C] &&
|
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,
|
// if they want to live to my south,
|
||||||
// their two northern rows must match
|
// their two northern rows must match
|
||||||
@@ -78,7 +92,7 @@ export class TrainingCell {
|
|||||||
// my middle row must match their north row
|
// my middle row must match their north row
|
||||||
this.values[Direction.W] === other.values[Direction.NW] &&
|
this.values[Direction.W] === other.values[Direction.NW] &&
|
||||||
this.values[Direction.C] === other.values[Direction.N] &&
|
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
|
// my south row must match their middle row
|
||||||
this.values[Direction.SW] === other.values[Direction.W] &&
|
this.values[Direction.SW] === other.values[Direction.W] &&
|
||||||
this.values[Direction.S] === other.values[Direction.C] &&
|
this.values[Direction.S] === other.values[Direction.C] &&
|
||||||
42
frontend/SourceGrid.js
Executable file
42
frontend/SourceGrid.js
Executable 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TrainingCell } from "./TrainingCell";
|
import { SourceCell } from "./SourceCell";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a single cell in a WfcGrid
|
* 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} i index in the cell-array of this cell
|
||||||
* @param {number} x x-coordinate of cell
|
* @param {number} x x-coordinate of cell
|
||||||
* @param {number} y y-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) {
|
constructor(i, x, y, options) {
|
||||||
if (!options.length) {
|
if (!options.length) {
|
||||||
@@ -17,7 +17,7 @@ export class WfcCell {
|
|||||||
throw Error("Bad >>options<< arg in WfcCell constructor. Must not be empty.", options);
|
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);
|
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);
|
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() {
|
getEntropy() {
|
||||||
const result = this.options.length;
|
return this.options.length;
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get lockedIn() {
|
get lockedIn() {
|
||||||
return this.getEntropy() === 1;
|
return this.options.length === 1;
|
||||||
}
|
|
||||||
|
|
||||||
get valid() {
|
|
||||||
return this.options.length > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get value() {
|
get value() {
|
||||||
if (this.options[0] === undefined) {
|
return this.options.length === 1 ? this.options[0].value : 0;
|
||||||
throw new Error("Bad! I do not have any options, and therefore no color");
|
|
||||||
}
|
|
||||||
return this.options[0].value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
frontend/WfcConstants.js
Executable file
21
frontend/WfcConstants.js
Executable 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",
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Direction } from "./WfcConstants.js";
|
import { Direction } from "./WfcConstants.js";
|
||||||
import { WfcCell } from "./WfcCell.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.
|
* 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} w width (in cells)
|
||||||
* @param {number} h height (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
|
* @type {Xorshift32} pre-seeded pseudo random number generator
|
||||||
*/
|
*/
|
||||||
constructor(w, h, trainingGrid, rng) {
|
constructor(w, h, sourceGrid, rng) {
|
||||||
|
/** @type {number} */
|
||||||
this.width = w;
|
this.width = w;
|
||||||
|
/** @type {number} */
|
||||||
this.height = h;
|
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;
|
this.rng = rng;
|
||||||
|
|
||||||
//
|
/** @type {WfcCell[]} */
|
||||||
// Populate the cells so each has all available options
|
this.cells = [];
|
||||||
// For now, this means *copying* all TrainingCell options into each cell
|
|
||||||
this.reset();
|
this.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,82 +37,89 @@ export class WfcGrid {
|
|||||||
console.log("Resetting Cells");
|
console.log("Resetting Cells");
|
||||||
const [w, h] = [this.width, this.height];
|
const [w, h] = [this.width, this.height];
|
||||||
const len = w * h;
|
const len = w * h;
|
||||||
this.cells = new Array(len);
|
this.cells = [];
|
||||||
for (let i = 0; i < len; i++) {
|
for (let i = 0; i < len; i++) {
|
||||||
const x = i % w;
|
const x = i % w;
|
||||||
const y = Math.floor(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");
|
console.log("Done");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the cells that currently have the lowest entropy
|
* Get the cells that currently have the lowest entropy
|
||||||
* @returns {number[]}
|
|
||||||
*/
|
*/
|
||||||
cellsIdsWithLowestEntropy() {
|
refreshLowEntropyCellIdCache() {
|
||||||
console.log("Finding cells with lowest entopy");
|
this.lowEntropyCellIdCache = [];
|
||||||
let result = [];
|
|
||||||
|
|
||||||
// set lowestEntropy to the highest possible entropy,
|
// set lowestEntropy to the highest possible entropy,
|
||||||
// and let's search for lower entropy in the cells
|
// 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) => {
|
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?
|
// 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
|
// 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
|
// at so far Clear the search results and start over with this cell's
|
||||||
// entropy as our target
|
// entropy as our target
|
||||||
result = [idx];
|
this.lowEntropyCellIdCache = [idx];
|
||||||
lowestEntropy = cell.getEntropy();
|
this.lowestEntropy = entropy;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Cell matches current entropy level, add it to search results.
|
// 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.
|
// Cell matches our current level of entropy, so we add it to our search results.
|
||||||
// at so far! Clear the results and start over.
|
// at so far! Clear the results and start over.
|
||||||
result.push(idx);
|
this.lowEntropyCellIdCache.push(idx);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.length <= 0) {
|
if (this.lowEntropyCellIdCache.length === 0) {
|
||||||
console.log("Found zero lowest-entropy cells.", { lowestEntropy });
|
console.log("Found zero lowest-entropy cells.", { entropy: this.lowestEntropy });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
/**
|
||||||
|
* 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
collapse() {
|
if (cellId === undefined) {
|
||||||
console.log("Starting collaps()");
|
cellId = this.rng.randomElement(this.lowEntropyCellIdCache);
|
||||||
let count = this.cells.length;
|
if (cellId === undefined) {
|
||||||
while (count > 0) {
|
throw new Error("Could not find a valid cell to start the collapse");
|
||||||
count--;
|
}
|
||||||
// Get a list of possible target cells
|
|
||||||
const lowEntropyCellIds = this.cellIdsWithLowestEntropy();
|
|
||||||
|
|
||||||
//
|
|
||||||
// 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 targetCell = this.cells[cellId];
|
||||||
const rCell = this.cells[rCellId];
|
if (!targetCell) {
|
||||||
|
throw new Error(`Could not find cell with index ${cellId}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** @type {TrainingCell} a randomly chosen option that was available to rCell */
|
/** @type {SourceCell} a randomly chosen option that was available to targetCell */
|
||||||
const rOption = this.rng.randomElement(rCell.options);
|
const targetOption = this.rng.randomElement(targetCell.options);
|
||||||
|
|
||||||
// Lock in the choice for this cell
|
// Lock in the choice for this cell
|
||||||
rCell.options = [rOption];
|
targetCell.options = [targetOption];
|
||||||
|
|
||||||
// _____ ____ _ _
|
// _____ ____ _ _
|
||||||
// | ____|_ __ _ __ ___ _ __| __ ) ___| | _____ _| |
|
// | ____|_ __ _ __ ___ _ __| __ ) ___| | _____ _| |
|
||||||
@@ -111,7 +128,7 @@ export class WfcGrid {
|
|||||||
// |_____|_| |_| \___/|_| |____/ \___|_|\___/ \_/\_/ (_)
|
// |_____|_| |_| \___/|_| |____/ \___|_|\___/ \_/\_/ (_)
|
||||||
// Locking in this cell has changed the grid.
|
// Locking in this cell has changed the grid.
|
||||||
// We must look at the cell's cardinal neighbours and update their options.
|
// We must look at the cell's cardinal neighbours and update their options.
|
||||||
for (let nArr of this.getNeighboursFor(rCell)) {
|
for (let nArr of this.neighbourCells(targetCell)) {
|
||||||
/** @type {number} direction of the neighbour */
|
/** @type {number} direction of the neighbour */
|
||||||
const neighbourDirection = nArr[0];
|
const neighbourDirection = nArr[0];
|
||||||
|
|
||||||
@@ -123,43 +140,62 @@ export class WfcGrid {
|
|||||||
const newOptions = [];
|
const newOptions = [];
|
||||||
|
|
||||||
for (let neighbourOption of neighbourCell.options) {
|
for (let neighbourOption of neighbourCell.options) {
|
||||||
if (neighbourOption.potentialNeighbours(rOption, neighbourDirection)) {
|
if (neighbourOption.potentialNeighbours(targetOption, neighbourDirection)) {
|
||||||
newOptions.push(neighbourOption);
|
newOptions.push(neighbourOption);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newEntropyLevel = newOptions.length;
|
||||||
|
|
||||||
// We've collapsed too deep.
|
// We've collapsed too deep.
|
||||||
if (newOptions.length === 0) {
|
if (newOptions.length === 0) {
|
||||||
|
const oldOptions = neighbourCell.options;
|
||||||
|
neighbourCell.options = newOptions;
|
||||||
console.error("We've removed all options from a neighbour!", {
|
console.error("We've removed all options from a neighbour!", {
|
||||||
rCell,
|
targetCell,
|
||||||
rOption,
|
targetOption,
|
||||||
neighbourCell,
|
neighbourCell,
|
||||||
neighbourDirection,
|
oldOptions,
|
||||||
newOptions,
|
Direction: Direction[neighbourDirection],
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
neighbourCell.options = newOptions;
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log("Done");
|
}
|
||||||
return 0;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the neighbours of a cell.
|
* Get the neighbours of a cell.
|
||||||
|
* @param {WfcCell} cell
|
||||||
*/
|
*/
|
||||||
getNeighboursFor(cell) {
|
neighbourCells(cell) {
|
||||||
const result = [];
|
const result = [];
|
||||||
|
|
||||||
|
//
|
||||||
|
// Northern neighbour
|
||||||
|
//
|
||||||
const yNorth = cell.y - 1;
|
const yNorth = cell.y - 1;
|
||||||
if (yNorth >= 0) {
|
if (yNorth > 0) {
|
||||||
const xNorth = cell.x;
|
const xNorth = cell.x;
|
||||||
const idx = this.width * yNorth + xNorth;
|
const idx = this.width * yNorth + xNorth;
|
||||||
result.push([Direction.N, this.cells[idx]]);
|
result.push([Direction.N, this.cells[idx]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Southern neighbour
|
||||||
|
//
|
||||||
const ySouth = cell.y + 1;
|
const ySouth = cell.y + 1;
|
||||||
if (ySouth < this.height) {
|
if (ySouth < this.height) {
|
||||||
const xSouth = cell.x;
|
const xSouth = cell.x;
|
||||||
@@ -167,6 +203,9 @@ export class WfcGrid {
|
|||||||
result.push([Direction.S, this.cells[idx]]);
|
result.push([Direction.S, this.cells[idx]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Eastern neighbour
|
||||||
|
//
|
||||||
const xEast = cell.x + 1;
|
const xEast = cell.x + 1;
|
||||||
if (xEast < this.width) {
|
if (xEast < this.width) {
|
||||||
const yEast = cell.y;
|
const yEast = cell.y;
|
||||||
@@ -174,6 +213,9 @@ export class WfcGrid {
|
|||||||
result.push([Direction.E, this.cells[idx]]);
|
result.push([Direction.E, this.cells[idx]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Western neighbour
|
||||||
|
//
|
||||||
const xWest = cell.x - 1;
|
const xWest = cell.x - 1;
|
||||||
if (xWest >= 0) {
|
if (xWest >= 0) {
|
||||||
const yWest = cell.y;
|
const yWest = cell.y;
|
||||||
|
|||||||
482
frontend/ascii_dungeon_crawler.html
Normal file
482
frontend/ascii_dungeon_crawler.html
Normal 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>
|
||||||
407
frontend/cellular_automata_map_generator.html
Normal file
407
frontend/cellular_automata_map_generator.html
Normal 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>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { crackdown } from "../utils/crackdown.js";
|
import { crackdown } from "../utils/crackdown.js";
|
||||||
import { parseArgs } from "../utils/parseArgs.js";
|
import { parseArgs } from "../utils/parseArgs.js";
|
||||||
import { MessageType } from "../utils/messages.js";
|
import { MessageType } from "../utils/messages.js";
|
||||||
import { sprintf } from "sprintf-js";
|
|
||||||
|
|
||||||
/** Regex to validate if a :help [topic] command i entered correctly */
|
/** Regex to validate if a :help [topic] command i entered correctly */
|
||||||
const helpRegex = /^:help(?:\s+(.*))?$/;
|
const helpRegex = /^:help(?:\s+(.*))?$/;
|
||||||
@@ -107,6 +106,7 @@ class MUDClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.websocket.onerror = (error) => {
|
this.websocket.onerror = (error) => {
|
||||||
|
console.log("Websocket error", error);
|
||||||
this.updateStatus("Connection Error", "error");
|
this.updateStatus("Connection Error", "error");
|
||||||
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
|
this.writeToOutput("Connection error occurred. Retrying...", { class: "error" });
|
||||||
};
|
};
|
||||||
@@ -203,7 +203,7 @@ class MUDClient {
|
|||||||
let help = helpRegex.exec(inputText);
|
let help = helpRegex.exec(inputText);
|
||||||
if (help) {
|
if (help) {
|
||||||
console.log("here");
|
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);
|
this.echo(inputText);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
573
frontend/dungeon_generator.html
Normal file
573
frontend/dungeon_generator.html
Normal 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>
|
||||||
@@ -28,11 +28,10 @@
|
|||||||
<h3>Tools</h3>
|
<h3>Tools</h3>
|
||||||
<button id="drawBtn" class="active" onclick="painter.setTool('draw')">Draw</button>
|
<button id="drawBtn" class="active" onclick="painter.setTool('draw')">Draw</button>
|
||||||
<button id="fillBtn" onclick="painter.setTool('fill')">Fill</button>
|
<button id="fillBtn" onclick="painter.setTool('fill')">Fill</button>
|
||||||
<button onclick="painter.toggleDrawingMode()" id="drawModeBtn">Toggle Drawing Mode</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tool-group">
|
<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.randomFill()">Random Fill</button>
|
||||||
<button onclick="painter.invertColors()">Invert</button>
|
<button onclick="painter.invertColors()">Invert</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { sprintf } from "sprintf-js";
|
import { sprintf } from "sprintf-js";
|
||||||
import { Xorshift32 } from "../utils/random.js";
|
import { Xorshift32 } from "../utils/random.js";
|
||||||
import { WfcGrid } from "./WfcGrid.js";
|
import { WfcGrid } from "./WfcGrid.js";
|
||||||
import { TrainingCell } from "./TrainingCell.js";
|
import { SourceCell } from "./SourceCell.js";
|
||||||
import { TrainingGrid } from "./TrainingGrid.js";
|
import { SourceGrid } from "./SourceGrid.js";
|
||||||
|
|
||||||
class PainApp {
|
class PainterApp {
|
||||||
/** @type {string} */
|
/** @type {number} The index of the color we're currently painting with */
|
||||||
activeColor = "#000";
|
toolPaletteIndex = 0;
|
||||||
/** @type {string} */
|
|
||||||
currentTool = "draw";
|
/** @type {string} Mode. Draw or fill */
|
||||||
|
mode = "draw";
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
isDrawing = false;
|
isDrawing = false;
|
||||||
/** @type {boolean} */
|
|
||||||
drawingMode = false;
|
|
||||||
|
|
||||||
/**@param {string[]} pal */
|
/**@param {string[]} pal */
|
||||||
constructor(dim, pal, gridElement, paletteElement, previewElement) {
|
constructor(dim, pal, gridElement, paletteElement, previewElement) {
|
||||||
@@ -20,67 +19,65 @@ class PainApp {
|
|||||||
this.dim = dim;
|
this.dim = dim;
|
||||||
/** @type {string[]} Default color palette */
|
/** @type {string[]} Default color palette */
|
||||||
this.palette = pal;
|
this.palette = pal;
|
||||||
/** @type {string[]} */
|
|
||||||
this.samplePixels = new Array(dim ** 2).fill(pal[pal.length - 1]);
|
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
this.gridElement = gridElement;
|
this.gridElement = gridElement;
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
this.previewElement = previewElement;
|
this.previewElement = previewElement;
|
||||||
/** @type {HTMLElement} */
|
/** @type {HTMLElement} */
|
||||||
this.paletteElement = paletteElement;
|
this.paletteElement = paletteElement;
|
||||||
/** @type {HTMLInputElement} */
|
|
||||||
|
|
||||||
this.trainingImage = new TrainingGrid(
|
this.reset();
|
||||||
this.samplePixels.map(() => {
|
|
||||||
return new TrainingCell();
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.createGrid();
|
|
||||||
this.createColorPalette();
|
|
||||||
this.updatePreview();
|
|
||||||
this.setActiveColor(pal[0]);
|
|
||||||
this.updateTrainingGrid();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = "";
|
this.gridElement.innerHTML = "";
|
||||||
|
|
||||||
for (let i = 0; i < this.dim ** 2; i++) {
|
for (let i = 0; i < this.dim ** 2; i++) {
|
||||||
const pixel = document.createElement("div");
|
const pixel = document.createElement("div");
|
||||||
pixel.className = "pixel";
|
pixel.className = `pal-idx-${this.getCell(i)}`;
|
||||||
pixel.setAttribute("data-index", i);
|
pixel.setAttribute("id", "cell-idx-" + i);
|
||||||
pixel.style.backgroundColor = this.samplePixels[i];
|
|
||||||
|
|
||||||
pixel.addEventListener("mousedown", (e) => this.startDrawing(e, i));
|
pixel.addEventListener("mousedown", (e) => this.mouseDown(e, i));
|
||||||
pixel.addEventListener("mouseenter", (e) => this.continueDrawing(e, i));
|
|
||||||
pixel.addEventListener("mouseup", () => this.stopDrawing());
|
|
||||||
|
|
||||||
this.gridElement.appendChild(pixel);
|
this.gridElement.appendChild(pixel);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent context menu and handle mouse events
|
// Prevent context menu and handle mouse events
|
||||||
this.gridElement.addEventListener("contextmenu", (e) => e.preventDefault());
|
this.gridElement.addEventListener("contextmenu", (e) => e.preventDefault());
|
||||||
document.addEventListener("mouseup", () => this.stopDrawing());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createColorPalette() {
|
createPaletteSwatch() {
|
||||||
this.paletteElement.innerHTML = "";
|
this.paletteElement.innerHTML = "";
|
||||||
|
|
||||||
this.palette.forEach((color, paletteIndex) => {
|
this.palette.forEach((color, paletteIndex) => {
|
||||||
const swatch = document.createElement("div");
|
const swatch = document.createElement("div");
|
||||||
swatch.classList.add("color-swatch");
|
swatch.classList.add("color-swatch");
|
||||||
swatch.classList.add(`pal-idx-${paletteIndex}`);
|
swatch.classList.add(`pal-idx-${paletteIndex}`);
|
||||||
swatch.classList.add(`pal-color-${color}`);
|
|
||||||
swatch.style.backgroundColor = color;
|
swatch.style.backgroundColor = color;
|
||||||
swatch.onclick = () => this.setActiveColor(paletteIndex);
|
swatch.onclick = () => this.setToolPaletteIndex(paletteIndex);
|
||||||
this.paletteElement.appendChild(swatch);
|
this.paletteElement.appendChild(swatch);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveColor(paletteIndex) {
|
setToolPaletteIndex(paletteIndex) {
|
||||||
//
|
//
|
||||||
this.activeColor = this.palette[paletteIndex];
|
this.toolPaletteIndex = paletteIndex;
|
||||||
|
|
||||||
const colorSwatches = this.paletteElement.querySelectorAll(".color-swatch");
|
const colorSwatches = this.paletteElement.querySelectorAll(".color-swatch");
|
||||||
colorSwatches.forEach((swatch) => {
|
colorSwatches.forEach((swatch) => {
|
||||||
@@ -90,7 +87,7 @@ class PainApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTool(tool) {
|
setTool(tool) {
|
||||||
this.currentTool = tool;
|
this.mode = tool;
|
||||||
document.querySelectorAll(".tools button").forEach((btn) => btn.classList.remove("active"));
|
document.querySelectorAll(".tools button").forEach((btn) => btn.classList.remove("active"));
|
||||||
document.getElementById(tool + "Btn").classList.add("active");
|
document.getElementById(tool + "Btn").classList.add("active");
|
||||||
|
|
||||||
@@ -105,71 +102,56 @@ class PainApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleDrawingMode() {
|
mouseDown(e, index) {
|
||||||
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) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.isDrawing = true;
|
|
||||||
this.applyTool(index);
|
this.applyTool(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {MouseEvent} e */
|
getCell(idx, y = undefined) {
|
||||||
continueDrawing(e, index) {
|
if (y === undefined) {
|
||||||
if (this.isDrawing && this.drawingMode) {
|
return this.sourceGrid.cells[idx].value;
|
||||||
this.applyTool(index);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateTrainingCell(index);
|
// Treat idx as an x-coordinate, and calculate an index
|
||||||
this.updatePreview(index);
|
return this.sourceGrid.cells[y * this.dim + idx].value;
|
||||||
}
|
|
||||||
|
|
||||||
stopDrawing() {
|
|
||||||
this.isDrawing = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyTool(index) {
|
applyTool(index) {
|
||||||
switch (this.currentTool) {
|
switch (this.mode) {
|
||||||
case "draw":
|
case "draw":
|
||||||
this.setPixel(index, this.activeColor);
|
this.setPixel(index, this.toolPaletteIndex);
|
||||||
break;
|
break;
|
||||||
case "fill":
|
case "fill":
|
||||||
this.floodFill(index, this.samplePixels[index], this.activeColor);
|
this.floodFill(index, this.toolPaletteIndex);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setPixel(index, color) {
|
setPixel(cellIdx, palIdx) {
|
||||||
this.samplePixels[index] = color;
|
const pixEl = document.getElementById("cell-idx-" + cellIdx);
|
||||||
const pixel = document.querySelector(`[data-index="${index}"]`);
|
this.sourceGrid.cells[cellIdx].value = palIdx;
|
||||||
pixel.style.backgroundColor = color;
|
pixEl.className = "pal-idx-" + palIdx;
|
||||||
|
this.updateSourceCell(cellIdx);
|
||||||
this.updatePreview();
|
this.updatePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
floodFill(startIndex, targetColor, fillColor) {
|
floodFill(startIndex, fillColorPalIdx) {
|
||||||
if (targetColor === fillColor) return;
|
const targetPalIdx = this.getCell(startIndex);
|
||||||
|
|
||||||
|
if (targetPalIdx === fillColorPalIdx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const stack = [startIndex];
|
const stack = [startIndex];
|
||||||
const visited = new Set();
|
const visited = new Set();
|
||||||
|
|
||||||
while (stack.length > 0) {
|
while (stack.length > 0) {
|
||||||
const index = stack.pop();
|
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);
|
visited.add(index);
|
||||||
this.setPixel(index, fillColor);
|
this.setPixel(index, fillColorPalIdx);
|
||||||
|
|
||||||
// Add neighbors
|
// Add neighbors
|
||||||
const row = Math.floor(index / this.dim);
|
const row = Math.floor(index / this.dim);
|
||||||
@@ -182,48 +164,27 @@ class PainApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCanvas() {
|
|
||||||
this.samplePixels.fill("#fff");
|
|
||||||
this.createGrid();
|
|
||||||
this.updatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
randomFill() {
|
randomFill() {
|
||||||
for (let i = 0; i < this.dim ** 2; i++) {
|
for (let i = 0; i < this.dim ** 2; i++) {
|
||||||
const randomColor = this.palette[Math.floor(Math.random() * this.palette.length)];
|
this.setPixel(i, Math.floor(Math.random() * this.palette.length));
|
||||||
this.setPixel(i, randomColor);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
invertColors() {
|
invertColors() {
|
||||||
for (let i = 0; i < this.dim ** 2; i++) {
|
for (let i = 0; i < this.dim ** 2; i++) {
|
||||||
const color = this.samplePixels[i];
|
const cell = this.getCell(i);
|
||||||
const r = 15 - parseInt(color.substr(1, 1), 16);
|
|
||||||
const g = 15 - parseInt(color.substr(2, 1), 16);
|
const inverted = cell % 2 === 0 ? cell + 1 : cell - 1;
|
||||||
const b = 15 - parseInt(color.substr(3, 1), 16);
|
|
||||||
const inverted =
|
|
||||||
"#" +
|
|
||||||
r.toString(16) + // red
|
|
||||||
g.toString(16) + // green
|
|
||||||
b.toString(16); // blue
|
|
||||||
|
|
||||||
this.setPixel(i, inverted);
|
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");
|
const canvas = document.createElement("canvas");
|
||||||
if (subImageIdx === undefined) {
|
|
||||||
canvas.width = this.dim;
|
canvas.width = this.dim;
|
||||||
canvas.height = this.dim;
|
canvas.height = this.dim;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
@@ -231,64 +192,52 @@ class PainApp {
|
|||||||
for (let i = 0; i < this.dim ** 2; i++) {
|
for (let i = 0; i < this.dim ** 2; i++) {
|
||||||
const x = i % this.dim;
|
const x = i % this.dim;
|
||||||
const y = Math.floor(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);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
|
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
|
||||||
this.previewElement.style.backgroundSize = "100%";
|
this.previewElement.style.backgroundSize = "100%";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTrainingGrid() {
|
updateSourceGrid() {
|
||||||
for (let i = 0; i < this.samplePixels.length; i++) {
|
for (let i = 0; i < this.dim ** 2; i++) {
|
||||||
this.updateTrainingCell(i);
|
this.updateSourceCell(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateTrainingCell(i) {
|
/** @param {number} i */
|
||||||
|
updateSourceCell(idx) {
|
||||||
const dim = this.dim;
|
const dim = this.dim;
|
||||||
const x = i % dim;
|
const x = idx % dim;
|
||||||
const y = Math.floor(i / 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 _x = (x + dim + dX) % dim; // add dim before modulo because JS modulo allows negative results
|
||||||
const _y = (y + dim + dY) % dim;
|
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
|
// | neighbour
|
||||||
// ---------------------|-----------
|
// ---------------------|-----------
|
||||||
colorAt(-1, -1), // | northwest
|
valueAt(-1, -1), // | northwest
|
||||||
colorAt(0, -1), // | north
|
valueAt(0, -1), // | north
|
||||||
colorAt(1, -1), // | northeast
|
valueAt(1, -1), // | northeast
|
||||||
|
|
||||||
colorAt(-1, 0), // | east
|
valueAt(-1, 0), // | east
|
||||||
this.samplePixels[i], //| -- self --
|
this.getCell(idx), // | -- self --
|
||||||
colorAt(1, 0), // | west
|
valueAt(1, 0), // | west
|
||||||
|
|
||||||
colorAt(-1, 1), // | southwest
|
valueAt(-1, 1), // | southwest
|
||||||
colorAt(0, 1), // | south
|
valueAt(0, 1), // | south
|
||||||
colorAt(1, 1), // | southeast
|
valueAt(1, 1), // | southeast
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
exportAsImage() {
|
exportAsImage() {
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = this.dim; // 9x upscale
|
canvas.width = this.dim;
|
||||||
canvas.height = this.dim;
|
canvas.height = this.dim;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
@@ -296,7 +245,7 @@ class PainApp {
|
|||||||
for (let i = 0; i < this.dim ** 2; i++) {
|
for (let i = 0; i < this.dim ** 2; i++) {
|
||||||
const x = i % this.dim;
|
const x = i % this.dim;
|
||||||
const y = Math.floor(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);
|
ctx.fillRect(x, y, 1, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +256,7 @@ class PainApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exportAsData() {
|
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 blob = new Blob([data], { type: "application/json" });
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.download = "pixel-art-data.json";
|
link.download = "pixel-art-data.json";
|
||||||
@@ -326,13 +275,15 @@ class PainApp {
|
|||||||
reader.onload = (event) => {
|
reader.onload = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.target.result);
|
const data = JSON.parse(event.target.result);
|
||||||
if (Array.isArray(data) && data.length === this.dim ** 2) {
|
if (!Array.isArray(data) && data.length === this.dim ** 2) {
|
||||||
this.samplePixels = data;
|
|
||||||
this.createGrid();
|
|
||||||
this.updatePreview();
|
|
||||||
} else {
|
|
||||||
alert("Invalid data format!");
|
alert("Invalid data format!");
|
||||||
}
|
}
|
||||||
|
data.forEach((v, k) => {
|
||||||
|
this.setPixel(k, v);
|
||||||
|
});
|
||||||
|
this.createGridHtmlElements();
|
||||||
|
this.updatePreview();
|
||||||
|
this.updateSourceGrid();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert("Error reading file!" + error);
|
alert("Error reading file!" + error);
|
||||||
}
|
}
|
||||||
@@ -344,50 +295,50 @@ class PainApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
waveFunction() {
|
waveFunction() {
|
||||||
this.updateTrainingGrid();
|
this.updateSourceGrid();
|
||||||
const wfcImg = new WfcGrid(
|
const wfcImg = new WfcGrid(
|
||||||
// this.previewElement.clientWidth,
|
// this.previewElement.clientWidth,
|
||||||
// this.previewElement.clientHeight,
|
// this.previewElement.clientHeight,
|
||||||
30,
|
10,
|
||||||
30,
|
10,
|
||||||
this.trainingImage.clone(),
|
this.sourceGrid.clone(),
|
||||||
new Xorshift32(Date.now()),
|
new Xorshift32(Date.now()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Could not "collapse" the image.
|
// Could not "collapse" the image.
|
||||||
// We should reset and try again?
|
// We should reset and try again?
|
||||||
let its = wfcImg.collapse();
|
let running = true;
|
||||||
|
let count = 0;
|
||||||
|
const maxCount = 1000;
|
||||||
|
|
||||||
if (its > 0) {
|
const collapseFunc = () => {
|
||||||
throw new Error(`Function Collapse failed with ${its} iterations left to go`);
|
running = wfcImg.collapse();
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.width = wfcImg.width;
|
canvas.width = wfcImg.width;
|
||||||
canvas.height = wfcImg.height;
|
canvas.height = wfcImg.height;
|
||||||
|
|
||||||
// debug values
|
|
||||||
canvas.width = 30;
|
|
||||||
canvas.height = 30;
|
|
||||||
//
|
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
let i = 0;
|
let i = 0;
|
||||||
for (let y = 0; y < canvas.height; y++) {
|
for (let y = 0; y < canvas.height; y++) {
|
||||||
for (let x = 0; x < canvas.width; x++) {
|
for (let x = 0; x < canvas.width; x++) {
|
||||||
console.log("pix");
|
|
||||||
const cell = wfcImg.cells[i++];
|
const cell = wfcImg.cells[i++];
|
||||||
if (cell.valid) {
|
ctx.fillStyle = cell.value;
|
||||||
ctx.fillStyle = "magenta";
|
|
||||||
ctx.fillRect(x, y, 1, 1);
|
ctx.fillRect(x, y, 1, 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
|
this.previewElement.style.backgroundImage = `url(${canvas.toDataURL()})`;
|
||||||
this.previewElement.style.backgroundSize = "100%";
|
this.previewElement.style.backgroundSize = "100%";
|
||||||
|
|
||||||
|
if (running && ++count < maxCount) {
|
||||||
|
setTimeout(collapseFunc, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collapseFunc();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const base_palette = [
|
const base_palette = [
|
||||||
"#000",
|
"#FFF",
|
||||||
"#007",
|
"#007",
|
||||||
"#00F",
|
"#00F",
|
||||||
"#070",
|
"#070",
|
||||||
@@ -403,9 +354,9 @@ const base_palette = [
|
|||||||
"#FF0",
|
"#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
|
// Calc inverted color
|
||||||
const invR = 15 - Number.parseInt(color.substr(1, 1), 16);
|
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);
|
const invColor = sprintf("#%x%x%x", invR, invG, invB);
|
||||||
|
|
||||||
// populate the palette
|
// populate the palette
|
||||||
palette[idx] = color;
|
palette.push(color);
|
||||||
palette[7 * 4 - 1 - idx] = invColor;
|
palette.push(invColor);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.painter = new PainApp(
|
window.painter = new PainterApp(
|
||||||
9,
|
9,
|
||||||
palette,
|
palette,
|
||||||
document.getElementById("gridContainer"), //
|
document.getElementById("gridContainer"), //
|
||||||
@@ -426,5 +377,33 @@ window.painter = new PainApp(
|
|||||||
document.getElementById("preview"), //
|
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);
|
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);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
* Serializing this object effectively saves the game.
|
* Serializing this object effectively saves the game.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Config } from "../config.js";
|
|
||||||
import { isIdSane, miniUid } from "../utils/id.js";
|
import { isIdSane, miniUid } from "../utils/id.js";
|
||||||
import { Xorshift32 } from "../utils/random.js";
|
import { Xorshift32 } from "../utils/random.js";
|
||||||
import { Character } from "./character.js";
|
import { Character } from "./character.js";
|
||||||
|
|||||||
8
node_modules/.vite/deps/_metadata.json
generated
vendored
8
node_modules/.vite/deps/_metadata.json
generated
vendored
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"hash": "b248c40f",
|
"hash": "a9cc42de",
|
||||||
"configHash": "86a557ed",
|
"configHash": "86a557ed",
|
||||||
"lockfileHash": "772b6e1c",
|
"lockfileHash": "56518f4e",
|
||||||
"browserHash": "37f1288b",
|
"browserHash": "f6412460",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"sprintf-js": {
|
"sprintf-js": {
|
||||||
"src": "../../sprintf-js/src/sprintf.js",
|
"src": "../../sprintf-js/src/sprintf.js",
|
||||||
"file": "sprintf-js.js",
|
"file": "sprintf-js.js",
|
||||||
"fileHash": "039885aa",
|
"fileHash": "41b15421",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,14 +30,9 @@ export class AuthenticationScene extends Scene {
|
|||||||
this.session.player = this.player;
|
this.session.player = this.player;
|
||||||
|
|
||||||
this.session.sendText(["= Success!", "((but I don't know what to do now...))"]);
|
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");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User typed `:create`
|
* User typed `:create`
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export class PasswordPrompt extends Prompt {
|
|||||||
//
|
//
|
||||||
// Block users who enter bad passwords too many times.
|
// Block users who enter bad passwords too many times.
|
||||||
if (this.player.failedPasswordsSinceLastLogin > Config.maxFailedLogins) {
|
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");
|
this.calamity("You have been locked out for too many failed password attempts, come back later");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ export class PasswordPrompt extends Prompt {
|
|||||||
if (this.player.blockedUntil > Date.now()) {
|
if (this.player.blockedUntil > Date.now()) {
|
||||||
//
|
//
|
||||||
// Try to re-login too soon, and your lockout lasts longer.
|
// 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");
|
this.calamity("You have been locked out for too many failed password attempts, come back later");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ItemBlueprint } from "../models/item.js";
|
|
||||||
import { gGame } from "../models/globals.js";
|
import { gGame } from "../models/globals.js";
|
||||||
|
|
||||||
//
|
//
|
||||||
@@ -12,7 +11,6 @@ import { gGame } from "../models/globals.js";
|
|||||||
// Seed the Game.ItemBlueprint store
|
// Seed the Game.ItemBlueprint store
|
||||||
export class ItemSeeder {
|
export class ItemSeeder {
|
||||||
seed() {
|
seed() {
|
||||||
//
|
|
||||||
// __ __
|
// __ __
|
||||||
// \ \ / /__ __ _ _ __ ___ _ __ ___
|
// \ \ / /__ __ _ _ __ ___ _ __ ___
|
||||||
// \ \ /\ / / _ \/ _` | '_ \ / _ \| '_ \/ __|
|
// \ \ /\ / / _ \/ _` | '_ \ / _ \| '_ \/ __|
|
||||||
@@ -54,7 +52,6 @@ export class ItemSeeder {
|
|||||||
specialEffect: "TBD",
|
specialEffect: "TBD",
|
||||||
});
|
});
|
||||||
|
|
||||||
//
|
|
||||||
// _
|
// _
|
||||||
// / \ _ __ _ __ ___ ___ _ __ ___
|
// / \ _ __ _ __ ___ ___ _ __ ___
|
||||||
// / _ \ | '__| '_ ` _ \ / _ \| '__/ __|
|
// / _ \ | '__| '_ ` _ \ / _ \| '__/ __|
|
||||||
@@ -76,7 +73,6 @@ export class ItemSeeder {
|
|||||||
armorHitPoints: 6,
|
armorHitPoints: 6,
|
||||||
});
|
});
|
||||||
|
|
||||||
//
|
|
||||||
// _ ___ _
|
// _ ___ _
|
||||||
// | |/ (_) |_ ___
|
// | |/ (_) |_ ___
|
||||||
// | ' /| | __/ __|
|
// | ' /| | __/ __|
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ export class Xorshift32 {
|
|||||||
* @returns {T} One element from the array. * @return {<T>}
|
* @returns {T} One element from the array. * @return {<T>}
|
||||||
*/
|
*/
|
||||||
randomElement(arr) {
|
randomElement(arr) {
|
||||||
|
if (arr instanceof Set) {
|
||||||
|
arr = [...arr];
|
||||||
|
}
|
||||||
const idx = this.lowerThan(arr.length);
|
const idx = this.lowerThan(arr.length);
|
||||||
|
|
||||||
return arr[idx];
|
return arr[idx];
|
||||||
|
|||||||
Reference in New Issue
Block a user