/** * 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.connect = function(maze) { this.connectOrientation(maze, 'noon'); this.connectOrientation(maze, 'three'); this.connectOrientation(maze, 'six'); this.connectOrientation(maze, 'nine'); }; proto.connectOrientation = function(maze, orientation) { var line = this[orientation]; maze.connect(this, orientation, line.a); maze.connect(this, orientation, line.b); }; 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.connect = function(maze) { maze.connect(this, 'noon', this.a); maze.connect(this, 'noon', this.b); maze.connect(this, 'three', this.a); maze.connect(this, 'three', this.b); maze.connect(this, 'six', this.a); maze.connect(this, 'six', this.b); maze.connect(this, 'nine', this.a); maze.connect(this, 'nine', this.b); }; 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.connect = function(maze) { this.connectOrientation(maze, 'noon'); this.connectOrientation(maze, 'three'); this.connectOrientation(maze, 'six'); this.connectOrientation(maze, 'nine'); }; proto.connectOrientation = function(maze, orientation) { var line = this[orientation]; if (maze.getIsPegOut(line.a) || maze.getIsPegOut(line.b)) { return; } maze.connect(this, orientation, line.a); maze.connect(this, orientation, line.b); }; 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(); }; // 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(); } // function eye( direction ) { // ctx.beginPath(); // ctx.arc( gridSize * 0.25 * direction, gridSize * 0.05, gridSize * 0.1, Math.PI, 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); // // eyes // ctx.fillStyle = 'hsla(0, 0%, 0%, 0.8)'; // eye( 1 ); // eye( -1 ); // // nose // ctx.beginPath(); // ctx.arc( 0, gridSize * 0.15, gridSize * 0.1, 0, Math.PI ); // ctx.fill(); // ctx.closePath(); ctx.restore(); }; /* globals FlyWheel, FreeSegment, FixedSegment, PivotSegment, cub */ function Maze() { this.freeSegments = []; this.fixedSegments = []; this.pivotSegments = []; 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) { var 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); segment.connect(this); this.freeSegments.push(segment); }; // vertical free segment proto['parse|'] = proto.addFreeVertSegment = function(pegX, pegY) { var segment = getVertSegment(pegX, pegY, FreeSegment); segment.connect(this); this.freeSegments.push(segment); }; // horizontal fixed segment proto['parse='] = proto.addFixedHorizSegment = function(pegX, pegY) { var segment = getHorizSegment(pegX, pegY, FixedSegment); segment.connect(this); this.fixedSegments.push(segment); }; // vertical fixed segment proto['parse!'] = proto.addFixedVertSegment = function(pegX, pegY) { var segment = getVertSegment(pegX, pegY, FixedSegment); segment.connect(this); 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 up segment proto['parse^'] = function(pegX, pegY) { var a = { x: pegX, y: pegY + 1 }; var b = { x: pegX, y: pegY - 1 }; var segment = new PivotSegment(a, b); segment.connect(this); 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); segment.connect(this); 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); segment.connect(this); 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); segment.connect(this); this.pivotSegments.push(segment); }; // 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; }; proto.connect = function(segment, orientation, position) { // flatten the key var key = orientation + ':' + position.x + ',' + position.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.getIsPegOut = function(peg) { return Math.abs(peg.x) > this.gridMax || Math.abs(peg.y) > this.gridMax; }; // -------------------------- -------------------------- // 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(item) { item.render(ctx, center, gridSize); }); // 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; // 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(); window["reddahApi"].loadCompleted(); // -------------------------- 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) { levelList.querySelector('[data-id="' + id + '"]').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; }
blurb: Tutorial instruction: Drag cub to star --- *=.=. ! . . . ! @=.=.
blurb: Tutorial instruction: Drag grid to rotate. Cub and star moves with grid. Orange links stay in place. --- * . . ! . . . ! @=.=.
blurb: ★ --- @=. . . . . ! *=. .
blurb: Tutorial instruction: Blue links move with grid. Rotate grid to connect blue and orange links in different ways. --- @-. . ! | . . . | *-.-.
blurb: ★ --- . . * | | | . . . | | | @ .=.
blurb: ★ --- *=.-. . . . | @-. .
blurb: ★ --- . .=. . | ! . . .-* | . . . . . @-. .
blurb: ★ --- . . . . * . . @ | ! | . . . . ! . . . .
blurb: ★ --- . @ . . ! | . . . . | .=.=.-. | . * . .
blurb: ★ --- . . . . * . . . ! . . .-. ! .=.=. @
blurb: ★ --- .-.-.-. | @ .-.-. * .=. . ! | .-.-. .
blurb: ★ --- . * . . .-.=. . | . . . . ! | .=. @ .
blurb: ★★ --- . . *-. .-.=. . | .=. . . | | @-.-.=.
blurb: ★★ --- .-@ .=. . . . . | .-. .-* | . .=.-.
blurb: ★★ --- . . .=. ! @-. .-. . .=. . . . * .
blurb: ★★ --- . @=. . | . .-.-. .-.-.-. ! ! . * . .
. . . . . | ! . . .-. . | . . . . * | . . .=. . | . @ . . .
@-.-. .-. | . . . . . . . .=. . . . . .=. | . .=.-* .
. . . . . . .=.-. @ | ! . . . .-. .=. . .=. ! * . . . .
. . . .-. ! . .-. . . ! | .=. . . . | . . . . * | .-@=. .=.
. . . . . . . .-. * ! . . .-. . .=. . . . | . @-. . .
. . .-.-. ! ! . .=.-. . | . .-. .-@ ! * .=. . . | .=. .-.=.
.=* . @=. | . .=. . . | | | .=. . .-. | . . . .=. ! . .-.-. .
. * . .-. | . . .=.-. ! | . . . . . . .-. .=. | . . .=.-@
.-.-. . . | . . . .-@ ! * . .-. . | ! .-. . .=. | ! . . .=. .
. . . . . . . . .-@ ! * . .=. . | ! .-. . . . . . . . .
. . . .=. | . . . .=. | . . .-. . ! | . .=. . . | ! ! .-@ . * .
. . .=.=. . . . . . . . . . @ . . . . . * . .=.=.
. . * . . . ! | | . .-. .-. . | . . . . .-. | ! | . . .=. . . | @-.-. .-. . | . .=. . .-.
@ .=. . .=. | | ! . . . .=. . | | . . . .-. . | ! . . . . . * | | .=. .-. . . | | | .-. . . .=.
.=. .=.-.-* | .-. . . . . | ! . . .-.-. . ! .-. .=.=. . @ .=. . . . | ! . .-. .-. .
instruction: Green links pivot with grid, but point in the same direction --- . .-* . | . . . . . .>. . . @ . .
. . .-.-@ . .<. . . .>. . . . | ! .-.-. . * ! . . . . .
. . . . . ^ .<. . . * . . . . . @ . . .>. v . . . . .
. .-. . . ^ . .<.=.=. .>. . .-@ * . . .=. . . . . .
.=. . .-* v . . . . . . . .-.J. @-. . . . v .<. . . .
.-.-. @>. ! ^ . . . . . | . . . . . | . . . .=* ^ . . .-. .>
.-. . . * . .>. . . | v .-. . . . ^ . . .-. . v @=.=. . .
. . .>. . ! | @=. .-. . . . . .=.> . . . . . . *>.<. .
* . @ . . v | . . . . . ! . . . . . ^ ! ! . .-. . . ! . . . . . v
. . . . . . | v @ . . . . * | | . . . . . . | ! ^ | K . . . .-.=. | . .-. . . . v .>. . . . .
. @-. .>.-. . . . . . . | * .>. .=. . ! . . . . . .> | ^ . . . .=. . . .=. . .=.>
. .-.-. .=. v . . . . . . | ! v .>. . . . * ^ . . . . . . | . .-.<. . . ! | | . . . .>.-@