vyy
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user