You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
435 lines
12 KiB
435 lines
12 KiB
/*
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|