上圖為網易雲盾的滑動拼圖驗證碼,同一張圖片裁剪的位置是固定的,猜想其應該有一個專門的圖片庫,保存了被裁剪的背景圖和可移動的滑塊。我的想法是,隨機生成圖片,隨機生成位置,再用canvas裁剪出滑塊和背景圖。下面介紹具體步驟。
1、首先隨便找一張圖片渲染到canvas上,這里#canvas作為畫布,#block作為裁剪出來的小滑塊。
<canvas width="310" height="155" id="canvas"></canvas> <canvas width="310" height="155" id="block"></canvas>
var canvas = document.getElementById('canvas') var block = document.getElementById('block') var canvas_ctx = canvas.getContext('2d') var block_ctx = block.getContext('2d') var img = document.createElement('img') img.onload = function() { canvas_ctx.drawImage(img, 0, 0, 310, 155) block_ctx.drawImage(img, 0, 0, 310, 155) }; img.src = 'img.jpg'
2.用canvas畫一個正方形
下面考慮如何裁剪出拼圖的形狀,拼圖形狀比較復雜,首先我們畫一個正方形,接着上邊的代碼寫:
var x = 150, y = 40, w = 42, r = 10, PI = Math.PI function draw(ctx) { ctx.beginPath() ctx.moveTo(x, y) ctx.lineTo(x + w, y) ctx.lineTo(x + w, y + w) ctx.lineTo(x, y + w) ctx.clip() } draw(canvas_ctx) draw(block_ctx)
x,y為正方形左上角的坐標,現在先隨便寫后邊生成的時候用隨機數,w為正方形的邊長,r為后邊畫缺口的圓的半徑。我們先把繪圖過程用函數封裝起來,方便后面同時操作背景和滑塊。用clip()方法裁剪圖片后生成一個正方形。
3.接下來用canvas畫正方形上邊和右邊的圓形("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):
function draw(ctx) { ctx.beginPath() ctx.moveTo(x,y) + ctx.lineTo(x+w/2,y)//繪制頂部圓 + ctx.arc(x+w/2,y-r+2, r,0,2*PI)//繪制頂部圓 + ctx.lineTo(x+w/2,y)//繪制頂部圓 ctx.lineTo(x+w,y) + ctx.lineTo(x+w,y+w/2)//繪制右部圓 + ctx.arc(x+w+r-2,y+w/2,r,0,2*PI) //繪制右部圓 + ctx.lineTo(x+w,y+w/2)//繪制右部圓 ctx.lineTo(x+w,y+w) ctx.lineTo(x,y+w) ctx.lineTo(x,y) ctx.clip() }
兩處注釋的位置將圓心往內偏移了2px,實現缺口的樣式。
4.用canvas畫左邊空心的部分
然后是左邊空心的部分,由於clip是裁剪路徑內的部分,因此直接像上面畫圓是不行的,我們開啟一條新的路徑,然后畫圓將這個正方形“遮蓋”出一個缺口,這里會用到globalCompositeOperation 屬性,'xor'顧名思義。代碼接上邊("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):
function draw(ctx) { ctx.beginPath() ... ctx.lineTo(x,y) ctx.clip() + ctx.beginPath() + ctx.arc(x,y+w/2, r,1.5*PI,0.5*PI) // 只需要畫正方形內的半圓就行,方便背景圖片的裁剪 + ctx.globalCompositeOperation = "xor" + ctx.fill() }
5.調整#block的大小
現在一個基本的拼圖形狀有了,我們調整#block的大小,並將裁剪出來的滑塊放入#block中("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):
img.onload = function() { ctx.drawImage(img, 0, 0, 310, 155) block_ctx.drawImage(img, 0, 0, 310, 155) + var blockWidth = w + r * 2 + var _y = y - r * 2 + 2 // 滑塊實際的y坐標 + var ImageData = block_ctx.getImageData(x, _y, blockWidth, blockWidth) + block.width = blockWidth + block_ctx.putImageData(ImageData, 0, _y) }
6.摳掉原圖片的滑塊部分
現在我們需要把左邊畫布展示原來的圖片,並且摳掉中間滑塊的部分,這里畫路徑的過程都是一樣的,唯一不同只是clip()那里改成fill()即可實現效果,我們前面已經把畫路徑的過程封裝成函數了,稍作改動即可("+"號代表比前一步新增的代碼,“-”號代表比前一步減少的代碼):
- function draw(ctx) { + function draw(ctx, operation) { ... - ctx.clip() + ctx.fillStyle = '#fff' + ctx[operation]() ... } + draw(canvas_ctx, 'fill') + draw(block_ctx, 'clip')
7.寫拖動事件
然后就是寫拖動事件,我們可以在鼠標按下時記錄鼠標位置,然后在拖動時給滑塊設置left值。最后在松開鼠標時,判斷滑塊此時的left值和最開始裁剪滑塊時的x值,如果在一定范圍內就算驗證通過,否則驗證失敗。
最后再加上隨機圖片和隨機剪切位置,基本就ok了。另外可以判斷下鼠標移動時y軸的變化,以判斷是否是“人”在操作,當然web安全這塊神魔亂舞的,我就不班門弄斧了,只是做個簡單判斷就行。
8.詳細代碼:
8.1html
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>滑動拼圖驗證碼</title> <style> .container { width: 310px; margin: 100px auto; } input { display: block; width: 290px; line-height: 40px; margin: 10px 0; padding: 0 10px; outline: none; border:1px solid #c8cccf; border-radius: 4px; color:#6a6f77; } #msg { width: 100%; line-height: 40px; font-size: 14px; text-align: center; } a:link,a:visited,a:hover,a:active { margin-left: 100px; color: #0366D6; } .block { position: absolute; left: 0; top: 0; cursor: pointer; cursor: grab; } .block:active { cursor: grabbing; } .sliderContainer { position: relative; text-align: center; width: 310px; height: 40px; line-height: 40px; margin-top: 15px; background: #f7f9fa; color: #45494c; border: 1px solid #e4e7eb; } .sliderContainer_active .slider { height: 38px; top: -1px; border: 1px solid #1991FA; } .sliderContainer_active .sliderMask { height: 38px; border-width: 1px; } .sliderContainer_success .slider { height: 38px; top: -1px; border: 1px solid #52CCBA; background-color: #52CCBA !important; } .sliderContainer_success .sliderMask { height: 38px; border: 1px solid #52CCBA; background-color: #D2F4EF; } .sliderContainer_success .sliderIcon { background-position: 0 0 !important; } .sliderContainer_fail .slider { height: 38px; top: -1px; border: 1px solid #f57a7a; background-color: #f57a7a !important; } .sliderContainer_fail .sliderMask { height: 38px; border: 1px solid #f57a7a; background-color: #fce1e1; } .sliderContainer_fail .sliderIcon { top: 14px; background-position: 0 -82px !important; } .sliderContainer_active .sliderText, .sliderContainer_success .sliderText, .sliderContainer_fail .sliderText { display: none; } .sliderMask { position: absolute; left: 0; top: 0; height: 40px; border: 0 solid #1991FA; background: #D1E9FE; } .slider { position: absolute; top: 0; left: 0; width: 40px; height: 40px; background: #fff; box-shadow: 0 0 3px rgba(0, 0, 0, 0.3); transition: background .2s linear; cursor: pointer; cursor: grab; } .slider:active { cursor: grabbing; } .slider:hover { background: #1991FA; } .slider:hover .sliderIcon { background-position: 0 -13px; } .sliderIcon { position: absolute; top: 15px; left: 13px; width: 14px; height: 12px; background: url(http://cstaticdun.126.net//2.6.3/images/icon_light.f13cff3.png) 0 -26px; background-size: 34px 471px; } .refreshIcon { position: absolute; right: 0; top: 0; width: 34px; height: 34px; cursor: pointer; background: url(http://cstaticdun.126.net//2.6.3/images/icon_light.f13cff3.png) 0 -437px; background-size: 34px 471px; } </style> </head> <body> <h3><a href="https://github.com/yeild/jigsaw">返回GitHub</a></h3> <div class="container"> <input value="admin" readonly/> <input type="password" value="1234567890" readonly/> <div id="captcha"></div> <div id="msg"></div> </div> <script src="./jigsaw.js"></script> <script> window.jigsaw.init({ el: document.getElementById('captcha'), onSuccess: function() { document.getElementById('msg').innerHTML = '登錄成功!' }, onFail: cleanMsg, onRefresh: cleanMsg }) function cleanMsg() { document.getElementById('msg').innerHTML = '' } </script> </body> </html>
8.2jigsaw.js
let w = 310 // canvas寬度 let h = 155 // canvas高度 const l = 42 // 滑塊邊長 const r = 9 // 滑塊半徑 const PI = Math.PI const L = l + r * 2 + 3 // 滑塊實際邊長 function getRandomNumberByRange (start, end) { return Math.round(Math.random() * (end - start) + start) } function createCanvas (width, height) { const canvas = document.createElement('canvas') canvas.width = width canvas.height = height return canvas } function createImg (onload) { const img = new Image() img.crossOrigin = "Anonymous" img.onload = onload img.onerror = () => { img.setSrc(getRandomImgSrc()) } img.setSrc = function (src) { const isIE = window.navigator.userAgent.indexOf('Trident') > -1 if (isIE) { // IE瀏覽器無法通過img.crossOrigin跨域,使用ajax獲取圖片blob然后轉為dataURL顯示 const xhr = new XMLHttpRequest() xhr.onloadend = function (e) { const file = new FileReader() // FileReader僅支持IE10+ file.readAsDataURL(e.target.response) file.onloadend = function (e) { img.src = e.target.result } } xhr.open('GET', src) xhr.responseType = 'blob' xhr.send() } else img.src = src } img.setSrc(getRandomImgSrc()) return img } function createElement (tagName, className) { const elment = document.createElement(tagName) elment.className = className return elment } function addClass (tag, className) { tag.classList.add(className) } function removeClass (tag, className) { tag.classList.remove(className) } function getRandomImgSrc () { return `https://picsum.photos/${w}/${h}/?image=${getRandomNumberByRange(0, 1084)}` } function draw (ctx, x, y, operation) { ctx.beginPath() ctx.moveTo(x, y) ctx.arc(x + l / 2, y - r + 2, r, 0.72 * PI, 2.26 * PI) ctx.lineTo(x + l, y) ctx.arc(x + l + r - 2, y + l / 2, r, 1.21 * PI, 2.78 * PI) ctx.lineTo(x + l, y + l) ctx.lineTo(x, y + l) ctx.arc(x + r - 2, y + l / 2, r + 0.4, 2.76 * PI, 1.24 * PI, true) ctx.lineTo(x, y) ctx.lineWidth = 2 ctx.fillStyle = 'rgba(255, 255, 255, 0.7)' ctx.strokeStyle = 'rgba(255, 255, 255, 0.7)' ctx.stroke() ctx[operation]() ctx.globalCompositeOperation = 'destination-over' } function sum (x, y) { return x + y } function square (x) { return x * x } class jigsaw { constructor ({ el, width = 310, height = 155, onSuccess, onFail, onRefresh }) { w = width h = height el.style.position = 'relative' el.style.width = w + 'px' Object.assign(el.style, { position: 'relative', width: w + 'px', margin: '0 auto' }) this.el = el this.onSuccess = onSuccess this.onFail = onFail this.onRefresh = onRefresh } init () { this.initDOM() this.initImg() this.bindEvents() } initDOM () { const canvas = createCanvas(w, h) // 畫布 const block = canvas.cloneNode(true) // 滑塊 const sliderContainer = createElement('div', 'sliderContainer') sliderContainer.style.width = w + 'px' const refreshIcon = createElement('div', 'refreshIcon') const sliderMask = createElement('div', 'sliderMask') const slider = createElement('div', 'slider') const sliderIcon = createElement('span', 'sliderIcon') const text = createElement('span', 'sliderText') block.className = 'block' text.innerHTML = '向右滑動填充拼圖' const el = this.el el.appendChild(canvas) el.appendChild(refreshIcon) el.appendChild(block) slider.appendChild(sliderIcon) sliderMask.appendChild(slider) sliderContainer.appendChild(sliderMask) sliderContainer.appendChild(text) el.appendChild(sliderContainer) Object.assign(this, { canvas, block, sliderContainer, refreshIcon, slider, sliderMask, sliderIcon, text, canvasCtx: canvas.getContext('2d'), blockCtx: block.getContext('2d') }) } initImg () { const img = createImg(() => { this.draw() this.canvasCtx.drawImage(img, 0, 0, w, h) this.blockCtx.drawImage(img, 0, 0, w, h) const y = this.y - r * 2 - 1 const ImageData = this.blockCtx.getImageData(this.x - 3, y, L, L) this.block.width = L this.blockCtx.putImageData(ImageData, 0, y) }) this.img = img } draw () { // 隨機創建滑塊的位置 this.x = getRandomNumberByRange(L + 10, w - (L + 10)) this.y = getRandomNumberByRange(10 + r * 2, h - (L + 10)) draw(this.canvasCtx, this.x, this.y, 'fill') draw(this.blockCtx, this.x, this.y, 'clip') } clean () { this.canvasCtx.clearRect(0, 0, w, h) this.blockCtx.clearRect(0, 0, w, h) this.block.width = w } bindEvents () { this.el.onselectstart = () => false this.refreshIcon.onclick = () => { this.reset() typeof this.onRefresh === 'function' && this.onRefresh() } let originX, originY, trail = [], isMouseDown = false const handleDragStart = function (e) { originX = e.clientX || e.touches[0].clientX originY = e.clientY || e.touches[0].clientY isMouseDown = true } const handleDragMove = (e) => { if (!isMouseDown) return false const eventX = e.clientX || e.touches[0].clientX const eventY = e.clientY || e.touches[0].clientY const moveX = eventX - originX const moveY = eventY - originY if (moveX < 0 || moveX + 38 >= w) return false this.slider.style.left = moveX + 'px' const blockLeft = (w - 40 - 20) / (w - 40) * moveX this.block.style.left = blockLeft + 'px' addClass(this.sliderContainer, 'sliderContainer_active') this.sliderMask.style.width = moveX + 'px' trail.push(moveY) } const handleDragEnd = (e) => { if (!isMouseDown) return false isMouseDown = false const eventX = e.clientX || e.changedTouches[0].clientX if (eventX === originX) return false removeClass(this.sliderContainer, 'sliderContainer_active') this.trail = trail const { spliced, verified } = this.verify() if (spliced) { if (verified) { addClass(this.sliderContainer, 'sliderContainer_success') typeof this.onSuccess === 'function' && this.onSuccess() } else { addClass(this.sliderContainer, 'sliderContainer_fail') this.text.innerHTML = '請再試一次' this.reset() } } else { addClass(this.sliderContainer, 'sliderContainer_fail') typeof this.onFail === 'function' && this.onFail() setTimeout(() => { this.reset() }, 1000) } } this.slider.addEventListener('mousedown', handleDragStart) this.slider.addEventListener('touchstart', handleDragStart) this.block.addEventListener('mousedown', handleDragStart) this.block.addEventListener('touchstart', handleDragStart) document.addEventListener('mousemove', handleDragMove) document.addEventListener('touchmove', handleDragMove) document.addEventListener('mouseup', handleDragEnd) document.addEventListener('touchend', handleDragEnd) } verify () { const arr = this.trail // 拖動時y軸的移動距離 const average = arr.reduce(sum) / arr.length const deviations = arr.map(x => x - average) const stddev = Math.sqrt(deviations.map(square).reduce(sum) / arr.length) const left = parseInt(this.block.style.left) return { spliced: Math.abs(left - this.x) < 10, verified: stddev !== 0, // 簡單驗證拖動軌跡,為零時表示Y軸上下沒有波動,可能非人為操作 } } reset () { this.sliderContainer.className = 'sliderContainer' this.slider.style.left = 0 this.block.style.left = 0 this.sliderMask.style.width = 0 this.clean() this.img.setSrc(getRandomImgSrc()) } } window.jigsaw = { init: function (opts) { return new jigsaw(opts).init() } }