让小熊吃到星星

Published on
/
/趣玩前端
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <title>让小熊吃到星星</title>
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <style>
      * {
        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;
      }
    </style>
  </head>
  <body>
    <div class="top-bar">
      <button class="button level-select-button">关卡</button>
    </div>

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

    <canvas></canvas>
    <p class="instruction"></p>
    <button class="button next-level-button">下一关</button>

    <div class="levels">
      <pre id="intro-fixed1" data-blurb="Tutorial">
blurb: Tutorial
instruction: 拖动小熊到星星
---
*=.=.
    !
. . .
    !
@=.=.
</pre
      >

      <pre id="intro-fixed2" data-blurb="Tutorial">
blurb: Tutorial
instruction: 拖动网格来旋转。小熊和星星会随着网格一起移动。橙线会保持不动。
---
* . .
    !
. . .
    !
@=.=.
</pre
      >

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

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

      <pre id="intro-free1" data-blurb="Tutorial">
blurb: Tutorial
instruction: 蓝线会随着网格移动。旋转网格来连接蓝线和橙线。
---
@-. .
!   | 
. . .
    |
*-.-.
</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: 绿线会随着网格旋转,但方向不变。
---
. .-* .
  |    
. . . .
       
. .>. .
       
. @ . .
</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: 红线会固定位置,但会随着网格旋转。
---
. . . .
       
@ .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>

    <script>
      /**
       * 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;
      }
    </script>
  </body>
</html>