<header>
  <p>
    <strong>Undo Heroes</strong>—Focus the maze, then navigate the maze with arrow keys.
    Use your browser's Undo/Redo buttons to recover from your mistakes!
  </p>
</header>
<p>Moves <span class="count" id="movecount">0</span></p>
<div id="maze" tabindex="0" autofocus>
  <button data-action="Left">⬅️</button>
  <button data-action="Right">➡️</button>
  <button data-action="Up">⬆️</button>
  <button data-action="Down">⬇️</button>
  <div id="goal" class="cell">🎖️</div>
  <div id="char" class="cell">😅</div>
</div>
  <p>
    To get this behavior on your own sites, check out <a href="https://github.com/samthor/undoer" target="_blank">Undoer</a> ('undoer' on NPM).
  </p>
body {
  display: flex;
  flex-flow: column;
  align-items: center;
  font-family: 'Roboto', 'Arial', sans-serif;
  font-size: 14px;
}

header {
  text-align: center;
  max-width: 320px;
}

p {
  margin: 0.5em 0;
}

.count {
  background: blue;
  display: inline-block;
  min-width: 4ch;
  border-radius: 4px;
  text-align: center;
  color: white;
}

#maze {
  margin: 0.5em 0;
  font-size: 24px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
  width: 13em;
  height: 13em;
  border: 4px solid #ccc;
  border-radius: 2px;
  background: #666;
  position: relative;

  #char, #goal {
    background: transparent;
    will-change: transform;
    z-index: 100;
    line-height: 32px;
    text-align: center;
  }
  #char {
    z-index: 102;
  }
  .cell {
    width: 1em;
    height: 1em;
    display: block;
    position: absolute;
    box-sizing: border-box;
    background: white;
  }

  .excite {
    z-index: 101;
    background: transparent;
    transition: transform 1.25s, opacity 1s 0.5s;
  }

  button {
    font: inherit;
    background: transparent;
    position: absolute;
    width: 1em;
    height: 1em;
    margin: 0;
    margin-left: -0.5em;
    margin-top: -0.5em;
    padding: 0;
    display: block;
    border: none;

    &:focus {
      outline: none;
      text-shadow: 0 0 1px blue;
      filter: hue-rotate(20deg) contrast(200%);
    }
  }
  button[data-action="Left"] {
    left: 0;
    top: 50%;
  }
  button[data-action="Up"] {
    left: 50%;
    top: 0;
  }
  button[data-action="Right"] {
    left: 100%;
    top: 50%;
  }
  button[data-action="Down"] {
    left: 50%;
    top: 100%;
  }
}

// nb. This is copy of https://github.com/samthor/undoer's code.

class Undoer {

  /**
   * @template T
   * @param {function(T)} callback to call when undo/redo occurs
   * @param {T=} zero the zero state for undoing everything
   */
  constructor(callback, zero=null) {
    this._duringUpdate = false;
    this._stack = [zero];
 
    // nb. Previous versions of this used `input` for browsers other than Firefox (as Firefox
    // _only_ supports execCommand on contentEditable)
    this._ctrl = document.createElement('div');
    this._ctrl.setAttribute('aria-hidden', 'true');
    this._ctrl.style.opacity = 0;
    this._ctrl.style.position = 'fixed';
    this._ctrl.style.top = '-1000px';
    this._ctrl.style.pointerEvents = 'none';
    this._ctrl.tabIndex = -1;

    this._ctrl.contentEditable = true;
    this._ctrl.textContent = '0';
    this._ctrl.style.visibility = 'hidden';  // hide element while not used

    this._ctrl.addEventListener('focus', (ev) => {
      // Safari needs us to wait, can't blur immediately.
      window.setTimeout(() => void this._ctrl.blur(), 0);
    });
    this._ctrl.addEventListener('input', (ev) => {
    console.info('got input', this._duringUpdate);
      if (!this._duringUpdate) {
        callback(this.data);
      }

      // clear selection, otherwise user copy gesture will copy value
      // nb. this _probably_ won't work in SD
      // nb. this is mitigated by the fact that we set visibility: 'hidden'
      const s = window.getSelection();
      if (s.containsNode(this._ctrl, true)) {
        s.removeAllRanges();
      }
    });
  }

  /**
   * @return {number} the current stack value
   */
  get _depth() {
    return +(this._ctrl.textContent) || 0;
  }

  /**
   * @return {T} the current data
   * @export
   */
  get data() {
    return this._stack[this._depth];
  }

  /**
   * Pushes a new undoable event. Adds to the browser's native undo/redo stack.
   *
   * @param {T} data the data for this undo event
   * @param {!Node=} parent to add to, uses document.body by default
   * @export
   */
  push(data, parent) {
    // nb. We can't remove this later: the only case we could is if the user undoes everything
    // and then does some _other_ action (which we can't detect).
    if (!this._ctrl.parentNode) {
      // nb. we check parentNode as this would remove contentEditable's history
      (parent || document.body).appendChild(this._ctrl);
    }

    const nextID = this._depth + 1;
    this._stack.splice(nextID, this._stack.length - nextID, data);

    const previousFocus = document.activeElement;
    this._duringUpdate = true;
    try {
      this._ctrl.style.visibility = null;
      this._ctrl.focus();
      document.execCommand('selectAll');
      document.execCommand('insertText', false, '' + nextID);  // must be string for Edge
    } finally {
      this._duringUpdate = false;
      this._ctrl.style.visibility = 'hidden';
    }

    previousFocus && previousFocus.focus();
  }
}


function randomChoice(opts, remove=false) {
  if (!opts.length) { return undefined; }
  const c = Math.floor(Math.random() * opts.length);
  const out = opts[c];
  if (remove) {
    opts.splice(c, 1);
  }
  return out;
}

function positionCell(cell, {x, y}) {
  if (!cell) {
    cell = document.createElement('div');
    cell.className = 'cell';
  }
  maze.appendChild(cell);
  cell.style.transform = `translate(${x}em, ${y}em)`;
  return cell;
}

const charPos = {x: 1, y: 1};
const dim = 13;
const mazeMap = [];

function addCell({x, y}) {
  const idx = y*dim + x;
  if (mazeMap[idx]) {
    return false;
  }
  
  const cell = positionCell(null, {x, y});
  mazeMap[idx] = cell;
  return true;
}
function getCell({x, y}) {
  const idx = y*dim + x;
  return mazeMap[idx];
}

const tails = [];
const digDir = [{x: 0, y: -1}, {x: 0, y: +1}, {x: -1, y: 0}, {x: +1, y: 0}];
function dig({x: fx, y: fy}, dist=0) {
  const opts = digDir.slice();
  let anyAdded = false;
  while (opts.length) {
    const choice = randomChoice(opts, true);
    const x = fx + choice.x * 2;
    const y = fy + choice.y * 2;

    const idx = y*dim + x;
    if (isInvalid({x, y})) {
      continue;
    }

    if (!addCell({x, y})) {
      continue;  // was already taken
    }
    addCell({x: fx + choice.x, y: fy + choice.y});  // dig tunnel
    dig({x, y}, dist+1);
    anyAdded = true;
  }

  if (!anyAdded) {
    tails.push({x: fx, y: fy, dist});
  }
}

const initialPos = {x: 1, y: 1};
addCell(initialPos);
dig(initialPos);
positionCell(char, initialPos);

const undoer = new Undoer((payload) => {
  console.info('got payload', payload);
  charPos.x = payload.x;
  charPos.y = payload.y;
  char.style.transform = `translate(${charPos.x}em, ${charPos.y}em)`;
  updateMaybeWinner();
  maze.focus();
}, initialPos);

tails.sort((a, b) => b.dist - a.dist);
const goalCell = tails[0];
positionCell(goal, goalCell);

maze.addEventListener('keydown', (ev) => {
  const key = ev.key.substr(0, 5) === 'Arrow' ? ev.key.substr(5) : ev.key;

  const dir = {x: 0, y: 0};
  switch (key) {
    case 'Up':
      dir.y = -1;
      break;
    case 'Down':
      dir.y = +1;
      break;
    case 'Left':
      dir.x = -1;
      break;
    case 'Right':
      dir.x = +1;
      break;
    default:
      return;
  }
  ev.preventDefault();
  ev.stopPropagation();

  const update = {
    x: charPos.x + dir.x,
    y: charPos.y + dir.y,
  };
  if (isInvalid(update) || !getCell(update)) {
    return;
  }
  charPos.x = update.x;
  charPos.y = update.y;
  undoer.push({x: charPos.x, y: charPos.y}); // save update
  maze.focus();
  movecount.textContent++;

  char.style.transform = `translate(${charPos.x}em, ${charPos.y}em)`;
  updateMaybeWinner();
});

let winnerInterval;
function updateMaybeWinner() {
  const winner = charPos.x === goalCell.x && charPos.y === goalCell.y;
  char.classList.toggle('winner', winner);
  window.clearInterval(winnerInterval);

  if (winner) {
    winnerInterval = window.setInterval(() => {
      spawnDumbEmoji(charPos);
    }, 125);
  }
}

function isInvalid({x, y}) {
  return x < 0 || y < 0 || x >= dim || y >= dim;
}

function randomAngle() {
  return Math.random() * Math.PI * 2;
}

function spawnDumbEmoji({x, y}) {
  const emoji = positionCell(null, {x, y});
  emoji.classList.add('excite');
  emoji.textContent = Math.random() < 0.5 ? '🎉' : '🎊';

  const baseTransform = emoji.style.transform + ` rotate(${randomAngle()}rad)`;
  emoji.style.transform = `${baseTransform} scale(0.01)`;
  emoji.style.opacity = 1;
  
  window.requestAnimationFrame(() => {
    emoji.style.transform = `${baseTransform} scale(1) rotate(${randomAngle()}rad) translate(50px) rotate(${randomAngle()}rad)`;
    emoji.style.opacity = 0;
    emoji.addEventListener('transitionend', (ev) => {
      if (ev.propertyName === 'opacity') {
        emoji.remove();
      }
    });
  });
}

maze.addEventListener('click', (ev) => {
  if (ev.target.localName === 'button') {
    const simulatedEvent = new CustomEvent('keydown');
    simulatedEvent.key = ev.target.dataset.action;
    maze.dispatchEvent(simulatedEvent);
  }
});
View Compiled

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.