UNPKG

sandpit

Version:

A playground for creative coding using JavaScript and the canvas element

1,060 lines (906 loc) 31.2 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.Stats = exports.Vector = exports.Color = exports.Mathematics = exports.Is = undefined; var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); var _dat = require('dat.gui/build/dat.gui'); var _dat2 = _interopRequireDefault(_dat); var _queryString = require('query-string'); var _queryString2 = _interopRequireDefault(_queryString); var _debounce = require('debounce'); var _debounce2 = _interopRequireDefault(_debounce); var _seedrandom = require('seedrandom'); var _seedrandom2 = _interopRequireDefault(_seedrandom); var _logger = require('./utils/logger'); var _logger2 = _interopRequireDefault(_logger); var _Color = require('./utils/Color'); var _Color2 = _interopRequireDefault(_Color); var _Is = require('./utils/Is'); var _Is2 = _interopRequireDefault(_Is); var _Mathematics = require('./utils/Mathematics'); var _Mathematics2 = _interopRequireDefault(_Mathematics); var _Stats = require('./utils/Stats'); var _Stats2 = _interopRequireDefault(_Stats); var _Vector = require('./utils/Vector'); var _Vector2 = _interopRequireDefault(_Vector); var _gyronorm = require('gyronorm'); var _gyronorm2 = _interopRequireDefault(_gyronorm); require('whatwg-fetch'); function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } /* global fetch */ /** * A playground for creative coding */ var Sandpit = function () { _createClass(Sandpit, null, [{ key: 'CANVAS', get: function get() { return '2d'; } }, { key: 'WEBGL', get: function get() { return 'webgl'; } }, { key: 'EXPERIMENTAL_WEBGL', get: function get() { return 'experimental-webgl'; } /** * @param {(string|object)} container - The container for the canvas to be added to * @param {string} type - Defines whether the context is 2d or 3d * @param {object} options - Optionally decide to ignore rescaling for retina displays, * disable putting settings into the query string, or add stats to the dom */ }]); function Sandpit(container, type, options) { _classCallCheck(this, Sandpit); _logger2.default.info('⛱ Welcome to Sandpit'); this._queryable = options && options.hasOwnProperty('queryable') ? options.queryable : true; this._retina = options && options.hasOwnProperty('retina') ? options.retina : true; this._stats = options && options.hasOwnProperty('stats') ? options.stats : false; this._setupContext(container, type, this._retina); } /** * Set up the canvas * @private */ _createClass(Sandpit, [{ key: '_setupContext', value: function _setupContext(container, type, retina) { // Check that the correct container type has been passed if (typeof container !== 'string' && (typeof container === 'undefined' ? 'undefined' : _typeof(container)) !== 'object') { throw new Error('Please provide a string or object reference to the container, like ".container", or document.querySelector(".container")'); } // Check that the type is set if (typeof type !== 'string' || type !== Sandpit.CANVAS && type !== Sandpit.WEBGL) { throw new Error('Please provide a context type - either `Sandpit.CANVAS` or `Sandpit.WEBGL`'); } // Either find the container, or just use the object var _container = void 0; if (typeof container === 'string') { _container = document.querySelector(container); } else if ((typeof container === 'undefined' ? 'undefined' : _typeof(container)) === 'object') { _container = container; } // Check the container is a dom element if (_Is2.default.element(_container)) { // Check the container is a canvas element // and if so, use it instead of making a new one if (_Is2.default.canvas(_container)) { this._canvas = _container; } else { this._canvas = document.createElement('canvas'); _container.appendChild(this._canvas); } // Set the width and height this._canvas.width = this._canvas.clientWidth; this._canvas.height = this._canvas.clientHeight; // Grab the context if (type === Sandpit.CANVAS) { this._context = this._canvas.getContext(type); } else if (type === Sandpit.WEBGL || type === Sandpit.EXPERIMENTAL_WEBGL) { this._context = this._canvas.getContext(Sandpit.WEBGL) || this._canvas.getContext(Sandpit.EXPERIMENTAL_WEBGL); } this._type = type; // Deal with retina displays if (type === Sandpit.CANVAS && window.devicePixelRatio !== 1 && this._retina) { this._handleRetina(); } // Sets up stats, if they are enabled if (this._stats) this.setupStats(); } else { throw new Error('The container is not a HTMLElement'); } } /** * Resizes the canvas for retina * @private */ }, { key: '_handleRetina', value: function _handleRetina() { var ratio = window.devicePixelRatio; // Increaser the canvas by the ratio this._canvas.width = this._canvas.clientWidth * ratio; this._canvas.height = this._canvas.clientHeight * ratio; // Scale the canvas to the new ratio this._context.scale(ratio, ratio); } /** * Sets up the settings gui via dat.gui * @param {object} settings - An object of key value pairs * for the setting name and default value * @param {boolean} queryable - Whether or not to store settings * in the query string for easy sharing * @private */ }, { key: '_setupSettings', value: function _setupSettings() { var _this = this; this._settings = {}; this._clearGui = true; this._resetGui = true; // Destroy the gui if new settings are being passed in if (this._gui) { this._gui.domElement.removeEventListener('touchmove', this._preventDefault); this._gui.destroy(); } this._gui = new _dat2.default.GUI(); this._gui.domElement.addEventListener('touchmove', this._preventDefault, false); // If queryable is true, set up the query string management // for storing settings if (this._queryable) { if (window.location.search) { var params = _queryString2.default.parse(window.location.search); Object.keys(params).forEach(function (key) { // Check if the param is a float, and if so, parse it if (parseFloat(params[key])) { params[key] = parseFloat(params[key]); } // If a setting matches the param, use the param if (_this.defaults[key]) { var param = params[key]; // Convert string to boolean if 'true' or 'false' if (param === 'true') param = true; if (param === 'false') param = false; if (_typeof(_this.defaults[key].value) !== 'object') { // If sticky is true, stick with the default setting // otherwise set the default to the param if (!_this.defaults[key].sticky) { _this.defaults[key].value = param; } } else { // If the param is an object, store the // name in a selected property if (!_this.defaults[key].sticky) { _this.defaults[key].selected = param; } else { // If sticky is true, force the default setting _this.defaults[key].selected = _this.defaults[key].value[Object.keys(_this.defaults[key].value)[0]]; } } } }); } } // Create settings folder and add each item to it var group = this._gui.addFolder('Settings'); Object.keys(this.defaults).forEach(function (name) { var options = false; var value = _this.defaults[name].value; if (value || _typeof(_this.defaults[name]) === 'object') { // If it's an object, supply the array or object, // and grab the right value if ((typeof value === 'undefined' ? 'undefined' : _typeof(value)) === 'object') { options = value; // If a selected option is available via the query // string, use that if (_this.defaults[name].selected) { _this._settings[name] = _this.defaults[name].selected; } else { // If not, grab the first item in the object or array _this._settings[name] = _Is2.default.array(value) ? value[0] : value[Object.keys(value)[0]]; } } else { // If it's not an object, pass the setting on _this._settings[name] = _this.defaults[name].value; } // If it's a colour, use a different method var guiField = _this.defaults[name].color ? group.addColor(_this._settings, name) : group.add(_this._settings, name, options); // Check for min, max and step, and add to the gui field if (_this.defaults[name].min !== undefined) guiField = guiField.min(_this.defaults[name].min); if (_this.defaults[name].max !== undefined) guiField = guiField.max(_this.defaults[name].max); if (_this.defaults[name].step !== undefined) guiField = guiField.step(_this.defaults[name].step); if (_this.defaults[name].editable === false) { guiField.domElement.style.pointerEvents = 'none'; guiField.domElement.style.opacity = 0.5; } // Handle the change event guiField.onChange((0, _debounce2.default)(function (value) { _this._change(name, value); }), 300); } else { // Handle properties that aren't tied to value - // usually settings that relate to the gui itself switch (name) { case 'clear': _this._clearGui = _this.defaults[name]; break; case 'reset': _this._resetGui = _this.defaults[name]; break; default: break; } } }); // Open the settings drawer group.open(); // Hide controls for mobile if (this.width <= 767) { this._gui.close(); } // If queryable is enabled, serialize the final settings // and push them to the query string if (this._queryable) { var query = _queryString2.default.stringify(this._settings); window.history.replaceState({}, null, this._getPathFromUrl() + '?' + query); // Adds a clear and reset button to the gui interface, // if they aren't disabled in the settings if (this._clearGui) this._gui.add({ clear: function clear() { _this.clear(); } }, 'clear'); if (this._resetGui) this._gui.add({ reset: function reset() { _this._reset(); } }, 'reset'); } } /** * Resets the settings in the query string, and offers a hook * to do something more fancy with sandpit.reset * @private */ }, { key: '_reset', value: function _reset() { if (this._queryable || this.reset) { if (this.reset) { // If there's a reset method available, run that this.reset(); } else { // If queryable, clear the query string window.history.replaceState({}, null, this._getPathFromUrl()); // Reload the video window.location.reload(); } } } /**vst * Handles a changed setting * @param {string} name - Setting name * @param {*} value - The new setting value * @private */ }, { key: '_change', value: function _change(name, value) { _logger2.default.info('Update fired on ' + name + ': ' + value); if (this._queryable) { var query = _queryString2.default.stringify(this._settings); window.history.pushState({}, null, this._getPathFromUrl() + '?' + query); } // If there is a change hook, use it if (this.change) { this.change(name, value); } } /** * Sets up the primary animation loop * @private */ }, { key: '_setupLoop', value: function _setupLoop() { this._time = 0; this._loop(); } /** * The primary animation loop * @private */ }, { key: '_loop', value: function _loop() { // Start stat recording for this frame if (this.stats) this.stats.begin(); // Clear the canvas if autoclear is set if (this._autoClear) this.clear(); // Loop! if (this.loop) this.loop(); // Increment time this._time++; // End stat recording for this frame if (this.stats) this.stats.end(); this._animationFrame = window.requestAnimationFrame(this._loop.bind(this)); } /** * Sets up event management * @private */ }, { key: '_setupEvents', value: function _setupEvents() { var _this2 = this; this._events = {}; this._setupResize(); this._setupInput(); // Loop through and add event listeners Object.keys(this._events).forEach(function (event) { if (_this2._events[event].disable) { // If the disable property exists, add prevent default to it _this2._events[event].disable.addEventListener(event, _this2._preventDefault, false); } _this2._events[event].context.addEventListener(event, _this2._events[event].event.bind(_this2), false); }); } /** * Sets up the resize event, optionally using a user defined option * @private */ }, { key: '_setupResize', value: function _setupResize() { this._resizeEvent = this.resize ? this.resize : this.resizeCanvas; this._events['resize'] = { event: this._resizeEvent, context: window }; } /** * Hooks up the mouse events * @private */ }, { key: '_setupMouse', value: function _setupMouse() { this._events['mousemove'] = { event: this._handleMouseMove, context: document }; this._events['mousedown'] = { event: this._handleMouseDown, context: document }; this._events['mouseenter'] = { event: this._handleMouseEnter, context: document }; this._events['mouseleave'] = { event: this._handleMouseLeave, context: document }; this._events['mouseup'] = { event: this._handleMouseUp, context: document }; } /** * Hooks up the touch events * @private */ }, { key: '_setupTouches', value: function _setupTouches() { var body = document.querySelector('body'); this._events['touchmove'] = { event: this._handleTouchMove, disable: document, context: body }; this._events['touchstart'] = { event: this._handleTouchStart, disable: document, context: body }; this._events['touchend'] = { event: this._handleTouchEnd, disable: document, context: body }; } /** * Stops an event bubbling up * @private */ }, { key: '_stopPropagation', value: function _stopPropagation(event) { event.stopPropagation(); } /** * Stops an event firing its default behaviour * @private */ }, { key: '_preventDefault', value: function _preventDefault(event) { event.preventDefault(); } /** * Hooks up the accelerometer events * @private */ }, { key: '_setupAccelerometer', value: function _setupAccelerometer() { var _this3 = this; this._gyroscope = new _gyronorm2.default(); this._gyroscope.init().then(function () { _this3._gyroscope.start(_this3._handleAccelerometer.bind(_this3)); }).catch(function (e) { _logger2.default.warn('Accelerometer is not supported by this device'); }); } /** * Defines the input object and sets up the mouse, accelerometer and touches * @param {event} event * @private */ }, { key: '_setupInput', value: function _setupInput() { this.input = {}; this._setupMouse(); this._setupTouches(); this._setupAccelerometer(); } /** * Handles the mousemove event * @param {event} event * @private */ }, { key: '_handleMouseMove', value: function _handleMouseMove(event) { event.touches = {}; event.touches[0] = event; this._handleTouches(event); if (this.move) this.move(event); } /** * Handles the mousedown event * @param {event} event * @private */ }, { key: '_handleMouseDown', value: function _handleMouseDown(event) { event.touches = {}; event.touches[0] = event; this._handleTouches(event); if (this.touch) this.touch(event); } /** * Handles the mouseup event * @param {event} event * @private */ }, { key: '_handleMouseUp', value: function _handleMouseUp(event) { this._handleRelease(); if (this.release) this.release(event); } /** * Handles the mouseenter event * @param {event} event * @private */ }, { key: '_handleMouseEnter', value: function _handleMouseEnter(event) { this.input.inFrame = true; } /** * Handles the mouseleave event * @param {event} event * @private */ }, { key: '_handleMouseLeave', value: function _handleMouseLeave(event) { this.input.inFrame = false; } /** * Handles the touchmove event * @param {event} event * @private */ }, { key: '_handleTouchMove', value: function _handleTouchMove(event) { // So, event.preventDefault() seems required to prevent pinching, // but sometimes pinches still rarely happen (3 - 5 fingers), so // is there a way to avoid this? Currently added a // focusTouchesOnCanvas() method to blow away all other // touch events, for use outside the demo environment, // but this isn't really a viable solution. If possible, // I'd use the bit below, but I've commented it out for now: // this._focusTouchesOnCanvas ? event.preventDefault() : event.stopPropagation() event.preventDefault(); this._handleTouches(event); if (this.move) this.move(event); } /** * Handles the touchstart event * @param {event} event * @private */ }, { key: '_handleTouchStart', value: function _handleTouchStart(event) { this._focusTouchesOnCanvas ? event.preventDefault() : event.stopPropagation(); this._handleTouches(event); if (this.touch) this.touch(event); } /** * Handles the touchend event * @param {event} event * @private */ }, { key: '_handleTouchEnd', value: function _handleTouchEnd(event) { this._focusTouchesOnCanvas ? event.preventDefault() : event.stopPropagation(); this._handleTouches(event); if (this.release) this.release(event); } /** * Handles the accelerometer event, using the * GyroNorm.js library * @param {event} event * @private */ }, { key: '_handleAccelerometer', value: function _handleAccelerometer(data) { this.input.accelerometer = data; // Apply some helpers to more easily // access x, y and rotation this.input.accelerometer.xAxis = data.do.beta; this.input.accelerometer.yAxis = data.do.gamma; this.input.accelerometer.rotation = data.do.alpha; this.input.accelerometer.gamma = data.do.gamma; this.input.accelerometer.beta = data.do.beta; this.input.accelerometer.alpha = data.do.alpha; // Fire the accelerometer event, if available if (this.accelerometer) this.accelerometer(); } /** * Handles a set of touches * @param {object} touches - An object containing touch information, in * the format {0: TouchItem, 1: TouchItem} * @private */ }, { key: '_handleTouches', value: function _handleTouches(event) { var _this4 = this; // Delete the length parameter from touches, // so we can loop through it delete event.touches.length; if (Object.keys(event.touches).length) { var touches = Object.keys(event.touches).map(function (key, i) { // Set the X & Y for input from the first touch if (i === 0) { _this4.input.x = event.touches[key].pageX; _this4.input.y = event.touches[key].pageY; } var touch = {}; // If there is previous touch, store it as a helper if (_this4.input.touches && _this4.input.touches[key]) { touch.previousX = _this4.input.touches[key].x; touch.previousY = _this4.input.touches[key].y; } // Store the x and y touch.x = event.touches[key].pageX; touch.y = event.touches[key].pageY; // If force is available, add it if (event.touches[key].force) touch.force = event.touches[key].force; return touch; }); // Update the touches this.input.touches = touches; } else { this._handleRelease(); } } /** * Deletes the appropriate data from inputs on release * @param {object} pointer - An object containing pointer information, * in the format of {pageX: x, pageY: y} * @private */ }, { key: '_handleRelease', value: function _handleRelease() { delete this.input.x; delete this.input.y; delete this.input.touches; } /** * Creates the settings GUI * @param {object} settings - An object containing key value pairs for each setting * @param {boolean} queryable - Enables query string storage of settings * @return {object} Context */ }, { key: 'clear', /** * Clears the canvas, and if change is set, * fires change * @param {boolean} boolean */ value: function clear() { if (this._type === Sandpit.CANVAS) { this._context.clearRect(0, 0, this.width, this.height); } else if (this._type === Sandpit.WEBGL || this._type === Sandpit.EXPERIMENTAL_WEBGL) { this._context.clearColor(0, 0, 0, 0); this._context.clear(this._context.COLOR_BUFFER_BIT | this._context.DEPTH_BUFFER_BIT); } if (this.change) this.change(); } /** * Grab the current path from the url * @private */ }, { key: '_getPathFromUrl', value: function _getPathFromUrl() { return window.location.toString().split(/[?#]/)[0]; } /** * Clear the query string */ }, { key: 'clearQueryString', value: function clearQueryString() { window.history.replaceState({}, null, this._getPathFromUrl()); } /** * Sets whether to or not to ignore the touch events for * multitouch, bypassing pinch to zoom, but meaning no * other touch events will work * @param {boolean} boolean */ }, { key: 'fill', /** * Fills the canvas with the provided colour * @param {string} color - The color to fill with, in string format * (for example, '#000', 'rgba(0, 0, 0, 0.5)') */ value: function fill(color) { this._fill = color; if (this._type === Sandpit.CANVAS) { this._context.fillStyle = this._fill; this._context.fillRect(0, 0, this.width, this.height); } else if (this._type === Sandpit.WEBGL || this._type === Sandpit.EXPERIMENTAL_WEBGL) { // TODO: Use fill to update the clearColor of a 3D canvas _logger2.default.warn('fill() is currently only supported in 2D'); } } /** * Returns a promise using fetch * https://github.com/github/fetch * @param {string} url - The url to fetch */ }, { key: 'get', value: function get(url) { return new Promise(function (resolve, reject) { fetch(url).then(function (response) { resolve(response.text()); }).catch(function (error) { _logger2.default.info('Get fail', error); reject(); }); }); } /** * Returns a random number generator based on a seed * @param {string} seed - The seed with which to create the random number * @returns {function} A function that returns a random number */ }, { key: 'random', value: function random() { var seed = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '123456'; return (0, _seedrandom2.default)(seed); } /** * Resizes the canvas to the window width and height */ }, { key: 'resizeCanvas', value: function resizeCanvas() { if (this._type === Sandpit.CANVAS && window.devicePixelRatio !== 1 && this._retina) { this._handleRetina(); } else { this._canvas.width = this._canvas.clientWidth; this._canvas.height = this._canvas.clientHeight; } if (this._type === Sandpit.WEBGL || this._type === Sandpit.EXPERIMENTAL_WEBGL) { this._context.viewport(0, 0, this._context.drawingBufferWidth, this._context.drawingBufferHeight); } if (this.change) this.change(); } /** * Handle the stats object * @param {object} stats - a Stats.js object, which can be imported * from Sandpit with `import { Stats } from 'sandpit'` */ }, { key: 'setupStats', value: function setupStats() { if (!this.stats) { this.stats = new _Stats2.default(); document.querySelector('body').appendChild(this.stats.dom); } } /** * Sets up resizing and input events and starts the loop */ }, { key: 'start', value: function start() { // Sets up the events this._setupEvents(); // Sets up setup if (this.setup) this.setup(); // Loop! if (!this.loop) _logger2.default.warn('Looks like you need to define a loop'); this._setupLoop(); } /** * Stops the loop and removes event listeners */ }, { key: 'stop', value: function stop() { var _this5 = this; // Stop the animation frame loop window.cancelAnimationFrame(this._animationFrame); // Delete element, if initiated if (this.canvas) { this.canvas.outerHTML = ''; delete this.canvas; } if (this.stats) { document.querySelector('body').removeChild(this.stats.dom); delete this.stats; } // Remove Gui, if initiated if (this._gui) { this._gui.domElement.removeEventListener('touchmove', this._preventDefault); this._gui.destroy(); } // Remove all event listeners Object.keys(this._events).forEach(function (event) { if (_this5._events[event].disable) { _this5._events[event].disable.removeEventListener(event, _this5._preventDefault); } _this5._events[event].context.removeEventListener(event, _this5._events[event].event.bind(_this5)); }); } }, { key: 'settings', set: function set(settings) { // Sets up settings if (settings && Object.keys(settings).length) { this.defaults = settings; this._setupSettings(); } } /** * Returns the settings object * @return {object} settings */ , get: function get() { return this._settings; } /** * Defines whether or not to return debugger messages from Sandpit * @param {boolean} boolean * @return {object} Context */ }, { key: 'debug', set: function set(boolean) { _logger2.default.active = boolean; } /** * Checks if debugger is active * @return {boolean} active */ , get: function get() { return _logger2.default.active; } /** * Sets whether the canvas autoclears after each render * @param {boolean} boolean */ }, { key: 'autoClear', set: function set(boolean) { this._autoClear = boolean; } /** * Checks if autoclear is on * @return {boolean} active */ , get: function get() { return this._autoClear; } }, { key: 'focusTouchesOnCanvas', set: function set(boolean) { this._focusTouchesOnCanvas = boolean; } /** * Checks if touches are focused on the canvas * @return {boolean} active */ , get: function get() { return this._focusTouchesOnCanvas; } /** * Returns the canvas context * @return {object} Context */ }, { key: 'context', get: function get() { return this._context; } /** * Returns the canvas object * @return {canvas} Canvas */ }, { key: 'canvas', get: function get() { return this._canvas; } /** * Returns the frame increment * @returns {number} Canvas width */ }, { key: 'time', get: function get() { return this._time; } /** * Returns the canvas width * @returns {number} Canvas width */ }, { key: 'width', get: function get() { return this._canvas.clientWidth; } /** * Sets the canvas width * @param {number} width - The width to make the canvas */ , set: function set(width) { this._canvas.width = width; } /** * Returns the canvas height * @returns {number} Canvas height */ }, { key: 'height', get: function get() { return this._canvas.clientHeight; } /** * Sets the canvas height * @param {number} height - The height to make the canvas */ , set: function set(height) { this._canvas.height = height; } }]); return Sandpit; }(); exports.Is = _Is2.default; exports.Mathematics = _Mathematics2.default; exports.Color = _Color2.default; exports.Vector = _Vector2.default; exports.Stats = _Stats2.default; exports.default = Sandpit;