// this uses the Backtracking Algorithm to generate a maze class Maze_CTX2D { static get DEBUG_PARENT_OBJECT() { return true; }; static get AUTO_CONTINUE_ON_FOCUS() { return false; }; static get SHOW_FPS_INTERVAL() { return 1000 / 4; }; // four times per second static get KEY_ARROW_UP() { return 'ArrowUp'; }; static get KEY_ARROW_DOWN() { return 'ArrowDown'; }; static get KEY_ARROW_LEFT() { return 'ArrowLeft'; }; static get KEY_ARROW_RIGHT() { return 'ArrowRight'; }; static get KEY_W() { return 'w'; }; static get KEY_S() { return 's'; }; static get KEY_A() { return 'a'; }; static get KEY_D() { return 'd'; }; static get KEY_ENTER() { return 'Enter'; }; static get KEY_SPACEBAR() { return ' '; }; static get KEY_ESCAPE() { return 'Escape'; }; static get STATE_MENU() { return 0; }; static get STATE_MAP_GEN_ANIM() { return 1; }; static get STATE_PLAYING() { return 2; }; static get STATE_ENDED() { return 3; }; static get ANIMATION_DELAY() { return 10; }; animationRequestId = 0; running = false; constructor(divId, width, height, difficulty = 1, showFps = false) { this.createCanvas(divId, width, height); this.canvasEl.title = "Playing: Maze"; this.ctx = this.canvasEl.getContext("2d"); this.ctx.textAlign = "center"; this.audioCtx = new AudioContext(); this.sounds = []; this.sounds[0] = new Audio('./snd/short-success.mp3'); this.audioCtx.createMediaElementSource(this.sounds[0]).connect(this.audioCtx.destination); this.sounds[1] = new Audio('./snd/footstep.mp3'); this.audioCtx.createMediaElementSource(this.sounds[1]).connect(this.audioCtx.destination); this.sounds[2] = new Audio('./snd/glass-knock.mp3'); this.audioCtx.createMediaElementSource(this.sounds[2]).connect(this.audioCtx.destination); this.sounds[3] = new Audio('./snd/flash.mp3'); this.audioCtx.createMediaElementSource(this.sounds[3]).connect(this.audioCtx.destination); this.sounds[4] = new Audio('./snd/metronome.mp3'); this.audioCtx.createMediaElementSource(this.sounds[4]).connect(this.audioCtx.destination); this.showFps = showFps; if (showFps) { // add framecounter and fps variables and html this.frameCounter = 0; this.initTime = performance.now(); const el = document.createElement('p'); this.frameLabel = document.createElement('span'); const floatRight = document.createElement('span'); floatRight.style = "float: right;"; this.fpsCounter = 0; this.fpsLabel = document.createElement('span'); el.appendChild(document.createTextNode('fps: ')); el.appendChild(this.fpsLabel); floatRight.appendChild(document.createTextNode('frame: ')); floatRight.appendChild(this.frameLabel); el.appendChild(floatRight); this.divEl.appendChild(el); } this.setDifficulty(difficulty); this.gameState = Maze_CTX2D.STATE_MENU; this.canvasEl.addEventListener("keydown", (e) => this.onKeyDown(e)); this.canvasEl.addEventListener("keyup", (e) => this.onKeyUp(e)); this.canvasEl.addEventListener("blur", (e) => this.onBlur(e)); this.canvasEl.addEventListener("focus", (e) => this.onFocus(e)); if (Maze_CTX2D.DEBUG_PARENT_OBJECT) window.game = this; if (typeof WTerminal === "function") { WTerminal.terminalAddCommand("restartgame", (t) => this.terminalRestartGame(t)); WTerminal.terminalAddCommand("printgame", (t) => t.printVar(this, "maze")); WTerminal.printLn("new Maze: @", divId, ' ', width, 'x', height, ' difficulty=', difficulty, ' showFps=', showFps); } this.drawCanvas(); } newGame(gridWidth, gridHeight) { gridWidth -= gridWidth % 2; gridWidth++; gridHeight -= gridHeight % 2; gridHeight++; this.gridWidth = gridWidth; this.gridHeight = gridHeight; this.gridCellWidth = this.width / gridWidth; this.gridCellHeight = this.height / gridHeight; this.gridCellSize = Math.floor(Math.min(this.gridCellWidth, this.gridCellHeight)); this.mapWidth = gridWidth * this.gridCellSize; this.mapHeight = gridHeight * this.gridCellSize; this.mapOffsetX = (this.width - this.mapWidth) / 2; this.mapOffsetY = (this.height - this.mapHeight) / 2; var result = this.generateBacktrackingMaze(gridWidth, gridHeight); this.maze = result.maze; this.animation = result.animation; this.animationPos = 0; this.animationStepTime = 0; this.animationStepDelay = Maze_CTX2D.ANIMATION_DELAY / this.animation.length; this.player = { x: 1, y: 0 }; // console.log("animation", this.animation); } playSound(index) { try { const snd = this.sounds[index]; snd.currentTime = 0; snd.play(); } catch (e) { console.log(`Failed to play sound '${index}': ${e}}`) } } createCanvas(divId, width = 0, height = 0, zoom = 1) { this.divEl = document.getElementById(divId); if (this.divEl === null) throw new Error("elementId not found: " + divId); while (this.divEl.firstChild) { this.divEl.removeChild(this.divEl.lastChild); } this.canvasEl = this.divEl.appendChild(document.createElement("canvas")); const c = this.canvasEl; this.width = width; this.height = height; if (width > 0 && height > 0) { c.style.width = width * zoom + 'px'; c.style.height = height * zoom + 'px'; c.width = width; c.height = height; } c.tabIndex = 0; // improtant for keyboard focus! // c.style.imageRendering = 'pixelated'; } drawCanvas() { if (this.showFps) { this.frameCounter++; this.fpsCounter++; const now = performance.now(); const diff = now - this.initTime; if (Maze_CTX2D.SHOW_FPS_INTERVAL < diff || !this.running) { this.initTime = now; const seconds = diff / 1000; const fps = this.fpsCounter / seconds; this.fpsCounter = 0; if (this.frameLabel) this.frameLabel.innerHTML = this.frameCounter; if (this.fpsLabel) { this.fpsLabel.innerHTML = Math.round((fps + Number.EPSILON) * 100) / 100; if (!this.running) this.fpsLabel.innerHTML += " (not running)"; } } } const ctx = this.ctx; ctx.clearRect(0, 0, this.width, this.height); if (this.gameState == Maze_CTX2D.STATE_MENU) { ctx.strokeStyle = 'black'; ctx.fillStyle = "#4080ff"; const FONT_SIZE = this.width > 440 ? 24 : 16; const x = this.width / 2; const y = this.height / 2; let text = "Menu"; ctx.font = "bold " + (2 * FONT_SIZE) + "px serif"; ctx.fillText(text, x, y - 3 * FONT_SIZE); ctx.strokeText(text, x, y - 3 * FONT_SIZE); ctx.font = "bold " + FONT_SIZE + "px mono"; ctx.fillStyle = "#b0b0b0"; text = `Difficulty: ${this.difficulty}`; ctx.fillText(text, x, y + 0 * FONT_SIZE); ctx.strokeText(text, x, y + 0 * FONT_SIZE); text = `Size: ${this.gridWidth} x ${this.gridHeight}`; ctx.fillText(text, x, y + 2 * FONT_SIZE); ctx.strokeText(text, x, y + 2 * FONT_SIZE); if (this.isFocused) return; this.drawBanner("Click here to continue. (unfocused)"); } else if (this.gameState == Maze_CTX2D.STATE_MAP_GEN_ANIM) { ctx.fillStyle = "#404040"; ctx.fillRect(this.mapOffsetX, this.mapOffsetY, this.mapWidth, this.mapHeight); ctx.fillStyle = "blue"; for (let i = 0; i < this.animationPos; i++) { const animationStep = this.animation[i]; if (animationStep.state == 0) { //clearRect ctx.clearRect(this.mapOffsetX + animationStep.x * this.gridCellSize, this.mapOffsetY + animationStep.y * this.gridCellSize, this.gridCellSize, this.gridCellSize); } else { //fillRect // ctx.fillRect(this.mapOffsetX + animationStep.x * this.gridCellSize, // this.mapOffsetY + animationStep.y * this.gridCellSize, this.gridCellSize, this.gridCellSize); ctx.beginPath(); ctx.arc(this.mapOffsetX + (0.5 + animationStep.x) * this.gridCellSize, this.mapOffsetY + (0.5 + animationStep.y) * this.gridCellSize, this.gridCellSize / 2, 0, Math.PI * 2); ctx.fill(); } } if (!this.running) { if (this.isFocused) this.drawBanner("press space to continue. (paused)"); else this.drawBanner("Click here to continue. (unfocused)"); } } else {// Maze_CTX2D.STATE_ENDED & Maze_CTX2D.STATE_PLAYING ctx.fillStyle = "#505050"; for (let i = 0; i < this.maze.length; i++) { for (let j = 0; j < this.maze[i].length; j++) { if (this.maze[i][j] != 0) { ctx.fillRect(this.mapOffsetX + j * this.gridCellSize, this.mapOffsetY + i * this.gridCellSize, this.gridCellSize, this.gridCellSize); } } } ctx.fillStyle = "red"; ctx.beginPath(); ctx.arc(this.mapOffsetX + (0.5 + this.player.x) * this.gridCellSize, this.mapOffsetY + (0.5 + this.player.y) * this.gridCellSize, this.gridCellSize / 2, 0, Math.PI * 2); ctx.fill(); if (this.gameState == Maze_CTX2D.STATE_ENDED) { if (this.isFocused) this.drawBanner("Press space or enter to continue", "Victory!"); ctx.lineWidth = 3; for (let p of this.fireworks) { ctx.strokeStyle = p.color; if (p.type >= 1) { ctx.beginPath(); ctx.arc(p.x, p.y, p.size * 2, 0, Math.PI * 2); ctx.stroke(); } else { ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x + p.vx * 0.1, p.y + p.vy * 0.1); ctx.stroke(); } } ctx.lineWidth = 1; if (!this.isFocused) this.drawBanner("Click here to continue. (unfocused)", "Paused"); } if (this.gameState == Maze_CTX2D.STATE_PLAYING) { if (!this.isFocused) this.drawBanner("Click here to continue. (unfocused)", "Paused"); } } } drawBanner(innerText, titleText) { const ctx = this.ctx; const FONT_SIZE = this.width > 440 ? 24 : 16; const x = this.width / 2; const y = this.height / 2; const y2 = y - FONT_SIZE / 2; ctx.strokeStyle = 'black'; const g = ctx.createLinearGradient(0, 0, this.width, 0); g.addColorStop(0, '#404040a0'); g.addColorStop(0.2, '#404040df'); g.addColorStop(0.8, '#404040df'); g.addColorStop(1, '#404040a0'); ctx.fillStyle = g; ctx.fillRect(0, y2 - 2, this.width + 1, FONT_SIZE + 5 * 2); ctx.strokeRect(0, y2 - 2, this.width + 1, FONT_SIZE + 5 * 2); if (typeof innerText === "string") { ctx.fillStyle = 'goldenrod';//this.isFocused ? 'goldenrod' : 'lightgray'; ctx.font = FONT_SIZE + "px serif"; ctx.strokeText(innerText, x, y2 + FONT_SIZE); ctx.fillText(innerText, x, y2 + FONT_SIZE); } if (typeof titleText === "string") { ctx.fillStyle = this.isFocused ? 'red' : 'gray'; ctx.font = 4 * FONT_SIZE + "px serif"; ctx.fillText(titleText, x, y - 2 * FONT_SIZE); ctx.strokeText(titleText, x, y - 2 * FONT_SIZE); } } updateCanvas() { const now = performance.now(); const timeDelta = (now - this.prevNow) / 1000; //timeDelta = (milli - milli) / toSeconds this.prevNow = now; //#state switch if (this.gameState == Maze_CTX2D.STATE_MAP_GEN_ANIM) { this.animationStepTime += timeDelta; while (this.animationStepTime >= this.animationStepDelay) { this.animationStepTime -= this.animationStepDelay; if (this.animationPos < this.animation.length) { this.animationPos++; } else { this.gameState = Maze_CTX2D.STATE_PLAYING; this.running = false; this.playSound(3); } } } else if (this.gameState == Maze_CTX2D.STATE_ENDED) { this.fireworksStepTime += timeDelta; if (this.fireworksStepTime > 3) { this.boom(); this.fireworksStepTime = 0 + Math.random() * .5; } for (let p of this.fireworks) { p.time += timeDelta; if (p.time > 2) { p.time = 0; p.type += 1; } if (p.type == 0) { p.x += p.vx * timeDelta; p.y += p.vy * timeDelta; } else if (p.type == 1) { p.size += 10 * timeDelta; } else { p.size += timeDelta; } } while (this.fireworks.length > 0 && this.fireworks[0].type == 3) this.fireworks.shift(); } this.drawCanvas(); if (this.running) { // loop this.animationRequestId = requestAnimationFrame(() => this.updateCanvas()); } else { // not looping this.animationRequestId = 0; } } startRunning(fn) { if (this.animationRequestId != 0) cancelAnimationFrame(this.animationRequestId); this.running = true; this.prevNow = performance.now(); if (this.showFps) { this.initTime = performance.now(); } this.animationRequestId = requestAnimationFrame(fn); } close() { if (this.animationRequestId != 0) { cancelAnimationFrame(this.animationRequestId); this.animationRequestId = 0; } this.running = false; } pausePlayGame() { this.running = !this.running; if (this.running) { this.startRunning(() => this.updateCanvas()); } } terminalPrintGame(term) { term.printVar(this, "pong"); } terminalRestartGame(term) { term.terminalClose(); this.restartGame(); // this.canvasEl.focus(); setTimeout(() => { this.canvasEl.focus(); this.pausePlayGame(); }, 200); } setDifficulty(difficulty) { if (difficulty <= 0) difficulty = 0; this.difficulty = difficulty; this.gridWidth = 11 + 4 * this.difficulty; this.gridHeight = 11 + 4 * this.difficulty; } onKeyDown(e) { if (e.key == Maze_CTX2D.KEY_ESCAPE) { console.log('esc'); console.log('running:', this.running); if (this.running) { this.pausePlayGame(); } else { this.gameState = Maze_CTX2D.STATE_MENU; this.drawCanvas(); } e.preventDefault(); return false; } switch (this.gameState) { case Maze_CTX2D.STATE_MENU: if (e.key == Maze_CTX2D.KEY_ENTER || e.key == Maze_CTX2D.KEY_SPACEBAR) { // console.log("ENTER!"); this.newGame(this.gridWidth, this.gridHeight); this.gameState = Maze_CTX2D.STATE_MAP_GEN_ANIM; this.startRunning(() => this.updateCanvas()); this.playSound(4); e.preventDefault(); return false; } if (e.key == Maze_CTX2D.KEY_S || e.key == Maze_CTX2D.KEY_ARROW_DOWN || e.key == Maze_CTX2D.KEY_A || e.key == Maze_CTX2D.KEY_ARROW_LEFT) { this.setDifficulty(this.difficulty - 1); this.playSound(1); this.drawCanvas(); e.preventDefault(); return false; } if (e.key == Maze_CTX2D.KEY_W || e.key == Maze_CTX2D.KEY_ARROW_UP || e.key == Maze_CTX2D.KEY_D || e.key == Maze_CTX2D.KEY_ARROW_RIGHT) { this.setDifficulty(this.difficulty + 1); this.playSound(1); this.drawCanvas(); e.preventDefault(); return false; } break; case Maze_CTX2D.STATE_MAP_GEN_ANIM: if (e.key == Maze_CTX2D.KEY_ENTER || e.key == Maze_CTX2D.KEY_SPACEBAR) { if (this.running) { this.animationPos = this.animation.length; } else { this.startRunning(() => this.updateCanvas()); } e.preventDefault(); return false; } break; case Maze_CTX2D.STATE_PLAYING: if (e.key == 'r') { this.gameState = Maze_CTX2D.STATE_MAP_GEN_ANIM; this.animationPos = 0; this.animationStepTime = 0; this.startRunning(() => this.updateCanvas()); e.preventDefault(); return false; } if (e.key == Maze_CTX2D.KEY_W || e.key == Maze_CTX2D.KEY_ARROW_UP) { if (this.player.y > 0) { if (this.maze[this.player.y - 1][this.player.x] == 0) { this.player.y -= 1; this.drawCanvas(); this.playSound(1); } else { this.playSound(2); } } e.preventDefault(); return false; } if (e.key == Maze_CTX2D.KEY_S || e.key == Maze_CTX2D.KEY_ARROW_DOWN) { if (this.player.y < this.gridHeight - 1) { if (this.maze[this.player.y + 1][this.player.x] == 0) { this.player.y += 1; if (this.player.y == this.gridHeight - 1) { this.gameState = Maze_CTX2D.STATE_ENDED; this.fireworks = []; this.fireworksStepTime = 0; this.boom(); this.playSound(0); // this.drawCanvas(); this.startRunning(() => this.updateCanvas()) } else { this.drawCanvas(); } this.playSound(1); } else { this.playSound(2); } } e.preventDefault(); return false; } if (e.key == Maze_CTX2D.KEY_A || e.key == Maze_CTX2D.KEY_ARROW_LEFT) { if (this.player.x > 0) { if (this.maze[this.player.y][this.player.x - 1] == 0) { this.player.x -= 1; this.drawCanvas(); this.playSound(1); } else { this.playSound(2); } } e.preventDefault(); return false; } if (e.key == Maze_CTX2D.KEY_D || e.key == Maze_CTX2D.KEY_ARROW_RIGHT) { if (this.player.x < this.gridWidth) { if (this.maze[this.player.y][this.player.x + 1] == 0) { this.player.x += 1; this.drawCanvas(); this.playSound(1); } else { this.playSound(2); } } e.preventDefault(); return false; } break; case Maze_CTX2D.STATE_ENDED: if (e.key == Maze_CTX2D.KEY_ENTER || e.key == Maze_CTX2D.KEY_SPACEBAR) { this.gameState = Maze_CTX2D.STATE_MENU; e.preventDefault(); return false; } break; } // if (e.key == Maze_CTX2D.KEY_ARROW_UP || e.key == Maze_CTX2D.KEY_W) { // this.human.vy = -this.speedHuman; // e.preventDefault(); // return false; // } else if (e.key == Maze_CTX2D.KEY_ARROW_DOWN || e.key == Maze_CTX2D.KEY_S) { // this.human.vy = this.speedHuman; // e.preventDefault(); // return false; // } else if (e.key == Maze_CTX2D.KEY_ENTER || e.key == Maze_CTX2D.KEY_SPACEBAR) { // //# next round/pause/play // if (this.gameState == Maze_CTX2D.STATE_ENDED) { // this.newRound(); // this.startRunning(() => this.updateCanvas()); // } else { // this.pausePlayGame(); // } // e.preventDefault(); // return false; // } return true; } onKeyUp(e) { // if (e.key == Maze_CTX2D.KEY_ARROW_UP || e.key == Maze_CTX2D.KEY_W) { // this.human.vy = 0; // e.preventDefault(); // return false; // } else if (e.key == Maze_CTX2D.KEY_ARROW_DOWN || e.key == Maze_CTX2D.KEY_S) { // this.human.vy = 0; // e.preventDefault(); // return false; // } return true; } onBlur() { this.isFocused = false; this.canvasEl.style.borderColor = null; if (this.running) { this.pausePlayGame(); } else { this.drawCanvas(); } } onFocus() { this.isFocused = true; this.canvasEl.style.borderColor = "red"; if (!this.running && (Maze_CTX2D.AUTO_CONTINUE_ON_FOCUS || this.gameState == Maze_CTX2D.STATE_MAP_GEN_ANIM || this.gameState == Maze_CTX2D.STATE_ENDED)) { this.pausePlayGame(); } else { this.drawCanvas(); } } boom() { const colors = ['red', 'green', 'blue', 'violet', 'orange', 'goldenrod']; const color = colors[Math.floor(Math.random() * colors.length)]; const cx = this.width / 2; const cy = this.height / 2; const speed = 100; const size = 10; const count = Math.random() < 0.75 ? 2 : 3; for (let i = 0; i < count; i++) { let p = {} p.x = cx; p.y = cy; let rot = Math.random() * Math.PI * .5 - 0.75 * Math.PI; p.vx = Math.cos(rot) * speed; p.vy = Math.sin(rot) * speed; p.type = 0; p.time = 0 + Math.random() * 0.5; p.size = size + Math.random() * 10; p.color = color; this.fireworks.push(p) } } generateBacktrackingMaze(width, height) { // Make them odd width -= width % 2; width++; height -= height % 2; height++; // Fill maze with 1's (walls) let maze = []; for (let i = 0; i < height; i++) { maze.push([]); for (let j = 0; j < width; j++) { maze[i].push(1); } } let anim = []; // Opening at top - start of maze maze[0][1] = 0; let start = []; do { start[0] = Math.floor(Math.random() * height) } while (start[0] % 2 == 0); do { start[1] = Math.floor(Math.random() * width) } while (start[1] % 2 == 0); maze[start[0]][start[1]] = 0; anim.push({ state: 1, x: start[1], y: start[0] }); // First open cell let openCells = [start]; while (openCells.length) { let cell, n; // Add unnecessary element for elegance of code // Allows openCells.pop() at beginning of do while loop openCells.push([-1, -1]); // Define current cell as last element in openCells // and get neighbors, discarding "locked" cells do { let step = openCells.pop(); if (step[0] > 0 && step[1] > 0) anim.push({ state: 0, x: step[1], y: step[0] }); if (openCells.length == 0) break; cell = openCells[openCells.length - 1]; n = this.getMazeNeighbors(maze, cell[0], cell[1]); } while (n.length == 0 && openCells.length > 0); // If we're done, don't bother continuing if (openCells.length == 0) break; // Choose random neighbor and add it to openCells let choice = n[Math.floor(Math.random() * n.length)]; openCells.push(choice); // Set neighbor to 0 (path, not wall) // Set connecting node between cell and choice to 0 let connectY = (choice[0] + cell[0]) / 2; let connectX = (choice[1] + cell[1]) / 2; maze[choice[0]][choice[1]] = 0; maze[connectY][connectX] = 0; anim.push({ state: 0, x: connectX, y: connectY }); anim.push({ state: 1, x: choice[1], y: choice[0] }); } // Opening at bottom - end of maze maze[maze.length - 1][maze[0].length - 2] = 0; // maze[maze.length - 2][maze[0].length - 2] = 0; anim.push({ state: 0, x: maze[0].length - 2, y: maze.length - 1 }); // anim.push({ state: 0, x: maze[0].length - 1, y: maze.length - 1 }); return { maze: maze, animation: anim }; } getMazeNeighbors(maze, ic, jc) { let final = []; for (let i = 0; i < 4; i++) { let n = [ic, jc]; // Iterates through four neighbors // [i][j - 2] // [i][j + 2] // [i - 2][j] // [i + 2][j] n[i % 2] += ((Math.floor(i / 2) * 2) || -2); if (n[0] < maze.length && n[1] < maze[0].length && n[0] > 0 && n[1] > 0) { if (maze[n[0]][n[1]] == 1) { final.push(n); } } } return final; } }//-> class Maze_CTX2D function startMaze(divId, width = 480, height = 320, difficulty = 1, showFps = true) { return new Maze_CTX2D(divId, width, height, difficulty, showFps); }