用微信小游戏实现龙舟大战-打粽子
端午节来啦!各位c粉有没有吃粽子啊!
前言
端午节来啦!今天沉默带大家来做个关于端午节的小游戏,我的设计思路是用龙舟打粽子,类似于飞机大战,只不过我们的场景是在河中。源码在文章后获取哟!
提示:以下是本篇文章正文内容,下面案例可供参考
一、体验视频
下面是小游戏的开发效果视频:
龙舟大战
二、开发流程
1.素材收集
龙舟大战,我们需要一张龙舟的图片和粽子的图片,这里我们还需要河面的背景图片。值得注意的是,龙舟打粽子还需要子弹的图片,为了体现端午节的元素,我们将子弹设定为粽子,当子弹接触到前方的粽子时,前方粽子爆炸特效也需要通过图片生成。具体图片素材如下图:
2.游戏逻辑实现
2.1 定义游戏开发基础类
- 相关的代码说明请看注解
import Sprite from'./sprite'import DataBus from'../databus'const databus =newDataBus()const __ ={timer:Symbol('timer'),}/**
* 简易的帧动画类实现
*/exportdefaultclassAnimationextendsSprite{constructor(imgSrc, width, height){super(imgSrc, width, height)// 当前动画是否播放中this.isPlaying =false// 动画是否需要循环播放this.loop =false// 每一帧的时间间隔this.interval =1000/60// 帧定时器this[__.timer]=null// 当前播放的帧this.index =-1// 总帧数this.count =0// 帧图片集合this.imgList =[]/**
* 推入到全局动画池里面
* 便于全局绘图的时候遍历和绘制当前动画帧
*/
databus.animations.push(this)}/**
* 初始化帧动画的所有帧
* 为了简单,只支持一个帧动画
*/initFrames(imgList){
imgList.forEach((imgSrc)=>{const img =newImage()
img.src = imgSrc
this.imgList.push(img)})this.count = imgList.length
}// 将播放中的帧绘制到canvas上aniRender(ctx){
ctx.drawImage(this.imgList[this.index],this.x,this.y,this.width *1.2,this.height *1.2)}// 播放预定的帧动画playAnimation(index =0, loop =false){// 动画播放的时候精灵图不再展示,播放帧动画的具体帧this.visible =falsethis.isPlaying =truethis.loop = loop
this.index = index
if(this.interval >0&&this.count){this[__.timer]=setInterval(this.frameLoop.bind(this),this.interval
)}}// 停止帧动画播放stop(){this.isPlaying =falseif(this[__.timer])clearInterval(this[__.timer])}// 帧遍历frameLoop(){this.index++if(this.index >this.count -1){if(this.loop){this.index =0}else{this.index--this.stop()}}}}
2.2 帧动画的简易实现
const __ ={poolDic:Symbol('poolDic')}/**
* 简易的对象池实现
* 用于对象的存贮和重复使用
* 可以有效减少对象创建开销和避免频繁的垃圾回收
* 提高游戏性能
*/exportdefaultclassPool{constructor(){this[__.poolDic]={}}/**
* 根据对象标识符
* 获取对应的对象池
*/getPoolBySign(name){returnthis[__.poolDic][name]||(this[__.poolDic][name]=[])}/**
* 根据传入的对象标识符,查询对象池
* 对象池为空创建新的类,否则从对象池中取
*/getItemByClass(name, className){const pool =this.getPoolBySign(name)const result =(pool.length
? pool.shift():newclassName())return result
}/**
* 将对象回收到对象池
* 方便后续继续使用
*/recover(name, instance){this.getPoolBySign(name).push(instance)}}
2.3 游戏基本元素精灵类
(粽子.子弹.击中特效)
/**
* 游戏基础的精灵类
*/exportdefaultclassSprite{constructor(imgSrc ='', width =0, height =0, x =0, y =0){this.img =newImage()this.img.src = imgSrc
this.width = width
this.height = height
this.x = x
this.y = y
this.visible =true}/**
* 将精灵图绘制在canvas上
*/drawToCanvas(ctx){if(!this.visible)return
ctx.drawImage(this.img,this.x,this.y,this.width,this.height
)}/**
* 简单的碰撞检测定义:
* 另一个精灵的中心点处于本精灵所在的矩形内即可
* @param{Sprite} sp: Sptite的实例
*/isCollideWith(sp){const spX = sp.x + sp.width /2const spY = sp.y + sp.height /2if(!this.visible ||!sp.visible)returnfalsereturn!!(spX >=this.x
&& spX <=this.x +this.width
&& spY >=this.y
&& spY <=this.y +this.height)}}
2.4 粽子类实现过程
import Animation from'../base/animation'import DataBus from'../databus'constENEMY_IMG_SRC='images/enemy.png'constENEMY_WIDTH=60constENEMY_HEIGHT=60const __ ={speed:Symbol('speed')}const databus =newDataBus()functionrnd(start, end){return Math.floor(Math.random()*(end - start)+ start)}exportdefaultclassEnemyextendsAnimation{constructor(){super(ENEMY_IMG_SRC,ENEMY_WIDTH,ENEMY_HEIGHT)this.initExplosionAnimation()}init(speed){this.x =rnd(0, window.innerWidth -ENEMY_WIDTH)this.y =-this.height
this[__.speed]= speed
this.visible =true}// 预定义爆炸的帧动画initExplosionAnimation(){const frames =[]constEXPLO_IMG_PREFIX='images/explosion'constEXPLO_FRAME_COUNT=19for(let i =0; i <EXPLO_FRAME_COUNT; i++){
frames.push(`${EXPLO_IMG_PREFIX+(i +1)}.png`)}this.initFrames(frames)}// 每一帧更新子弹位置update(){this.y +=this[__.speed]// 对象回收if(this.y > window.innerHeight +this.height) databus.removeEnemey(this)}}
2.5 粽子子弹类实现
import Sprite from'../base/sprite'import DataBus from'../databus'constBULLET_IMG_SRC='images/bullet.png'constBULLET_WIDTH=16constBULLET_HEIGHT=30const __ ={speed:Symbol('speed')}const databus =newDataBus()exportdefaultclassBulletextendsSprite{constructor(){super(BULLET_IMG_SRC,BULLET_WIDTH,BULLET_HEIGHT)}init(x, y, speed){this.x = x
this.y = y
this[__.speed]= speed
this.visible =true}// 每一帧更新子弹位置update(){this.y -=this[__.speed]// 超出屏幕外回收自身if(this.y <-this.height) databus.removeBullets(this)}}
2.6 玩家类(龙舟)
import Sprite from'../base/sprite'import Bullet from'./bullet'import DataBus from'../databus'const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
// 玩家相关常量设置constPLAYER_IMG_SRC='images/hero.png'constPLAYER_WIDTH=80constPLAYER_HEIGHT=80const databus =newDataBus()exportdefaultclassPlayerextendsSprite{constructor(){super(PLAYER_IMG_SRC,PLAYER_WIDTH,PLAYER_HEIGHT)// 玩家默认处于屏幕底部居中位置this.x = screenWidth /2-this.width /2this.y = screenHeight -this.height -30// 用于在手指移动的时候标识手指是否已经在龙舟上了this.touched =falsethis.bullets =[]// 初始化事件监听this.initEvent()}/**
* 当手指触摸屏幕的时候
* 判断手指是否在龙舟上
* @param {Number} x: 手指的X轴坐标
* @param {Number} y: 手指的Y轴坐标
* @return {Boolean}: 用于标识手指是否在龙舟上的布尔值
*/checkIsFingerOnAir(x, y){const deviation =30return!!(x >=this.x - deviation
&& y >=this.y - deviation
&& x <=this.x +this.width + deviation
&& y <=this.y +this.height + deviation)}/**
* 根据手指的位置设置龙舟的位置
* 保证手指处于龙舟中间
* 同时限定龙舟的活动范围限制在屏幕中
*/setAirPosAcrossFingerPosZ(x, y){let disX = x -this.width /2let disY = y -this.height /2if(disX <0) disX =0elseif(disX > screenWidth -this.width) disX = screenWidth -this.width
if(disY <=0) disY =0elseif(disY > screenHeight -this.height) disY = screenHeight -this.height
this.x = disX
this.y = disY
}/**
* 玩家响应手指的触摸事件
* 改变龙舟的位置
*/initEvent(){
canvas.addEventListener('touchstart',((e)=>{
e.preventDefault()const x = e.touches[0].clientX
const y = e.touches[0].clientY
//if(this.checkIsFingerOnAir(x, y)){this.touched =truethis.setAirPosAcrossFingerPosZ(x, y)}}))
canvas.addEventListener('touchmove',((e)=>{
e.preventDefault()const x = e.touches[0].clientX
const y = e.touches[0].clientY
if(this.touched)this.setAirPosAcrossFingerPosZ(x, y)}))
canvas.addEventListener('touchend',((e)=>{
e.preventDefault()this.touched =false}))}/**
* 玩家射击操作
* 射击时机由外部决定
*/shoot(){const bullet = databus.pool.getItemByClass('bullet', Bullet)
bullet.init(this.x +this.width /2- bullet.width /2,this.y -10,10)
databus.bullets.push(bullet)}}
2.7 背景类(河面)
import Sprite from'../base/sprite'const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
constBG_IMG_SRC='images/bg.jpg'constBG_WIDTH=512constBG_HEIGHT=512/**
* 游戏背景类
* 提供update和render函数实现无限滚动的背景功能
*/exportdefaultclassBackGroundextendsSprite{constructor(ctx){super(BG_IMG_SRC,BG_WIDTH,BG_HEIGHT)this.top =0this.render(ctx)}update(){this.top +=2if(this.top >= screenHeight)this.top =0}/**
* 背景图重绘函数
* 绘制两张图片,两张图片大小和屏幕一致
* 第一张漏出高度为top部分,其余的隐藏在屏幕上面
* 第二张补全除了top高度之外的部分,其余的隐藏在屏幕下面
*/render(ctx){
ctx.drawImage(this.img,0,0,this.width,this.height,0,-screenHeight +this.top,
screenWidth,
screenHeight
)
ctx.drawImage(this.img,0,0,this.width,this.height,0,this.top,
screenWidth,
screenHeight
)}}
2.8 展示分数和结算界面实现
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const atlas =newImage()
atlas.src ='images/Common.png'exportdefaultclassGameInfo{renderGameScore(ctx, score){
ctx.fillStyle ='#ffffff'
ctx.font ='20px Arial'
ctx.fillText(
score,10,30)}renderGameOver(ctx, score){
ctx.drawImage(atlas,0,0,119,108, screenWidth /2-150, screenHeight /2-100,300,300)
ctx.fillStyle ='#ffffff'
ctx.font ='20px Arial'
ctx.fillText('游戏结束',
screenWidth /2-40,
screenHeight /2-100+50)
ctx.fillText(`得分: ${score}`,
screenWidth /2-40,
screenHeight /2-100+130)
ctx.drawImage(
atlas,120,6,39,24,
screenWidth /2-60,
screenHeight /2-100+180,120,40)
ctx.fillText('重新开始',
screenWidth /2-40,
screenHeight /2-100+205)/**
* 重新开始按钮区域
* 方便简易判断按钮点击
*/this.btnArea ={startX: screenWidth /2-40,startY: screenHeight /2-100+180,endX: screenWidth /2+50,endY: screenHeight /2-100+255}}}
2.9 全局音效管理器实现
let instance
/**
* 统一的音效管理器
*/exportdefaultclassMusic{constructor(){if(instance)return instance
instance =thisthis.bgmAudio =newAudio()this.bgmAudio.loop =truethis.bgmAudio.src ='audio/bgm.mp3'this.shootAudio =newAudio()this.shootAudio.src ='audio/bullet.mp3'this.boomAudio =newAudio()this.boomAudio.src ='audio/boom.mp3'this.playBgm()}playBgm(){this.bgmAudio.play()}playShoot(){this.shootAudio.currentTime =0this.shootAudio.play()}playExplosion(){this.boomAudio.currentTime =0this.boomAudio.play()}}
2.10 管控游戏状态实现
import Pool from'./base/pool'let instance
/**
* 全局状态管理器
*/exportdefaultclassDataBus{constructor(){if(instance)return instance
instance =thisthis.pool =newPool()this.reset()}reset(){this.frame =0this.score =0this.bullets =[]this.enemys =[]this.animations =[]this.gameOver =false}/**
* 回收敌人,进入对象池
* 此后不进入帧循环
*/removeEnemey(enemy){const temp =this.enemys.shift()
temp.visible =falsethis.pool.recover('enemy', enemy)}/**
* 回收子弹,进入对象池
* 此后不进入帧循环
*/removeBullets(bullet){const temp =this.bullets.shift()
temp.visible =falsethis.pool.recover('bullet', bullet)}}
2.11 游戏入口主函数实现
import Player from'./player/index'import Enemy from'./npc/enemy'import BackGround from'./runtime/background'import GameInfo from'./runtime/gameinfo'import Music from'./runtime/music'import DataBus from'./databus'const ctx = canvas.getContext('2d')const databus =newDataBus()/**
* 游戏主函数
*/exportdefaultclassMain{constructor(){// 维护当前requestAnimationFrame的idthis.aniId =0this.restart()}restart(){
databus.reset()
canvas.removeEventListener('touchstart',this.touchHandler
)this.bg =newBackGround(ctx)this.player =newPlayer(ctx)this.gameinfo =newGameInfo()this.music =newMusic()this.bindLoop =this.loop.bind(this)this.hasEventBind =false// 清除上一局的动画
window.cancelAnimationFrame(this.aniId)this.aniId = window.requestAnimationFrame(this.bindLoop,
canvas
)}/**
* 随着帧数变化的敌机生成逻辑
* 帧数取模定义成生成的频率
*/enemyGenerate(){if(databus.frame %30===0){const enemy = databus.pool.getItemByClass('enemy', Enemy)
enemy.init(6)
databus.enemys.push(enemy)}}// 全局碰撞检测collisionDetection(){const that =this
databus.bullets.forEach((bullet)=>{for(let i =0, il = databus.enemys.length; i < il; i++){const enemy = databus.enemys[i]if(!enemy.isPlaying && enemy.isCollideWith(bullet)){
enemy.playAnimation()
that.music.playExplosion()
bullet.visible =false
databus.score +=1break}}})for(let i =0, il = databus.enemys.length; i < il; i++){const enemy = databus.enemys[i]if(this.player.isCollideWith(enemy)){
databus.gameOver =truebreak}}}// 游戏结束后的触摸事件处理逻辑touchEventHandler(e){
e.preventDefault()const x = e.touches[0].clientX
const y = e.touches[0].clientY
const area =this.gameinfo.btnArea
if(x >= area.startX
&& x <= area.endX
&& y >= area.startY
&& y <= area.endY)this.restart()}/**
* canvas重绘函数
* 每一帧重新绘制所有的需要展示的元素
*/render(){
ctx.clearRect(0,0, canvas.width, canvas.height)this.bg.render(ctx)
databus.bullets
.concat(databus.enemys).forEach((item)=>{
item.drawToCanvas(ctx)})this.player.drawToCanvas(ctx)
databus.animations.forEach((ani)=>{if(ani.isPlaying){
ani.aniRender(ctx)}})this.gameinfo.renderGameScore(ctx, databus.score)// 游戏结束停止帧循环if(databus.gameOver){this.gameinfo.renderGameOver(ctx, databus.score)if(!this.hasEventBind){this.hasEventBind =truethis.touchHandler =this.touchEventHandler.bind(this)
canvas.addEventListener('touchstart',this.touchHandler)}}}// 游戏逻辑更新主函数update(){if(databus.gameOver)returnthis.bg.update()
databus.bullets
.concat(databus.enemys).forEach((item)=>{
item.update()})this.enemyGenerate()this.collisionDetection()if(databus.frame %20===0){this.player.shoot()this.music.playShoot()}}// 实现游戏帧循环loop(){
databus.frame++this.update()this.render()this.aniId = window.requestAnimationFrame(this.bindLoop,
canvas
)}}
3 结束语
上述主要介绍了小游戏关键的实现点,在该游戏的场景下,如果有兴趣的同学可以将粽子类在多添加几个,让粽子的种类多起来,这样可玩性很高!我主要是起一个抛砖引玉的作用,再次祝福大家端午节万事顺遂,多多吃粽子,吃好喝好!
下附小游戏源码下载地址:https://github.com/41809310102/mygames
版权归原作者 沉默着忍受 所有, 如有侵权,请联系我们删除。