Add Prim/Kruskal maze gen & nodeData refactor
All checks were successful
Build, Test & Push Frontend / quality-check (pull_request) Successful in 1m10s
Build, Test & Push Frontend / docker (pull_request) Has been skipped

Implement Prim and Kruskal maze generation in the Labyrinth component with animated generation and UI guards. Introduces isAnimationRunning signal, mazeAnimationSpeed, maze node order tracking and animateMazeGeneration; createRandom(now takes a boolean) triggers either Prim or Kruskal flow, sets random start/end, and animates. Refactor Node.distance -> nodeData across models, components and the PathfindingService (Dijkstra/A*) to use nodeData for g-scores/ids. Add SharedFunctions.shuffleArray utility and update i18n (EN/DE) with labels for Prim/Kruskal. Misc: minor cleanup/init changes and drawing logic adjustments to support the new maze flows.
This commit is contained in:
2026-02-09 14:55:05 +01:00
parent bbec113f5d
commit e8354bfecd
8 changed files with 219 additions and 73 deletions

View File

@@ -6,11 +6,12 @@
<app-information [algorithmInformation]="algoInformation"/> <app-information [algorithmInformation]="algoInformation"/>
<div class="controls-container"> <div class="controls-container">
<div class="controls-panel"> <div class="controls-panel">
<button matButton="filled" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button> <button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('dijkstra')">{{ 'PATHFINDING.DIJKSTRA' | translate }}</button>
<button matButton="filled" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button> <button matButton="filled" [disabled]="isAnimationRunning()" (click)="visualize('astar')">{{ 'PATHFINDING.ASTAR' | translate }}</button>
</div> </div>
<div class="controls-panel"> <div class="controls-panel">
<button matButton="filled" (click)="createRandom()">{{ 'PATHFINDING.CLEAR_BOARD' | translate }}</button> <button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(true)">{{ 'LABYRINTH.PRIM' | translate }}</button>
<button matButton="filled" [disabled]="isAnimationRunning()" (click)="createRandom(false)">{{ 'LABYRINTH.KRUSKAL' | translate }}</button>
</div> </div>
<div class="legend"> <div class="legend">

View File

@@ -1,4 +1,4 @@
import {AfterViewInit, Component, inject, ViewChild} from '@angular/core'; import {AfterViewInit, Component, inject, signal, ViewChild} from '@angular/core';
import {Information} from '../../information/information'; import {Information} from '../../information/information';
import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card'; import {MatCard, MatCardContent, MatCardHeader, MatCardTitle} from '@angular/material/card';
import {TranslatePipe} from '@ngx-translate/core'; import {TranslatePipe} from '@ngx-translate/core';
@@ -62,10 +62,13 @@ export class LabyrinthComponent implements AfterViewInit {
startNode: Node | null = null; startNode: Node | null = null;
endNode: Node | null = null; endNode: Node | null = null;
animationSpeed = 3; animationSpeed = 3;
mazeAnimationSpeed = 1;
pathLength = "0"; pathLength = "0";
executionTime = 0; executionTime = 0;
private timeoutIds: number[] = []; private timeoutIds: number[] = [];
protected mazeNodesInOrder: Node[] = [];
readonly isAnimationRunning = signal(false);
ngAfterViewInit(): void { ngAfterViewInit(): void {
if (this.genericGridComponent) { if (this.genericGridComponent) {
@@ -85,14 +88,167 @@ export class LabyrinthComponent implements AfterViewInit {
initializeMazeGrid = (grid: Node[][]): void => { initializeMazeGrid = (grid: Node[][]): void => {
this.grid = grid; this.grid = grid;
this.createRandom(); this.createRandom(true);
}; };
createRandom(): void { createRandom(prim: boolean): void {
this.isAnimationRunning.set(true);
this.stopAnimations(); this.stopAnimations();
this.clearPath(); this.clearPath();
this.startNode = null; this.startNode = null;
this.endNode = null; this.endNode = null;
if (prim)
{
this.createPrimMaze();
}
else{
this.createKruskalMaze();
}
this.cleanupGrid();
this.genericGridComponent.drawGrid();
}
// ------- Kuskal -------
private createKruskalMaze(): void {
this.initKuskal();
this.mazeNodesInOrder = [];
const walls = this.findWallsWithADistanceOfTwoRooms();
SharedFunctions.shuffleArray(walls);
for (const wallInfo of walls) {
const { row, col, roomA, roomB } = wallInfo;
if (roomA.nodeData !== roomB.nodeData) {
const wallNode = this.grid[row][col];
wallNode.isWall = true;
this.mazeNodesInOrder.push(wallNode);
const oldId = roomB.nodeData;
const newId = roomA.nodeData;
this.mergeSets(oldId, newId);
}
}
this.setRandomStartAndEnd();
this.animateMazeGeneration();
}
private initKuskal() {
let roomId = 0;
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
const node = this.grid[row][col];
node.isStart = false;
node.isEnd = false;
if (row % 2 === 0 && col % 2 === 0) {
node.isWall = false;
node.nodeData = roomId++;
} else {
node.isWall = true;
}
}
}
}
private mergeSets(oldId: number, newId: number): void {
for (let r = 0; r < this.gridRows; r += 2) {
for (let c = 0; c < this.gridCols; c += 2) {
if (this.grid[r][c].nodeData === oldId) {
this.grid[r][c].nodeData = newId;
}
}
}
}
private findWallsWithADistanceOfTwoRooms() {
const walls: { row: number, col: number, roomA: Node, roomB: Node }[] = [];
for (let row = 0; row < this.gridRows; row++) {
for (let col = 0; col < this.gridCols; col++) {
if (row % 2 === 0 && col % 2 !== 0 && col > 0 && col < this.gridCols - 1) {
walls.push({
row, col,
roomA: this.grid[row][col - 1],
roomB: this.grid[row][col + 1]
});
}
if (row % 2 !== 0 && col % 2 === 0 && row > 0 && row < this.gridRows - 1) {
walls.push({
row, col,
roomA: this.grid[row - 1][col],
roomB: this.grid[row + 1][col]
});
}
}
}
return walls;
}
private setRandomStartAndEnd(): void {
const lastRow = Math.floor((this.gridRows - 1) / 2) * 2;
const lastCol = Math.floor((this.gridCols - 1) / 2) * 2;
const corners = [
{ r: 0, c: 0 },
{ r: 0, c: lastCol },
{ r: lastRow, c: 0 },
{ r: lastRow, c: lastCol }
];
const startIndex = Math.floor(Math.random() * corners.length);
let endIndex = Math.floor(Math.random() * corners.length);
while (endIndex === startIndex) {
endIndex = Math.floor(Math.random() * corners.length);
}
const start = corners[startIndex];
const end = corners[endIndex];
this.startNode = this.grid[start.r][start.c];
this.startNode.isStart = true;
this.startNode.isWall = false;
this.endNode =this.grid[end.r][end.c];
this.endNode.isEnd = true;
this.endNode.isWall = false;
}
// ------- PRIM -------
private createPrimMaze(): void {
this.initPrim();
this.mazeNodesInOrder = [];
const frontier: Node[] = [];
const {startRow, startCol, startNode} = this.findStartNode();
this.mazeNodesInOrder.push(startNode);
this.getNeighborWalls(startRow, startCol, frontier);
while (frontier.length > 0) {
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
const lastIndex = frontier.length - 1;
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
const wallNode = frontier.pop()!;
const target = wallNode.linkedNode;
if (!target || target.isVisited) {
continue;
}
wallNode.isVisited = true;
target.isVisited = true;
this.mazeNodesInOrder.push(wallNode, target);
this.getNeighborWalls(target.row, target.col, frontier);
}
this.setRandomStartAndEnd();
this.animateMazeGeneration();
}
private initPrim() {
for (let row = 0; row < this.grid.length; row++) { for (let row = 0; row < this.grid.length; row++) {
for (let col = 0; col < this.grid[row].length; col++) { for (let col = 0; col < this.grid[row].length; col++) {
this.grid[row][col].isWall = true; this.grid[row][col].isWall = true;
@@ -100,34 +256,6 @@ export class LabyrinthComponent implements AfterViewInit {
this.grid[row][col].isEnd = false; this.grid[row][col].isEnd = false;
} }
} }
const frontier: Node[] = [];
const {startRow, startCol, startNode} = this.findStartNode();
this.startNode = startNode;
this.getNeighborWalls(startRow, startCol, frontier);
while (frontier.length > 0) {
const randomIndex = SharedFunctions.randomIntFromInterval(0, frontier.length - 1);
//swap and pop from array
const lastIndex = frontier.length - 1;
[frontier[randomIndex], frontier[lastIndex]] = [frontier[lastIndex], frontier[randomIndex]];
const wallFromFrontierList = frontier.pop()!;
const target = wallFromFrontierList.linkedNode;
if (!target || target.isVisited) {
continue;
}
wallFromFrontierList.isWall = false;
wallFromFrontierList.isVisited = true;
target.isWall = false;
target.isVisited = true;
this.getNeighborWalls(target.row, target.col, frontier);
}
this.findEndNode(startNode);
this.cleanupGrid();
this.genericGridComponent.drawGrid();
} }
private cleanupGrid() { private cleanupGrid() {
@@ -139,32 +267,12 @@ export class LabyrinthComponent implements AfterViewInit {
} }
} }
private findEndNode(startNode: Node) {
let endFound = false;
while (!endFound) {
const endRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1);
const endCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1);
const endNode = this.grid[endRow][endCol];
if (endNode != startNode && !endNode.isWall) {
endNode.isWall = false;
endNode.isEnd = true;
endNode.isVisited = true;
this.endNode = endNode;
endFound = true;
}
}
}
private findStartNode() { private findStartNode() {
const startRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1); const startRow: number = SharedFunctions.randomEventIntFromInterval(this.gridRows - 1);
const startCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1); const startCol: number = SharedFunctions.randomEventIntFromInterval(this.gridCols - 1);
const startNode = this.grid[startRow][startCol]; const startNode = this.grid[startRow][startCol];
startNode.isWall = false; startNode.isWall = false;
startNode.isStart = true;
startNode.isVisited = true; startNode.isVisited = true;
return {startRow, startCol, startNode}; return {startRow, startCol, startNode};
} }
@@ -220,7 +328,7 @@ export class LabyrinthComponent implements AfterViewInit {
isWall: false, isWall: false,
isVisited: false, isVisited: false,
isPath: false, isPath: false,
distance: Infinity, nodeData: Infinity,
linkedNode: null, linkedNode: null,
hScore: 0, hScore: 0,
fScore: Infinity, fScore: Infinity,
@@ -255,7 +363,7 @@ export class LabyrinthComponent implements AfterViewInit {
const node = this.grid[row][col]; const node = this.grid[row][col];
node.isVisited = false; node.isVisited = false;
node.isPath = false; node.isPath = false;
node.distance = Infinity; node.nodeData = Infinity;
node.linkedNode = null; node.linkedNode = null;
} }
} }
@@ -274,7 +382,7 @@ export class LabyrinthComponent implements AfterViewInit {
const id = globalThis.setTimeout(() => { const id = globalThis.setTimeout(() => {
if (!node.isStart && !node.isEnd) { if (!node.isStart && !node.isEnd) {
node.isVisited = true; node.isVisited = true;
this.genericGridComponent?.drawNode(node); // Redraw single node this.genericGridComponent?.drawNode(node);
} }
}, this.animationSpeed * i); }, this.animationSpeed * i);
@@ -295,6 +403,31 @@ export class LabyrinthComponent implements AfterViewInit {
this.timeoutIds.push(id); this.timeoutIds.push(id);
} }
} }
private animateMazeGeneration(): void {
for (let i = 0; i < this.mazeNodesInOrder.length; i++) {
const id = globalThis.setTimeout(() => {
const node = this.mazeNodesInOrder[i];
node.isWall = false;
this.genericGridComponent?.drawNode(node);
if (i === this.mazeNodesInOrder.length - 1) {
this.cleanupGrid();
if (this.startNode) {
this.genericGridComponent?.drawNode(this.startNode);
}
if (this.endNode) {
this.genericGridComponent?.drawNode(this.endNode);
}
}
if (i == this.mazeNodesInOrder.length - 1) {
this.isAnimationRunning.set(false);
}
}, this.mazeAnimationSpeed * i);
this.timeoutIds.push(id);
}
}
//utility //utility
private getNeighborWalls(row: number, col: number, frontier: Node[]): void{ private getNeighborWalls(row: number, col: number, frontier: Node[]): void{

View File

@@ -119,7 +119,7 @@ export class PathfindingComponent implements AfterViewInit {
isWall: false, isWall: false,
isVisited: false, isVisited: false,
isPath: false, isPath: false,
distance: Infinity, nodeData: Infinity,
linkedNode: null, linkedNode: null,
hScore: 0, hScore: 0,
fScore: Infinity, fScore: Infinity,
@@ -436,7 +436,7 @@ export class PathfindingComponent implements AfterViewInit {
const node = this.grid[row][col]; const node = this.grid[row][col];
node.isVisited = false; node.isVisited = false;
node.isPath = false; node.isPath = false;
node.distance = Infinity; node.nodeData = Infinity;
node.linkedNode = null; node.linkedNode = null;
} }
} }

View File

@@ -6,7 +6,7 @@ export interface Node {
isWall: boolean; isWall: boolean;
isVisited: boolean; isVisited: boolean;
isPath: boolean; isPath: boolean;
distance: number; nodeData: number; //can be used as distance or id or something
linkedNode: Node | null; linkedNode: Node | null;
fScore: number; fScore: number;
hScore: number; hScore: number;

View File

@@ -33,7 +33,7 @@ export class PathfindingService {
// Dijkstra's Algorithm // Dijkstra's Algorithm
dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } { dijkstra(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
const visitedNodesInOrder: Node[] = []; const visitedNodesInOrder: Node[] = [];
startNode.distance = 0; startNode.nodeData = 0;
const unvisitedNodes: Node[] = this.getAllNodes(grid); const unvisitedNodes: Node[] = this.getAllNodes(grid);
while (unvisitedNodes.length > 0) { while (unvisitedNodes.length > 0) {
@@ -44,7 +44,7 @@ export class PathfindingService {
continue; continue;
} }
const isTrapped = closestNode.distance === Infinity; const isTrapped = closestNode.nodeData === Infinity;
if (isTrapped) if (isTrapped)
{ {
return { visitedNodesInOrder, nodesInShortestPathOrder: [] }; return { visitedNodesInOrder, nodesInShortestPathOrder: [] };
@@ -65,13 +65,13 @@ export class PathfindingService {
} }
private sortNodesByDistance(unvisitedNodes: Node[]): void { private sortNodesByDistance(unvisitedNodes: Node[]): void {
unvisitedNodes.sort((nodeA, nodeB) => nodeA.distance - nodeB.distance); unvisitedNodes.sort((nodeA, nodeB) => nodeA.nodeData - nodeB.nodeData);
} }
private updateUnvisitedNeighbors(node: Node, grid: Node[][]): void { private updateUnvisitedNeighbors(node: Node, grid: Node[][]): void {
const unvisitedNeighbors = this.getUnvisitedNeighbors(node, grid); const unvisitedNeighbors = this.getUnvisitedNeighbors(node, grid);
for (const neighbor of unvisitedNeighbors) { for (const neighbor of unvisitedNeighbors) {
neighbor.distance = node.distance + 1; neighbor.nodeData = node.nodeData + 1;
neighbor.linkedNode = node; neighbor.linkedNode = node;
} }
} }
@@ -79,10 +79,10 @@ export class PathfindingService {
// A* Search Algorithm // A* Search Algorithm
aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } { aStar(grid: Node[][], startNode: Node, endNode: Node): { visitedNodesInOrder: Node[], nodesInShortestPathOrder: Node[] } {
const visitedNodesInOrder: Node[] = []; const visitedNodesInOrder: Node[] = [];
startNode.distance = 0; startNode.nodeData = 0;
startNode['hScore'] = this.calculateHeuristic(startNode, endNode); startNode['hScore'] = this.calculateHeuristic(startNode, endNode);
// fScore = gScore + hScore // fScore = gScore + hScore
startNode['fScore'] = startNode.distance + startNode['hScore']; startNode['fScore'] = startNode.nodeData + startNode['hScore'];
const openSet: Node[] = [startNode]; const openSet: Node[] = [startNode];
const allNodes = this.getAllNodes(grid); const allNodes = this.getAllNodes(grid);
@@ -97,7 +97,7 @@ export class PathfindingService {
continue; continue;
} }
const isTrapped = currentNode.distance === Infinity; const isTrapped = currentNode.nodeData === Infinity;
if (isTrapped) if (isTrapped)
{ {
return {visitedNodesInOrder, nodesInShortestPathOrder: []}; return {visitedNodesInOrder, nodesInShortestPathOrder: []};
@@ -114,9 +114,9 @@ export class PathfindingService {
const neighbors = this.getUnvisitedNeighbors(currentNode, grid); const neighbors = this.getUnvisitedNeighbors(currentNode, grid);
for (const neighbor of neighbors) { for (const neighbor of neighbors) {
const tentativeGScore = currentNode.distance + 1; // Distance from start to neighbor const tentativeGScore = currentNode.nodeData + 1; // Distance from start to neighbor
if (tentativeGScore < neighbor.distance) { if (tentativeGScore < neighbor.nodeData) {
this.updateNeighborNode(neighbor, currentNode, tentativeGScore, endNode, openSet); this.updateNeighborNode(neighbor, currentNode, tentativeGScore, endNode, openSet);
} }
} }
@@ -137,10 +137,10 @@ export class PathfindingService {
private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) { private updateNeighborNode(neighbor: Node, currentNode: Node, tentativeGScore: number, endNode: Node, openSet: Node[]) {
neighbor.linkedNode = currentNode; neighbor.linkedNode = currentNode;
neighbor.distance = tentativeGScore; neighbor.nodeData = tentativeGScore;
neighbor['distance'] = this.calculateHeuristic(neighbor, endNode); neighbor['nodeData'] = this.calculateHeuristic(neighbor, endNode);
neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode); neighbor['hScore'] = this.calculateHeuristic(neighbor, endNode);
neighbor['fScore'] = neighbor.distance + neighbor['hScore']; neighbor['fScore'] = neighbor.nodeData + neighbor['hScore'];
if (!openSet.includes(neighbor)) { if (!openSet.includes(neighbor)) {
openSet.push(neighbor); openSet.push(neighbor);
@@ -151,7 +151,7 @@ export class PathfindingService {
for (const node of allNodes) { for (const node of allNodes) {
if (node !== startNode) { if (node !== startNode) {
node['fScore'] = Infinity; node['fScore'] = Infinity;
node.distance = Infinity; // gScore node.nodeData = Infinity; // gScore
} }
} }
} }

View File

@@ -14,4 +14,12 @@ export class SharedFunctions {
static randomEventIntFromInterval(interval: number): number { static randomEventIntFromInterval(interval: number): number {
return Math.floor(Math.random() * (interval / 2)) * 2; return Math.floor(Math.random() * (interval / 2)) * 2;
} }
static shuffleArray<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
} }

View File

@@ -364,6 +364,8 @@
}, },
"LABYRINTH": { "LABYRINTH": {
"TITLE": "Labyrinth-Erzeugung", "TITLE": "Labyrinth-Erzeugung",
"PRIM": "Erzeuge Prim's Labyrinth",
"KRUSKAL": "Erzeuge Kruskal's Labyrinth",
"EXPLANATION": { "EXPLANATION": {
"TITLE": "Algorithmen", "TITLE": "Algorithmen",
"PRIM_EXPLANATION": "startet an einem zufälligen Punkt und erweitert das Labyrinth, indem er immer eine zufällige benachbarte Wand zu einer bereits besuchten Zelle auswählt und diese öffnet. Vorteil: Erzeugt sehr gleichmäßige, natürlich wirkende Labyrinthe mit vielen kurzen Sackgassen. Visuell wirkt es wie ein organisches Wachstum von einem Zentrum aus.", "PRIM_EXPLANATION": "startet an einem zufälligen Punkt und erweitert das Labyrinth, indem er immer eine zufällige benachbarte Wand zu einer bereits besuchten Zelle auswählt und diese öffnet. Vorteil: Erzeugt sehr gleichmäßige, natürlich wirkende Labyrinthe mit vielen kurzen Sackgassen. Visuell wirkt es wie ein organisches Wachstum von einem Zentrum aus.",

View File

@@ -363,6 +363,8 @@
}, },
"LABYRINTH": { "LABYRINTH": {
"TITLE": "Labyrinth Generation", "TITLE": "Labyrinth Generation",
"PRIM": "Generate Prim's Labyrinth",
"KRUSKAL": "Generate Kruskal's Labyrinth",
"EXPLANATION": { "EXPLANATION": {
"TITLE": "Algorithms", "TITLE": "Algorithms",
"PRIM_EXPLANATION": "starts at a random point and expands the labyrinth by always selecting a random neighboring wall of an already visited cell and opening it. Advantage: Produces very uniform, natural-looking labyrinths with many short dead ends. Visually, it appears like organic growth from a central point.", "PRIM_EXPLANATION": "starts at a random point and expands the labyrinth by always selecting a random neighboring wall of an already visited cell and opening it. Advantage: Produces very uniform, natural-looking labyrinths with many short dead ends. Visually, it appears like organic growth from a central point.",