写在开始
工作这么些年,一直以来写了太多的常规业务,大多数不是后台系统就是小程序,然后就是H5啥的,最近公司在使用各种厂家的地图,做相关地图的开发,但是也都是跟常规业务有关的东西。在大约半年前有点想写点游戏的相关的东西,有时候也在关注这方面的东西,刚好元旦放假,就自己搓了一个贪吃蛇玩玩,顺便写一篇文章(之前都没写过,主要自己一直不怎么想写东西)。
什么都不说,先上效果图
ps:UI图我是网上找的,自己又ps加工了一下,要是哪位设计大大说我盗图,这里说明一下不好意思哈,我不作商业用途,就是纯写文章用。
在看代码之前,先要了解下这个游戏的基本情况
说明
贪吃蛇是一款比较经典的游戏,小时候就玩过很多次,当然现在有很多复杂的贪吃蛇各种效果都有,我这里呢就是实现基本的东西和简单的效果。
关系
classDiagram
Canvas <|-- Scene
Canvas <|-- Events
Scene <|-- SnakeGame
Scene <|-- Food
Scene <|-- Snake
Scene <|-- BackGround
Canvas:canvas类
class Canvas{
drawText()
drawImage()
on()
remove()
get layer()
get events()
...
}
Scene:场景类
class Scene{
add()
join()
loadSource()
update()
clear()
...
}
SnakeGame:游戏类
class SnakeGame{
initGame()
start()
_registerAction()
_loadImgSource()
...
}
class Events{
- Map _listeners
on()
remove()
findListenerByKey()
事件系统作为canvas的成员...
}
class Food{
setScene()
draw()
collideCheck()
}
class Snake{
setScene()
draw()
collideCheck()
}
class BackGround{
setScene()
draw()
collideCheck()
}
类简介
- canvas类提供了基础的绘图能力
- events类提供事件注册和消息相关
- Scene类注册场景,继承canvas基础能力,同时提供了资源加载、场景重绘、添加成员、进入场景等能力
- SnakeGame类游戏主进程,继承了Scene,包含了游戏相关的事件注册、行为注册以及各种游戏初始化相关的执行逻辑
- Food类、BackGround类、snake类包含 draw绘制,碰撞检测等实现了SnakeService.common接口
好了,上面啰嗦了半天,是时候代码出场了
ps:要是大佬又闲情雅致看到,有写的不好之处,请帮忙指正,谢谢了!
基础的canvas
class Canvas {
constructor(config?: CanvasTypes.constructorParam) {
/**
这里太长了是巴拉巴拉的判断和createElement('canvas'),方便阅读先去掉
*/
//......
this._canvas = canvas;
this._ctx = canvas?.getContext('2d');
this._events = new Events();
}
get layer() {
return this._canvas;
}
get events() {
return this._events;
}
getLayerWH(): CanvasTypes.rect {
return {
width: this._canvas.width,
height: this._canvas.height,
};
}
/**
* 注册事件/给canvas添加事件
* @param key
* @param listener
* @param isAddLayer 是否给canvas也添加事件,默认false
*/
on(key: string, listener: EventListenerOrEventListenerObject, isAddLayer: boolean = false) {
}
remove(key: string) {}
setGlobalCompositeOperation(key?: string) {}
setAttributes(obj: CanvasTypes.attributes) {}
drawRect(color: string, rectPram: CanvasTypes.rectPram) {}
clearRect(rectPram: CanvasTypes.rectPram) {}
drawText(option: CanvasTypes.text) {}
rotate(num: number) {}
translate(x: number, y: number) {}
/**
* 将图像添加到画布中
* @param img 添加对象
* @param s1 要添加得对象裁剪规则
* @param p2 要添加得对象放置规则
* @param gco 添加对象合成操作规则,参考canvas得GlobalCompositeOperation属性
*/
drawImage(obj: CanvasTypes.drawImage) {}
}
这个类没什么好说的,就是提供canvas的一些能力,只不过封装了一下,参数变化了
Scene 场景类
class Scene extends Canvas {
// 属性和constructor代码去掉,占位置.......
/**
* 添加事物到场景中
* @param member
*/
add(member: any) {
if (!Reflect.has(member, 'setScene')) {
throw Error('加入场景的成员必须具备setScene函数');
}
if (!Reflect.has(member, 'draw')) {
throw Error('加入场景的成员必须具备draw函数');
}
member.setScene(this);
member.draw();
this._memberContainer.add(member);
}
/**
* 加入其他场景
* @param scene
*/
join(scene: ScenePo) {
scene.drawImage({
// 要传入的参数 ...
});
}
/**
* 添加加载好的资源
* @param sourceData
*/
addSourceMap(sourceData: Map<any, any>) {
this.sourceMap = sourceData;
}
/**
* 简单图片加载资源
* @param url 可以是网络资源也可以是images里面的图片文件名字xxx.png
* @param callback
*/
loadSource(url: string, callback: callback) {
// 场景加载资源用...
}
// 更新场景,调用场景内的成员绘制方法draw
update() {
this.clear();
forEach(this._memberContainer, item => {
item.draw();
});
}
// 清理
clear() {
this.clearRect({
// ...
});
}
}
场景类的作用就相当于一个容器,你可以往里面加入成员,同时也可以把这个容器加入到另一个容器。同时场景里面有成员需要呈现的所有资源,以及更新重绘场景和成员的方式,没个场景管好自己的事就行
Food 食物类
class Food implements SnakeService.common {
// 属性代码去掉,占位置.......
/**
* 设置场景
* @param scene
*/
setScene(scene: ScenePo) {
this.scene = scene;
}
_updateCollideTag(status: boolean) {
this._isCollided = status;
}
collideCheck(snake: SnakePo) {
// 计算与蛇碰撞...
}
_calFoodPosition(): CanvasTypes.position {
// 计算位置,保证位置出现在置入的场景范围内....
}
_getFoodImg(position: CanvasTypes.position, img?: any) {
// 获取食物图...
}
reload() {
this._calFoodPosition();
}
/**
* 绘制没有碰撞前的食物
*/
_drawOld() {
this.scene.drawImage(this._getFoodImg({ ...this.position }));
}
draw() {
// 没有碰撞保持之前的位置不变
if (this.position?.x && !this._isCollided) {
this._drawOld();
return;
}
const _position = this._calFoodPosition();
this.scene.drawImage(this._getFoodImg({ ..._position }));
}
}
食物类、蛇类和背景类因为实现的接口是同一个,都有draw、setScene等方法,只是实现的逻辑不一样而已,但是要说明下的是背景的实例和蛇与食物的实例是不在同一个场景,但是最后又到会合成到一个画面中,因为背景是不用动态变化的,保持一个状态就行,这里就不一一粘贴了,下面就只把蛇类粘贴一下。
蛇类
class Snake implements SnakeService.common {
// 属性代码和部分逻辑代码去掉,占位置.......
/**
* 设置前进方向
* @param direction
*/
setDirection(direction: SnakeService.direction) {
this.direction = direction;
}
_getBodyItem(data?: position): position {
// 获取身体块...
}
/**
* 设置场景
* @param scene
*/
setScene(scene: ScenePo) {
this.scene = scene;
}
_getSnakeLastBody(): position {}
_addHeader(body: position) {
body.direction = this.direction;
this.body.unshift(body);
}
// 下面是行为...
up() {}
down() {}
left() {}
right() {}
_getBodyImg(position: CanvasTypes.position, img?: any) {}
_rotateRange(name: string, data: position) {
// 获取身体、头部、尾巴的img的方向
}
draw() {
// 绘制....
}
/**
* 说明:身体是由每块组成得
* 最初想的处理移动得方式想着是整体动起来,在运动得时候后一块变成前一块得位置,但是这样做要循环处理数据
* 最终还是做成视觉上的移动,就是每次操作是将最后一块拿到最前面来,造成视觉上的移动效果
*/
_changeBody() {
const _header = utils.findFirstItem(this.body);
const _body = this._getSnakeLastBody();
if (_body) {
switch (this.direction) {
// 根据移动方向 设置body.....
}
}
this.body.unshift(_body);
}
/**
* 边界检测
* @returns
*/
_checkOverSide(): boolean {
// 判断碰壁结果
// 所在场景范围
const { width, height } = this.scene;
const { x, y } = utils.findFirstItem(this.body);
return x <= 0 || x >= width || y <= 0 || y >= height;
}
/**
* 检测吃到蛇身体
*/
_checkEatSelf(): boolean {}
// 碰撞检测
collideCheck(food: FoodPo) {
const _isOverSide = this._checkOverSide();
const _isEatSelf = this._checkEatSelf();
if (_isOverSide || _isEatSelf) return true;
// 检测食物碰撞,并重绘食物
if (food.collideCheck(this)) this.body.push(this.lastBody);
return false;
}
}
轮到游戏主进程了
// 去掉依赖的类
// 这里是要初始化获取的图片资源
const headerImg = ['headerUp', 'headerDown', 'headerLeft', 'headerRight'];
const tailImg = ['tailUp', 'tailDown', 'tailLeft', 'tailRight'];
// 游戏本身就是一个场景,继承下Scene
class SnakeGame extends Scene {
snake!: SnakePo;
food!: FoodPo;
backGround!: BackGroundPo;
SF_scene!: ScenePo;
fps = 1000 / 3;
timer: any;
isPause: boolean = true;
source = ['bg', 'body', 'gameover', ...headerImg, 'start', ...tailImg, 'food'];
// 资源加载计数器
loadSourceNum = 0;
score = 0;
gameTimeTotal = 0;
timeTimer: any;
// 内置事件回调
handleLoadSource!: callback;
handlePause!: callback;
handleEnd!: callback;
handleScoreChange!: callback;
constructor(data: CanvasTypes.initData) {
// 去掉代码节省空间...
}
// 游戏初始化入口
async initGame(handleCallback?: SnakeService.handleCallback) {
if (handleCallback...) {
// 把内置事件的赋值代码去掉...
}
// 初始化需要的额外场景对象,以备在资源加载好后,注入资源,以便后续直接绘制
this._addNewScene();
// 加载游戏开始所需的资源
const _res = await this._loadImgSource();
if (_res) {
// 将资源加到食物和蛇的场景中
this.SF_scene.addSourceMap(this.sourceMap);
this._initGame();
} else {
console.log('资源加载出错...');
}
}
_addNewScene() {
const _sceneData = {...};
const _scene = new Scene(_sceneData);
// 食物和蛇所在的场景layer
this.SF_scene = _scene;
}
// 初始化游戏所需要的对象和事件、行为
_initGame() {
this.backGround = new BackGround();
this.food = new Food();
this.snake = new Snake();
this.add(this.backGround);
this.SF_scene.add(this.snake);
this.SF_scene.add(this.food);
this.SF_scene.join(this);
this._drawTimeAndLengthText();
// 注册事件
this._initAction();
}
/**
* 资源加载器
*/
_loadImgSource() {
/**
*使用Promise只是为了调用initGame的时候,在资源都加载完了后不在下面的消息中,调用各场景资源注入和
*初始化游戏所需要的对象和事件、行为
*/
return new Promise((resolve, reject) => {
try {
console.log('资源加载中...');
this._registerAction('sourceLoading', () => {
this.loadSourceNum += 1;
if (this.loadSourceNum === this.source.length) {
console.log('资源加载结束');
resolve(true);
}
// 资源加载进度条百分数
if (this.handleLoadSource)
this.handleLoadSource((this.loadSourceNum / this.source.length) * 100);
});
forEach(this.source, item => {
this.loadSource(item, () => {
const _load = this._getAction('sourceLoading');
_load();
});
});
} catch (error) {
resolve(false);
}
});
}
reloadGame() {
...
}
_start() {
if (this.isPause) return;
// 触发行为
const fn = this._getAction(this.snake.direction + '');
if (fn) fn();
// 碰撞检测
const isTrue = this.snake.collideCheck(this.food);
// 分数变化通知
this.score += 1;
if (this.handleScoreChange) this.handleScoreChange(this.score);
if (isTrue) {
this.end();
return;
}
this._updateView();
}
pause() {
if (this.isPause) return;
this.isPause = true;
clearInterval(this.timer);
clearInterval(this.timeTimer);
}
start() {
if (!this.isPause) return;
if (this.handlePause) this.handlePause(false);
this._startSumTime();
this.isPause = false;
this.timer = setInterval(this._start.bind(this), this.fps);
}
// 游戏结束
end() {
...
}
// 各场景更新自己的内容并绘制
_updateView() {
this.update();
this.SF_scene.update();
this.SF_scene.join(this);
this._drawTimeAndLengthText();
}
_startSumTime() {
this.timeTimer = setInterval(() => {
this.gameTimeTotal += 1;
}, 1000);
}
_drawTimeAndLengthText() {
...
}
_drawTime() {
this._drawCommonText(...);
}
_drawSnakeLength() {
this._drawCommonText(...);
}
_drawCommonText(text: string, x?: number) {
// 绘制文字,时间,长度,得分啥的
...
}
// 获得行为
_getAction(action: string): callback {
return this.events.findListenerByKey(action);
}
// 注册行为
_registerAction(action: string, callback: callback) {
this.on(action, callback);
}
// 初始化行为
_initAction() {
// 注册蛇的行为,让键盘触发
this._registerAction(direction.up + '', this.snake.up.bind(this.snake));
...
...
this._addGlobalEvents();
}
_addGlobalEvents() {
window.onkeydown = e => {
// 键盘触发,省略一堆代码
...
// 触发行为
this._start();
};
}
}
游戏主场景功能也不多,只是稍微杂点,主要就是注册行为将蛇的动作和键盘泵绑定,初始化资源和食物,蛇,背,其他事件回调等等,_updateView函数的功能就是利用场景的update()便利场景中的成员的draw()去更新view。
再附带一个截图中的开始,结束等ui,react的组件
import React, { useRef, useEffect, useState } from 'react';
import Snake from '...';
import Loading from '...';
import Start from '...';
import GameOver from '...';
let snakeGame: any;
export default () => {
const _canvas = useRef(null);
const [process, setProcess] = useState(0);
const [isPause, setIsPause] = useState(false);
const [isOver, setIsOver] = useState(null);
useEffect(() => {
if (process >= 100) setIsPause(true);
}, [process]);
useEffect(() => {
const ele = ele1.current;
if (ele) {
const _scene = {
width: 800,
height: 760,
ele,
};
const _s = new Snake(_scene);
snakeGame = _s;
_s.initGame({
handleLoadSource: val => {
setProcess(val);
},
handlePause: val => {
setIsPause(val);
if (!val) setIsOver(null);
},
handleEnd: val => {
setIsOver(val);
},
});
}
}, []);
return (
<>
<div className="bgc-white border-dash">
{isOver ? (
<GameOver score={isOver.score} len={isOver.length} start={() => snakeGame.start()} />
) : null}
{process >= 100 && isPause ? <Start start={() => snakeGame.start()} /> : null}
<Loading process={process} />
{/* 游戏主界面 */}
<canvas className="border-base" ref={_canvas} />
</div>
</>
);
};
写在最后(祝大家新的一年新气象!!)
其实最开始就是玩,只是想写完就行,完成个基本的点点再移动,吃了变大就好了,没想着搞什么UI,加入各种图层图片啥的,可能没个程序员都写完自己的东西后,都是想着尽量做到最好吧,后面就各种细节完善,然后又去找UI图,p图,不过完成后,觉得还是挺好的,写完心情也比较开心吧 不知道大家尤其是前端写页面,或者常规的那些业务,尤其是界面css弄多了之后,会不会觉得感觉自己像没做什么一样,就是天天写这些东西,如果大家有这种想法,我觉得可以换种内容自己玩玩,毕竟前端东西挺多的,没事我觉得玩玩node也行。有时候换种不同的方式也是放松自己...