<div class="top-bar">
  <button class="button level-select-button">Levels</button>
</div>

<ol class="level-list"></ol>

<canvas></canvas>
<p class="instruction"></p>
<button class="button next-level-button">Next level</button>

<div class="levels">

<pre id="intro-fixed1" data-blurb="Tutorial">
blurb: Tutorial
instruction: Drag cub to star
---
*=.=.
    !
. . .
    !
@=.=.
</pre>

<pre id="intro-fixed2" data-blurb="Tutorial">
blurb: Tutorial
instruction: Drag grid to rotate. Cub and star moves with grid. Orange links stay in place.
---
* . .
    !
. . .
    !
@=.=.
</pre>

<pre id="intro-fixed3" data-blurb="★">
blurb: ★
---
@=. .

. . .
    !
*=. .
</pre>

<pre id="intro-free1" data-blurb="Tutorial">
blurb: Tutorial
instruction: Blue links move with grid. Rotate grid to connect blue and orange links in different ways.
---
@-. .
!   | 
. . .
    |
*-.-.
</pre>

<pre id="m3x3-2-med" data-blurb="★">
blurb: ★
---
. . *
| | | 
. . .
| | | 
@ .=.
</pre>

<pre id="m3x3-fixed-switch" data-blurb="★">
blurb: ★
---
*=.-.
 
. . .
    | 
@-. .
</pre>

<pre id="m4x4-2" data-blurb="★">
blurb: ★
---
. .=. .
  | !  
. . .-*
  |    
. . . .
       
. @-. .
</pre>

<pre id="m4x4-1" data-blurb="★">
blurb: ★
---
. . . .
       
* . . @
  | ! |
. . . .
  !    
. . . .
</pre>

<pre id="m4x4-3" data-blurb="★">
blurb: ★
---
. @ . .
! |    
. . . .
      |
.=.=.-.
|       
. * . .
</pre>

<pre id="m4x4-4" data-blurb="★">
blurb: ★
---
. . . .
       
* . . .
    !  
. . .-.
!      
.=.=. @
</pre>

<pre id="m4x4-5" data-blurb="★">
blurb: ★
---
.-.-.-.
|       
@ .-.-.
       
* .=. .
!   |   
.-.-. .
</pre>

<pre id="m4x4-6-med" data-blurb="★">
blurb: ★
---
. * . .

.-.=. .
  |
. . . .
!   |
.=. @ .
</pre>

<pre id="m4x4-7-hard1" data-blurb="★★">
blurb: ★★
---
. . *-.
       
.-.=. .
      |
.=. . .
  | |  
@-.-.=.
</pre>

<pre id="m4x4-8-hard2" data-blurb="★★">
blurb: ★★
---
.-@ .=.
       
. . . .
    |  
.-. .-*
  |    
. .=.-.
</pre>

<pre id="m4x4-9-hard1" data-blurb="★★">
blurb: ★★
---
. . .=.
  !    
@-. .-.
     
. .=. .
       
. . * .
</pre>

<pre id="m4x4-10-hard1" data-blurb="★★">
blurb: ★★
---
. @=. .
  |   
. .-.-.
       
.-.-.-.
!     ! 
. * . .
</pre>

<pre id="m5x5-3" data-blurb="★">
. . . . .
  | !   
. . .-. .
  |     
. . . . *
  |     
. . .=. .
  |     
. @ . . .
</pre>

<pre id="m5x5-1" data-blurb="★">
@-.-. .-.
    |    
. . . . .
         
. . .=. .
         
. . . .=.
    |    
. .=.-* .
</pre>

<pre id="m5x5-2" data-blurb="★★">
. . . . .
        
. .=.-. @
|       !
. . . .-.
        
.=. . .=.
!       
* . . . .
</pre>

<pre id="m5x5-4" data-blurb="★★">
. . . .-.
      ! 
. .-. . .
  !     |
.=. . . .
|        
. . . . *
|        
.-@=. .=.
</pre>

<pre id="m5x5-5" data-blurb="★★">
. . . . .

. . .-. *
    !
. . .-. .

.=. . . .
    |
. @-. . .
</pre>

<pre id="m5x5-6" data-blurb="★★">
. . .-.-.
!   !    
. .=.-. .
|   
. .-. .-@
!
* .=. . .
      |
.=. .-.=.
</pre>

<pre id="m5x5-7" data-blurb="★★★">
.=* . @=.
|
. .=. . .
|   | |
.=. . .-.
        |
. . . .=.
!    
. .-.-. .
</pre>

<pre id="m5x5-8" data-blurb="★★★">
. * . .-.
  |     
. . .=.-.
!       | 
. . . . .
        
. .-. .=.
        |
. . .=.-@
</pre>

<pre id="m5x5-9" data-blurb="★★★">
.-.-. . .
    |    
. . . .-@
  !      
* . .-. .
|   !    
.-. . .=.
    |   !
. . .=. .
</pre>

<pre id="m5x5-10" data-blurb="★★">
. . . . .
         
. . . .-@
  !      
* . .=. .
|   !    
.-. . . .
         
. . . . .
</pre>

<pre id="m5x5-11" data-blurb="★★★">
. . . .=.
  |     
. . . .=.
|        
. . .-. .
! |     
. .=. . .
|   !   !
.-@ . * .
</pre>

<pre id="m5x5-12" data-blurb="★★">
. . .=.=.
        
. . . . .
        
. . . . @
        
. . . . .
        
* . .=.=.
</pre>

<pre id="m6x6-1-hard1" data-blurb="★★★">
. . * . . .
  ! | |  
. .-. .-. .
          |
. . . . .-.
      | ! |
. . .=. . .
    |
@-.-. .-. .
          |
. .=. . .-.
</pre>

<pre id="m6x6-2" data-blurb="★★★">
@ .=. . .=.
  | | !
. . . .=. .
  |     |
. . . .-. .
|   !
. . . . . *
|     |
.=. .-. . .
  |   | |
.-. . . .=.
</pre>

<pre id="m6x6-3" data-blurb="★★★">
.=. .=.-.-*
  |        
.-. . . . .
        | !
. . .-.-. .
!          
.-. .=.=. .
           
@ .=. . . .
  |     !
. .-. .-. .
</pre>

<pre id="pivot-4x4-intro" data-blurb="Tutorial">
instruction: Green links pivot with grid, but point in the same direction
---
. .-* .
  |    
. . . .
       
. .>. .
       
. @ . .
</pre>

<pre id="pivot-5x5-2" data-blurb="★★">
. . .-.-@
        
. .<. . .
        
.>. . . .
| !      
.-.-. . *
  !     
. . . . .
</pre>

<pre id="pivot-5x5-swirly" data-blurb="★★★">
. . . . .
      ^ 
.<. . . *
        
. . . . .
        
@ . . .>.
  v     
. . . . .
</pre>

<pre id="pivot-5x5-1" data-blurb="★★★">
. .-. . .
      ^ 
. .<.=.=.
        
.>. . .-@
        
* . . .=.
        
. . . . .
</pre>

<pre id="pivot-5x5-3" data-blurb="★★">
.=. . .-*
    v   
. . . . .
         
. . .-.J.
         
@-. . . .
    v   
.<. . . .
</pre>

<pre id="pivot-5x5-4" data-blurb="★★★">
.-.-. @>.
!     ^ 
. . . . .
  |      
. . . . .
  |     
. . . .=*
    ^    
. . .-. .>
</pre>

<pre id="pivot-5x5-5" data-blurb="★★★">
.-. . . *
        
. .>. . .
|       v
.-. . . .
  ^      
. . .-. .
      v 
@=.=. . .
</pre>

<pre id="pivot-5x5-6" data-blurb="★★★">
. . .>. .
  ! |    
@=. .-. .
        
. . . .=.>
         
. . . . .
         
. *>.<. .
</pre>

<pre id="pivot-5x5-7" data-blurb="★★★">
* . @ . .
v   |   
. . . . .
      !  
. . . . .
^     ! !
. .-. . .
  !     
. . . . .
    v
</pre>

<pre id="pivot-6x6-1" data-blurb="★★★">
. . . . . .
| v         
@ . . . . *
  | |      
. . . . . .
| !   ^ | K
. . . .-.=.
|          
. .-. . . .
v          
.>. . . . .
</pre>

<pre id="pivot-6x6-3" data-blurb="★★★">
. @-. .>.-.
          
. . . . . .
          |
* .>. .=. .
    !      
. . . . . .>
      |   ^
. . . .=. .
           
. .=. . .=.>
</pre>

<pre id="pivot-6x6-2" data-blurb="★★★">
. .-.-. .=.
      v   
. . . . . .
  |     ! v
.>. . . . *
    ^      
. . . . . .
|          
. .-.<. . .
! |       |
. . . .>.-@
</pre>

<pre id="m44" data-blurb="★★">
. .=. *-.
        
. . .=. .
!        
. . . . .
  |   ! 
. . . . .
  |     |
. @ . .=.
</pre>

<pre id="m45" data-blurb="★★">
@ * .>. .
        
. .=.=. .
|     | 
.>. . . .
        
. . . .>.
|       
.=. . .-.
</pre>

<pre id="m46" data-blurb="★★★">
.-. . .
    ^ 
. . . .
       
.L. . .
      !
@ . .-*
</pre>

<pre id="m47" data-blurb="★★">
@ . . . . .
v v v v v v
. . . . . .
 
. . . . . .
           
. . . . . .
  v v v v v
. . . . . .
           
. . . .=. *
v v v v   v
</pre>

<pre id="m48" data-blurb="★">
.-.<.>.=. .
W !       |
. . .A. . *
    |   |  
. .=. . . .
^ !        
. .D.-.=.=@
          | 
. . .-.-. .
|          
.#.=. .<. .
    v     v
</pre>

<pre id="m49" data-blurb="★★★">
. . .-@ .
    |   
. . . .J.
        
* . . . .
| !     !
. . . . .
    v   !
. . . .-.
</pre>

<pre id="m50" data-blurb="★★★">
*=. . .
    v  
. . . .
^     |
. . . .
  ^   |
@ .>. .
</pre>

<pre id="rotate-tut" data-blurb="Tutorial">
instruction: Red links are fixed in place, but rotate with grid
---
. . . .
       
@ .4. .
  |    
. . .-*
       
. . . .
</pre>

<pre id="rotate1" data-blurb="★">
. . .-*
    |  
. . . .
    5  
.4. . .
|      
@ . . .
</pre>

<pre id="rotate2" data-blurb="★★">
@ .-.=.
    |  
. . .4.
    |  
* . . .
|   |  
. . . . 
</pre>

<pre id="rotate3" data-blurb="★★">
. . * .
! 5 v  
. . . @
  |    
. .4. .
  !    
. . . .
</pre>

<pre id="rotate3b" data-blurb="★★">
* . . .
! 5    
. . . @
  |    
. .4. .
  !    
. . . .
</pre>

<pre id="rotate-5x5-1" data-blurb="★★">
. . . .-@
      8 
. .=. . .
        
*=. . . .
        
. .-. . .
        
. . . . .
</pre>

<pre id="rotate-5x5-2" data-blurb="★★">
. . . . .
        
. . . .6*
  |     
. . . .=.
  |     
.4. . . .
        |
. . . .-@
</pre>

<pre id="rotate-5x5-2b" data-blurb="★★★">
. . . . .
  !   | 
.-.-. . .
  v   |
. . .-. .
        
@ . . . .
    5   
. . .=* .
</pre>

<pre id="rotate-6x6-1" data-blurb="★★★">
@4.=. . . .
          
. . . . . .
      v 8 |
.-.-. . . .
!   !   ^   
. . . . . .
           
. .>. . . .
!          
* . .4. . .
</pre>

<pre id="rotate-6x6-2" data-blurb="★★★">
. . *<. . .
          
.=. .-. . .
        5
. . . .-. .
        |   
. . . . . .
           
. . . . . .
    5     | 
. .=. . @-.
</pre>

<pre id="rotate-6x6-3" data-blurb="★★★">
.4. . . . @
!          
.-. . .=. .
! 
. . . . . .
!           
.>.6. . . .
!          
. . . .=.-.
      ^    
. . . . * .
</pre>


</div>
* { box-sizing: border-box; }

body {
  margin: 0;
  padding: 0;
  overflow-x: hidden;
  font-family: 'Avenir Next', Avenir, sans-serif;
  font-weight: 500;
  font-size: 20px;
  color: #555;
}

canvas {
  cursor: move;
  display: block;
  position: absolute;
  max-width: 100%;
  left: 0;
  top: 0;
}

.is-cub-hovered,
.is-cub-hovered canvas {
  cursor: -webkit-grab;
  cursor: grab;
}

.is-cub-dragging,
.is-cub-dragging canvas {
  cursor: -webkit-grabbing;
  cursor: grabbing;
}

.instruction {
  padding: 0 10px;
  text-align: center;
  position: absolute;
  width: 100%;
  padding-bottom: 40px;
}

.button {
  font-family: 'Avenir Next', Avenir, sans-serif;
  font-weight: 500;
  font-size: 20px;
  padding: 5px 15px;
  margin: 10px;
  background: #BBB;
  color: white;
  border-radius: 5px;
  border: none;
  cursor: pointer;
}

.button:hover {
  background: #09F;
}

.top-bar {
  position: absolute;
  left: 0;
  top: 0;
}

.level-select-button {
  position: relative;
  z-index: 2; /* above canvas */
}

.next-level-button {
  position: absolute;
  left: 50%;
  -webkit-transform: translateX(-110px) scale(0.5);
  transform: translateX(-110px) scale(0.5);
  opacity: 0;
  background: #09F;
  width: 200px;
  height: 80px;
  pointer-events: none;
  -webkit-transition: -webkit-transform 0.2s, opacity 0.2s;
  transition: transform 0.2s, opacity 0.2s;
}

.next-level-button:hover {
  background: #2BF;
}

.next-level-button.is-open {
  display: inline-block;
  pointer-events: auto;
  -webkit-transform: translateX(-110px) scale(1);
  transform: translate(-110px) scale(1);
  opacity: 1;
}

/* ---- level list ---- */

.level-list {
  position: absolute;
  background: #EEE;
  width: 100%;
  min-height: 100%;
  left: 0;
  top: 0;
  margin: 0;
  list-style: none;
  padding: 10px;
  z-index: 3; /* above canvas, level select button */
  left: -100%;
  transition: left 0.2s;
}

.level-list.is-open {
  left: 0;
}

.level-list__item {
  display: inline-block;
  background: #DDD;
  margin: 5px;
  padding: 10px;
  width: 80px;
  height: 80px;
  text-align: center;
  border-radius: 10px;
  position: relative;
}

.level-list__item:hover {
  color: #09F;
  cursor: pointer;
  background: white;
}

.level-list__item.is-playing {
  background: #09F;
  color: white;
}

.level-list__item__number {
  display: block;
  font-size: 30px;
  line-height: 35px;
}

.level-list__item__blurb {
  display: block;
  font-size: 16px;
}

.level-list__item__check {
  position: absolute;
  right: -10px;
  top: -10px;
  width: 30px;
  line-height: 30px;
  background: #555;
  border-radius: 15px;
  color: white;
  display: none;
}

.level-list__item.did-complete .level-list__item__check {
  display: block;
}

/* ---- level pres ---- */

.levels { display: none; }
/**
 * EvEmitter v1.0.2
 * Lil' event emitter
 * MIT License
 */

/* jshint unused: true, undef: true, strict: true */

( function( global, factory ) {
  // universal module definition
  /* jshint strict: false */ /* globals define, module */
  if ( typeof define == 'function' && define.amd ) {
    // AMD - RequireJS
    define( factory );
  } else if ( typeof module == 'object' && module.exports ) {
    // CommonJS - Browserify, Webpack
    module.exports = factory();
  } else {
    // Browser globals
    global.EvEmitter = factory();
  }

}( this, function() {

"use strict";

function EvEmitter() {}

var proto = EvEmitter.prototype;

proto.on = function( eventName, listener ) {
  if ( !eventName || !listener ) {
    return;
  }
  // set events hash
  var events = this._events = this._events || {};
  // set listeners array
  var listeners = events[ eventName ] = events[ eventName ] || [];
  // only add once
  if ( listeners.indexOf( listener ) == -1 ) {
    listeners.push( listener );
  }

  return this;
};

proto.once = function( eventName, listener ) {
  if ( !eventName || !listener ) {
    return;
  }
  // add event
  this.on( eventName, listener );
  // set once flag
  // set onceEvents hash
  var onceEvents = this._onceEvents = this._onceEvents || {};
  // set onceListeners object
  var onceListeners = onceEvents[ eventName ] = onceEvents[ eventName ] || {};
  // set flag
  onceListeners[ listener ] = true;

  return this;
};

proto.off = function( eventName, listener ) {
  var listeners = this._events && this._events[ eventName ];
  if ( !listeners || !listeners.length ) {
    return;
  }
  var index = listeners.indexOf( listener );
  if ( index != -1 ) {
    listeners.splice( index, 1 );
  }

  return this;
};

proto.emitEvent = function( eventName, args ) {
  var listeners = this._events && this._events[ eventName ];
  if ( !listeners || !listeners.length ) {
    return;
  }
  var i = 0;
  var listener = listeners[i];
  args = args || [];
  // once stuff
  var onceListeners = this._onceEvents && this._onceEvents[ eventName ];

  while ( listener ) {
    var isOnce = onceListeners && onceListeners[ listener ];
    if ( isOnce ) {
      // remove listener
      // remove before trigger to prevent recursion
      this.off( eventName, listener );
      // unset once flag
      delete onceListeners[ listener ];
    }
    // trigger listener
    listener.apply( this, args );
    // get next listener
    i += isOnce ? 0 : 1;
    listener = listeners[i];
  }

  return this;
};

return EvEmitter;

}));

/*!
 * Unipointer v2.1.0
 * base class for doing one thing with pointer event
 * MIT license
 */

/*jshint browser: true, undef: true, unused: true, strict: true */

( function( window, factory ) {
  // universal module definition
  /* jshint strict: false */ /*global define, module, require */
  if ( typeof define == 'function' && define.amd ) {
    // AMD
    define( [
      'ev-emitter/ev-emitter'
    ], function( EvEmitter ) {
      return factory( window, EvEmitter );
    });
  } else if ( typeof module == 'object' && module.exports ) {
    // CommonJS
    module.exports = factory(
      window,
      require('ev-emitter')
    );
  } else {
    // browser global
    window.Unipointer = factory(
      window,
      window.EvEmitter
    );
  }

}( window, function factory( window, EvEmitter ) {

'use strict';

function noop() {}

function Unipointer() {}

// inherit EvEmitter
var proto = Unipointer.prototype = Object.create( EvEmitter.prototype );

proto.bindStartEvent = function( elem ) {
  this._bindStartEvent( elem, true );
};

proto.unbindStartEvent = function( elem ) {
  this._bindStartEvent( elem, false );
};

/**
 * works as unbinder, as you can ._bindStart( false ) to unbind
 * @param {Boolean} isBind - will unbind if falsey
 */
proto._bindStartEvent = function( elem, isBind ) {
  // munge isBind, default to true
  isBind = isBind === undefined ? true : !!isBind;
  var bindMethod = isBind ? 'addEventListener' : 'removeEventListener';

  if ( window.navigator.pointerEnabled ) {
    // W3C Pointer Events, IE11. See https://coderwall.com/p/mfreca
    elem[ bindMethod ]( 'pointerdown', this );
  } else if ( window.navigator.msPointerEnabled ) {
    // IE10 Pointer Events
    elem[ bindMethod ]( 'MSPointerDown', this );
  } else {
    // listen for both, for devices like Chrome Pixel
    elem[ bindMethod ]( 'mousedown', this );
    elem[ bindMethod ]( 'touchstart', this );
  }
};

// trigger handler methods for events
proto.handleEvent = function( event ) {
  var method = 'on' + event.type;
  if ( this[ method ] ) {
    this[ method ]( event );
  }
};

// returns the touch that we're keeping track of
proto.getTouch = function( touches ) {
  for ( var i=0; i < touches.length; i++ ) {
    var touch = touches[i];
    if ( touch.identifier == this.pointerIdentifier ) {
      return touch;
    }
  }
};

// ----- start event ----- //

proto.onmousedown = function( event ) {
  // dismiss clicks from right or middle buttons
  var button = event.button;
  if ( button && ( button !== 0 && button !== 1 ) ) {
    return;
  }
  this._pointerDown( event, event );
};

proto.ontouchstart = function( event ) {
  this._pointerDown( event, event.changedTouches[0] );
};

proto.onMSPointerDown =
proto.onpointerdown = function( event ) {
  this._pointerDown( event, event );
};

/**
 * pointer start
 * @param {Event} event
 * @param {Event or Touch} pointer
 */
proto._pointerDown = function( event, pointer ) {
  // dismiss other pointers
  if ( this.isPointerDown ) {
    return;
  }

  this.isPointerDown = true;
  // save pointer identifier to match up touch events
  this.pointerIdentifier = pointer.pointerId !== undefined ?
    // pointerId for pointer events, touch.indentifier for touch events
    pointer.pointerId : pointer.identifier;

  this.pointerDown( event, pointer );
};

proto.pointerDown = function( event, pointer ) {
  this._bindPostStartEvents( event );
  this.emitEvent( 'pointerDown', [ event, pointer ] );
};

// hash of events to be bound after start event
var postStartEvents = {
  mousedown: [ 'mousemove', 'mouseup' ],
  touchstart: [ 'touchmove', 'touchend', 'touchcancel' ],
  pointerdown: [ 'pointermove', 'pointerup', 'pointercancel' ],
  MSPointerDown: [ 'MSPointerMove', 'MSPointerUp', 'MSPointerCancel' ]
};

proto._bindPostStartEvents = function( event ) {
  if ( !event ) {
    return;
  }
  // get proper events to match start event
  var events = postStartEvents[ event.type ];
  // bind events to node
  events.forEach( function( eventName ) {
    window.addEventListener( eventName, this );
  }, this );
  // save these arguments
  this._boundPointerEvents = events;
};

proto._unbindPostStartEvents = function() {
  // check for _boundEvents, in case dragEnd triggered twice (old IE8 bug)
  if ( !this._boundPointerEvents ) {
    return;
  }
  this._boundPointerEvents.forEach( function( eventName ) {
    window.removeEventListener( eventName, this );
  }, this );

  delete this._boundPointerEvents;
};

// ----- move event ----- //

proto.onmousemove = function( event ) {
  this._pointerMove( event, event );
};

proto.onMSPointerMove =
proto.onpointermove = function( event ) {
  if ( event.pointerId == this.pointerIdentifier ) {
    this._pointerMove( event, event );
  }
};

proto.ontouchmove = function( event ) {
  var touch = this.getTouch( event.changedTouches );
  if ( touch ) {
    this._pointerMove( event, touch );
  }
};

/**
 * pointer move
 * @param {Event} event
 * @param {Event or Touch} pointer
 * @private
 */
proto._pointerMove = function( event, pointer ) {
  this.pointerMove( event, pointer );
};

// public
proto.pointerMove = function( event, pointer ) {
  this.emitEvent( 'pointerMove', [ event, pointer ] );
};

// ----- end event ----- //


proto.onmouseup = function( event ) {
  this._pointerUp( event, event );
};

proto.onMSPointerUp =
proto.onpointerup = function( event ) {
  if ( event.pointerId == this.pointerIdentifier ) {
    this._pointerUp( event, event );
  }
};

proto.ontouchend = function( event ) {
  var touch = this.getTouch( event.changedTouches );
  if ( touch ) {
    this._pointerUp( event, touch );
  }
};

/**
 * pointer up
 * @param {Event} event
 * @param {Event or Touch} pointer
 * @private
 */
proto._pointerUp = function( event, pointer ) {
  this._pointerDone();
  this.pointerUp( event, pointer );
};

// public
proto.pointerUp = function( event, pointer ) {
  this.emitEvent( 'pointerUp', [ event, pointer ] );
};

// ----- pointer done ----- //

// triggered on pointer up & pointer cancel
proto._pointerDone = function() {
  // reset properties
  this.isPointerDown = false;
  delete this.pointerIdentifier;
  // remove events
  this._unbindPostStartEvents();
  this.pointerDone();
};

proto.pointerDone = noop;

// ----- pointer cancel ----- //

proto.onMSPointerCancel =
proto.onpointercancel = function( event ) {
  if ( event.pointerId == this.pointerIdentifier ) {
    this._pointerCancel( event, event );
  }
};

proto.ontouchcancel = function( event ) {
  var touch = this.getTouch( event.changedTouches );
  if ( touch ) {
    this._pointerCancel( event, touch );
  }
};

/**
 * pointer cancel
 * @param {Event} event
 * @param {Event or Touch} pointer
 * @private
 */
proto._pointerCancel = function( event, pointer ) {
  this._pointerDone();
  this.pointerCancel( event, pointer );
};

// public
proto.pointerCancel = function( event, pointer ) {
  this.emitEvent( 'pointerCancel', [ event, pointer ] );
};

// -----  ----- //

// utility function for getting x/y coords from event
Unipointer.getPointerPoint = function( pointer ) {
  return {
    x: pointer.pageX,
    y: pointer.pageY
  };
};

// -----  ----- //

return Unipointer;

}));

function FreeSegment( a, b ) {
  this.type = 'FreeSegment';
  this.a = a;
  this.b = b;
  // orientations
  this.noon = {
    a: a,
    b: b
  };
  this.three = {
    a: { x: -a.y, y: a.x },
    b: { x: -b.y, y: b.x }
  };
  this.six = {
    a: { x: -a.x, y: -a.y },
    b: { x: -b.x, y: -b.y }
  };
  this.nine = {
    a: { x: a.y, y: -a.x },
    b: { x: b.y, y: -b.x }
  };
}


var proto = FreeSegment.prototype;

proto.render = function( ctx, center, gridSize ) {
  var ax = this.a.x * gridSize;
  var ay = this.a.y * gridSize;
  var bx = this.b.x * gridSize;
  var by = this.b.y * gridSize;
  ctx.strokeStyle = 'hsla(200, 80%, 50%, 0.7)';
  ctx.lineWidth = gridSize * 0.6;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo( ax, ay );
  ctx.lineTo( bx, by );
  ctx.stroke();
  ctx.closePath();
};


function FixedSegment( a, b ) {
  this.type = 'FixedSegment';
  this.a = a;
  this.b = b;
  // orientations
  this.noon = { a: a, b: b };
  this.three = { a: a, b: b };
  this.six = { a: a, b: b };
  this.nine = { a: a, b: b };
}

var proto = FixedSegment.prototype;

proto.render = function( ctx, center, gridSize ) {
  var ax = this.a.x * gridSize;
  var ay = this.a.y * gridSize;
  var bx = this.b.x * gridSize;
  var by = this.b.y * gridSize;
  ctx.strokeStyle = 'hsla(30, 100%, 40%, 0.6)';
  ctx.lineWidth = gridSize * 0.8;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo( ax, ay );
  ctx.lineTo( bx, by );
  ctx.stroke();
  ctx.closePath();
};

function PivotSegment( a, b ) {
  this.type = 'FreeSegment';
  this.a = a;
  this.b = b;
  var dx = b.x - a.x;
  var dy = b.y - a.y;
  this.delta = { x: dx, y: dy };
  // orientations
  this.noon = {
    a: a,
    b: b
  };
  this.three = {
    a: { x: -a.y, y: a.x },
    b: { x: -a.y + dx, y: a.x + dy }
  };
  this.six = {
    a: { x: -a.x, y: -a.y },
    b: { x: -a.x + dx, y: -a.y + dy }
  };
  this.nine = {
    a: { x: a.y, y: -a.x },
    b: { x: a.y + dx, y: -a.x + dy }
  };
}


var proto = PivotSegment.prototype;

proto.render = function( ctx, center, gridSize, mazeAngle ) {
  var ax = this.a.x * gridSize;
  var ay = this.a.y * gridSize;
  var bx = this.delta.x * gridSize;
  var by = this.delta.y * gridSize;
  ctx.save();

  ctx.translate( ax, ay );
  ctx.rotate( -mazeAngle );
  var color = 'hsla(150, 100%, 35%, 0.7)'
  // line
  ctx.strokeStyle = color;
  ctx.lineWidth = gridSize * 0.4;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo( 0, 0 );
  ctx.lineTo( bx, by );
  ctx.stroke();
  ctx.closePath();
  // circle
  ctx.fillStyle = color;
  ctx.beginPath();
  ctx.arc( 0, 0, gridSize * 0.4, 0, Math.PI * 2 );
  ctx.fill();
  ctx.closePath();

  ctx.restore();
};

var TAU = Math.PI * 2;

function RotateSegment( a, b ) {
  this.type = 'RotateSegment';
  this.a = a;
  this.b = b;
  // orientations
  var dx = b.x - a.x;
  var dy = b.y - a.y;
  this.delta = { x: dx, y: dy };
  this.theta = Math.atan2( dy, dx );
  this.noon = { a: a, b: b };
  this.three = { a: a, b: this.getB( TAU/4 ) };
  this.six = { a: a, b: this.getB( TAU/2 ) };
  this.nine = { a: a, b: this.getB( TAU*3/4 ) };
}

var proto = RotateSegment.prototype;

proto.getB = function( angle ) {
  return {
    x: Math.round( this.a.x + Math.cos( this.theta + angle ) * 2 ),
    y: Math.round( this.a.y + Math.sin( this.theta + angle ) * 2 ),
  };
};

proto.render = function( ctx, center, gridSize, mazeAngle ) {
  var ax = this.a.x * gridSize;
  var ay = this.a.y * gridSize;
  ctx.save();
  ctx.translate( ax, ay );
  ctx.rotate( mazeAngle );
  var color = 'hsla(0, 100%, 50%, 0.6)';
  ctx.strokeStyle = color;
  ctx.fillStyle = color;
  // axle
  ctx.lineWidth = gridSize* 0.8;
  ctx.lineJoin = 'round';
  ctx.rotate(TAU/8);
  ctx.strokeRect( -gridSize*0.2, -gridSize*0.2, gridSize*0.4, gridSize*0.4 );
  ctx.rotate(-TAU/8);
  // line
  ctx.lineWidth = gridSize * 0.8;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo( 0, 0 );
  
  var bx = this.delta.x * gridSize;
  var by = this.delta.y * gridSize;
  ctx.lineTo( bx, by );
  ctx.stroke();
  ctx.closePath();
  ctx.restore();
};

// rotational physics model

var TAU = Math.PI * 2;

function FlyWheel( props ) {
  this.angle = 0;
  this.friction = 0.95;
  this.velocity = 0;

  for ( var prop in props ) {
    this[ prop ] = props[ prop ];
  }
}

var proto = FlyWheel.prototype;

proto.integrate = function() {
  this.velocity *= this.friction;
  this.angle += this.velocity;
  this.normalizeAngle();
};

proto.applyForce = function( force ) {
  this.velocity += force;
};

proto.normalizeAngle = function() {
  this.angle = ( ( this.angle % TAU ) + TAU ) % TAU;
};

proto.setAngle = function( theta ) {
  var velo = theta - this.angle;
  if ( velo > TAU/2 ) {
    velo -= TAU;
  } else if ( velo < -TAU/2 ) {
    velo += TAU;
  }
  var force = velo - this.velocity;
  this.applyForce( force );
};


var cub = {
  offset: { x: 0, y: 0 },
};

var pegOrienter = {
  noon: function( peg ) {
    return peg;
  },
  three: function( peg ) {
    return { x: peg.y, y: -peg.x };
  },
  six: function( peg ) {
    return { x: -peg.x, y: -peg.y };
  },
  nine: function( peg ) {
    return { x: -peg.y, y: peg.x };
  },
};

cub.setPeg = function( peg, orientation ) {
  peg = pegOrienter[ orientation ]( peg );
  this.peg = peg;

  this.noon = { x: peg.x, y: peg.y };
  this.three = { x: -peg.y, y: peg.x };
  this.six = { x: -peg.x, y: -peg.y };
  this.nine = { x: peg.y, y: -peg.x };
};

var offsetOrienter = {
  noon: function( offset ) {
    return offset;
  },
  three: function( offset ) {
    // flip y because its rendering
    return { x: offset.y, y: -offset.x };
  },
  six: function( offset ) {
    return { x: -offset.x, y: -offset.y };
  },
  nine: function( offset ) {
    // flip y because its rendering
    return { x: -offset.y, y: offset.x };
  },
};

cub.setOffset = function( offset, orientation ) {
  this.offset = offsetOrienter[ orientation ]( offset );
};

// ----- render ----- //

cub.render = function( ctx, mazeCenter, gridSize, angle, isHovered ) {
  function circle( x, y, radius ) {
    ctx.beginPath();
    ctx.arc( x, y, radius, 0, Math.PI * 2 );
    ctx.fill();
    ctx.closePath();
  }

  var x = this.peg.x * gridSize + this.offset.x;
  var y = this.peg.y * gridSize + this.offset.y;
  ctx.save();
  ctx.translate( mazeCenter.x, mazeCenter.y );
  ctx.rotate( angle );
  ctx.translate( x, y );
  ctx.rotate( -angle );
  ctx.fillStyle = 'hsla(330, 100%, 40%, 1)';
  var scale = isHovered ? 1.15 : 1;
  ctx.scale( scale, scale );
  circle( 0, 0, gridSize * 0.6 );
  circle( gridSize * -0.45, gridSize * -0.35, gridSize * 0.3 );
  circle( gridSize * 0.45, gridSize * -0.35, gridSize * 0.3 );

  ctx.restore();
};


/* globals FlyWheel, FreeSegment, FixedSegment, PivotSegment, RotateSegment, cub */

function Maze() {
  this.freeSegments = [];
  this.fixedSegments = [];
  this.pivotSegments = [];
  this.rotateSegments = [];
  this.flyWheel = new FlyWheel({
    friction: 0.8
  });
  this.connections = {};
}

var proto = Maze.prototype;

proto.loadText = function( text ) {
  // separate --- sections, YAML front matter first, maze source second;
  var sections = text.split('---\n');
  // YAML front matter
  var frontMatter = {};
  if ( sections.length > 1 ) {
    frontMatter = getFrontMatter( sections[0] );
  }
  // set instruction
  var instructElem = document.querySelector('.instruction');
  instructElem.innerHTML = frontMatter.instruction || '';

  var mazeSrc = sections[ sections.length - 1 ];
  var lines = mazeSrc.split('\n');
  var gridCount = this.gridCount = lines[0].length;
  var gridMax = this.gridMax = ( gridCount - 1 ) / 2;

  for ( var i=0; i < lines.length; i++ ) {
    var line = lines[i];
    var chars = line.split('');
    for ( var j=0; j < chars.length; j++ ) {
      var character = chars[j];
      var pegX = j - gridMax;
      var pegY = i - gridMax;
      var parseMethod = 'parse' + character;
      if ( this[ parseMethod ] ) {
        this[ parseMethod ]( pegX, pegY );
      }
    }
  }
};

function getFrontMatter( text ) {
  if ( !text ) {
    return;
  }
  var frontMatter = {};
  text.split('\n').forEach( function( line ) {
    if ( !line ) {
      return;
    }
    var parts = line.split(':');
    var key = parts[0].trim();
    var value = parts[1].trim();
    if ( value === 'true' ) {
      value = true; // boolean true
    } else if ( value === 'false' ) {
      value = false; // boolean false
    } else if ( value.match(/$\d+(\.\d+)?^/) ) {
      value = parseFloat( value, 10 ); // number
    } else if ( value.match(/$\d+\.\d+^/) ) {
      value = parseFloat( value ); // float
    }
    frontMatter[ key ] = value;
  });
  return frontMatter;
}


// -------------------------- parsers -------------------------- //

// horizontal free segment
proto['parse-'] = proto.addFreeHorizSegment = function( pegX, pegY ) {
  var segment = getHorizSegment( pegX, pegY, FreeSegment );
  this.connectSegment( segment );
  this.freeSegments.push( segment );
};

// vertical free segment
proto['parse|'] = proto.addFreeVertSegment = function( pegX, pegY ) {
  var segment = getVertSegment( pegX, pegY, FreeSegment );
  this.connectSegment( segment );
  this.freeSegments.push( segment );
};

// horizontal fixed segment
proto['parse='] = proto.addFixedHorizSegment = function( pegX, pegY ) {
  var segment = getHorizSegment( pegX, pegY, FixedSegment );
  this.connectSegment( segment );
  this.fixedSegments.push( segment );
};

// vertical fixed segment
proto['parse!'] = proto.addFixedVertSegment = function( pegX, pegY ) {
  var segment = getVertSegment( pegX, pegY, FixedSegment );
  this.connectSegment( segment );
  this.fixedSegments.push( segment );
};

function getHorizSegment( pegX, pegY, Segment ) {
  var a = { x: pegX + 1, y: pegY };
  var b = { x: pegX - 1, y: pegY };
  return new Segment( a, b );
}

function getVertSegment( pegX, pegY, Segment ) {
  var a = { x: pegX, y: pegY + 1 };
  var b = { x: pegX, y: pegY - 1 };
  return new Segment( a, b );
}

// ----- pivot ----- //

// pivot up segment
proto['parse^'] = proto.addPivotUpSegment = function( pegX, pegY ) {
  var a = { x: pegX, y: pegY + 1 };
  var b = { x: pegX, y: pegY - 1 };
  var segment = new PivotSegment( a, b );
  this.connectSegment( segment );
  this.pivotSegments.push( segment );
};

// pivot down segment
proto.parsev = proto.addPivotDownSegment = function( pegX, pegY ) {
  var a = { x: pegX, y: pegY - 1 };
  var b = { x: pegX, y: pegY + 1 };
  var segment = new PivotSegment( a, b );
  this.connectSegment( segment );
  this.pivotSegments.push( segment );
};

// pivot left segment
proto['parse<'] = proto.addPivotLeftSegment = function( pegX, pegY ) {
  var a = { x: pegX + 1, y: pegY };
  var b = { x: pegX - 1, y: pegY };
  var segment = new PivotSegment( a, b );
  this.connectSegment( segment );
  this.pivotSegments.push( segment );
};

// pivot right segment
proto['parse>'] = proto.addPivotRightSegment = function( pegX, pegY ) {
  var a = { x: pegX - 1, y: pegY };
  var b = { x: pegX + 1, y: pegY };
  var segment = new PivotSegment( a, b );
  this.connectSegment( segment );
  this.pivotSegments.push( segment );
};

// ----- rotate ----- //

proto.parse8 = proto.addRotateUpSegment = function( pegX, pegY ) {
  var a = { x: pegX, y: pegY + 1 };
  var b = { x: pegX, y: pegY - 1 };
  var segment = new RotateSegment( a, b );
  this.connectSegment( segment );
  this.rotateSegments.push( segment );
};

proto.parse4 = proto.addRotateLeftSegment = function( pegX, pegY ) {
  var a = { x: pegX + 1, y: pegY };
  var b = { x: pegX - 1, y: pegY };
  var segment = new RotateSegment( a, b );
  this.connectSegment( segment );
  this.rotateSegments.push( segment );
};

proto.parse5 = proto.addRotateUpSegment = function( pegX, pegY ) {
  var a = { x: pegX, y: pegY - 1 };
  var b = { x: pegX, y: pegY + 1 };
  var segment = new RotateSegment( a, b );
  this.connectSegment( segment );
  this.rotateSegments.push( segment );
};

proto.parse6 = proto.addRotateRightSegment = function( pegX, pegY ) {
  var a = { x: pegX - 1, y: pegY };
  var b = { x: pegX + 1, y: pegY };
  var segment = new RotateSegment( a, b );
  this.connectSegment( segment );
  this.rotateSegments.push( segment );
};

// ----- combos ----- //

// free & fixed horizontal
proto['parse#'] = function( pegX, pegY ) {
  this.addFreeHorizSegment( pegX, pegY );
  this.addFixedHorizSegment( pegX, pegY );
};

// free & fixed vertical
proto.parse$ = function( pegX, pegY ) {
  this.addFreeVertSegment( pegX, pegY );
  this.addFixedVertSegment( pegX, pegY );
};

// pivot up + fixed vertical
proto.parseI = function( pegX, pegY ) {
  this.addPivotUpSegment( pegX, pegY );
  this.addFixedVertSegment( pegX, pegY );
};

// pivot left + fixed horizontal
proto.parseJ = function( pegX, pegY ) {
  this.addPivotLeftSegment( pegX, pegY );
  this.addFixedHorizSegment( pegX, pegY );
};

// pivot down + fixed vertical
proto.parseK = function( pegX, pegY ) {
  this.addPivotDownSegment( pegX, pegY );
  this.addFixedVertSegment( pegX, pegY );
};

// pivot right + fixed horizontal
proto.parseL = function( pegX, pegY ) {
  this.addPivotRightSegment( pegX, pegY );
  this.addFixedHorizSegment( pegX, pegY );
};

// pivot up + free vertical
proto.parseW = function( pegX, pegY ) {
  this.addPivotUpSegment( pegX, pegY );
  this.addFreeVertSegment( pegX, pegY );
};

// pivot left + free horizontal
proto.parseA = function( pegX, pegY ) {
  this.addPivotLeftSegment( pegX, pegY );
  this.addFreeHorizSegment( pegX, pegY );
};

// pivot down + free vertical
proto.parseS = function( pegX, pegY ) {
  this.addPivotDownSegment( pegX, pegY );
  this.addFreeVertSegment( pegX, pegY );
};

// pivot right + free horizontal
proto.parseD = function( pegX, pegY ) {
  this.addPivotRightSegment( pegX, pegY );
  this.addFreeHorizSegment( pegX, pegY );
};

// start position
proto['parse@'] = function( pegX, pegY ) {
  this.startPosition = { x: pegX, y: pegY };
  cub.setPeg( this.startPosition, 'noon' );
};

// goal position
proto['parse*'] = function( pegX, pegY ) {
  this.goalPosition = { x: pegX, y: pegY };
};

// --------------------------  -------------------------- //

proto.updateItemGroups = function() {
  var itemGroups = {};
  this.items.forEach( function( item ) {
    if ( itemGroups[ item.type ] === undefined ) {
      itemGroups[ item.type ] = [];
    }
    itemGroups[ item.type ].push( item );
  });
  this.itemGroups = itemGroups;
};

var orientations = [ 'noon', 'three', 'six', 'nine' ];

proto.connectSegment = function( segment ) {
  orientations.forEach( function( orientation ) {
    var line = segment[ orientation ];
    // check that pegs are not out of maze
    if ( this.getIsPegOut( line.a ) || this.getIsPegOut( line.b ) ) {
      return;
    }
    this.connectPeg( segment, orientation, line.a );
    this.connectPeg( segment, orientation, line.b );
  }, this );
};

proto.getIsPegOut = function( peg ) {
  return Math.abs( peg.x ) > this.gridMax ||
    Math.abs( peg.y ) > this.gridMax;
};

proto.connectPeg = function( segment, orientation, peg ) {
  // flatten the key
  var key = orientation + ':' + peg.x + ',' + peg.y;
  var connection = this.connections[ key ];
  // create connections array if not already there
  if ( !connection ) {
    connection = this.connections[ key ] = [];
  }
  if ( connection.indexOf( segment ) == -1 ) {
    connection.push( segment );
  }
};

// --------------------------  -------------------------- //

proto.update = function() {
  this.flyWheel.integrate();
  var angle = this.flyWheel.angle;
  if ( angle < TAU/8 ) {
    this.orientation = 'noon';
  } else if ( angle < TAU * 3/8 ) {
    this.orientation = 'three';
  } else if ( angle < TAU * 5/8 ) {
    this.orientation = 'six';
  } else if ( angle < TAU * 7/8 ) {
    this.orientation = 'nine';
  } else {
    this.orientation = 'noon';
  }
};

proto.attractAlignFlyWheel = function() {
  // attract towards
  var angle = this.flyWheel.angle;
  var target;
  if ( angle < TAU/8 ) {
    target = 0;
  } else if ( angle < TAU * 3/8 ) {
    target = TAU/4;
  } else if ( angle < TAU * 5/8 ) {
    target = TAU/2;
  } else if ( angle < TAU * 7/8 ) {
    target = TAU * 3/4;
  } else {
    target = TAU;
  }
  var attraction = ( target - angle ) * 0.03;
  this.flyWheel.applyForce( attraction );
};

var TAU = Math.PI * 2;

var orientationAngles = {
  noon: 0,
  three: TAU/4,
  six: TAU/2,
  nine: TAU * 3/4
};

proto.render = function( ctx, center, gridSize, angle ) {
  var orientationAngle = orientationAngles[ angle ];
  var gridMax = this.gridMax;
  angle = orientationAngle !== undefined ? orientationAngle : angle || 0;


  ctx.save();
  ctx.translate( center.x, center.y );
  // fixed segments
  this.fixedSegments.forEach( function( segment ) {
    segment.render( ctx, center, gridSize );
  });
  // rotate segments
  this.rotateSegments.forEach( function( segment ) {
    segment.render( ctx, center, gridSize, angle );
  });
  // rotation
  ctx.rotate( angle );

  ctx.lineWidth = gridSize * 0.2;
  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  // axle
  ctx.lineWidth = gridSize * 0.2;
  ctx.strokeStyle = 'hsla(0, 0%, 50%, 0.2)';
  // strokeCircle( ctx, 0, 0, gridSize/2 );
  ctx.save();
  ctx.rotate( Math.PI/4 );
  ctx.strokeRect( -gridSize/5, -gridSize/5, gridSize*2/5, gridSize*2/5 );
  ctx.restore();
  // start position
  ctx.strokeStyle = 'hsla(330, 100%, 50%, 0.3)';
  ctx.lineWidth = gridSize * 0.15;
  var startX = this.startPosition.x * gridSize;
  var startY = this.startPosition.y * gridSize;
  strokeCircle( ctx, startX, startY, gridSize * 0.5 );

  // pegs
  for ( var pegY = -gridMax; pegY <= gridMax; pegY += 2 ) {
    for ( var pegX = -gridMax; pegX <= gridMax; pegX += 2 ) {
      var pegXX = pegX * gridSize;
      var pegYY = pegY * gridSize;
      ctx.fillStyle = 'hsla(0, 0%, 50%, 0.6)';
      fillCircle( ctx, pegXX, pegYY, gridSize * 0.15 );
    }
  }
  // free segments
  this.freeSegments.forEach( function( segment ) {
    segment.render( ctx, center, gridSize );
  });
  // pivot segments
  this.pivotSegments.forEach( function( segment ) {
    segment.render( ctx, center, gridSize, angle );
  });
  // goal position
  var goalX = this.goalPosition.x * gridSize;
  var goalY = this.goalPosition.y * gridSize;
  ctx.lineWidth = gridSize * 0.3;
  ctx.fillStyle = 'hsla(50, 100%, 50%, 1)';
  ctx.strokeStyle = 'hsla(50, 100%, 50%, 1)';
  renderGoal( ctx, goalX, goalY, angle, gridSize * 0.6, gridSize * 0.3 );

  ctx.restore();
};

function fillCircle( ctx, x, y, radius ) {
  ctx.beginPath();
  ctx.arc( x, y, radius, 0, Math.PI * 2 );
  ctx.fill();
  ctx.closePath();
}

function strokeCircle( ctx, x, y, radius ) {
  ctx.beginPath();
  ctx.arc( x, y, radius, 0, Math.PI * 2 );
  ctx.stroke();
  ctx.closePath();
}

function renderGoal( ctx, x, y, mazeAngle, radiusA, radiusB ) {
  ctx.save();
  ctx.translate( x, y );
  ctx.rotate( -mazeAngle );
  ctx.beginPath();
  for ( var i=0; i<11; i++ ) {
    var theta = Math.PI*2 * i/10 + Math.PI/2;
    var radius = i % 2 ? radiusA : radiusB;
    var dx = Math.cos( theta ) * radius;
    var dy = Math.sin( theta ) * radius;
    ctx[ i ? 'lineTo' : 'moveTo' ]( dx, dy );
  }
  ctx.fill();
  ctx.stroke();
  ctx.closePath();
  ctx.restore();
}


function WinAnimation( x, y ) {
  this.x = x;
  this.y = y;
  this.startTime = new Date();
  this.isPlaying = true;
}

// length of animation in milliseconds
var duration = 1000;

var proto = WinAnimation.prototype;

proto.update = function() {
  if ( !this.isPlaying ) {
    return;
  }
  this.t = ( ( new Date() ) - this.startTime ) / duration;
  this.isPlaying = this.t <= 1;
};

proto.render = function( ctx ) {
  if ( !this.isPlaying ) {
    return;
  }

  ctx.save();
  ctx.translate( this.x, this.y );

  // big burst
  this.renderBurst( ctx );
  // small burst
  ctx.save();
  ctx.scale( 0.5, -0.5 );
  this.renderBurst( ctx );
  ctx.restore();

  ctx.restore();
};

proto.renderBurst = function( ctx ) {

  var t = this.t;
  var dt = 1 - t;
  var easeT = 1 - dt*dt*dt*dt*dt*dt*dt*dt;
  var dy = easeT * -100;
  // scale math
  var st = 2 - this.t*2;
  var scale = (1-t*t*t) * 1.5;
  var spin = Math.PI * 1 * t*t*t;

  for ( var i=0; i<5; i++ ) {
    ctx.save();
    ctx.rotate( Math.PI * 2/5 * i );
    ctx.translate( 0, dy );
    ctx.scale( scale, scale );
    ctx.rotate( spin );
    renderStar( ctx );
    ctx.restore();
  }
};

function renderStar( ctx ) {
  ctx.lineWidth = 8;
  ctx.lineJoin = 'round';
  ctx.lineCap = 'round';
  ctx.fillStyle = 'hsla(50, 100%, 50%, 1)';
  ctx.strokeStyle = 'hsla(50, 100%, 50%, 1)';
  ctx.beginPath();
  for ( var i=0; i<11; i++ ) {
    var theta = Math.PI*2 * i/10 + Math.PI/2;
    var radius = i % 2 ? 20 : 10;
    var dx = Math.cos( theta ) * radius;
    var dy = Math.sin( theta ) * radius;
    ctx[ i ? 'lineTo' : 'moveTo' ]( dx, dy );
  }
  ctx.fill();
  ctx.stroke();
  ctx.closePath();
}

/* globals cub, WinAnimation, Unipointer, Maze */

var docElem = document.documentElement;
var canvas = document.querySelector('canvas');
var ctx = canvas.getContext('2d');
// size canvas;
var canvasSize = Math.min( window.innerWidth, window.innerHeight );
var canvasWidth = canvas.width = window.innerWidth * 2;
var canvasHeight = canvas.height = window.innerHeight * 2;
var maze;
var PI = Math.PI;
var TAU = PI * 2;
var dragAngle = null;
var cubDragMove = null;
var isCubHovered = false;
var isCubDragging = false;
var winAnim;
var unipointer = new Unipointer();

// ----- config ----- //

var gridSize = Math.min( 40, canvasSize/12 );
var mazeCenter = {
  x: canvasWidth/4,
  y: Math.min( gridSize * 8, canvasHeight/4 )
};

// ----- instruction ----- //

var instructElem = document.querySelector('.instruction');
instructElem.style.top = ( mazeCenter.y + gridSize * 5.5 ) + 'px';

// ----- build level select, levels array ----- //

var levelList = document.querySelector('.level-list');
var levelsElem = document.querySelector('.levels');
var levels = [];

(function() {
  var levelPres = levelsElem.querySelectorAll('pre');
  var fragment = document.createDocumentFragment();
  for ( var i=0; i < levelPres.length; i++ ) {
    var pre = levelPres[i];
    var listItem = document.createElement('li');
    listItem.className = 'level-list__item';
    var id = pre.id;
    listItem.innerHTML = '<span class="level-list__item__number">' + ( i + 1 ) +
      '</span> <span class="level-list__item__blurb">' +
      pre.getAttribute('data-blurb') + '</span>' +
      '<span class="level-list__item__check">✔</span>';
    listItem.setAttribute( 'data-id', id );
    fragment.appendChild( listItem );
    levels.push( id );
  }

  levelList.appendChild( fragment );

})();

// ----- levels button ----- //

var levelSelectButton = document.querySelector('.level-select-button');
var nextLevelButton = document.querySelector('.next-level-button');

levelSelectButton.addEventListener( 'click', function() {
  levelList.classList.add('is-open');
});

nextLevelButton.style.top = ( mazeCenter.y + gridSize * 5.5 ) + 'px';

// ----- level list ----- //

levelList.addEventListener( 'click', function( event ) {
  var item = getParent( event.target, '.level-list__item' );
  if ( !item ) {
    return;
  }
  // load level from id
  var id = item.getAttribute('data-id');
  loadLevel( id );
});

function getParent( elem, selector ) {
  var parent = elem;
  while ( parent != document.body ) {
    if ( parent.matches( selector ) ) {
      return parent;
    }
    parent = parent.parentNode;
  }
}

// ----- load level ----- //

function loadLevel( id ) {
  var pre = levelsElem.querySelector( '#' + id );

  maze = new Maze();
  maze.id = id;

  if ( !pre ) {
    console.error( 'pre not found for ' + id );
    return;
  }

  // load maze level from pre text
  maze.loadText( pre.textContent );
  // close ui
  levelList.classList.remove('is-open');
  nextLevelButton.classList.remove('is-open');
  window.scrollTo( 0, 0 );
  // highlight list
  var previousItem = levelList.querySelector('.is-playing');
  if ( previousItem ) {
    previousItem.classList.remove('is-playing');
  }
  levelList.querySelector('[data-id="' + id + '"]').classList.add('is-playing');
  localStorage.setItem( 'currentLevel', id );
}

// ----- init ----- //

var initialLevel = localStorage.getItem('currentLevel') || levels[0];
loadLevel( initialLevel );

unipointer.bindStartEvent( canvas );
window.addEventListener( 'mousemove', onHoverMousemove );
animate();

// -------------------------- drag rotation -------------------------- //

var canvasLeft = canvas.offsetLeft;
var canvasTop = canvas.offsetTop;

var pointerBehavior;

// ----- pointerBehavior ----- //

var cubDrag = {};
var mazeRotate = {};

// -----  ----- //

unipointer.pointerDown = function( event, pointer ) {
  event.preventDefault();
  var isInsideCub = getIsInsideCub( pointer );
  pointerBehavior = isInsideCub ? cubDrag : mazeRotate;

  pointerBehavior.pointerDown( event, pointer );

  this._bindPostStartEvents( event );
};

function getIsInsideCub( pointer ) {
  var position = getCanvasMazePosition( pointer );
  var cubDeltaX = Math.abs( position.x - cub[ maze.orientation ].x * gridSize );
  var cubDeltaY = Math.abs( position.y - cub[ maze.orientation ].y * gridSize );
  var bound = gridSize * 1.5;
  return cubDeltaX <= bound && cubDeltaY <= bound;
}

function getCanvasMazePosition( pointer ) {
  var canvasX = pointer.pageX - canvasLeft;
  var canvasY = pointer.pageY - canvasTop;
  return {
    x: canvasX - mazeCenter.x,
    y: canvasY - mazeCenter.y,
  };
}

// ----- unipointer ----- //

unipointer.pointerMove = function( event, pointer ) {
  pointerBehavior.pointerMove( event, pointer );
};

unipointer.pointerUp = function( event, pointer ) {
  pointerBehavior.pointerUp( event, pointer );
  this._unbindPostStartEvents();
};

// ----- cubDrag ----- //

var dragStartPosition, dragStartPegPosition, rotatePointer;

cubDrag.pointerDown = function( event, pointer ) {
  var segments = getCubConnections();
  if ( !segments || !segments.length ) {
    return;
  }
  isCubDragging = true;
  dragStartPosition = { x: pointer.pageX, y: pointer.pageY };
  dragStartPegPosition = {
    x: cub[ maze.orientation ].x * gridSize + mazeCenter.x,
    y: cub[ maze.orientation ].y * gridSize + mazeCenter.y,
  };
  docElem.classList.add('is-cub-dragging');
};

cubDrag.pointerMove = function( event, pointer ) {
  if ( !isCubDragging ) {
    return;
  }
  cubDragMove = {
    x: pointer.pageX - dragStartPosition.x,
    y: pointer.pageY - dragStartPosition.y,
  };
};

cubDrag.pointerUp = function() {
  cubDragMove = null;
  docElem.classList.remove('is-cub-dragging');
  isCubDragging = false;
  // set at peg
  cub.setOffset( { x: 0, y: 0 }, maze.orientation );
  // check level complete
  if ( cub.peg.x == maze.goalPosition.x && cub.peg.y == maze.goalPosition.y ) {
    completeLevel();
    console.log('win');
  }
};

// ----- rotate ----- //

var dragStartAngle, dragStartMazeAngle, moveAngle;
var mazeRotate = {};


mazeRotate.pointerDown = function( event, pointer ) {
  dragStartAngle = moveAngle = getDragAngle( pointer );
  dragStartMazeAngle = maze.flyWheel.angle;
  dragAngle = dragStartMazeAngle;
  rotatePointer = pointer;
};

function getDragAngle( pointer ) {
  var position = getCanvasMazePosition( pointer );
  return normalizeAngle( Math.atan2( position.y, position.x ) );
}

mazeRotate.pointerMove = function( event, pointer ) {
  rotatePointer = pointer;
  moveAngle = getDragAngle( pointer );
  var deltaAngle = moveAngle - dragStartAngle;
  dragAngle = normalizeAngle( dragStartMazeAngle + deltaAngle );
};

mazeRotate.pointerUp = function() {
  dragAngle = null;
  rotatePointer = null;
};


// ----- animate ----- //

function animate() {
  update();
  render();
  requestAnimationFrame( animate );
}

// ----- update ----- //

function update() {
  // drag cub
  dragCub();
  // rotate grid
  if ( dragAngle ) {
    maze.flyWheel.setAngle( dragAngle );
  } else {
    maze.attractAlignFlyWheel();
  }
  maze.update();
  if ( winAnim ) {
    winAnim.update();
  }
}

function dragCub() {
  if ( !cubDragMove ) {
    return;
  }

  var segments = getCubConnections();

  var dragPosition = {
    x: dragStartPegPosition.x + cubDragMove.x,
    y: dragStartPegPosition.y + cubDragMove.y,
  };

  // set peg position
  var dragPeg = getDragPeg( segments, dragPosition );
  cub.setPeg( dragPeg, maze.orientation );

  // set drag offset
  var cubDragPosition = getDragPosition( segments, dragPosition );

  var cubPosition = getCubPosition();
  var offset = {
    x: cubDragPosition.x - cubPosition.x,
    y: cubDragPosition.y - cubPosition.y,
  };
  cub.setOffset( offset, maze.orientation );

}

function getCubPosition() {
  return {
    x: cub[ maze.orientation ].x * gridSize + mazeCenter.x,
    y: cub[ maze.orientation ].y * gridSize + mazeCenter.y,
  };
}

function getCubConnections() {
  var pegX = cub[ maze.orientation ].x;
  var pegY = cub[ maze.orientation ].y;
  var key = maze.orientation + ':' + pegX + ',' + pegY;
  return maze.connections[ key ];
}

function getDragPosition( segments, dragPosition ) {
  if ( segments.length == 1 ) {
    return getSegmentDragPosition( segments[0], dragPosition );
  }

  // get closest segments positions
  var dragCandidates = segments.map( function( segment ) {
    var position = getSegmentDragPosition( segment, dragPosition );
    return {
      position: position,
      distance: getDistance( dragPosition, position ),
    };
  });

  dragCandidates.sort( distanceSorter);

  return dragCandidates[0].position;
}

function getSegmentDragPosition( segment, dragPosition ) {
  var line = segment[ maze.orientation ];
  var isHorizontal = line.a.y == line.b.y;
  var x, y;
  if ( isHorizontal ) {
    x = getSegmentDragCoord( line, 'x', dragPosition );
    y = line.a.y * gridSize + mazeCenter.y;
  } else {
    x = line.a.x * gridSize + mazeCenter.x;
    y = getSegmentDragCoord( line, 'y', dragPosition );
  }
  return { x: x, y: y };
}

function getSegmentDragCoord( line, axis, dragPosition ) {
  var a = line.a[ axis ];
  var b = line.b[ axis ];
  var min = a < b ? a : b;
  var max = a > b ? a : b;
  min = min * gridSize + mazeCenter[ axis ];
  max = max * gridSize + mazeCenter[ axis ];
  return Math.max( min, Math.min( max, dragPosition[ axis ] ) );
}

function distanceSorter( a, b ) {
 return a.distance - b.distance;
}

function getDragPeg( segments, dragPosition ) {
  var pegs = [];
  segments.forEach( function( segment ) {
    var line = segment[ maze.orientation ];
    addPegPoint( line.a, pegs );
    addPegPoint( line.b, pegs );
  });

  var pegCandidates = pegs.map( function( pegKey ) {
    // revert string back to object with integers
    var parts = pegKey.split(',');
    var peg = {
      x: parseInt( parts[0], 10 ),
      y: parseInt( parts[1], 10 ),
    };
    var pegPosition = {
      x: peg.x * gridSize + mazeCenter.x,
      y: peg.y * gridSize + mazeCenter.y,
    };
    return {
      peg: peg,
      distance: getDistance( dragPosition, pegPosition ),
    };
  });

  pegCandidates.sort( distanceSorter );

  return pegCandidates[0].peg;
}

function getDistance( a, b ) {
  var dx = b.x - a.x;
  var dy = b.y - a.y;
  return Math.sqrt( dx * dx + dy * dy );
}

function addPegPoint( point, pegs ) {
  // use strings to prevent dupes
  var key = point.x + ',' + point.y;
  if ( pegs.indexOf( key ) == -1 ) {
    pegs.push( key );
  }
}

// ----- hover ----- //

function onHoverMousemove( event ) {
  var isInsideCub = getIsInsideCub( event );
  if ( isInsideCub == isCubHovered ) {
    return;
  }
  // change
  isCubHovered = isInsideCub;
  var changeClass = isInsideCub ? 'add' : 'remove';
  docElem.classList[ changeClass ]('is-cub-hovered');
}

// ----- render ----- //

function render() {
  ctx.clearRect( 0, 0, canvasWidth, canvasHeight );
  ctx.save();
  ctx.scale( 2, 2 );
  renderRotateHandle();
  // maze
  maze.render( ctx, mazeCenter, gridSize, maze.flyWheel.angle );
  // win animation
  if ( winAnim ) {
    winAnim.render( ctx );
  }
  // cub
  var isHovered = isCubHovered || isCubDragging;
  cub.render( ctx, mazeCenter, gridSize, maze.flyWheel.angle, isHovered );
  ctx.restore();
}

function renderRotateHandle() {
  // rotate handle
  if ( !rotatePointer ) {
    return;
  }

  ctx.lineCap = 'round';
  ctx.lineJoin = 'round';
  ctx.lineWidth = gridSize * 0.5;
  var color = '#EEE';
  ctx.strokeStyle = color;
  ctx.fillStyle = color;

  // pie slice
  ctx.beginPath();
  var pieRadius = maze.gridMax * gridSize;
  ctx.moveTo( mazeCenter.x, mazeCenter.y );
  var pieDirection = normalizeAngle( normalizeAngle( moveAngle ) -
    normalizeAngle( dragStartAngle ) ) > TAU/2 ;
  ctx.arc( mazeCenter.x, mazeCenter.y, pieRadius, dragStartAngle, moveAngle, pieDirection );
  ctx.lineTo( mazeCenter.x, mazeCenter.y );
  ctx.stroke();
  ctx.fill();
  ctx.closePath();
}

// -------------------------- completeLevel -------------------------- //

var completedLevels = localStorage.getItem('completedLevels');
completedLevels = completedLevels ? completedLevels.split(',') : [];

completedLevels.forEach( function( id ) {
  var item = levelList.querySelector('[data-id="' + id + '"]');
  if ( item ) {
    item.classList.add('did-complete');
  }
});

function completeLevel() {
  var cubPosition = getCubPosition();
  winAnim = new WinAnimation( cubPosition.x, cubPosition.y );
  levelList.querySelector('[data-id="' + maze.id + '"]').classList.add('did-complete');
  if ( completedLevels.indexOf( maze.id ) == -1 ) {
    completedLevels.push( maze.id );
    localStorage.setItem( 'completedLevels', completedLevels.join(',') );
  }
  if ( getNextLevel() ) {
    setTimeout( function() {
      nextLevelButton.classList.add('is-open');
    }, 1000 );
  }
}

function getNextLevel() {
  var index = levels.indexOf( maze.id );
  return levels[ index + 1 ];
}

// -------------------------- next level -------------------------- //

nextLevelButton.addEventListener( 'click', function() {
  var nextLevel = getNextLevel();
  if ( nextLevel ) {
    loadLevel( nextLevel );
  }
});

// -------------------------- utils -------------------------- //

function normalizeAngle( angle ) {
  return ( ( angle % TAU ) + TAU ) % TAU;
}

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.