/** * Scratch-off canvas * * NOTE: this code monkeypatches Function.prototype.bind() if it doesn't * already exist. * * NOTE: This is demo code that has been converted to be less demo-y. * But it is still demo-y. * * To make (more) correct: * o add error handling on image loads * o fix inefficiencies * * depends on jQuery>=1.7 * * Copyright (c) 2012 Brian "Beej Jorgensen" Hall <[email protected]> * * Permission is hereby granted, free of charge, to any person obtaining * a copy of this software and associated documentation files (the * "Software"), to deal in the Software without restriction, including * without limitation the rights to use, copy, modify, merge, publish, * distribute, sublicense, and/or sell copies of the Software, and to * permit persons to whom the Software is furnished to do so, subject to * the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ Scratcher = (function () { /** * Helper function to extract the coordinates from an event, whether the * event is a mouse or touch. */ function getEventCoords(ev) { var first, coords = {}; var origEv = ev.originalEvent; // get from jQuery if (origEv.changedTouches != undefined) { first = origEv.changedTouches[0]; coords.pageX = first.pageX; coords.pageY = first.pageY; } else { coords.pageX = ev.pageX; coords.pageY = ev.pageY; } return coords; }; /** * Helper function to get the local coords of an event in an element. * * @param elem element in question * @param ev the event */ function getLocalCoords(elem, coords) { var offset = $(elem).offset(); return { 'x': coords.pageX - offset.left, 'y': coords.pageY - offset.top }; }; /** * Construct a new scratcher object * * @param canvasId [string] the canvas DOM ID, e.g. 'canvas2' * @param backImage [string, optional] URL to background (bottom) image * @param frontImage [string, optional] URL to foreground (top) image */ function Scratcher(canvasId, backImage) { this.canvas = { 'main': $('#' + canvasId).get(0) }; this.fresh = true; this.mouseDown = true; this.canvasId = canvasId; this._setupCanvases(); // finish setup from constructor now this.setImages(backImage); this._eventListeners = {}; }; /** * Set the images to use */ Scratcher.prototype.setImages = function (backImage) { this.image = { 'back': { 'url': backImage, 'img': null } }; if (backImage) { this._loadImages(); // start image loading from constructor now } }; /** * Returns how scratched the scratcher is * * By adjusting the stride, you get a less accurate result, but it is * quicker to compute (pixels are skipped) * * @param stride [optional] pixel step value, default 1 * * @return the fraction the canvas has been scratched (0.0 -> 1.0) */ Scratcher.prototype.fullAmount = function (stride) { var i, l; var can = this.canvas.main; var ctx = can.getContext('2d'); var count, total; var pixels, pdata; if (!stride || stride < 1) { stride = 1; } stride *= 4; // 4 elements per pixel pixels = ctx.getImageData(0, 0, can.width, can.height); pdata = pixels.data; l = pdata.length; // 4 entries per pixel total = (l / stride) | 0; for (i = count = 0; i < l; i += stride) { if (pdata[i] != 0) { count++; } } return count / total; }; /** * Recomposites the canvases onto the screen * * Note that my preferred method (putting the background down, then the * masked foreground) doesn't seem to work in FF with "source-out" * compositing mode (it just leaves the destination canvas blank.) I * like this method because mentally it makes sense to have the * foreground drawn on top of the background. * * Instead, to get the same effect, we draw the whole foreground image, * and then mask the background (with "source-atop", which FF seems * happy with) and stamp that on top. The final result is the same, but * it's a little bit weird since we're stamping the background on the * foreground. * * OPTIMIZATION: This naively redraws the entire canvas, which involves * four full-size image blits. An optimization would be to track the * dirty rectangle in scratchLine(), and only redraw that portion (i.e. * in each drawImage() call, pass the dirty rectangle as well--check out * the drawImage() documentation for details.) This would scale to * arbitrary-sized images, whereas in its current form, it will dog out * if the images are large. */ Scratcher.prototype.recompositeCanvases = function () { var can = this.canvas.main; var ctx = can.getContext('2d'); ctx.globalCompositeOperation = 'copy'; ctx.drawImage(this.image.back.img, 0, 0); ctx.globalCompositeOperation = 'destination-out'; }; /** * Draw a scratch line * * Dispatches the 'scratch' event. * * @param x,y the coordinates * @param fresh start a new line if true */ Scratcher.prototype.scratchLine = function (x, y) { var can = this.canvas.main; var ctx = can.getContext('2d'); ctx.lineWidth = 30; ctx.lineCap = ctx.lineJoin = 'round'; ctx.strokeStyle = 'rgba(0,0,0,1)'; // can be any opaque color if (this.fresh) { ctx.beginPath(); this.fresh = false; // this +0.01 hackishly causes Linux Chrome to draw a // "zero"-length line (a single point), otherwise it doesn't // draw when the mouse is clicked but not moved: ctx.moveTo(x + 0.01, y); } ctx.lineTo(x, y); ctx.stroke(); // call back if we have it this.dispatchEvent(this.createEvent('scratch')); }; /** * Set up the main canvas and listeners */ Scratcher.prototype._setupCanvases = function () { var c = this.canvas.main; // create the temp and draw canvases, and set their dimensions // to the same as the main canvas: /** * On mouse move, if mouse down, draw a line * * We do this on the window to smoothly handle mousing outside * the canvas */ function mousemove_handler(e) { if (!this.mouseDown) { return true; } var local = getLocalCoords(c, getEventCoords(e)); this.scratchLine(local.x, local.y); return false; }; $(document).on('mousemove', mousemove_handler.bind(this)); $(document).on('touchmove', mousemove_handler.bind(this)); }; /** * Reset the scratcher * * Dispatches the 'reset' event. * */ Scratcher.prototype.reset = function () { // clear the draw canvas this.fresh = true; this.recompositeCanvases(); // call back if we have it this.dispatchEvent(this.createEvent('reset')); }; /** * returns the main canvas jQuery object for this scratcher */ Scratcher.prototype.mainCanvas = function () { return this.canvas.main; }; /** * Handle loading of needed image resources * * Dispatches the 'imagesloaded' event */ Scratcher.prototype._loadImages = function () { var loadCount = 0; // callback for when the images get loaded function imageLoaded(e) { loadCount++; if (loadCount >= 1) { // call the callback with this Scratcher as an argument: this.dispatchEvent(this.createEvent('imagesloaded')); this.recompositeCanvases(); } } // load BG and FG images for (k in this.image) if (this.image.hasOwnProperty(k)) { this.image[k].img = document.createElement('img'); // image is global $(this.image[k].img).on('load', imageLoaded.bind(this)); this.image[k].img.src = this.image[k].url; } }; /** * Create an event * * Note: not at all a real DOM event */ Scratcher.prototype.createEvent = function (type) { var ev = { 'type': type, 'target': this, 'currentTarget': this }; return ev; }; /** * Add an event listener */ Scratcher.prototype.addEventListener = function (type, handler) { var el = this._eventListeners; type = type.toLowerCase(); if (!el.hasOwnProperty(type)) { el[type] = []; } if (el[type].indexOf(handler) == -1) { el[type].push(handler); } }; /** * Remove an event listener */ Scratcher.prototype.removeEventListener = function (type, handler) { var el = this._eventListeners; var i; type = type.toLowerCase(); if (!el.hasOwnProperty(type)) { return; } if (handler) { if ((i = el[type].indexOf(handler)) != -1) { el[type].splice(i, 1); } } else { el[type] = []; } }; /** * Dispatch an event */ Scratcher.prototype.dispatchEvent = function (ev) { var el = this._eventListeners; var i, len; var type = ev.type.toLowerCase(); if (!el.hasOwnProperty(type)) { return; } len = el[type].length; for (i = 0; i < len; i++) { el[type][i].call(this, ev); } }; /** * Set up a bind if you don't have one * * Notably, Mobile Safari and the Android web browser are missing it. * IE8 doesn't have it, but <canvas> doesn't work there, anyway. * * From MDN: * * https://developer.mozilla.org/en/JavaScript/Reference/Global_Objects/Function/bind#Compatibility */ if (!Function.prototype.bind) { Function.prototype.bind = function (oThis) { if (typeof this !== "function") { // closest thing possible to the ECMAScript 5 internal // IsCallable function throw new TypeError("Function.prototype.bind - what is trying to be bound is not callable"); } var aArgs = Array.prototype.slice.call(arguments, 1), fToBind = this, fNOP = function () { }, fBound = function () { return fToBind.apply(this instanceof fNOP ? this : oThis || window, aArgs.concat(Array.prototype.slice.call(arguments))); }; fNOP.prototype = this.prototype; fBound.prototype = new fNOP(); return fBound; }; } return Scratcher; })(); (function () { function supportsCanvas() { return !!document.createElement('canvas').getContext; } /** * Handle scratch event on a scratcher */ function scratcherProgressHandler(ev) { // Test every pixel. Very accurate, but might be slow on large // canvases on underpowered devices: //var pct = (scratcher.fullAmount() * 100)|0; // Only test every 32nd pixel. 32x faster, but might lead to // inaccuracy: var pct = (this.fullAmount(32) * 100) | 0; if (pct < 3) { if (!$('.scratcher').hasClass('complete')) { $('.scratcher').addClass('complete'); if (!$('#moment').hasClass('appear')) { $('#moment').addClass('appear') } } } } function scratcherLoadingHandler(ev) { $('.scratcher').show(); } /** * Assuming canvas works here, do all initial page setup */ function initScratcher() { // create new scratcher var scratcher = new Scratcher('fx-scratcher'); scratcher.addEventListener('imagesloaded', scratcherLoadingHandler); var scratcherImage = 'data:image/png;base64,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'; //var scratcherImage = 'https://firefox.club.tw/static/img/event/every-moment/scratch/scratch-gray.png'; scratcher.setImages(scratcherImage); scratcher.addEventListener('scratch', scratcherProgressHandler); $('#reset').click(function() { scratcher.reset(); if ($('.scratcher').hasClass('complete')) { $('.scratcher').removeClass('complete'); if ($('#moment').hasClass('appear')) { $('#moment').removeClass('appear') } } }); } $(function () { if (supportsCanvas()) { initScratcher(); } else { $('#scratcher-box').hide(); $('#lamebrowser').show(); } }); })();
<div id="scratch"> <div class="scratcher"> <img id="fx-icon" src="https://firefox.club.tw/static/img/event/every-moment/scratch/scratch-firefox.png"/> <canvas id="fx-scratcher"></canvas> </div> </div> <button id="reset">重置</button>
body { background-color: #000036; } .scratcher { display: none; width: 125px; height: 125px; margin: auto; top: 0; bottom: 0; left: 0; right: 0; position: absolute; background: url(https://firefox.club.tw/static/img/event/every-moment/scratch/scratch-white.png) no-repeat; } .scratcher #fx-scratcher, .scratcher #fx-icon { position: absolute; } .scratcher #fx-scratcher { transition: opacity 0.5s ease-out 0s; } .scratcher.complete #fx-scratcher { opacity: 0; } .scratcher #fx-icon { transition: all 0.5s ease-out 0.5s; left: 0; top: 0; width: 125px; height: 125px; } .scratcher.complete #fx-icon { width: 300px; height: 300px; opacity: 0; left: -83px; top: -100px; }