/* author: leeenx @ 贪吃蛇的 view 类 */ // 链表类 import Chain from "../lib/utils/Chain"; import food from "../food.png"; import TWEEN from "@tweenjs/tween.js"; import eye from "../eye.png"; import mouth from "../mouth.png"; import tear from "../tear.png"; import stars from "../stars.png"; const startColor = "#fec321"; const endColor = "#e29b01"; // view 类 export default class view { // 构造函数 constructor(dom, width, height) { const canvas = document.createElement("canvas"); canvas.style.width = width + "px"; canvas.style.height = height + "px"; canvas.width = width * window.devicePixelRatio; canvas.height = height * window.devicePixelRatio; const ctx = canvas.getContext("2d"); ctx.scale(window.devicePixelRatio, window.devicePixelRatio); dom.appendChild(canvas); // 创建 view 的蛇 this.snake = new Chain(); // 保证 updateTicker 指针永远指向 view this.updateTicker = this.updateTicker.bind(this); // 挂载到this this.canvas = canvas; this.ctx = ctx; this.foodImg = new Image(120, 120); this.foodImg.src = food; this.eyeImg = new Image(143, 65); this.eyeImg.src = eye; this.mouthImg = new Image(131, 67); this.mouthImg.src = mouth; this.tearImg = new Image(129, 154); this.tearImg.src = tear; this.starsImg = new Image(224, 162); this.starsImg.src = stars; } // 初始化 init(config = {}) { // 蛇的尺寸挂载到 config config.size = { width: config.width / config.column, height: config.height / config.row, }; // 初化data this.data = config.data; // 全局 config 挂载 this.config = config; // 食物 this.food = {}; this.food.visible = false; this.lastFood = null; // 通过 model.zone 创建一张快速定位表 this.createQuickMap(this.data.zone); this.tail = null; // 同步 model 的初始数据 for (let { data } of this.data.snake) { this.snake.push(data); } } setInterval(interval, timer) { this.interval = interval; this.timer = timer; } destroy() { this.snake.clean(); } // 快速寻位表 createQuickMap(map) { let { width, height } = this.config.size; // 快速表 this.quickMap = []; for (let { col, row } of map) { this.quickMap.push([col * width, row * height]); } } // 快速计算position getPosition(index) { return this.quickMap[index]; } // 随机生成食物 feed(index) { this.food.visible = 1; this.food.dirty++; this.food.clearDirty++; const [left, top] = this.getPosition(index); Object.assign(this.food, { index, left, top }); } // ticker update updateTicker() { // this.render(); } // 状态更新 update(data) { // 食物更新 if (this.food.index !== data.food) { this.lastFood = this.food.index; this.feed(data.food); } this.tail = this.snake.last(); this.updateDelta(data.snake); } // 增量更新 updateDelta(snakeA, snakeB = this.snake) { // snakeA === model.snake, snakeB === view.snake this.updateTail(snakeA, snakeB) .then(() => this.updateHead(snakeA, snakeB)) .catch(() => this.wholeUpdate(snakeA, snakeB)) .then(() => this.render()); } // 检查蛇头 updateHead(snakeA, snakeB) { return new Promise((resolve, reject) => { // snakeA 与 snakeB 做头指针比较 let headA, headB = snakeB.first(); // 指针指向头部 snakeA.setPointer(snakeA.HEAD); while (snakeB.length <= snakeA.length) { headA = snakeA.next(); // 头节点匹配 if (headA.data === headB.data) { // 执行 then 通道 return resolve(); } // 不匹配 else { // 向snakeB插入头节点 if (snakeA.HEAD === headA.index) { snakeB.unshift(headA.data); } // 向snakeB插入第二个节点 else { snakeB.insertAfter(0, headA.data); } } } // 头指针未匹配上,走 catch 通道 reject(); }); } // 检查蛇尾 updateTail(snakeA, snakeB) { return new Promise((resolve, reject) => { // snakeA 与 snakeB 做尾指针比较 let tailA = snakeA.last(), tailB; while (snakeB.length !== 0) { tailB = snakeB.last(); // 尾节点匹配 if (tailA.data === tailB.data) { // 执行 then 通道 return resolve(); } // 不匹配 else { this.tail = snakeB.pop(); } } // 尾指针未匹配上,走 catch 通道 reject(); }); } // 全量更新 wholeUpdate(snakeA, snakeB) { console.log(">>>>>>>>>>>>>>>>>", "low performance"); // 把视图上的蛇回收 while (snakeB.length !== 0) { snakeB.pop(); } // 重头开始插入 snake for (let { data } of snakeA) { snakeB.unshift(data); } } renderFood(t) { let { width, height } = this.config.size; const ratio = Math.abs(t - 0.5) * 0.5 + 1; if (this.food && this.food.visible) { this.ctx.drawImage( this.foodImg, this.food.left - (width * (ratio - 1)) / 2, this.food.top - (height * (ratio - 1)) / 2, width * ratio, height * ratio ); } } renderSnake(t, lose) { let { length } = this.snake; let { width, height } = this.config.size; if (lose) t = 1 - lose; const list = []; for (let body of this.snake) { list.push(body); } let lastFoodPos = this.lastFood === null ? this.lastFood : this.getPosition(this.lastFood); lastFoodPos = lastFoodPos ? { x: lastFoodPos[0] + width / 2, y: lastFoodPos[1] + height / 2 } : lastFoodPos; const points = list.map(({ data }) => { const [x, y] = this.getPosition(data); return { x: x + width / 2, y: y + height / 2 }; }); const { ctx } = this; const lineWidth = (width / 120) * 90; let pre = null; let headPos = null; let headRad = null; if (this.tail !== null) { let [x, y] = this.getPosition(this.tail.data); x = x + width / 2; y = y + height / 2; points.push({ x, y }); length++; } points.forEach(({ x, y }, i) => { if (!pre) { pre = { x, y }; } else { const start = i === 1 ? { x: (pre.x - x) * t + x, y: (pre.y - y) * t + y } : pre; const end = i === length - 1 ? { x: (x - pre.x) * (1 - t) + pre.x, y: (y - pre.y) * (1 - t) + pre.y, } : { x, y }; const isV = pre.x === x; const gStart = { x: isV ? x : x - i * (x - pre.x), y: isV ? y - i * (y - pre.y) : y, }; const gEnd = { x: isV ? x : x + (length - i) * (x - pre.x), y: isV ? y + (length - i) * (y - pre.y) : y, }; const gradient = ctx.createLinearGradient( gStart.x, gStart.y, gEnd.x, gEnd.y ); gradient.addColorStop(0, startColor); gradient.addColorStop(1, endColor); ctx.strokeStyle = gradient; ctx.beginPath(); ctx.moveTo(start.x, start.y); ctx.lineCap = "round"; ctx.lineWidth = lineWidth; ctx.lineTo(end.x, end.y); ctx.stroke(); ctx.closePath(); if ( lastFoodPos && Math.abs(lastFoodPos.x - x) < 1 && Math.abs(lastFoodPos.y - y) < 1 ) { if (i === length - 2) this.lastFood = null; ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc( x, y, (lineWidth + (width - lineWidth) * (1 - i / length)) / 2, 0, Math.PI * 2 ); ctx.fill(); } if (i === 1) { headPos = start; headRad = Math.atan2(end.y - pre.y, end.x - pre.x) - Math.PI / 2; } pre = { x, y }; } }); lose ? this.renderTear(headPos, headRad, lose) : this.renderEye(headPos, headRad); const foodPos = { x: this.food.left + width / 2, y: this.food.top + height / 2, }; const foodInRange = Math.abs(foodPos.x - headPos.x) <= width * 2 && Math.abs(foodPos.y - headPos.y) <= width * 2; if (foodInRange && !lose) this.renderMouth(headPos, headRad, t); } renderMouth(pos, rad, t) { t = 1; let { width, height } = this.config.size; const r = (width / 120) * 90; const { ctx } = this; ctx.save(); ctx.translate(pos.x, pos.y); ctx.rotate(rad); const imgHeight = (width / this.mouthImg.width) * this.mouthImg.height; ctx.drawImage( this.mouthImg, -width / 2, -height / 2 + imgHeight / 4, width, imgHeight * t ); ctx.restore(); } renderTear(pos, rad, t) { let { width } = this.config.size; const r = ((width / 120) * 90) / 2; const { ctx } = this; { ctx.save(); ctx.translate(pos.x, pos.y); ctx.rotate(rad); const displayWidth = (width / 120) * this.tearImg.width; const displayHeight = (width / 120) * this.tearImg.height; ctx.drawImage( this.tearImg, -displayWidth / 2, -displayHeight / 2 + (width / 120) * 100 - r, displayWidth, displayHeight ); ctx.restore(); } { ctx.save(); ctx.translate(pos.x, pos.y); ctx.rotate(rad); const displayWidth = (width / 120) * this.starsImg.width * t; const displayHeight = (width / 120) * this.starsImg.height * t; ctx.drawImage( this.starsImg, -displayWidth / 2, -displayHeight / 2 + (width / 120) * 100 - r, displayWidth, displayHeight ); ctx.restore(); } } renderEye(pos, rad) { let { width } = this.config.size; const r = ((width / 120) * 90) / 2; const { ctx } = this; ctx.save(); ctx.translate(pos.x, pos.y); ctx.rotate(rad); const displayWidth = (width / 120) * this.eyeImg.width; const displayHeight = (width / 120) * this.eyeImg.height; ctx.drawImage( this.eyeImg, -displayWidth / 2, -displayHeight / 2 + (width / 120) * 100 - r, displayWidth, displayHeight ); ctx.restore(); } renderLose() { return new Promise((resolve) => { this.currentTween && this.currentTween.stop(); const tween = new TWEEN.Tween({ t: 0 }); tween.easing(TWEEN.Easing.Elastic.Out); tween.to({ t: 1 }, 500); tween.onComplete(resolve); tween.onUpdate(({ t }) => { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.renderFood(1); this.renderSnake(1, t); requestAnimationFrame(() => { TWEEN.update(); }); }); requestAnimationFrame(() => { TWEEN.update(); }); tween.start(); }); } // 渲染 render() { if (this.interval) { const tween = new TWEEN.Tween({ t: 0 }); this.currentTween = tween; tween.to({ t: 1 }, this.interval); tween.onUpdate(({ t }) => { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.renderFood(t); this.renderSnake(t); requestAnimationFrame(() => { TWEEN.update(); }); }); requestAnimationFrame(() => { TWEEN.update(); }); tween.start(); } else { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.renderFood(0); this.renderSnake(0); } } }