Skip to content

Idea for roguelike map generation #4

@ncot-tech

Description

@ncot-tech

Yet another interesting video!

I'm very sorry for disturbing you again, but I hope you welcome feedback and
discussion in any case. Well, here is my take on your new approach. To start
with, I would trust the algorithms more, even though I also make departures
here and there (as can be seen below).

What I would do:

Split the problem in smaller parts, trust the algos:

a.) Room placement: Iterative random placement with collision avoidance.
As the total space isn't really big (even in the case of a slow computer),
we can afford test-and-throw away approach to placing rectangles.
Brute force. Can be changed later.

So: Create a dungeon layout with rooms that are spatially separated,
avoiding overlaps or rooms being too close together.

b.) Corridor generation: Prim's algorithm for Minimum Spanning Trees (MST).
This ensure that all generated rooms are connected to each other with the
minimum total length of corridors, preventing isolated rooms and creating
an efficient path network. It can get hairy otherwise; a spiders web.

Prim’s algorithm is not inherently graphical or 2D. It’s a graph algorithm,
meaning it works on an abstract mathematical graph: a set of nodes (vertices)
connected by edges with weights, usually representing cost, distance, time,
etc. Here: distance.

Prim’s algo grows the MST one node at a time, always choosing the smallest-weight
edge that connects a new node to the already-built tree. It’s a greedy algorithm.
At each step, it picks the best local option, hoping this leads to a global optimum
(which it does for MSTs).

So when we have the abstract dungeon ready, we can layout the visual paths.

c.) Corridor pathfinding: Heuristic L- (and to make it more interesting Z-shaped)
paths. So, create direct corridors between rooms that are easy to navigate and
visualise.

Sketch through JavaScript/HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Optimized Dungeon Layout Game</title>
    <style>
        body { margin: 0; font-family: Arial, sans-serif; }
        #canvas { border: 1px solid black; background-color: #f0f0f0; }
        #controls { padding: 10px; background-color: #ddd; }
        button { margin: 5px; padding: 5px 10px; }
        #stats { margin-top: 10px; font-size: 12px; }
    </style>
</head>
<body>
    <div id="controls">
        <button onclick="game.resetDungeon()">Reset Dungeon</button>
        <button onclick="game.clearCanvas()">Clear Canvas</button>
        <button onclick="game.toggleAnimation()">Toggle Animation</button>
        <button onclick="game.clearHistory()">Clear History</button>
        <div id="stats"></div>
    </div>
    <canvas id="canvas" width="800" height="600"></canvas>

    <script>
        const CONFIG = {
            gridSize: 20,
            roomCount: 10,
            minRoomSize: 30,
            maxRoomSize: 60,
            minRoomDistance: 40,
            maxPlacementAttempts: 50,
            dotSpeed: 120, // pixels per second
            pauseTime: 1000, // milliseconds
            historyDecayTime: 30000, // 30 seconds before rooms become less avoided
            maxHistorySize: 50 // max number of visits to remember
        };

        // pools for memory management
        class ObjectPool {
            constructor(createFn, resetFn) {
                this.createFn = createFn;
                this.resetFn = resetFn;
                this.pool = [];
                this.active = [];
            }

            get() {
                let obj = this.pool.pop();
                if (!obj) {
                    obj = this.createFn();
                }
                this.active.push(obj);
                return obj;
            }

            release(obj) {
                const index = this.active.indexOf(obj);
                if (index > -1) {
                    this.active.splice(index, 1);
                    this.resetFn(obj);
                    this.pool.push(obj);
                }
            }

            releaseAll() {
                while (this.active.length > 0) {
                    this.release(this.active[0]);
                }
            }
        }

        // Vector utility class
        class Vector2 {
            constructor(x = 0, y = 0) {
                this.x = x;
                this.y = y;
            }

            set(x, y) {
                this.x = x;
                this.y = y;
                return this;
            }

            copy(other) {
                this.x = other.x;
                this.y = other.y;
                return this;
            }

            distance(other) {
                return Math.hypot(this.x - other.x, this.y - other.y);
            }

            normalize() {
                const len = Math.hypot(this.x, this.y);
                if (len > 0) {
                    this.x /= len;
                    this.y /= len;
                }
                return this;
            }

            scale(factor) {
                this.x *= factor;
                this.y *= factor;
                return this;
            }

            add(other) {
                this.x += other.x;
                this.y += other.y;
                return this;
            }
        }

        // Visit history tracker
        class VisitHistory {
            constructor() {
                this.visits = new Map(); // roomIndex -> array of visit times
                this.totalVisits = 0;
            }

            recordVisit(roomIndex) {
                const currentTime = performance.now();
                
                if (!this.visits.has(roomIndex)) {
                    this.visits.set(roomIndex, []);
                }
                
                const roomVisits = this.visits.get(roomIndex);
                roomVisits.push(currentTime);
                this.totalVisits++;
                
                // only recent visits within history size limit!
                if (roomVisits.length > CONFIG.maxHistorySize) {
                    roomVisits.shift();
                }
                
                // clean up old visits
                this.cleanupOldVisits(currentTime);
            }

            cleanupOldVisits(currentTime) {
                const cutoffTime = currentTime - CONFIG.historyDecayTime;
                
                for (let [roomIndex, visits] of this.visits) {
                    const filteredVisits = visits.filter(time => time > cutoffTime);
                    if (filteredVisits.length === 0) {
                        this.visits.delete(roomIndex);
                    } else {
                        this.visits.set(roomIndex, filteredVisits);
                    }
                }
            }

            getVisitCount(roomIndex) {
                return this.visits.has(roomIndex) ? this.visits.get(roomIndex).length : 0;
            }

            getRecentVisitCount(roomIndex, timeWindow = 10000) {
                if (!this.visits.has(roomIndex)) return 0;
                
                const currentTime = performance.now();
                const cutoffTime = currentTime - timeWindow;
                
                return this.visits.get(roomIndex).filter(time => time > cutoffTime).length;
            }

            getUnvisitedRooms(totalRooms) {
                const unvisited = [];
                for (let i = 0; i < totalRooms; i++) {
                    if (!this.visits.has(i)) {
                        unvisited.push(i);
                    }
                }
                return unvisited;
            }

            getLeastVisitedRooms(totalRooms, count = 3) {
                const roomCounts = [];
                for (let i = 0; i < totalRooms; i++) {
                    roomCounts.push({
                        roomIndex: i,
                        visitCount: this.getVisitCount(i),
                        recentVisits: this.getRecentVisitCount(i)
                    });
                }
                
                // sort by recent visits first, then by total visits
                roomCounts.sort((a, b) => {
                    if (a.recentVisits !== b.recentVisits) {
                        return a.recentVisits - b.recentVisits;
                    }
                    return a.visitCount - b.visitCount;
                });
                
                return roomCounts.slice(0, count).map(item => item.roomIndex);
            }

            clear() {
                this.visits.clear();
                this.totalVisits = 0;
            }

            getStats() {
                const uniqueRoomsVisited = this.visits.size;
                const totalVisits = Array.from(this.visits.values()).reduce((sum, visits) => sum + visits.length, 0);
                return { uniqueRoomsVisited, totalVisits };
            }
        }

        // MST impl. using Prim's algorithm
        class MST {
            static generate(rooms) {
                if (rooms.length === 0) return [];
                
                const edges = [];
                const visited = new Set();
                const distances = new Map();
                
                // start with first room
                visited.add(0);
                
                // add all edges from first room
                for (let i = 1; i < rooms.length; i++) {
                    const dist = rooms[0].position.distance(rooms[i].position);
                    distances.set(`0-${i}`, dist);
                    edges.push({ from: 0, to: i, weight: dist });
                }
                
                const mstEdges = [];
                
                while (visited.size < rooms.length && edges.length > 0) {
                    // find minimum weight edge connecting visited to unvisited
                    edges.sort((a, b) => a.weight - b.weight);
                    
                    let minEdge = null;
                    let minIndex = -1;
                    
                    for (let i = 0; i < edges.length; i++) {
                        const edge = edges[i];
                        if (visited.has(edge.from) && !visited.has(edge.to)) {
                            minEdge = edge;
                            minIndex = i;
                            break;
                        }
                    }
                    
                    if (minEdge) {
                        mstEdges.push(minEdge);
                        visited.add(minEdge.to);
                        
                        // add new edges from the newly connected room
                        for (let i = 0; i < rooms.length; i++) {
                            if (!visited.has(i)) {
                                const key = `${minEdge.to}-${i}`;
                                if (!distances.has(key)) {
                                    const dist = rooms[minEdge.to].position.distance(rooms[i].position);
                                    distances.set(key, dist);
                                    edges.push({ from: minEdge.to, to: i, weight: dist });
                                }
                            }
                        }
                        
                        edges.splice(minIndex, 1);
                    } else {
                        break;
                    }
                }
                
                return mstEdges;
            }
        }

        class Room {
            constructor(x, y, id) {
                this.position = new Vector2(x, y);
                this.id = id;
                this.width = Math.floor(Math.random() * (CONFIG.maxRoomSize - CONFIG.minRoomSize + 1)) + CONFIG.minRoomSize;
                this.height = Math.floor(Math.random() * (CONFIG.maxRoomSize - CONFIG.minRoomSize + 1)) + CONFIG.minRoomSize;
                this.visitCount = 0;
                this.lastVisitTime = 0;
            }

            getConnectionPoint(targetRoom) {
                const left = this.position.x - this.width / 2;
                const right = this.position.x + this.width / 2;
                const top = this.position.y - this.height / 2;
                const bottom = this.position.y + this.height / 2;
                
                let nearestX = Math.max(left, Math.min(right, targetRoom.position.x));
                let nearestY = Math.max(top, Math.min(bottom, targetRoom.position.y));
                
                // if point is inside room, move to nearest edge
                // think this is like "rougelike" style?
                if (nearestX > left && nearestX < right && nearestY > top && nearestY < bottom) {
                    const distances = [
                        { pos: left, coord: 'x', val: nearestX - left },
                        { pos: right, coord: 'x', val: right - nearestX },
                        { pos: top, coord: 'y', val: nearestY - top },
                        { pos: bottom, coord: 'y', val: bottom - nearestY }
                    ];
                    
                    const nearest = distances.reduce((min, curr) => curr.val < min.val ? curr : min);
                    if (nearest.coord === 'x') {
                        nearestX = nearest.pos;
                    } else {
                        nearestY = nearest.pos;
                    }
                }
                
                return new Vector2(nearestX, nearestY);
            }

            draw(ctx, visitHistory) {
                const visitCount = visitHistory.getVisitCount(this.id - 1);
                const recentVisits = visitHistory.getRecentVisitCount(this.id - 1, 5000);
                
                // color based on visit history
                let fillColor = '#1E90FF'; // blue
                if (visitCount === 0) {
                    fillColor = '#32CD32'; // green for unvisited
                } else if (recentVisits > 0) {
                    fillColor = '#FF6B6B'; // red for recently visited
                } else if (visitCount > 0) {
                    fillColor = '#FFA500'; // orange for visited but not recent
                }
                
                ctx.fillStyle = fillColor;
                ctx.fillRect(
                    this.position.x - this.width / 2, 
                    this.position.y - this.height / 2, 
                    this.width, 
                    this.height
                );
                
                // draw checkerboard pattern
                ctx.fillStyle = visitCount === 0 ? '#228B22' : '#4682B4';
                const cellSize = 5;
                for (let i = 0; i < this.width - 4; i += cellSize) {
                    for (let j = 0; j < this.height - 4; j += cellSize) {
                        if ((Math.floor(i / cellSize) + Math.floor(j / cellSize)) % 2 === 0) {
                            ctx.fillRect(
                                this.position.x - this.width / 2 + 2 + i,
                                this.position.y - this.height / 2 + 2 + j,
                                cellSize,
                                cellSize
                            );
                        }
                    }
                }
                
                // draw room ID and visit count
                ctx.fillStyle = '#000';
                ctx.font = '12px Arial';
                ctx.fillText(`R${this.id}`, this.position.x - 10, this.position.y - 5);
                if (visitCount > 0) {
                    ctx.fillText(`(${visitCount})`, this.position.x - 10, this.position.y + 10);
                }
            }
        }

        // class with optimised path generation
        class Corridor {
            constructor(roomA, roomB) {
                this.roomA = roomA;
                this.roomB = roomB;
                this.path = this.generatePath();
                this.totalLength = this.calculateLength();
            }

            generatePath() {
                const startPoint = this.roomA.getConnectionPoint(this.roomB);
                const endPoint = this.roomB.getConnectionPoint(this.roomA);
                
                const path = [startPoint];
                const minOffset = CONFIG.gridSize * 1.5;
                
                const dx = endPoint.x - startPoint.x;
                const dy = endPoint.y - startPoint.y;
                
                // path type based on distance
                if (Math.abs(dx) > Math.abs(dy)) {
                    this.addHorizontalPath(path, startPoint, endPoint, minOffset);
                } else {
                    this.addVerticalPath(path, startPoint, endPoint, minOffset);
                }
                
                path.push(endPoint);
                return path;
            }

            addHorizontalPath(path, start, end, minOffset) {
                const dx = end.x - start.x;
                const dy = end.y - start.y;
                
                const horizontalOffset = dx > 0 ? minOffset : -minOffset;
                const firstX = start.x + horizontalOffset;
                path.push(new Vector2(firstX, start.y));
                
                if (Math.abs(dy) > minOffset) {
                    const targetX = end.x + (dx > 0 ? -minOffset : minOffset);
                    path.push(new Vector2(targetX, start.y));
                    path.push(new Vector2(targetX, end.y));
                } else {
                    const midY = (start.y + end.y) / 2;
                    path.push(new Vector2(firstX, midY));
                    
                    const targetX = end.x + (dx > 0 ? -minOffset : minOffset);
                    path.push(new Vector2(targetX, midY));
                    path.push(new Vector2(targetX, end.y));
                }
            }

            addVerticalPath(path, start, end, minOffset) {
                const dx = end.x - start.x;
                const dy = end.y - start.y;
                
                const verticalOffset = dy > 0 ? minOffset : -minOffset;
                const firstY = start.y + verticalOffset;
                path.push(new Vector2(start.x, firstY));
                
                if (Math.abs(dx) > minOffset) {
                    const targetY = end.y + (dy > 0 ? -minOffset : minOffset);
                    path.push(new Vector2(start.x, targetY));
                    path.push(new Vector2(end.x, targetY));
                } else {
                    const midX = (start.x + end.x) / 2;
                    path.push(new Vector2(midX, firstY));
                    
                    const targetY = end.y + (dy > 0 ? -minOffset : minOffset);
                    path.push(new Vector2(midX, targetY));
                    path.push(new Vector2(end.x, targetY));
                }
            }

            calculateLength() {
                let length = 0;
                for (let i = 1; i < this.path.length; i++) {
                    length += this.path[i].distance(this.path[i - 1]);
                }
                return length;
            }

            draw(ctx) {
                if (this.path.length < 2) return;
                
                ctx.strokeStyle = '#FF0000';
                ctx.lineWidth = 4;
                ctx.beginPath();
                ctx.moveTo(this.path[0].x, this.path[0].y);
                
                for (let i = 1; i < this.path.length; i++) {
                    ctx.lineTo(this.path[i].x, this.path[i].y);
                }
                ctx.stroke();
            }
        }

        // smart movement system with history-aware pathfinding
        class MovementSystem {
            constructor(visitHistory) {
                this.position = new Vector2();
                this.targetPosition = new Vector2();
                this.velocity = new Vector2();
                this.currentRoomIndex = 0;
                this.targetRoomIndex = 0;
                this.currentPath = [];
                this.pathIndex = 0;
                this.isMoving = false;
                this.pauseTimer = 0;
                this.connectionMap = new Map();
                this.visitHistory = visitHistory;
            }

            initialize(rooms, corridors) {
                if (rooms.length === 0) return;
                
                this.buildConnectionMap(rooms, corridors);
                this.currentRoomIndex = Math.floor(Math.random() * rooms.length);
                this.position.copy(rooms[this.currentRoomIndex].position);
                this.targetPosition.copy(this.position);
                this.isMoving = false;
                this.pauseTimer = 0;
                
                // record initial room visit
                this.visitHistory.recordVisit(this.currentRoomIndex);
            }

            buildConnectionMap(rooms, corridors) {
                this.connectionMap.clear();
                
                for (let i = 0; i < rooms.length; i++) {
                    this.connectionMap.set(i, []);
                }
                
                for (let corridor of corridors) {
                    const roomAIdx = rooms.indexOf(corridor.roomA);
                    const roomBIdx = rooms.indexOf(corridor.roomB);
                    
                    this.connectionMap.get(roomAIdx).push({
                        roomIndex: roomBIdx,
                        path: corridor.path
                    });
                    
                    this.connectionMap.get(roomBIdx).push({
                        roomIndex: roomAIdx,
                        path: [...corridor.path].reverse()
                    });
                }
            }

            update(deltaTime, rooms) {
                if (rooms.length < 2) return;
                
                if (this.isMoving) {
                    this.updateMovement(deltaTime);
                } else {
                    this.pauseTimer += deltaTime;
                    if (this.pauseTimer >= CONFIG.pauseTime) {
                        this.startNewJourney(rooms);
                    }
                }
            }

            updateMovement(deltaTime) {
                if (this.pathIndex >= this.currentPath.length) {
                    this.completeJourney();
                    return;
                }
                
                const target = this.currentPath[this.pathIndex];
                const distance = this.position.distance(target);
                const moveDistance = CONFIG.dotSpeed * (deltaTime / 1000);
                
                if (distance <= moveDistance) {
                    this.position.copy(target);
                    this.pathIndex++;
                } else {
                    // smooth interpolation
                    const direction = new Vector2(target.x - this.position.x, target.y - this.position.y);
                    direction.normalize().scale(moveDistance);
                    this.position.add(direction);
                }
            }

            chooseNextRoom(rooms) {
                const connections = this.connectionMap.get(this.currentRoomIndex);
                if (!connections || connections.length === 0) return null;
                
                // get unvisited rooms first
                const unvisitedRooms = this.visitHistory.getUnvisitedRooms(rooms.length);
                
                // filter connections to only include unvisited rooms, if any exist
                let preferredConnections = connections.filter(conn => 
                    unvisitedRooms.includes(conn.roomIndex)
                );
                
                if (preferredConnections.length === 0) {
                    // if no unvisited rooms are directly connected, use least visited
                    const leastVisited = this.visitHistory.getLeastVisitedRooms(rooms.length, 5);
                    preferredConnections = connections.filter(conn => 
                        leastVisited.includes(conn.roomIndex)
                    );
                }
                
                // if still no preferred connections, use all connections but weight them
                if (preferredConnections.length === 0) {
                    preferredConnections = connections;
                }
                
                // weight selection based on visit history
                const weightedConnections = preferredConnections.map(conn => {
                    const visitCount = this.visitHistory.getVisitCount(conn.roomIndex);
                    const recentVisits = this.visitHistory.getRecentVisitCount(conn.roomIndex, 10000);
                    
                    // lower weight means higher preference
                    let weight = 1;
                    if (visitCount === 0) {
                        weight = 0.1; // strongly prefer unvisited
                    } else {
                        weight = 1 + (visitCount * 0.5) + (recentVisits * 2);
                    }
                    
                    return { ...conn, weight };
                });
                
                // weighted random selection (inverse weight - lower weight = higher chance)
                const totalInverseWeight = weightedConnections.reduce((sum, conn) => sum + (1 / conn.weight), 0);
                let random = Math.random() * totalInverseWeight;
                
                for (let conn of weightedConnections) {
                    random -= (1 / conn.weight);
                    if (random <= 0) {
                        return conn;
                    }
                }
                
                // fallback to last connection
                return weightedConnections[weightedConnections.length - 1];
            }

            startNewJourney(rooms) {
                const selectedConnection = this.chooseNextRoom(rooms);
                
                if (selectedConnection) {
                    this.targetRoomIndex = selectedConnection.roomIndex;
                    this.currentPath = selectedConnection.path;
                    this.pathIndex = 0;
                    this.isMoving = true;
                    this.pauseTimer = 0;
                }
            }

            completeJourney() {
                this.isMoving = false;
                this.pauseTimer = 0;
                this.currentRoomIndex = this.targetRoomIndex;
                this.currentPath = [];
                this.pathIndex = 0;
                
                // record the visit
                this.visitHistory.recordVisit(this.currentRoomIndex);
            }

            draw(ctx) {
                // outer glow
                ctx.shadowBlur = 15;
                ctx.shadowColor = '#FF6B35';
                ctx.fillStyle = '#FF6B35';
                ctx.beginPath();
                ctx.arc(this.position.x, this.position.y, 8, 0, 2 * Math.PI);
                ctx.fill();
                
                // inner bright center
                ctx.shadowBlur = 5;
                ctx.shadowColor = '#FFFF00';
                ctx.fillStyle = '#FFFF00';
                ctx.beginPath();
                ctx.arc(this.position.x, this.position.y, 4, 0, 2 * Math.PI);
                ctx.fill();
                
                // reset shadow
                ctx.shadowBlur = 0;
            }
        }

        // separate! rendering concerns
        class Renderer {
            constructor(canvas) {
                this.canvas = canvas;
                this.ctx = canvas.getContext('2d');
            }

            clear() {
                this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
            }

            drawGrid() {
                this.ctx.strokeStyle = '#ccc';
                this.ctx.lineWidth = 1;
                
                for (let x = 0; x < this.canvas.width; x += CONFIG.gridSize) {
                    this.ctx.beginPath();
                    this.ctx.moveTo(x, 0);
                    this.ctx.lineTo(x, this.canvas.height);
                    this.ctx.stroke();
                }
                
                for (let y = 0; y < this.canvas.height; y += CONFIG.gridSize) {
                    this.ctx.beginPath();
                    this.ctx.moveTo(0, y);
                    this.ctx.lineTo(this.canvas.width, y);
                    this.ctx.stroke();
                }
            }

            render(rooms, corridors, movementSystem, visitHistory) {
                this.clear();
                this.drawGrid();
                
                // corridors
                for (let corridor of corridors) {
                    corridor.draw(this.ctx);
                }
                
                // rooms with history visualisation
                for (let room of rooms) {
                    room.draw(this.ctx, visitHistory);
                }
                
                // moving dot
                movementSystem.draw(this.ctx);
            }
        }


        class Game {
            constructor() {
                this.canvas = document.getElementById('canvas');
                this.renderer = new Renderer(this.canvas);
                this.visitHistory = new VisitHistory();
                this.rooms = [];
                this.corridors = [];
                this.movementSystem = new MovementSystem(this.visitHistory);
                this.animationRunning = true;
                this.lastTime = 0;
                
                // object pools
                this.roomPool = new ObjectPool(
                    () => new Room(0, 0, 0),
                    (room) => {
                        room.position.set(0, 0);
                        room.id = 0;
                    }
                );
                
                this.vectorPool = new ObjectPool(
                    () => new Vector2(),
                    (vector) => vector.set(0, 0)
                );
            }

            initialize() {
                this.generateRooms();
                this.connectRooms();
                this.movementSystem.initialize(this.rooms, this.corridors);
                this.startGameLoop();
            }

            generateRooms() {
                // clear existing rooms
                this.roomPool.releaseAll();
                this.rooms = [];
                
                const usedPositions = [];

                for (let i = 0; i < CONFIG.roomCount; i++) {
                    let attempts = 0;
                    let validPosition = false;
                    let x, y;
                    
                    while (!validPosition && attempts < CONFIG.maxPlacementAttempts) {
                        x = Math.random() * (this.canvas.width - CONFIG.maxRoomSize) + CONFIG.maxRoomSize / 2;
                        y = Math.random() * (this.canvas.height - CONFIG.maxRoomSize) + CONFIG.maxRoomSize / 2;
                        
                        validPosition = true;
                        for (let pos of usedPositions) {
                            if (Math.hypot(pos.x - x, pos.y - y) < CONFIG.minRoomDistance) {
                                validPosition = false;
                                break;
                            }
                        }
                        attempts++;
                    }
                    
                    if (validPosition) {
                        const room = new Room(x, y, i + 1);
                        this.rooms.push(room);
                        usedPositions.push({ x, y });
                    }
                }
            }

            connectRooms() {
                this.corridors = [];
                if (this.rooms.length === 0) return;
                
                const mstEdges = MST.generate(this.rooms);
                
                for (let edge of mstEdges) {
                    const corridor = new Corridor(this.rooms[edge.from], this.rooms[edge.to]);
                    this.corridors.push(corridor);
                }
            }

            update(deltaTime) {
                if (this.animationRunning) {
                    this.movementSystem.update(deltaTime, this.rooms);
                    this.updateStats();
                }
            }

            updateStats() {
                const stats = this.visitHistory.getStats();
                const unvisitedCount = this.visitHistory.getUnvisitedRooms(this.rooms.length).length;
                const statsDiv = document.getElementById('stats');
                statsDiv.innerHTML = `
                    Rooms Visited: ${stats.uniqueRoomsVisited}/${this.rooms.length} | 
                    Total Visits: ${stats.totalVisits} | 
                    Unvisited: ${unvisitedCount}
                `;
            }

            render() {
                this.renderer.render(this.rooms, this.corridors, this.movementSystem, this.visitHistory);
            }

            gameLoop(currentTime) {
                const deltaTime = currentTime - this.lastTime;
                this.lastTime = currentTime;
                
                this.update(deltaTime);
                this.render();
                
                requestAnimationFrame((time) => this.gameLoop(time));
            }

            startGameLoop() {
                this.lastTime = performance.now();
                this.gameLoop(this.lastTime);
            }

            resetDungeon() {
                this.generateRooms();
                this.connectRooms();
                this.visitHistory.clear();
                this.movementSystem.initialize(this.rooms, this.corridors);
            }

            clearCanvas() {
                this.rooms = [];
                this.corridors = [];
                this.visitHistory.clear();
                this.movementSystem.initialize(this.rooms, this.corridors);
            }

            clearHistory() {
                this.visitHistory.clear();
            }

            toggleAnimation() {
                this.animationRunning = !this.animationRunning;
            }
        }

        // Initialize game
        const game = new Game();
        game.initialize();
        window.game = game;
</script>

<script>
        // Event listeners for buttons
        document.getElementById('resetDungeon').addEventListener('click', () => {
            game.resetDungeon();
        });

        document.getElementById('clearCanvas').addEventListener('click', () => {
            game.clearCanvas();
        });

        document.getElementById('clearHistory').addEventListener('click', () => {
            game.clearHistory();
        });

        document.getElementById('toggleAnimation').addEventListener('click', () => {
            game.toggleAnimation();
        });
</script>

</body>
</html>

a.) When generateRooms() is called, it attempts to place a specified number of
rooms (CONFIG.roomCount) randomly within the canvas bounds. For each new room,
it generates a random (x, y) coordinate. But before "placing" it,
it checks if this new room's potential position is too close to any previously
placed rooms (closer than CONFIG.minRoomDistance).
If it is too close, the position is discarded, and a new random position is
attempted. This process is repeated for a maximum number of attempts
(CONFIG.maxPlacementAttempts) to prevent infinite loops if a valid
placement becomes impossible.

b.) The MST.generate() method is responsible for connecting the rooms. It
treats the rooms as nodes in a graph and the potential corridors between
them as edges. It then applies a variation of Prim's Algorithm to find a
Minimum Spanning Tree (MST) of this graph. This you have explored already
in depth--I think. Prim's algorithm starts with an arbitrary node (here,
the first room) and iteratively adds the "cheapest" edge (shortest distance
between rooms) that connects a visited node to an unvisited node, until all
nodes are visited.

c.) Within the Corridor class, the generatePath() method creates a "path"
(a series of Vector2 points) that the "dot" will follow. The "dot" here
is an enhancement to illustrate further additions to the game. Instead of
complex pathfinding algorithms like A* (which I first tried, but would be
overkill for simple room-to-room connections), it uses a heuristic approach:

  1. It first finds the closest "connection point" on the boundary of each
    room to the other room's center. This heuristic mimics the visual
    appearance of paths one might traverse in a Manhattan-like grid
    (which you have in your video), but it's not a direct application
    of the Manhattan distance formula for pathfinding.
    It's more of a "rectilinear routing" strategy.

  2. It then generates an L-shaped or Z-shaped path depending on whether
    the horizontal or vertical distance between the rooms is greater.
    This involves adding one or two intermediate points to create a
    "jog" that avoids cutting directly through rooms or creating
    excessively long diagonal paths. The minOffset ensures the bends
    are a certain distance from the room edges.

There are some additions to the code, which only is there to show possible
extensions. The "dot" is wandering the maze and keeps a history where
it has been. The memory however has its limits and the dots forgets
where it has been. Suitable for revisiting rooms ..

Translating code from JavaScript to a pseudo-C style .. Code written
in one programming language can often be transcribed into another.
The result may be awkward or clumsy if the programming idioms differ
too much, but more often than not, the underlying structure and logic
remain clearly recognisable. Translating it "down" with some
access to OO, the code is as follow (including my additions).
The object/class concepts can naturally be simulated also, to
the obvious consequence of much more code ..

STRUCT CONFIG {
    INT gridSize;
    INT roomCount;
    INT minRoomSize;
    INT maxRoomSize;
    INT minRoomDistance;
    INT maxPlacementAttempts;
    FLOAT dotSpeed;
    INT pauseTime;
    INT historyDecayTime;
    INT maxHistorySize;
};


// --- Utils ---

CLASS ObjectPool {
    FUNCTION createFn;
    FUNCTION resetFn;
    ARRAY pool;
    ARRAY active;

    CONSTRUCTOR(createFn, resetFn) {
        THIS.createFn = createFn;
        THIS.resetFn = resetFn;
        // Init pool and active arrays
    }

    Object get() {
        IF (pool IS NOT EMPTY) {
            obj = POP FROM pool;
        } ELSE {
            obj = CALL createFn();
        }
        ADD obj TO active;
        RETURN obj;
    }

    VOID release(Object obj) {
        IF (obj IS IN active) {
            REMOVE obj FROM active;
            CALL resetFn(obj);
            ADD obj TO pool;
        }
    }

    VOID releaseAll() {
        WHILE (active IS NOT EMPTY) {
            CALL release(FIRST ELEMENT OF active);
        }
    }
}


CLASS Vector2 {
    FLOAT x, y;

    CONSTRUCTOR(x = 0, y = 0) {
        THIS.x = x;
        THIS.y = y;
    }

    VOID set(FLOAT x, FLOAT y) {
        THIS.x = x;
        THIS.y = y;
    }

    VOID copy(Vector2 other) {
        THIS.x = other.x;
        THIS.y = other.y;
    }

    FLOAT distance(Vector2 other) {
        RETURN SQRT(POW(THIS.x - other.x, 2) + POW(THIS.y - other.y, 2));
    }

    VOID normalize() {
        FLOAT len = distance(NEW Vector2(0,0)); // Euclidian distance from origin
        IF (len > 0) {
            THIS.x /= len;
            THIS.y /= len;
        }
    }

    VOID scale(FLOAT factor) {
        THIS.x *= factor;
        THIS.y *= factor;
    }

    VOID add(Vector2 other) {
        THIS.x += other.x;
        THIS.y += other.y;
    }
}

// special addition
CLASS VisitHistory {
    MAP<INT, ARRAY<FLOAT>> visits; // roomIndex -> array of visit times
    INT totalVisits;

    CONSTRUCTOR() {
        // Init visits map and totalVisits
    }

    VOID recordVisit(INT roomIndex) {
        FLOAT currentTime = GET_CURRENT_TIME_MS();
        IF (visits DOES NOT HAVE roomIndex) {
            visits.SET(roomIndex, NEW ARRAY<FLOAT>());
        }
        ADD currentTime TO visits.GET(roomIndex);
        totalVisits++;

        // Remove oldest visits, if history size exceeded
        IF (visits.GET(roomIndex).LENGTH > CONFIG.maxHistorySize) {
            REMOVE FIRST ELEMENT FROM visits.GET(roomIndex);
        }
        cleanupOldVisits(currentTime);
    }

    VOID cleanupOldVisits(FLOAT currentTime) {
        FLOAT cutoffTime = currentTime - CONFIG.historyDecayTime;
        FOR EACH roomIndex, visitsArray IN visits {
            FILTER visitsArray TO KEEP ONLY TIMES > cutoffTime;
            IF (filteredVisitsArray IS EMPTY) {
                REMOVE roomIndex FROM visits;
            } ELSE {
                visits.SET(roomIndex, filteredVisitsArray);
            }
        }
    }

    INT getVisitCount(INT roomIndex) {
        RETURN visits.HAS(roomIndex) ? visits.GET(roomIndex).LENGTH : 0;
    }

    INT getRecentVisitCount(INT roomIndex, INT timeWindowMs) {
        // Similar to getVisitCount, but filters for visits within timeWindowMs
    }

    ARRAY<INT> getUnvisitedRooms(INT totalRooms) {
        ARRAY<INT> unvisited;
        FOR (INT i = 0; i < totalRooms; i++) {
            IF (visits DOES NOT HAVE i) {
                ADD i TO unvisited;
            }
        }
        RETURN unvisited;
    }

    ARRAY<INT> getLeastVisitedRooms(INT totalRooms, INT count) {
        ARRAY<STRUCT {INT roomIndex, INT visitCount, INT recentVisits}> roomCounts;
        // Populate roomCounts with visit data for all rooms
        // Sort roomCounts by recentVisits then by visitCount (ascending)
        // Return 'count' number of roomIndexes from the sorted list
    }

    VOID clear() {
        visits.CLEAR();
        totalVisits = 0;
    }

    STRUCT {INT uniqueRoomsVisited, INT totalVisits} getStats() {
        // Calc and return stats
    }
}


// --- game logic ---

// Minimum Spanning Tree generator (using Prim's Algorithm concept)
CLASS MST {
    STATIC ARRAY<STRUCT {INT from, INT to, FLOAT weight}> generate(ARRAY<Room> rooms) {
        IF (rooms IS EMPTY) RETURN EMPTY ARRAY;

        ARRAY<STRUCT {INT from, INT to, FLOAT weight}> edges;
        SET<INT> visited;
        MAP<STRING, FLOAT> distances; // Stores distances, to avoid recomputing

        ADD 0 TO visited; // Start with first room

        // Add initial edges from room 0 to all others
        FOR (INT i = 1; i < rooms.LENGTH; i++) {
            FLOAT dist = rooms[0].position.distance(rooms[i].position);
            distances.SET("0-i", dist);
            ADD {from: 0, to: i, weight: dist} TO edges;
        }

        ARRAY<STRUCT {INT from, INT to, FLOAT weight}> mstEdges;

        WHILE (visited.SIZE < rooms.LENGTH AND edges IS NOT EMPTY) {
            // Sort edges by weight
            SORT edges ASCENDING BY weight;

            STRUCT {INT from, INT to, FLOAT weight} minEdge = NULL;
            INT minIndex = -1;

            FOR (INT i = 0; i < edges.LENGTH; i++) {
                STRUCT edge = edges[i];
                IF (visited HAS edge.from AND visited DOES NOT HAVE edge.to) {
                    minEdge = edge;
                    minIndex = i;
                    BREAK;
                }
            }

            IF (minEdge IS NOT NULL) {
                ADD minEdge TO mstEdges;
                ADD minEdge.to TO visited;

                // Add new edges from the newly connected room to unvisited rooms
                FOR (INT i = 0; i < rooms.LENGTH; i++) {
                    IF (visited DOES NOT HAVE i) {
                        STRING key = CONCAT(minEdge.to, "-", i);
                        IF (distances DOES NOT HAVE key) {
                            FLOAT dist = rooms[minEdge.to].position.distance(rooms[i].position);
                            distances.SET(key, dist);
                            ADD {from: minEdge.to, to: i, weight: dist} TO edges;
                        }
                    }
                }
                REMOVE edge AT minIndex FROM edges;
            } ELSE {
                BREAK; // No more connections possible
            }
        }
        RETURN mstEdges;
    }
}

// A Room in the dungeon
CLASS Room {
    Vector2 position;
    INT id;
    INT width, height;
    // (Additional properties for drawing, not! core logic)

    CONSTRUCTOR(x, y, id) {
        THIS.position = NEW Vector2(x, y);
        THIS.id = id;
        THIS.width = RANDOM_INT(CONFIG.minRoomSize, CONFIG.maxRoomSize);
        THIS.height = RANDOM_INT(CONFIG.minRoomSize, CONFIG.maxRoomSize);
    }

    Vector2 getConnectionPoint(Room targetRoom) {
        // Calc the point on this room's boundary closest to the target room's center.
        // If the target room's center is inside this room, move the point to the nearest edge.
        // This is a "roguelike" connection style, which I guess we are after.
        RETURN NEW Vector2(nearestX, nearestY);
    }

    VOID draw(Context ctx, VisitHistory visitHistory) {
        // If we also want to simulate the rest of the JS code:
        // Determine fill color based on visit history (unvisited, recently visited, visited)
        // Draw rectangle for room
        // Draw checkerboard pattern
        // Draw room ID and visit count text
    }
}


CLASS Corridor {
    Room roomA, roomB;
    ARRAY<Vector2> path;
    FLOAT totalLength;

    CONSTRUCTOR(roomA, roomB) {
        THIS.roomA = roomA;
        THIS.roomB = roomB;
        THIS.path = generatePath();
        THIS.totalLength = calculateLength();
    }

    ARRAY<Vector2> generatePath() {
        Vector2 startPoint = roomA.getConnectionPoint(roomB);
        Vector2 endPoint = roomB.getConnectionPoint(roomA);
        ARRAY<Vector2> path = {startPoint};

        // Generate a path with intermediate points using "L" or "Z" shapes
        // based on relative positions of startPoint and endPoint.
        // This creates "optimised" L-shaped or Z-shaped corridors.
        IF (ABS(endPoint.x - startPoint.x) > ABS(endPoint.y - startPoint.y)) {
            addHorizontalPath(path, startPoint, endPoint, CONFIG.gridSize * 1.5);
        } ELSE {
            addVerticalPath(path, startPoint, endPoint, CONFIG.gridSize * 1.5);
        }
        ADD endPoint TO path;
        RETURN path;
    }

    VOID addHorizontalPath(ARRAY<Vector2> path, Vector2 start, Vector2 end, FLOAT minOffset) {
        // Adds points for a horisontal-priority path (e.g., start -> horisontal_turn -> vertical_turn -> end)
    }

    VOID addVerticalPath(ARRAY<Vector2> path, Vector2 start, Vector2 end, FLOAT minOffset) {
        // Adds points for a vertical-priority path (e.g., start -> vertical_turn -> horizontal_turn -> end)
    }

    FLOAT calculateLength() {
        // Sums distances between consecutive points in 'path'
        RETURN totalLength;
    }

    VOID draw(Context ctx) {
        // Draw lines connecting points in 'path'
    }
}

// If we are after simulation as the JS script ..
// Handles the movement of the "dot" through the dungeon
CLASS MovementSystem {
    Vector2 position;
    Vector2 targetPosition;
    Vector2 velocity;
    INT currentRoomIndex;
    INT targetRoomIndex;
    ARRAY<Vector2> currentPath;
    INT pathIndex;
    BOOL isMoving;
    FLOAT pauseTimer;
    MAP<INT, ARRAY<STRUCT {INT roomIndex, ARRAY<Vector2> path}>> connectionMap; // Adjacency list for graph
    VisitHistory visitHistory;

    CONSTRUCTOR(visitHistory) {
        THIS.visitHistory = visitHistory;
        // Init position, velocity, etc.
    }

    VOID initialize(ARRAY<Room> rooms, ARRAY<Corridor> corridors) {
        // Build connectionMap from rooms and corridors
        // Set initial position to a random room
        // Record initial room visit
    }

    VOID buildConnectionMap(ARRAY<Room> rooms, ARRAY<Corridor> corridors) {
        // Populate connectionMap: For each room, list connected rooms and the path to them.
        // Ensures bidirectional connections.
    }

    VOID update(FLOAT deltaTime, ARRAY<Room> rooms) {
        IF (isMoving) {
            updateMovement(deltaTime);
        } ELSE {
            pauseTimer += deltaTime;
            IF (pauseTimer >= CONFIG.pauseTime) {
                startNewJourney(rooms);
            }
        }
    }

    VOID updateMovement(FLOAT deltaTime) {
        IF (pathIndex IS AT END OF currentPath) {
            completeJourney();
            RETURN;
        }

        Vector2 target = currentPath[pathIndex];
        FLOAT distance = position.distance(target);
        FLOAT moveDistance = CONFIG.dotSpeed * (deltaTime / 1000.0);

        IF (distance <= moveDistance) {
            position.copy(target);
            pathIndex++;
        } ELSE {
            // Move partially towards target
            Vector2 direction = NEW Vector2(target.x - position.x, target.y - position.y);
            direction.normalize().scale(moveDistance);
            position.add(direction);
        }
    }

    STRUCT {INT roomIndex, ARRAY<Vector2> path} chooseNextRoom(ARRAY<Room> rooms) {
        ARRAY<STRUCT {INT roomIndex, ARRAY<Vector2> path}> connections = connectionMap.GET(currentRoomIndex);
        IF (connections IS EMPTY) RETURN NULL;

        // PRIORITY:
        // 1. Unvisited directly connected rooms.
        // 2. If none, least visited rooms (globally or within a small set).
        // 3. Otherwise, use all connections and apply weights based on visit history
        //    (lower weight for less visited/less recently visited).
        // Select one connection using a weighted random approach.
        RETURN selectedConnection;
    }

    VOID startNewJourney(ARRAY<Room> rooms) {
        STRUCT selectedConnection = chooseNextRoom(rooms);
        IF (selectedConnection IS NOT NULL) {
            targetRoomIndex = selectedConnection.roomIndex;
            currentPath = selectedConnection.path;
            pathIndex = 0;
            isMoving = TRUE;
            pauseTimer = 0;
        }
    }

    VOID completeJourney() {
        isMoving = FALSE;
        pauseTimer = 0;
        currentRoomIndex = targetRoomIndex;
        currentPath = EMPTY ARRAY;
        pathIndex = 0;
        visitHistory.recordVisit(currentRoomIndex);
    }

    VOID draw(Context ctx) {
        // Draw the moving dot with glow effects
    }
}


CLASS Renderer {
    Canvas canvas;
    Context ctx;

    CONSTRUCTOR(canvas) {
        THIS.canvas = canvas;
        THIS.ctx = canvas.getContext('2d');
    }

    VOID clear() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
    }

    VOID drawGrid() {
        // Draw a background grid on the canvas
    }

    VOID render(ARRAY<Room> rooms, ARRAY<Corridor> corridors, MovementSystem movementSystem, VisitHistory visitHistory) {
        clear();
        drawGrid();
        FOR EACH corridor IN corridors { corridor.draw(ctx); }
        FOR EACH room IN rooms { room.draw(ctx, visitHistory); }
        movementSystem.draw(ctx);
    }
}


// --- Main ---
// The logic here might be dependent on the exact impl.
// which can vary.
CLASS Game {
    Canvas canvas;
    Renderer renderer;
    VisitHistory visitHistory;
    ARRAY<Room> rooms;
    ARRAY<Corridor> corridors;
    MovementSystem movementSystem;
    BOOL animationRunning;
    FLOAT lastTime;

    // Object Pools (roomPool, vectorPool)

    CONSTRUCTOR() {
        // Get canvas element
        // Init renderer, visitHistory, movementSystem
        // Init object pools
        // Set initial animation state
    }

    VOID initialize() {
        generateRooms();
        connectRooms();
        movementSystem.initialize(THIS.rooms, THIS.corridors);
        startGameLoop();
    }

    VOID generateRooms() {
        // Clear existing rooms
        // Try to place CONFIG.roomCount rooms randomly on the canvas
        // Each room must be at least CONFIG.minRoomDistance from others.
        // Use CONFIG.maxPlacementAttempts to avoid infinite loops if placement is difficult.
        // Use roomPool to get new Room objects.
    }

    VOID connectRooms() {
        // Clear existing corridors
        IF (rooms IS EMPTY) RETURN;
        ARRAY<STRUCT {INT from, INT to, FLOAT weight}> mstEdges = MST.generate(THIS.rooms);
        FOR EACH edge IN mstEdges {
            ADD NEW Corridor(rooms[edge.from], rooms[edge.to]) TO corridors;
        }
    }

    VOID update(FLOAT deltaTime) {
        IF (animationRunning) {
            movementSystem.update(deltaTime, THIS.rooms);
            updateStats();
        }
    }

    VOID updateStats() {
        // Get stats from visitHistory and update display.
    }

    VOID render() {
        renderer.render(THIS.rooms, THIS.corridors, THIS.movementSystem, THIS.visitHistory);
    }

    VOID gameLoop(FLOAT currentTime) {
        FLOAT deltaTime = currentTime - lastTime;
        lastTime = currentTime;

        update(deltaTime);
        render();

        // Request next animation frame (similar to a continuous loop)
        REQUEST_ANIMATION_FRAME(gameLoop);
    }

    VOID startGameLoop() {
        lastTime = GET_CURRENT_TIME_MS();
        gameLoop(lastTime);
    }

    VOID resetDungeon() {
        generateRooms();
        connectRooms();
        visitHistory.clear();
        movementSystem.initialize(THIS.rooms, THIS.corridors);
    }

    VOID clearCanvas() {
        THIS.rooms = EMPTY ARRAY;
        THIS.corridors = EMPTY ARRAY;
        visitHistory.clear();
        movementSystem.initialize(THIS.rooms, THIS.corridors); // Re-initialise with empty rooms to stop movement
        renderer.clear();
        updateStats(); // Update stats display to reflect cleared state
    }

    VOID toggleAnimation() {
        animationRunning = !animationRunning;
    }

    VOID clearHistory() {
        visitHistory.clear();
        updateStats();
    }
}

// Global game instance
Game game;

// On window load (or similar event)
VOID main() {
    game = NEW Game();
    game.initialize();
}

Rgds!
/Set

Originally posted by @Feyerabend in #3 (comment)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions