<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%;
}
}
View Compiled
// 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
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.