UNPKG

web-luck-draw

Version:

一个基于原生 js 的(大转盘抽奖 / 九宫格抽奖)插件

1,220 lines (1,211 loc) 49 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); class Lucky { constructor() { this.htmlFontSize = 16; this.dpr = 1; // 初始化一些公共设置 this.setDpr(); this.setHTMLFontSize(); } /** * 设备像素比 */ setDpr() { window.dpr = this.dpr = (window.devicePixelRatio || 2) * 1.3; } /** * 根标签的字体大小 */ setHTMLFontSize() { this.htmlFontSize = +getComputedStyle(document.documentElement).fontSize.slice(0, -2); } /** * 根据 dpr 缩放 canvas 并处理位移 * @param canvas 画布 * @param width 将要等比缩放的宽 * @param height 将要等比缩放的高 */ optimizeClarity(canvas, width, height) { const { dpr } = this; const compute = (len) => { return (len * dpr - len) / (len * dpr) * (dpr / 2) * 100; }; canvas.style.transform = `scale(${1 / dpr}) translate( ${-compute(width)}%, ${-compute(height)}% )`; } /** * 转换单位 * @param { string } value 将要转换的值 * @param config * @return { number } 返回新的字符串 */ changeUnits(value, { denominator = 1, clean = false }) { return Number(value.replace(/^(\-*[0-9.]*)([a-z%]*)$/, (value, num, unit) => { switch (unit) { case '%': num *= (denominator / 100); break; case 'px': num *= 1; break; case 'rem': num *= this.htmlFontSize; break; default: num *= 1; break; } return clean || unit === '%' ? num : num * this.dpr; })); } } /** * 判断是否是期望的类型 * @param { any } param 将要判断的变量 * @param { ...string } types 期望的类型 * @return { boolean } 返回期望是否正确 */ const isExpectType = (param, ...types) => { return types.some(type => Object.prototype.toString.call(param).slice(8, -1).toLowerCase() === type); }; /** * 移除\n * @param { string } str 将要处理的字符串 * @return { string } 返回新的字符串 */ const removeEnter = (str) => { return [].filter.call(str, s => s !== '\n').join(''); }; /** * 参数校验器 * @param data 将要校验的参数 * @param params 校验规则 * @param msg 警告信息 * @return { boolean } 校验成功返回true, 反之false */ // export const paramsValidator = (data: any, params = {}, msg = '') => { // if (isExpectType(data, 'object')) data = [data] // return data.every((item, index) => { // for (let key in params) { // if (params[key] === 1 && !item.hasOwnProperty(key)) { // return !!console.error(`参数 ${msg}[${index}] 缺少 ${key} 属性`) // } // else if (isExpectType(params[key], 'object') && item[key]) { // if (!paramsValidator( // item[key], params[key], msg ? `${msg}[${index}].${key}` : key // )) return false // } // } // return true // }) // } /** * 通过padding计算 * @return { object } block 边框信息 */ const computePadding = (block) => { let padding = block.padding.replace(/px/g, '').split(' ').map(n => ~~n) || [0], paddingTop = 0, paddingBottom = 0, paddingLeft = 0, paddingRight = 0; switch (padding.length) { case 1: paddingTop = paddingBottom = paddingLeft = paddingRight = padding[0]; break; case 2: paddingTop = paddingBottom = padding[0]; paddingLeft = paddingRight = padding[1]; break; case 3: paddingTop = padding[0]; paddingLeft = paddingRight = padding[1]; paddingBottom = padding[2]; break; default: paddingTop = padding[0]; paddingBottom = padding[1]; paddingLeft = padding[2]; paddingRight = padding[3]; } // 检查是否单独传入值, 并且不是0 const res = { paddingTop, paddingBottom, paddingLeft, paddingRight }; for (let key in res) { // 是否含有这个属性, 并且是数字或字符串 res[key] = block.hasOwnProperty(key) && isExpectType(block[key], 'string', 'number') ? ~~String(block[key]).replace(/px/g, '') : res[key]; } return [paddingTop, paddingBottom, paddingLeft, paddingRight]; }; /** * 转换为运算角度 * @param { number } deg 数学角度 * @return { number } 运算角度 */ const getAngle = (deg) => { return Math.PI / 180 * deg; }; /** * 根据角度计算圆上的点 * @param { number } deg 运算角度 * @param { number } r 半径 * @return { Array<number> } 坐标[x, y] */ const getArcPointerByDeg = (deg, r) => { return [+(Math.cos(deg) * r).toFixed(8), +(Math.sin(deg) * r).toFixed(8)]; }; /** * 根据点计算切线方程 * @param { number } x 横坐标 * @param { number } y 纵坐标 * @return { Array<number> } [斜率, 常数] */ const getTangentByPointer = (x, y) => { let k = -x / y; let b = -k * x + y; return [k, b]; }; // 根据三点画圆弧 const drawRadian = (ctx, r, start, end, direction = true) => { if (Math.abs(end - start).toFixed(8) >= getAngle(180).toFixed(8)) { let middle = (end + start) / 2; if (direction) { drawRadian(ctx, r, start, middle, direction); drawRadian(ctx, r, middle, end, direction); } else { drawRadian(ctx, r, middle, end, direction); drawRadian(ctx, r, start, middle, direction); } return false; } if (!direction) [start, end] = [end, start]; const [x1, y1] = getArcPointerByDeg(start, r); const [x2, y2] = getArcPointerByDeg(end, r); const [k1, b1] = getTangentByPointer(x1, y1); const [k2, b2] = getTangentByPointer(x2, y2); let x0 = (b2 - b1) / (k1 - k2); let y0 = (k2 * b1 - k1 * b2) / (k2 - k1); if (isNaN(x0)) { Math.abs(x1) === +r.toFixed(8) && (x0 = x1); Math.abs(x2) === +r.toFixed(8) && (x0 = x2); } if (k1 === Infinity || k1 === -Infinity) { y0 = k2 * x0 + b2; } else if (k2 === Infinity || k2 === -Infinity) { y0 = k1 * x0 + b1; } ctx.lineTo(x1, y1); ctx.arcTo(x0, y0, x2, y2, r); }; // 绘制扇形 const drawSector = (ctx, minRadius, maxRadius, start, end, gutter, background) => { if (!minRadius) minRadius = gutter; let maxGutter = getAngle(90 / Math.PI / maxRadius * gutter); let minGutter = getAngle(90 / Math.PI / minRadius * gutter); let maxStart = start + maxGutter; let maxEnd = end - maxGutter; let minStart = start + minGutter; let minEnd = end - minGutter; ctx.beginPath(); ctx.fillStyle = background; ctx.moveTo(...getArcPointerByDeg(maxStart, maxRadius)); drawRadian(ctx, maxRadius, maxStart, maxEnd, true); // 如果 getter 比按钮短就绘制圆弧, 反之计算新的坐标点 if (minEnd > minStart) drawRadian(ctx, minRadius, minStart, minEnd, false); else ctx.lineTo(...getArcPointerByDeg((start + end) / 2, gutter / 2 / Math.abs(Math.sin((start - end) / 2)))); ctx.closePath(); ctx.fill(); }; // 绘制圆角矩形 const drawRoundRect = (ctx, x, y, w, h, r, color) => { let min = Math.min(w, h); if (r > min / 2) r = min / 2; ctx.beginPath(); ctx.fillStyle = color; ctx.moveTo(x + r, y); ctx.lineTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r); ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r); ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r); ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r); ctx.closePath(); ctx.fill(); }; /** * 创建线性渐变色 */ const getLinearGradient = (ctx, x, y, w, h, background) => { const context = /linear-gradient\((.+)\)/.exec(background)[1] .split(',') // 根据逗号分割 .map((text) => text.trim()); // 去除两边空格 let deg = context.shift(), direction = [0, 0, 0, 0]; // 通过起始点和角度计算渐变终点的坐标点, 这里感谢泽宇大神提醒我使用勾股定理.... if (deg.includes('deg')) { deg = deg.slice(0, -3) % 360; // 根据4个象限定义起点坐标, 根据45度划分8个区域计算终点坐标 const getLenOfTanDeg = (deg) => Math.tan(deg / 180 * Math.PI); if (deg >= 0 && deg < 45) direction = [x, y + h, x + w, y + h - w * getLenOfTanDeg(deg - 0)]; else if (deg >= 45 && deg < 90) direction = [x, y + h, (x + w) - h * getLenOfTanDeg(deg - 45), y]; else if (deg >= 90 && deg < 135) direction = [x + w, y + h, (x + w) - h * getLenOfTanDeg(deg - 90), y]; else if (deg >= 135 && deg < 180) direction = [x + w, y + h, x, y + w * getLenOfTanDeg(deg - 135)]; else if (deg >= 180 && deg < 225) direction = [x + w, y, x, y + w * getLenOfTanDeg(deg - 180)]; else if (deg >= 225 && deg < 270) direction = [x + w, y, x + h * getLenOfTanDeg(deg - 225), y + h]; else if (deg >= 270 && deg < 315) direction = [x, y, x + h * getLenOfTanDeg(deg - 270), y + h]; else if (deg >= 315 && deg < 360) direction = [x, y, x + w, y + h - w * getLenOfTanDeg(deg - 315)]; } // 创建四个简单的方向坐标 else if (deg.includes('top')) direction = [x, y + h, x, y]; else if (deg.includes('bottom')) direction = [x, y, x, y + h]; else if (deg.includes('left')) direction = [x + w, y, x, y]; else if (deg.includes('right')) direction = [x, y, x + w, y]; // 创建线性渐变必须使用整数坐标 const gradient = ctx.createLinearGradient(...direction.map(n => n >> 0)); // 这里后期重构, 先用any代替 return context.reduce((gradient, item, index) => { const info = item.split(' '); if (info.length === 1) gradient.addColorStop(index, info[0]); else if (info.length === 2) gradient.addColorStop(...info); return gradient; }, gradient); }; /** * 缓动函数 * t: current time(当前时间) * b: beginning value(初始值) * c: change in value(变化量) * d: duration(持续时间) * * 感谢张鑫旭大佬 https://github.com/zhangxinxu/Tween */ // 二次方的缓动 const quad = { easeIn: function (t, b, c, d) { if (t >= d) t = d; return c * (t /= d) * t + b; }, easeOut: function (t, b, c, d) { if (t >= d) t = d; return -c * (t /= d) * (t - 2) + b; } }; class LuckyWheel extends Lucky { /** * 大转盘构造器 * @param el 元素标识 * @param data 抽奖配置项 */ constructor(el, data = {}) { super(); this.blocks = []; this.prizes = []; this.buttons = []; this.defaultConfig = { gutter: '0px', offsetDegree: 0, speed: 20, accelerationTime: 2500, decelerationTime: 2500, }; this.defaultStyle = { fontSize: '18px', fontColor: '#000', fontStyle: 'microsoft yahei ui,microsoft yahei,simsun,sans-serif', fontWeight: '400', background: '#fff', wordWrap: true, lengthLimit: '90%', }; this.Radius = 0; // 大转盘半径 this.prizeRadius = 0; // 奖品区域半径 this.prizeDeg = 0; // 奖品数学角度 this.prizeRadian = 0; // 奖品运算角度 this.rotateDeg = 0; // 转盘旋转角度 this.maxBtnRadius = 0; // 最大按钮半径 this.startTime = 0; // 开始时间戳 this.endTime = 0; // 停止时间戳 this.stopDeg = 0; // 刻舟求剑 this.endDeg = 0; // 停止角度 this.animationId = 0; // 帧动画id this.FPS = 16.6; // 屏幕刷新率 this.prizeImgs = [[]]; this.btnImgs = [[]]; this.box = document.querySelector(el); this.canvas = document.createElement('canvas'); this.box.appendChild(this.canvas); this.ctx = this.canvas.getContext('2d'); this.setData(data); // 收集首次渲染的图片 let willUpdate = [[]]; this.prizes && (willUpdate = this.prizes.map(prize => prize.imgs)); this.buttons && (willUpdate.push(...this.buttons.map(btn => btn.imgs))); this.init(willUpdate); } /** * 初始化数据 * @param data */ setData(data) { this.blocks = data.blocks || []; this.prizes = data.prizes || []; this.buttons = data.buttons || []; this.startCallback = data.start; this.endCallback = data.end; for (let key in data.defaultConfig) { this.defaultConfig[key] = data.defaultConfig[key]; } for (let key in data.defaultStyle) { this.defaultStyle[key] = data.defaultStyle[key]; } } /** * 初始化 canvas 抽奖 * @param { Array<ImgType[]> } willUpdateImgs 需要更新的图片 */ init(willUpdateImgs) { this.setDpr(); this.setHTMLFontSize(); const { box, canvas, ctx, dpr } = this; if (!box) return; canvas.width = canvas.height = box.offsetWidth * dpr; this.Radius = canvas.width / 2; this.optimizeClarity(canvas, this.Radius * 2, this.Radius * 2); ctx.translate(this.Radius, this.Radius); const endCallBack = () => { // 开始绘制 this.draw(); // 防止多次绑定点击事件 canvas.onclick = e => { ctx.beginPath(); ctx.arc(0, 0, this.maxBtnRadius, 0, Math.PI * 2, false); if (!ctx.isPointInPath(e.offsetX, e.offsetY)) return; if (this.startTime) return; this.startCallback?.(e); }; }; // 同步加载图片 let num = 0, sum = 0; if (isExpectType(willUpdateImgs, 'array')) { this.draw(); // 先画一次防止闪烁, 因为加载图片是异步的 willUpdateImgs.forEach((imgs, cellIndex) => { if (!imgs) return false; imgs.forEach((imgInfo, imgIndex) => { sum++; this.loadAndCacheImg(cellIndex, imgIndex, () => { num++; if (sum === num) endCallBack.call(this); }); }); }); } if (!sum) endCallBack.call(this); } /** * 单独加载某一张图片并计算其实际渲染宽高 * @param { number } cellIndex 奖品索引 * @param { number } imgIndex 奖品图片索引 * @param { Function } callBack 图片加载完毕回调 */ loadAndCacheImg(cellIndex, imgIndex, callBack) { // 先判断index是奖品图片还是按钮图片, 并修正index的值 const isPrize = cellIndex < this.prizes.length; const cellName = isPrize ? 'prizes' : 'buttons'; const imgName = isPrize ? 'prizeImgs' : 'btnImgs'; cellIndex = isPrize ? cellIndex : cellIndex - this.prizes.length; // 获取图片信息 const cell = this[cellName][cellIndex]; if (!cell || !cell.imgs) return; const imgInfo = cell.imgs[imgIndex]; if (!imgInfo) return; // 创建图片 let imgObj = new Image(); if (!this[imgName][cellIndex]) this[imgName][cellIndex] = []; // 创建缓存 this[imgName][cellIndex][imgIndex] = imgObj; imgObj.src = imgInfo.src; imgObj.onload = () => callBack.call(this); } /** * 计算图片的渲染宽高 * @param imgObj 图片标签元素 * @param imgInfo 图片信息 * @param computedWidth 宽度百分比 * @param computedHeight 高度百分比 * @return [渲染宽度, 渲染高度] */ computedWidthAndHeight(imgObj, imgInfo, computedWidth, computedHeight) { // 根据配置的样式计算图片的真实宽高 if (!imgInfo.width && !imgInfo.height) { // 如果没有配置宽高, 则使用图片本身的宽高 return [imgObj.width, imgObj.height]; } else if (imgInfo.width && !imgInfo.height) { // 如果只填写了宽度, 没填写高度 let trueWidth = this.getWidth(imgInfo.width, computedWidth); // 那高度就随着宽度进行等比缩放 return [trueWidth, imgObj.height * (trueWidth / imgObj.width)]; } else if (!imgInfo.width && imgInfo.height) { // 如果只填写了宽度, 没填写高度 let trueHeight = this.getHeight(imgInfo.height, computedHeight); // 那宽度就随着高度进行等比缩放 return [imgObj.width * (trueHeight / imgObj.height), trueHeight]; } // 如果宽度和高度都填写了, 就如实计算 return [ this.getWidth(imgInfo.width, computedWidth), this.getHeight(imgInfo.height, computedHeight) ]; } /** * 开始绘制 */ draw() { const { ctx, dpr, defaultConfig, defaultStyle } = this; ctx.clearRect(-this.Radius, -this.Radius, this.Radius * 2, this.Radius * 2); // 绘制blocks边框 this.prizeRadius = this.blocks.reduce((radius, block) => { ctx.beginPath(); ctx.fillStyle = block.background; ctx.arc(0, 0, radius, 0, Math.PI * 2, false); ctx.fill(); return radius - this.getLength(block.padding.split(' ')[0]) * dpr; }, this.Radius); // 计算起始弧度 this.prizeDeg = 360 / this.prizes.length; this.prizeRadian = getAngle(this.prizeDeg); let start = getAngle(-90 + this.rotateDeg + defaultConfig.offsetDegree); // 计算文字横坐标 const getFontX = (line) => { return this.getOffsetX(ctx.measureText(line).width); }; // 计算文字纵坐标 const getFontY = (font, height, lineIndex) => { // 优先使用字体行高, 要么使用默认行高, 其次使用字体大小, 否则使用默认字体大小 const lineHeight = font.lineHeight || defaultStyle.lineHeight || font.fontSize || defaultStyle.fontSize; return this.getHeight(font.top, height) + (lineIndex + 1) * this.getLength(lineHeight) * dpr; }; ctx.save(); // 绘制prizes奖品区域 this.prizes.forEach((prize, prizeIndex) => { // 计算当前奖品区域中间坐标点 let currMiddleDeg = start + prizeIndex * this.prizeRadian; // 奖品区域可见高度 let prizeHeight = this.prizeRadius - this.maxBtnRadius; // 绘制背景 drawSector(ctx, this.maxBtnRadius, this.prizeRadius, currMiddleDeg - this.prizeRadian / 2, currMiddleDeg + this.prizeRadian / 2, this.getLength(defaultConfig.gutter) * dpr, prize.background || defaultStyle.background || 'rgba(0, 0, 0, 0)'); // 计算临时坐标并旋转文字 let x = Math.cos(currMiddleDeg) * this.prizeRadius; let y = Math.sin(currMiddleDeg) * this.prizeRadius; ctx.translate(x, y); ctx.rotate(currMiddleDeg + getAngle(90)); // 绘制图片 prize.imgs && prize.imgs.forEach((imgInfo, imgIndex) => { if (!this.prizeImgs[prizeIndex]) return; const prizeImg = this.prizeImgs[prizeIndex][imgIndex]; if (!prizeImg) return; const [trueWidth, trueHeight] = this.computedWidthAndHeight(prizeImg, imgInfo, this.prizeRadian * this.prizeRadius, prizeHeight); ctx.drawImage(prizeImg, this.getOffsetX(trueWidth), this.getHeight(imgInfo.top, prizeHeight), trueWidth, trueHeight); }); // 逐行绘制文字 prize.fonts && prize.fonts.forEach(font => { let fontColor = font.fontColor || defaultStyle.fontColor; let fontWeight = font.fontWeight || defaultStyle.fontWeight; let fontSize = this.getLength(font.fontSize || defaultStyle.fontSize); let fontStyle = font.fontStyle || defaultStyle.fontStyle; ctx.fillStyle = fontColor; ctx.font = `${fontWeight} ${fontSize * dpr}px ${fontStyle}`; let lines = [], text = String(font.text); if (font.hasOwnProperty('wordWrap') ? font.wordWrap : defaultStyle.wordWrap) { text = removeEnter(text); let str = ''; for (let i = 0; i < text.length; i++) { str += text[i]; let currWidth = ctx.measureText(str).width; let maxWidth = (this.prizeRadius - getFontY(font, prizeHeight, lines.length)) * Math.tan(this.prizeRadian / 2) * 2 - this.getLength(defaultConfig.gutter) * dpr; if (currWidth > this.getWidth(font.lengthLimit || defaultStyle.lengthLimit, maxWidth)) { lines.push(str.slice(0, -1)); str = text[i]; } } if (str) lines.push(str); if (!lines.length) lines.push(text); } else { lines = text.split('\n'); } lines.filter(line => !!line).forEach((line, lineIndex) => { ctx.fillText(line, getFontX(line), getFontY(font, prizeHeight, lineIndex)); }); }); // 修正旋转角度和原点坐标 ctx.rotate(getAngle(360) - currMiddleDeg - getAngle(90)); ctx.translate(-x, -y); }); ctx.restore(); // 绘制按钮 this.buttons.forEach((btn, btnIndex) => { let radius = this.getHeight(btn.radius); // 绘制背景颜色 this.maxBtnRadius = Math.max(this.maxBtnRadius, radius); ctx.beginPath(); ctx.fillStyle = btn.background || 'rgba(0, 0, 0, 0)'; ctx.arc(0, 0, radius, 0, Math.PI * 2, false); ctx.fill(); // 绘制指针 if (btn.pointer) { ctx.beginPath(); ctx.fillStyle = btn.background || 'rgba(0, 0, 0, 0)'; ctx.moveTo(-radius, 0); ctx.lineTo(radius, 0); ctx.lineTo(0, -radius * 2); ctx.closePath(); ctx.fill(); } // 绘制按钮图片 btn.imgs && btn.imgs.forEach((imgInfo, imgIndex) => { if (!this.btnImgs[btnIndex]) return; const btnImg = this.btnImgs[btnIndex][imgIndex]; if (!btnImg) return; // 计算图片真实宽高 const [trueWidth, trueHeight] = this.computedWidthAndHeight(btnImg, imgInfo, this.getHeight(btn.radius) * 2, this.getHeight(btn.radius) * 2); // 绘制图片 ctx.drawImage(btnImg, this.getOffsetX(trueWidth), this.getHeight(imgInfo.top, radius), trueWidth, trueHeight); }); // 绘制按钮文字 btn.fonts && btn.fonts.forEach(font => { let fontColor = font.fontColor || defaultStyle.fontColor; let fontWeight = font.fontWeight || defaultStyle.fontWeight; let fontSize = this.getLength(font.fontSize || defaultStyle.fontSize); let fontStyle = font.fontStyle || defaultStyle.fontStyle; ctx.fillStyle = fontColor; ctx.font = `${fontWeight} ${fontSize * dpr}px ${fontStyle}`; String(font.text).split('\n').forEach((line, lineIndex) => { ctx.fillText(line, getFontX(line), getFontY(font, radius, lineIndex)); }); }); }); } /** * 对外暴露: 开始抽奖方法 */ play() { // 再次拦截, 因为play是可以异步调用的 if (this.startTime) return; cancelAnimationFrame(this.animationId); this.startTime = Date.now(); this.prizeFlag = undefined; this.run(); } /** * 对外暴露: 缓慢停止方法 * @param index 中奖索引 */ stop(index) { this.prizeFlag = Number(index) % this.prizes.length; } /** * 实际开始执行方法 * @param num 记录帧动画执行多少次 */ run(num = 0) { const { prizeFlag, prizeDeg, rotateDeg, defaultConfig } = this; let interval = Date.now() - this.startTime; // 先完全旋转, 再停止 if (interval >= defaultConfig.accelerationTime && prizeFlag !== undefined) { // 记录帧率 this.FPS = interval / num; // 记录开始停止的时间戳 this.endTime = Date.now(); // 记录开始停止的位置 this.stopDeg = rotateDeg; // 最终停止的角度 this.endDeg = 360 * 5 - prizeFlag * prizeDeg - rotateDeg - defaultConfig.offsetDegree; cancelAnimationFrame(this.animationId); return this.slowDown(); } this.rotateDeg = (rotateDeg + quad.easeIn(interval, 0, defaultConfig.speed, defaultConfig.accelerationTime)) % 360; this.draw(); this.animationId = window.requestAnimationFrame(this.run.bind(this, num + 1)); } /** * 缓慢停止的方法 */ slowDown() { const { prizes, prizeFlag, stopDeg, endDeg, defaultConfig } = this; let interval = Date.now() - this.endTime; if (interval >= defaultConfig.decelerationTime) { this.startTime = 0; this.endCallback?.({ ...prizes.find((prize, index) => index === prizeFlag) }); return cancelAnimationFrame(this.animationId); } this.rotateDeg = quad.easeOut(interval, stopDeg, endDeg, defaultConfig.decelerationTime) % 360; this.draw(); this.animationId = window.requestAnimationFrame(this.slowDown.bind(this)); } /** * 获取长度 * @param length 将要转换的长度 * @return 返回长度 */ getLength(length) { if (isExpectType(length, 'number')) return length; if (isExpectType(length, 'string')) return this.changeUnits(length, { clean: true }); return 0; } /** * 获取相对宽度 * @param length 将要转换的宽度 * @param width 宽度计算百分比 * @return 返回相对宽度 */ getWidth(length, width = this.prizeRadian * this.prizeRadius) { if (isExpectType(length, 'number')) return length * this.dpr; if (isExpectType(length, 'string')) return this.changeUnits(length, { denominator: width }); return 0; } /** * 获取相对高度 * @param length 将要转换的高度 * @param height 高度计算百分比 * @return 返回相对高度 */ getHeight(length, height = this.prizeRadius) { if (isExpectType(length, 'number')) return length * this.dpr; if (isExpectType(length, 'string')) return this.changeUnits(length, { denominator: height }); return 0; } /** * 获取相对(居中)X坐标 * @param width * @return 返回x坐标 */ getOffsetX(width) { return -width / 2; } } class LuckyGrid extends Lucky { /** * 九宫格构造器 * @param el 元素标识 * @param data 抽奖配置项 */ constructor(el, data = {}) { super(); this.rows = 3; this.cols = 3; this.blocks = []; this.prizes = []; this.defaultConfig = { gutter: 5, speed: 20, accelerationTime: 2500, decelerationTime: 2500, }; this.defaultStyle = { borderRadius: 20, fontColor: '#000', fontSize: '18px', fontStyle: 'microsoft yahei ui,microsoft yahei,simsun,sans-serif', fontWeight: '400', background: '#fff', shadow: '', wordWrap: true, lengthLimit: '90%', }; this.activeStyle = { background: '#ffce98', shadow: '', }; this.boxWidth = 0; // 九宫格宽度 this.boxHeight = 0; // 九宫格高度 this.cellWidth = 0; // 格子宽度 this.cellHeight = 0; // 格子高度 this.startTime = 0; // 开始时间戳 this.endTime = 0; // 结束时间戳 this.currIndex = 0; // 当前index累加 this.stopIndex = 0; // 刻舟求剑 this.endIndex = 0; // 停止索引 this.demo = false; // 是否自动游走 this.timer = 0; // 游走定时器 this.animationId = 0; // 帧动画id this.FPS = 16.6; // 屏幕刷新率 // 所有格子 this.cells = []; // 图片缓存 this.cellImgs = []; // 边框绘制信息 this.blockData = []; this.box = document.querySelector(el); this.canvas = document.createElement('canvas'); this.box.appendChild(this.canvas); this.ctx = this.canvas.getContext('2d'); this.setData(data); // 收集首次渲染的图片 let willUpdate = [[]]; this.prizes && (willUpdate = this.prizes.map(prize => prize.imgs)); this.button && (willUpdate[this.cols * this.rows - 1] = this.button.imgs); this.init(willUpdate); } /** * 初始化数据 * @param data */ setData(data) { this.rows = Number(data.rows) || 3; this.cols = Number(data.cols) || 3; this.blocks = data.blocks || []; this.prizes = data.prizes || []; this.button = data.button; this.startCallback = data.start; this.endCallback = data.end; const config = this.defaultConfig; const style = this.defaultStyle; const active = this.activeStyle; for (let key in data.defaultConfig) { config[key] = data.defaultConfig[key]; } config.gutter = this.getLength(config.gutter) * this.dpr; config.speed /= 40; for (let key in data.defaultStyle) { style[key] = data.defaultStyle[key]; } style.borderRadius = this.getLength(style.borderRadius) * this.dpr; for (let key in data.activeStyle) { active[key] = data.activeStyle[key]; } } /** * 初始化 canvas 抽奖 * @param willUpdateImgs 需要更新的图片 */ init(willUpdateImgs) { this.setDpr(); this.setHTMLFontSize(); const { box, canvas, dpr, defaultStyle, defaultConfig } = this; if (!box) return; this.boxWidth = canvas.width = box.offsetWidth * dpr; this.boxHeight = canvas.height = box.offsetHeight * dpr; this.optimizeClarity(canvas, this.boxWidth, this.boxHeight); // 合并奖品和按钮一起渲染 this.cells = [...this.prizes]; if (this.button) this.cells[this.cols * this.rows - 1] = { ...this.button }; this.cells.forEach(cell => { cell.col = cell.col || 1; cell.row = cell.row || 1; }); // 计算所有边框信息, 并获取奖品区域 this.blockData = []; this.prizeArea = this.blocks.reduce(({ x, y, w, h }, block) => { const [paddingTop, paddingBottom, paddingLeft, paddingRight] = computePadding(block).map(n => n * dpr); this.blockData.push([x, y, w, h, block.borderRadius ? this.getLength(block.borderRadius) * dpr : 0, block.background]); return { x: x + paddingLeft, y: y + paddingTop, w: w - paddingLeft - paddingRight, h: h - paddingTop - paddingBottom }; }, { x: 0, y: 0, w: this.boxWidth, h: this.boxHeight }); // 计算单一奖品格子的宽度和高度 this.cellWidth = (this.prizeArea.w - defaultConfig.gutter * (this.cols - 1)) / this.cols; this.cellHeight = (this.prizeArea.h - defaultConfig.gutter * (this.rows - 1)) / this.rows; const endCallBack = () => { // 开始首次渲染 this.draw(); // 中奖标识开始游走 this.demo && this.walk(); // 点击按钮开始, 这里不能使用 addEventListener if (this.button) canvas.onclick = e => { const [x, y, width, height] = this.getGeometricProperty([ this.button.x, this.button.y, this.button.col || 1, this.button.row || 1 ]); if (e.offsetX < x || e.offsetY < y || e.offsetX > x + width || e.offsetY > y + height) return false; if (this.startTime) return; this.startCallback?.(e); }; }; // 同步加载图片 let num = 0, sum = 0; if (isExpectType(willUpdateImgs, 'array')) { this.draw(); // 先画一次防止闪烁, 因为加载图片是异步的 willUpdateImgs.forEach((imgs, cellIndex) => { if (!imgs) return false; imgs.forEach((imgInfo, imgIndex) => { sum++; this.loadAndCacheImg(cellIndex, imgIndex, () => { num++; if (sum === num) endCallBack.call(this); }); }); }); } if (!sum) endCallBack.call(this); } /** * 单独加载某一张图片并计算其实际渲染宽高 * @param { number } prizeIndex 奖品索引 * @param { number } imgIndex 奖品图片索引 * @param { Function } callBack 图片加载完毕回调 */ loadAndCacheImg(prizeIndex, imgIndex, callBack) { const prize = this.cells[prizeIndex]; if (!prize || !prize.imgs) return; const imgInfo = prize.imgs[imgIndex]; if (!this.cellImgs[prizeIndex]) this.cellImgs[prizeIndex] = []; // 加载 defaultImg 默认图片 let defaultImg = new Image(); this.cellImgs[prizeIndex][imgIndex] = { defaultImg }; defaultImg.src = imgInfo.src; let num = 0, sum = 1; defaultImg.onload = () => { num++; num === sum && callBack.call(this); }; // 如果有 activeImg 则多加载一张 if (!imgInfo.hasOwnProperty('activeSrc')) return; sum++; let activeImg = new Image(); this.cellImgs[prizeIndex][imgIndex].activeImg = activeImg; activeImg.src = imgInfo.activeSrc; activeImg.onload = () => { num++; num === sum && callBack.call(this); }; } /** * 计算图片的渲染宽高 * @param imgObj 图片标签元素 * @param imgInfo 图片信息 * @param cell 格子信息 * @return [渲染宽度, 渲染高度] */ computedWidthAndHeight(imgObj, imgInfo, cell) { // 根据配置的样式计算图片的真实宽高 if (!imgInfo.width && !imgInfo.height) { // 如果没有配置宽高, 则使用图片本身的宽高 return [imgObj.width, imgObj.height]; } else if (imgInfo.width && !imgInfo.height) { // 如果只填写了宽度, 没填写高度 let trueWidth = this.getWidth(imgInfo.width, cell.col); // 那高度就随着宽度进行等比缩放 return [trueWidth, imgObj.height * (trueWidth / imgObj.width)]; } else if (!imgInfo.width && imgInfo.height) { // 如果只填写了宽度, 没填写高度 let trueHeight = this.getHeight(imgInfo.height, cell.row); // 那宽度就随着高度进行等比缩放 return [imgObj.width * (trueHeight / imgObj.height), trueHeight]; } // 如果宽度和高度都填写了, 就分别计算 return [ this.getWidth(imgInfo.width, cell.col), this.getHeight(imgInfo.height, cell.row) ]; } /** * 绘制九宫格抽奖 */ draw() { const { ctx, dpr, defaultStyle, activeStyle } = this; // 清空画布 ctx.clearRect(0, 0, this.boxWidth, this.boxWidth); // 绘制所有边框 this.blockData.forEach(([x, y, w, h, r, background]) => { drawRoundRect(ctx, x, y, w, h, r, this.handleBackground(x, y, w, h, background)); }); // 绘制所有格子 this.cells.forEach((prize, cellIndex) => { let [x, y, width, height] = this.getGeometricProperty([prize.x, prize.y, prize.col, prize.row]); const isActive = cellIndex === this.currIndex % this.prizes.length >> 0; // 处理阴影 (暂时先用any, 这里后续要优化) const shadow = (isActive ? activeStyle.shadow : (prize.shadow || defaultStyle.shadow)) .replace(/px/g, '') // 清空px字符串 .split(',')[0].split(' ') // 防止有人声明多个阴影, 截取第一个阴影 .map((n, i) => i < 3 ? Number(n) * dpr : n); // 把数组的前三个值*像素比 // 绘制阴影 if (shadow.length === 4) { ctx.shadowColor = shadow[3]; ctx.shadowOffsetX = shadow[0]; ctx.shadowOffsetY = shadow[1]; ctx.shadowBlur = shadow[2]; // 修正(格子+阴影)的位置, 这里使用逗号运算符 shadow[0] > 0 ? (width -= shadow[0]) : (width += shadow[0], x -= shadow[0]); shadow[1] > 0 ? (height -= shadow[1]) : (height += shadow[1], y -= shadow[1]); } drawRoundRect(ctx, x, y, width, height, prize.borderRadius ? this.getLength(prize.borderRadius) * dpr : this.getLength(defaultStyle.borderRadius), this.handleBackground(x, y, width, height, prize.background, isActive)); // 清空阴影 ctx.shadowColor = 'rgba(255, 255, 255, 0)'; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0; ctx.shadowBlur = 0; // 绘制图片 prize.imgs && prize.imgs.forEach((imgInfo, imgIndex) => { if (!this.cellImgs[cellIndex]) return false; const cellImg = this.cellImgs[cellIndex][imgIndex]; if (!cellImg) return false; const renderImg = (isActive && cellImg.activeImg) || cellImg.defaultImg; const [trueWidth, trueHeight] = this.computedWidthAndHeight(renderImg, imgInfo, prize); ctx.drawImage(renderImg, x + this.getOffsetX(trueWidth, prize.col), y + this.getHeight(imgInfo.top, prize.row), trueWidth, trueHeight); }); // 绘制文字 prize.fonts && prize.fonts.forEach(font => { // 字体样式 let style = isActive && activeStyle.fontStyle ? activeStyle.fontStyle : (font.fontStyle || defaultStyle.fontStyle); // 字体加粗 let fontWeight = isActive && activeStyle.fontWeight ? activeStyle.fontWeight : (font.fontWeight || defaultStyle.fontWeight); // 字体大小 let size = isActive && activeStyle.fontSize ? this.getLength(activeStyle.fontSize) : this.getLength(font.fontSize || defaultStyle.fontSize); // 字体行高 const lineHeight = isActive && activeStyle.lineHeight ? activeStyle.lineHeight : font.lineHeight || defaultStyle.lineHeight || font.fontSize || defaultStyle.fontSize; ctx.font = `${fontWeight} ${size * dpr}px ${style}`; ctx.fillStyle = (isActive && activeStyle.fontColor) ? activeStyle.fontColor : (font.fontColor || defaultStyle.fontColor); let lines = [], text = String(font.text); // 计算文字换行 if (font.hasOwnProperty('wordWrap') ? font.wordWrap : defaultStyle.wordWrap) { text = removeEnter(text); let str = ''; for (let i = 0; i < text.length; i++) { str += text[i]; let currWidth = ctx.measureText(str).width; let maxWidth = this.getWidth(font.lengthLimit || defaultStyle.lengthLimit, prize.col); if (currWidth > maxWidth) { lines.push(str.slice(0, -1)); str = text[i]; } } if (str) lines.push(str); if (!lines.length) lines.push(text); } else { lines = text.split('\n'); } lines.forEach((line, lineIndex) => { ctx.fillText(line, x + this.getOffsetX(ctx.measureText(line).width, prize.col), y + this.getHeight(font.top, prize.row) + (lineIndex + 1) * this.getLength(lineHeight) * dpr); }); }); }); } /** * 处理背景色 * @param x * @param y * @param width * @param height * @param background * @param isActive */ handleBackground(x, y, width, height, background, isActive = false) { const { ctx, defaultStyle, activeStyle } = this; background = isActive ? activeStyle.background : (background || defaultStyle.background); // 处理线性渐变 if (background.includes('linear-gradient')) { background = getLinearGradient(ctx, x, y, width, height, background); } return background; } /** * 对外暴露: 开始抽奖方法 */ play() { if (this.startTime) return; clearInterval(this.timer); cancelAnimationFrame(this.animationId); this.startTime = Date.now(); this.prizeFlag = undefined; this.run(); } /** * 对外暴露: 缓慢停止方法 * @param index 中奖索引 */ stop(index) { this.prizeFlag = index % this.prizes.length; } /** * 实际开始执行方法 * @param num 记录帧动画执行多少次 */ run(num = 0) { const { currIndex, prizes, prizeFlag, startTime, defaultConfig } = this; let interval = Date.now() - startTime; // 先完全旋转, 再停止 if (interval >= defaultConfig.accelerationTime && prizeFlag !== undefined) { // 记录帧率 this.FPS = interval / num; // 记录开始停止的时间戳 this.endTime = Date.now(); // 记录开始停止的索引 this.stopIndex = currIndex; // 最终停止的索引 this.endIndex = prizes.length * 5 + prizeFlag - (currIndex >> 0); cancelAnimationFrame(this.animationId); return this.slowDown(); } this.currIndex = (currIndex + quad.easeIn(interval, 0.1, defaultConfig.speed, defaultConfig.accelerationTime)) % prizes.length; this.draw(); this.animationId = window.requestAnimationFrame(this.run.bind(this, num + 1)); } /** * 缓慢停止的方法 */ slowDown() { const { prizes, prizeFlag, stopIndex, endIndex, defaultConfig } = this; let interval = Date.now() - this.endTime; if (interval > defaultConfig.decelerationTime) { this.startTime = 0; this.endCallback?.({ ...prizes.find((prize, index) => index === prizeFlag) }); return cancelAnimationFrame(this.animationId); } this.currIndex = quad.easeOut(interval, stopIndex, endIndex, defaultConfig.decelerationTime) % prizes.length; this.draw(); this.animationId = window.requestAnimationFrame(this.slowDown.bind(this)); } /** * 开启中奖标识自动游走 */ walk() { clearInterval(this.timer); this.timer = window.setInterval(() => { this.currIndex += 1; this.draw(); }, 1300); } /** * 计算奖品格子的几何属性 * @param { array } [...矩阵坐标, col, row] * @return { array } [...真实坐标, width, height] */ getGeometricProperty([x, y, col, row]) { const { defaultConfig, cellWidth, cellHeight } = this; let res = [ this.prizeArea.x + (cellWidth + defaultConfig.gutter) * x, this.prizeArea.y + (cellHeight + defaultConfig.gutter) * y ]; col && row && res.push(cellWidth * col + defaultConfig.gutter * (col - 1), cellHeight * row + defaultConfig.gutter * (row - 1)); return res; } /** * 获取长度 * @param length 将要转换的长度 * @return 返回长度 */ getLength(length) { if (isExpectType(length, 'number')) return length; if (isExpectType(length, 'string')) return this.changeUnits(length, { clean: true }); return 0; } /** * 转换并获取宽度 * @param width 将要转换的宽度 * @param col 横向合并的格子 * @return 返回相对宽度 */ getWidth(width, col = 1) { if (isExpectType(width, 'number')) return width * this.dpr; if (isExpectType(width, 'string')) return this.changeUnits(width, { denominator: this.cellWidth * col + this.defaultConfig.gutter * (col - 1) }); return 0; } /** * 转换并获取高度 * @param height 将要转换的高度 * @param row 纵向合并的格子 * @return 返回相对高度 */ getHeight(height, row = 1) { if (isExpectType(height, 'number')) return height * this.dpr; if (isExpectType(height, 'string')) return this.changeUnits(height, { denominator: this.cellHeight * row + this.defaultConfig.gutter * (row - 1) }); return 0; } /** * 获取相对(居中)X坐标 * @param width * @param col */ getOffsetX(width, col = 1) { return (this.cellWidth * col + this.defaultConfig.gutter * (col - 1) - width) / 2; } } exports.LuckyGrid = LuckyGrid; exports.LuckyWheel = LuckyWheel;