"use strict"; // It's strict! // // Helper functions // // Return the id for the opposite direction of the direction given. function oppositeDir( iDir ) { if( iDir%2 === 1 ) return iDir-1; return iDir+1; } /** * Create a new button element using the dom that has the given label, * and calls the movePlayer method on the given map, along the provided * dimension, and in the specified direction. * * The button created is then returned. */ function createMoveButton( rMazeMap, iDim, iDir, sLabel ) { let btn = document.createElement('button'); btn.addEventListener( 'click', MazeMap.prototype.movePlayer.bind( rMazeMap, iDim, iDir ) ); if( sLabel === null || sLabel === '' ) { btn.appendChild( document.createTextNode('Dim ' + (j+1) + ': -') ); } else { btn.appendChild( document.createTextNode( sLabel ) ); } return btn; } /** * Class: RandomLcg * * This implements a Linear Congruential Generator PRNG. Is this the best * PRNG? Nope! Is it decently random for our purposes, sure. * * Why was this implemented? I wanted to be able to share mazes, in order to * do that we needed two things that we can't get from JS by default: * 1. To be able to set the seed (and get it if possible). * 2. To be sure that the same algorithm would be used on every version of * every browser. * * Unfortunately JavaScript doesn't gurantee either of these things, so * instead of writing a CMWC and using a bunch of memory I just used the * settings from the glibc random function and here we are. * * @constructor */ function RandomLcg() { // Current state this.iState = 0; // Initial seed, remember this so we can display it easily later. this.iSeed = 0; // Set the seed randomly to start. this.inventSeed(); } // // Replace the current seed and reset the state. // RandomLcg.prototype.setSeed = function setSeed( iSeed ) { this.iState = this.iSeed = iSeed; } // // Get the seed that was initially used on this random number generator. // RandomLcg.prototype.getSeed = function getSeed() { return this.iSeed; } // // Make up a seed. // RandomLcg.prototype.inventSeed = function inventSeed() { // Based on my reading it's safest to assume that we can get 16 bits worth // of random data reliably. Let's build a 32 bit number from two 16 bit // numbers. this.setSeed( (Math.floor(Math.random()*0xffff)) | (Math.floor(Math.random()*0xffff)<<16) ); } // // Get us a random number between 0 and 1.0, exclusive of the upper bound. // RandomLcg.prototype.random = function random() { this.iState = ((this.iState * 1103515245) + 12345) & 0x7fffffff; return this.iState/(0x7fffffff+1); } // // Get us a random integer between 0 and max, exclusive of the upper bound. // RandomLcg.prototype.randInt = function randInt( max ) { return Math.floor(this.random() * max); } // Lets just build a shared prng object to use all over. let lRand = new RandomLcg(); /** * Class: Signal * * Super simple implementation of a signal/slot concept. I didn't need most of * the features, so this just lets us connect 0-parameter functions and call * them en-masse whenever the signal is fired. * * @constructor */ function Signal() { this.aSlot = new Array(); } // // Connect this signal to a new slot (function to call). I recommend using bind // to create valid object-function references that have state supposed to // disembodied functions. // Signal.prototype.connect = function connect( fSlot ) { this.aSlot.push( fSlot ); } // // Trigger the signal, and notify all slots bound to this signal. // Signal.prototype.emit = function emit() { for( let j = 0; j < this.aSlot.length; j++ ) { (this.aSlot[j])(); } } /** * Class: Cell * * Simple container that tracks info about a cell in the maze. * * @constructor */ function Cell() { this.iDist = 0; this.iPath = 0; this.iWalls = 0; } /** * Class: Position * * A simple class that keeps track of coordinates in N-dimensional space. * That's really just an array of numbers with N spaces in it. * * @constructor */ function Position( iDims, Vals ) { if( typeof iDims === 'string' ) { let sChunks = iDims.split(','); this.iDims = sChunks.length; this.aiValues = new Array(this.iDims); for( let j = 0; j < this.iDims; j++ ) { this.aiValues[j] = parseInt( sChunks[j].trim(), 10 ); } } else { // Store dimension count this.iDims = iDims; // Check to see if Vals is a non-empty array if( Array.isArray(Vals) && Vals.length > 0 ) { // Make sure Vals has the right number of elements if( Vals.length != iDims ) { throw new Error( 'Position must be initialized with no dimensional data, '+ 'or the correct number of elements.'); } // If it does have the correct number of elements, just // use it instead of creating a new array this.aiValues = Vals; } else { // We don't have values from the constructor, let's just // create a new blank one... this.aiValues = new Array( this.iDims ); // ...and set the position to zero in all dimensions. for( let j = 0; j < this.iDims; j++ ) { this.aiValues[j] = 0; } } } } // // Get the number of dimensions defined in this position. // Position.prototype.getDims = function getDims() { return this.iDims; } // // Get the value of one dimension of the coordinate in this position. // Position.prototype.get = function get( iDim ) { return this.aiValues[iDim]; } // // Set the value of one dimension of the coordinate in this position. // This modifies the position in place. // Position.prototype.set = function set( iDim, iVal ) { this.aiValues[iDim] = iVal; } // // Apply a delta to the specified dimension in the current position. // This modifies the position in place. // Position.prototype.add = function add( iDim, iDelta ) { this.aiValues[iDim] += iDelta; return this.aiValues[iDim]; } // // Copy the current position object and return one with a modified value in // the specified dimension. // Position.prototype.translate = function translate( iDim, iDelta ) { let tmp = new Position( this.iDims, this.aiValues.slice() ); tmp.add( iDim, iDelta ); return tmp; } // // Return an exact copy of this position object. // Position.prototype.copy = function translate() { return new Position( this.iDims, this.aiValues.slice() ); } // // Compare two position objects for equality. Return true if they are the same, // false otherwise. // Position.prototype.equals = function equals( rhs ) { if( this.iDims != rhs.iDims && this.aiValues.length != rhs.aiValues.length ) return false; for( let j = 0; j < this.aiValues.length; j++ ) { if( this.aiValues[j] != rhs.aiValues[j] ) return false; } return true; } // // Converts the position to a nicely formatted string of numbers. // Position.prototype.toString = function toString() { let ret = this.aiValues[0].toString(); for( let j = 1; j < this.aiValues.length; j++ ) { ret += ',' + this.aiValues[j].toString(); } return ret; } /** * Class: MazeMap * * The maze itself. This doesn't do a whole lot on it's own except track data * and manage the player position and worms. The worms do the real work of * generating a maze. * * @constructor */ function MazeMap( Dimensions ) { // Store dimensional data this.Dimensions = Dimensions; this.aWorms = new Array(); this.pPlayer = null; this.pGoal = null; this.ePlayerMoved = new Signal(); this.eVictory = new Signal(); this.ePlayerSetup = new Signal(); // Compute the total number of cells let iTotalSize = 1; for( let j = 0; j < Dimensions.getDims(); j++ ) { iTotalSize *= Dimensions.get( j ); } // Allocate cell array, and initialize cells this.aCell = new Array( iTotalSize ); for( let j = 0; j < iTotalSize; j++ ) { this.aCell[j] = new Cell(); } } // // Get the number of dimensions in this maze. // MazeMap.prototype.getDims = function getDims() { return this.Dimensions.getDims(); } // // Get the size of the specified dimension. // MazeMap.prototype.getSize = function getSize( iDim ) { return this.Dimensions.get( iDim ); } // // Get a reference to the current player position in the maze. // MazeMap.prototype.getPlayerPos = function getPlayerPos() { return this.pPlayer; } // // Replace the player position with a new position. // MazeMap.prototype.setPlayerPos = function setPlayerPos( pNewPos ) { this.pPlayer = pNewPos; this.ePlayerMoved.emit(); } // // Move the player the specified amount (iDelta) along the specified dimension // (iDim). This takes walls and maze borders into account, and will not move // the player in an "illegal" way. // MazeMap.prototype.movePlayer = function movePlayer( iDim, iDelta ) { let cCur = this.get( this.pPlayer ); let iBit = iDim*2; if( iDelta > 0 ) iBit++; if( (cCur.iWalls&(1<= this.getSize( iDim ) ) return; this.pPlayer.add( iDim, iDelta ); this.ePlayerMoved.emit(); if( this.pPlayer.equals( this.pGoal ) ) { this.eVictory.emit(); } } // // Helper that determines if the provided position is inside the maze or not. // MazeMap.prototype.isInside = function isInside( Position ) { if( Position.getDims() != this.Dimensions.getDims() ) { throw new Error( 'Number of dimensions in map and position do not match.' ); } for( let j = 0; j < this.getDims(); j++ ) { if( Position.get( j ) < 0 ) return false; if( Position.get( j ) >= this.Dimensions.get( j ) ) return false; } return true; } // // Internal helper function. This converts from a Position object to an array // index, effectively flattening an arbitrarily dimensional coordinate into a // one dimensional array coordinate. This is used to find the actual storage // location of cells internally. // MazeMap.prototype.getIndex = function getIndex( Position ) { if( !this.isInside( Position ) ) { throw new Error('Position is outside of map.'); } let iIdx = 0; let iScale = 1; for( let j = 0; j < this.getDims(); j++ ) { iIdx += Position.get( j ) * iScale; iScale *= this.Dimensions.get( j ); } return iIdx; } // // Get a cell at the given Position. // MazeMap.prototype.get = function get( Position ) { return this.aCell[this.getIndex( Position )]; } // // Create a new worm and add it to the maze. Specify the starting position // and the loop chance (betweer 0.0 and 1.0). This returns the ID that the // added worm was assigned, which starts at one and goes up from there. // MazeMap.prototype.addWorm = function addWorm( pStart, dLoopChance ) { if( this.pPlayer === null ) { this.pPlayer = pStart; this.ePlayerSetup.emit(); } else if( this.pGoal === null ) { this.pGoal = pStart; } let iNewId = this.aWorms.length+1; this.aWorms.push( new Worm( iNewId, pStart, this, dLoopChance ) ); return iNewId; } // // Worker function. This calls the timestep funcion on each worm until they // report that they are done working and have exhausted all possible moves. // // At the moment this function assumes we have 2 worms and connects them // automatically once it's done running. // MazeMap.prototype.buildMaze = function buildMaze() { do { for( let j = 0; j < this.aWorms.length; j++ ) { if( !this.aWorms[j].timestep() ) { this.aWorms.splice( j, 1 ); j--; } } } while( this.aWorms.length > 0 ); this.connect( 1, 2 ); } // // Connect the pathways created by two worms, specified by iWormId1 and // iWormId2 to each other. this searches all walls in the maze and looks for // a wall that seperates the path created by these two worms, then finds the // wall that seperates the longest combined pathway between the two, and opens // it up into a pathway. // MazeMap.prototype.connect = function connect( iWormId1, iWormId2 ) { let p = new Position( this.getDims() ); let pMax1 = null; let pMax2 = null; let iDistMax = 0; let iDirMax = 0; let iDim = 0; for(;;) { let c = this.get( p ); if( c.iPath === iWormId1 || c.iPath === iWormId2 ) { // This cell is one of the two paths we want to connect, let's // see if there's a cell from the other path nearby. for( iDim = 0; iDim < this.getDims(); iDim++ ) { // Look 'down' in the current dimension let t = p.translate( iDim, -1 ); for( let iDir = 0; iDir < 2; iDir++ ) { // Is the current position inside the maze? if( t.get( iDim ) >= 0 && t.get( iDim ) < this.getSize( iDim ) ) { // Get cell here. let c2 = this.get( t ); if( c.iPath !== c2.iPath && (c2.iPath === iWormId1 || c2.iPath === iWormId2 ) ) { let iDist = c.iDist + c2.iDist; if( iDist > iDistMax ) { iDistMax = iDist; pMax1 = p.copy(); pMax2 = t.copy(); iDirMax = iDim*2+iDir; } } } // Look the other direction t.add( iDim, 2 ); } } } // This is the rediculous engine that lets us iterate through // the entire maze, one cell at a time. This basically increments our // position by one, but wraps at the edges of the maze. for( iDim = 0; iDim < this.getDims(); iDim++ ) { let iNewVal = p.add( iDim, 1 ); if( iNewVal < this.getSize( iDim ) ) break; p.set( iDim, 0 ); } // If we ran out of dimensions then it means that we hit the last // cell in the grid. if( iDim == this.getDims() ) break; } this.get( pMax1 ).iWalls |= (1< 0 ) { // We are near a wall, pick a random wall to open a hole in this.rMazeMap.get(this.pPosition).iWalls |= iDirs[lRand.randInt(iDirs.length)]; this.rMazeMap.get(this.pPosition).iPath = this.iId; } } // // Perform one step as descirbed in the constructor. // Worm.prototype.timestep = function timestep() { // Handy to reference how many dimensions we have let iDims = this.rMazeMap.getDims(); // Possible directions let pDirs = []; let pLoopDirs; let cCur; for(;;) { cCur = this.rMazeMap.get( this.pPosition ); let pBack = null; pLoopDirs = []; for( let j = 0; j < iDims; j++ ) { let iSize = this.rMazeMap.getSize( j ); let pPos = this.pPosition.translate( j, -1 ); if( pPos.get( j ) >= 0 ) { let xCell = this.rMazeMap.get( pPos ); if( xCell.iPath === 0 ) { pDirs.push( new Vector( pPos, j*2 ) ); } else if( xCell.iPath === this.iId && xCell.iDist === this.iDist-1 ) { pBack = pPos; } else if( (cCur.iWalls&(1<<(j*2))) === 0 && xCell.iPath === this.iId ) { pLoopDirs.push( new Vector( pPos, j*2 ) ); } } pPos = this.pPosition.translate( j, 1 ); if( pPos.get( j ) < iSize ) { let xCell = this.rMazeMap.get( pPos ); if( xCell.iPath === 0 ) { pDirs.push( new Vector( pPos, j*2+1 ) ); } else if( xCell.iPath === this.iId && xCell.iDist === this.iDist-1 ) { pBack = pPos; } else if( (cCur.iWalls&(1<<(j*2+1))) === 0 && xCell.iPath === this.iId ) { pLoopDirs.push( new Vector( pPos, j*2+1 ) ); } } } if( pDirs.length > 0 ) { break; } else { if( pBack !== null ) { this.pPosition = pBack; this.iDist--; } else { return false; } } } cCur = this.rMazeMap.get( this.pPosition ); let iSel = lRand.randInt( pDirs.length ); cCur.iWalls |= (1< 0 && lRand.random() <= this.dLoopChance ) { iSel = pLoopDirs[lRand.randInt( pLoopDirs.length )]; cCur.iWalls |= (1< window.innerHeight/2 ) iTargetSize = window.innerHeight/2; } this.iBorder = 3; this.iIconSize = Math.floor( ( (iTargetSize/this.rMazeMap.getSize( 0 )) - 3 - (this.iIconSquare*3) )/this.iIconSquare ); if( this.iIconSize > 15 ) this.iIconSize = 15; else if( this.iIconSize < 5 ) this.iIconSize = 5; this.iCellSize = this.iBorder + this.iIconSquare*(this.iIconSize+this.iBorder); this.eCanvas = document.createElement('canvas'); this.eCanvas.width = this.iCellSize*this.rMazeMap.getSize( 0 ); this.eCanvas.height = this.iCellSize*this.rMazeMap.getSize( 1 ); eMazeContainer.appendChild( this.eCanvas ); this.ctx = this.eCanvas.getContext("2d"); this.ctx.lineWidth = 1.0; this.ctx.font = Math.ceil(this.iIconSize) + 'px sans serif'; this.ctx.textBaseline = 'top'; this.aMoveButtons = []; this.render(); this.btnBox = eUIContainer; this.readoutBox = eReadoutBox; this.readoutNode = document.createTextNode(''); this.readoutBox.appendChild( this.readoutNode ); this.updateReadout(); let cardTbl; let cardRow; let cardTd; cardTbl = document.createElement('table'); cardRow = document.createElement('tr'); cardTbl.appendChild( cardRow ); cardRow.appendChild( document.createElement('td') ); cardTd = document.createElement('td'); this.aMoveButtons[2] = cardTd.appendChild( createMoveButton( this.rMazeMap, 1, -1, "North" ) ); cardRow.appendChild( cardTd ); cardRow.appendChild( document.createElement('td') ); if( this.rMazeMap.getDims() >= 3 ) { cardTd = document.createElement('td'); this.aMoveButtons[4] = cardTd.appendChild( createMoveButton( this.rMazeMap, 2, -1, "Up (^)" ) ); cardRow.appendChild( cardTd ); } cardRow = document.createElement('tr'); cardTbl.appendChild( cardRow ); cardTd = document.createElement('td'); this.aMoveButtons[0] = cardTd.appendChild( createMoveButton( this.rMazeMap, 0, -1, "West" ) ); cardRow.appendChild( cardTd ); cardRow.appendChild( document.createElement('td') ); cardTd = document.createElement('td'); this.aMoveButtons[1] = cardTd.appendChild( createMoveButton( this.rMazeMap, 0, 1, "East" ) ); cardRow.appendChild( cardTd ); if( this.rMazeMap.getDims() >= 3 ) { cardRow.appendChild( document.createElement('td') ); } cardRow = document.createElement('tr'); cardTbl.appendChild( cardRow ); cardRow.appendChild( document.createElement('td') ); cardTd = document.createElement('td'); this.aMoveButtons[3] = cardTd.appendChild( createMoveButton( this.rMazeMap, 1, 1, "South" ) ); cardRow.appendChild( cardTd ); cardRow.appendChild( document.createElement('td') ); if( this.rMazeMap.getDims() >= 3 ) { cardTd = document.createElement('td'); this.aMoveButtons[5] = cardTd.appendChild( createMoveButton( this.rMazeMap, 2, 1, "Down (v)" ) ); cardRow.appendChild( cardTd ); } this.btnBox.appendChild( cardTbl ); if( this.rMazeMap.getDims() >= 3 ) { cardTbl = document.createElement('table'); for( let j = 3; j < this.rMazeMap.getDims(); j++ ) { cardRow = document.createElement('tr'); cardTbl.appendChild( cardRow ); cardTd = document.createElement('td'); this.aMoveButtons[j*2] = cardTd.appendChild( createMoveButton( this.rMazeMap, j, -1, (j+1) + '-' ) ); cardRow.appendChild( cardTd ); cardTd = document.createElement('td'); this.aMoveButtons[j*2+1] = cardTd.appendChild( createMoveButton( this.rMazeMap, j, 1, (j+1) + '+' ) ); cardRow.appendChild( cardTd ); } this.btnBox.appendChild( cardTbl ); } this.updateButtons(); } // Setup RenderCanvas2D as a child class of Render RenderCanvas2D.prototype = Object.create(Render.prototype); RenderCanvas2D.prototype.constructor = RenderCanvas2D; // // Performs the bulk of the work of resetting and rendering the maze. This is // called whenever anything changes at all and the entire maze floor is redrawn. // // Since these are such simple graphics there's not much of an issue with this // approach. It could be optomized and only the parts that have changed could // bo modified, but it's probably not worth it in the long run. We're as // likely to travel along any dimension as X or Y, and every dimension other // than X and Y requrie a full redraw of the maze. // RenderCanvas2D.prototype.render = function render() { let iSize = this.iCellSize; this.ctx.clearRect( 0, 0, this.eCanvas.width, this.eCanvas.height ); this.ctx.beginPath(); let p; if( this.rMazeMap.pPlayer === null ) p = this.pExtPosition.copy(); else p = this.rMazeMap.pPlayer.copy(); let iPlayerIcon = Math.floor(this.iIconSquare*0.5) + Math.floor(this.iIconSquare*0.5) * this.iIconSquare; this.ctx.beginPath(); this.ctx.strokeStyle = 'whitesmoke'; for( let x = 0; x < this.rMazeMap.getSize( 0 ); x++ ) { this.ctx.moveTo( x*iSize, 0 ); this.ctx.lineTo( x*iSize, this.rMazeMap.getSize( 1 )*iSize ); } for( let y = 0; y < this.rMazeMap.getSize( 1 ); y++ ) { this.ctx.moveTo( 0, y*iSize ); this.ctx.lineTo( this.rMazeMap.getSize( 0 )*iSize, y*iSize ); } this.ctx.stroke(); this.ctx.beginPath(); this.ctx.strokeStyle = 'black'; for( let x = 0; x < this.rMazeMap.getSize( 0 ); x++ ) { for( let y = 0; y < this.rMazeMap.getSize( 1 ); y++ ) { p.set( 0, x ); p.set( 1, y ); let c = this.rMazeMap.get( p ); if( p.equals( this.rMazeMap.pGoal ) ) { let oldStyle = this.ctx.fillStyle; this.ctx.fillStyle = 'palegreen'; this.ctx.fillRect( x*iSize+2, y*iSize+2, iSize-4, iSize-4 ); this.ctx.fillStyle = oldStyle; } if( (c.iWalls&1) === 0 && x === 0) { this.ctx.moveTo( x*iSize, y*iSize ); this.ctx.lineTo( x*iSize, (y+1)*iSize ); } if( (c.iWalls&2) === 0 ) { this.ctx.moveTo( (x+1)*iSize, y*iSize ); this.ctx.lineTo( (x+1)*iSize, (y+1)*iSize ); } if( (c.iWalls&4) === 0 && y === 0) { this.ctx.moveTo( x*iSize, y*iSize ); this.ctx.lineTo( (x+1)*iSize, y*iSize ); } if( (c.iWalls&8) === 0 ) { this.ctx.moveTo( x*iSize, (y+1)*iSize ); this.ctx.lineTo( (x+1)*iSize, (y+1)*iSize ); } // Extended dimenisons (above 2 :-P) let iIcon = 0; for( let ed = 2; ed < this.rMazeMap.getDims(); ed++ ) { if( iIcon == iPlayerIcon ) iIcon++; if( (c.iWalls&(1<<(ed*2))) !== 0 ) { this.renderDirIcon( x, y, iIcon, ed*2 ); } iIcon++; if( iIcon == iPlayerIcon ) iIcon++; if( (c.iWalls&(1<<(ed*2+1))) !== 0 ) { this.renderDirIcon( x, y, iIcon, ed*2+1 ); } iIcon++; } } } this.ctx.stroke(); // Draw the player if( this.rMazeMap.pPlayer !== null ) { let bx = this.rMazeMap.pPlayer.get(0)*iSize + this.iBorder + Math.floor(iPlayerIcon%this.iIconSquare)*(this.iBorder+this.iIconSize); let by = this.rMazeMap.pPlayer.get(1)*iSize + this.iBorder + Math.floor(iPlayerIcon/this.iIconSquare)*(this.iBorder+this.iIconSize); this.rMazeMap.pPlayer.equals( p ); this.ctx.beginPath(); this.ctx.ellipse( bx+this.iIconSize*0.5, by+this.iIconSize*0.5, this.iIconSize*0.4, this.iIconSize*0.4, 0, Math.PI*2.0, false ); this.ctx.fill(); } } // // Helper function that draws the icons for the travel icons for dimensions // after the first two. // RenderCanvas2D.prototype.renderDirIcon = function renderDirIcon( x, y, iIcon, iDir ) { let bx = x*this.iCellSize + this.iBorder + (iIcon%this.iIconSquare)*(this.iIconSize+this.iBorder); let by = y*this.iCellSize + this.iBorder + Math.floor(iIcon/this.iIconSquare)*(this.iIconSize+this.iBorder); //this.ctx.rect(bx, by, this.iIconSize, this.iIconSize ); switch( iDir ) { case 4: // Up this.ctx.moveTo( bx, by+this.iIconSize ); this.ctx.lineTo( bx+this.iIconSize/2, by ); this.ctx.lineTo( bx+this.iIconSize, by+this.iIconSize ); break; case 5: // Down this.ctx.moveTo( bx, by ); this.ctx.lineTo( bx+this.iIconSize/2, by+this.iIconSize ); this.ctx.lineTo( bx+this.iIconSize, by ); break; default: let label = Math.floor((iDir/2.0)+1).toString() + (((iDir%2)===0)?'-':'+'); this.ctx.fillText( label, bx, by ); break; } } // // Slot that updates the enabled/disabled status of the UI buttons after the // player's position has changed. // RenderCanvas2D.prototype.updateButtons = function updateButtons() { let c = this.rMazeMap.get( this.rMazeMap.pPlayer ); for( let j = 0; j < this.rMazeMap.getDims()*2; j++ ) { this.aMoveButtons[j].disabled = (c.iWalls&(1<= 0 ) { trgUrl = trgUrl.substring(0, query); } trgUrl += '?seed=' + lRand.getSeed() + '&dims=' + p.toString(); dLink.href = trgUrl; dLink.appendChild( document.createTextNode('Share this maze!') ); d.appendChild(dLink); } // These are needed to export symbols we want to keep post minification window['lostInit'] = lostInit; window['RenderCanvas2D'] = RenderCanvas2D;