使用canvas+ts实现坦克大战小游戏

lxf2023-02-16 15:49:59

前言

技术栈 canvas + ts + webpack

预览地址:1933669775.github.io/tanKeDaZhan…

源码:github.com/1933669775/…

欢迎批评指正

项目架构

webpack配置

简单配置一下

具体有:scss、ts、压缩js、压缩html、webpack-cli一些基础的东西

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const TerserPlugin = require("terser-webpack-plugin")

module.exports = {
  entry: './src/ts/index.ts',
  output: {
    filename: '[name].[contenthash].js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  module: {
    rules: [
      {
        test: /.s[ac]ss$/i,
        use: [
          'style-loader',
          'css-loader',
          'sass-loader',
        ],
      },
      {
        test: /.tsx?$/,
        use: 'ts-loader',
        exclude: /node_modules/,
      },
      { test: /.txt$/, use: 'raw-loader' }
    ]
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: 'index.html',
      minify:{
        collapseWhitespace: true,
      }
    }),
  ],
  optimization: {
    runtimeChunk: 'single',
    mangleWasmImports: true,
    minimize: true,
    minimizer: [new TerserPlugin()],
    splitChunks: {
      cacheGroups: {
        vendor: {
          test: /[\/]node_modules[\/]/,
          name: 'vendors',
          chunks: 'all',
        },
      },
    },
  },
  devServer: {
    static: './dist',
  },
}

代码结构

使用canvas+ts实现坦克大战小游戏

BattleCity -- 公共父类,存放多个类通用的属性或方法

Bullet -- 子弹类,存放子弹生成及移动,绘制子弹等逻辑

config -- 存放固定配置

CreateMap -- 地图类,存放绘制地图逻辑

Enemy --敌人类,存放创建敌人,敌人移动等逻辑

Tank --坦克类,存放坦克的移动逻辑,碰撞逻辑,绘制逻辑

父类

import Tank from "./Tank";
import config from './config'
// @ts-ignore
import Modal from "custom-dialog"

type hitObj = {
  x: number,
  y: number,
  w: number,
  h: number
}

// 坦克大战、类,所有类的父亲
const canvas = document.querySelector('canvas') as HTMLCanvasElement
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D

export default class BattleCity {
  // canvas 元素
  canvas: HTMLCanvasElement
  // canvas绘画的上下文对象
  ctx: CanvasRenderingContext2D
  // canvas的宽度
  cw: number
  // cnavas的高度
  ch: number
  // 配置信息
  config: any
  // 弹框
  dialog = Modal

  // 地图对象
  static barrierObj_: Array<{
    x: number,
    y: number,
    w: number,
    h: number,
    type: string
  }> = []
  // 敌人对象
  static enemyAll_: Array<{
    // 子弹的定时器ID
    bulletId?: NodeJS.Timer,
    // 转向的定时器ID
    turnToId?: NodeJS.Timer,
    tankObj: Tank
  }> = []
  // 子弹对象
  static bulletAll_: Array<{
    x: number,
    y: number,
    dir: string,
    seed: number,
    color: string
  }> = []
  // 关卡参数
  static levelParams_: {
    enemySeed: number,
    enemyAmount: number,
    enemyCeiling: number,
    enemyLife: number,
    myTankLife: number,
    enemyCreateSeed: number,
  }
  // 关卡关数
  static level_: number = 0
  // 消灭敌人
  static enemyVanishNum_: number = 0
  // 游戏是否结束
  static isFinish_: Boolean

  constructor() {
    this.canvas = canvas
    this.cw = this.canvas.width
    this.ch = this.canvas.height
    this.ctx = ctx
    this.config = config
    this.dialog = new Modal()
  }

  // 绘制边框矩形
  borderRect(x: number, y: number, w: number, h: number) {
    const { ctx } = this
    ctx.beginPath()
    ctx.fillRect(x, y, w, h)
    ctx.strokeRect(x, y, w, h)
    ctx.closePath()
  }

  // 根据图形中心旋转
  // x、y、宽、高、角度
  rotate(x: number, y: number, w: number, h: number, r: number) {
    let xx = x + (w / 2)
    let yy = y + (h / 2)

    const { ctx } = this
    ctx.translate(xx, yy);
    ctx.rotate((r * Math.PI) / 180);
    ctx.translate(- xx, - yy)
  }

  // 碰撞检测
  hitDetection(hitObj1: hitObj, hitObj2: hitObj) {
    return hitObj1.x + hitObj1.w >= hitObj2.x &&
        hitObj1.x <= hitObj2.x + hitObj2.w &&
        hitObj1.y + hitObj1.h >= hitObj2.y &&
        hitObj1.y <= hitObj2.y + hitObj2.h;
  }

  // 更新游戏状态的页面元素
  updateStatus() {
    (<Element>document.querySelector('#myLife')).innerHTML = String(this.myTanke.tankObj.lifeVal);
    (<Element>document.querySelector('#enemyNum')).innerHTML = String(this.levelParams.enemyAmount - this.enemyVanishNum)
  }

  // 地图对象
  get barrierObj() {
    return BattleCity.barrierObj_
  }
  set barrierObj(val) {
    BattleCity.barrierObj_ = val
  }
  // 敌人对象
  get enemyAll() {
    return BattleCity.enemyAll_
  }
  set enemyAll(val) {
    BattleCity.enemyAll_ = val
  }
  // 子弹对象
  get bulletAll() {
    return BattleCity.bulletAll_
  }
  set bulletAll(val) {
    BattleCity.bulletAll_ = val
  }
  // 关卡参数
  get levelParams() {
    return BattleCity.levelParams_
  }
  set levelParams(val) {
    BattleCity.levelParams_ = val
  }
  // 消灭敌人数量
  get enemyVanishNum() {
    return BattleCity.enemyVanishNum_
  }
  set enemyVanishNum(val) {
    BattleCity.enemyVanishNum_ = val
  }
  // 关卡关数
  get level(){
    return BattleCity.level_
  }
  set level(val) {
    BattleCity.level_ = val
  }
  // 游戏是否结束
  get isFinish(){
    return BattleCity.isFinish_
  }
  set isFinish(val) {
    BattleCity.isFinish_ = val
  }

  // 主角坦克
  get myTanke() {
    return BattleCity.enemyAll_.find(v => v.tankObj.color === this.config.myTankColor) as { tankObj: Tank, lifeVal: number }
  }
}

存放通用的属性或者方法

子弹类

子弹绘制

// 子弹对象类型
 bulletAll: Array<{
    x: number,
    y: number,
    dir: string,
    seed: number,
    color: string
  }>
// 绘制子弹
// 绘制一个简单的小圆球
redrawBullet() {
    const { ctx } = this
    this.bulletAll?.forEach(v => {
      ctx.beginPath()
      ctx.save()
      ctx.fillStyle = v.color
      ctx.arc(v.x, v.y, 3, 0, Math.PI * 2)
      ctx.fill()
      ctx.restore()
      ctx.closePath()
    })
}

这里bulletAll是一个对象,里面存放是所有子弹,创建子弹时候会往bulletAll里面添加对象,这里就会绘制。

子弹移动

// 子弹移动
// 这种写法是为了防止this问题,了解过react的小伙伴应该不陌生
  move = () => {
    const { canvas } = this
    // 子弹超出边界就会删除
    this.bulletAll = this.bulletAll?.filter(v => {
      return !(v.x < 0 || v.x > canvas.width || v.y < 0 || v.y > canvas.height)
    })

    // 根据方向改变子弹位置
    this.bulletAll = this.bulletAll?.map(v => {
      switch (v.dir) {
        case '上' : v.y -= v.seed; break;
        case '下' : v.y += v.seed; break;
        case '左' : v.x -= v.seed; break;
        case '右' : v.x += v.seed; break;
      }
      return v
    }) || []

    this.isBulletHit()
    this.redrawBullet()
  }

这里根据bulletAll里面的dir方向字段来判断x、y的加减操作

子弹碰撞检测

// 判断子弹碰撞
  isBulletHit() {
    const mc = this.config.myTankColor
    // 第一层遍历 遍历子弹
    this.bulletAll = this.bulletAll.filter((v1) => {
      const bulletHitObj = {
        x: v1.x,
        y: v1.y,
        w: 5,
        h: 5,
      }
      // 是否删除子弹
      let isRemoveBullet = false
      // 是否删除敌人
      let isRemoveEnemy = false
      // 是否删除我的坦克
      let isRemoveMyTanke = false

      // 遍历墙
      this.barrierObj = this.barrierObj.filter(v2 => {
        // 子弹是否撞到墙
        let isHit = this.hitDetection(bulletHitObj, v2)
        // 撞上了就会删除这个子弹
        if (isHit) isRemoveBullet = true

        // 如果撞上了会返回一个false,本次循环会被过滤掉
        // 如果是障碍物不会删除
        return v2.type === 'z' ? true : !isHit
      })

      // 家没了
      if (this.barrierObj.filter(v2 => v2.type === 'j').length <= 0 && !this.isFinish) {
        this.isFinish = true
        // 删除主角坦克
        this.enemyAll = this.enemyAll.filter(v => v.tankObj.color !== mc)
        this.dialog.alert({
          content: '家没了,大侠请重新来过',
          buttons: {
            ok(){
              return true;
            },
          }
        })
        return
      }

      // 主角子弹判断
      if (v1.color === mc) {
        // 遍历敌人
        this.enemyAll = this.enemyAll.filter((v2, i2) => {
          if (v2.tankObj.color === mc) return true
          // 子弹对敌人的碰撞检测
          let isHit = this.hitDetection(bulletHitObj, {
            x: v2.tankObj.tankX,
            y: v2.tankObj.tankY,
            w: v2.tankObj.tankW,
            h: v2.tankObj.tankH,
          })
          // 撞上了
          if (isHit) {
            isRemoveBullet = true
            // 坦克不在无敌状态才可以扣除生命值
            if (!v2.tankObj.isInvincible) {
              this.enemyAll[i2].tankObj.lifeVal -= 1
            }
            // 击中扣除生命值
            // 判断生命值是否小于0
            if (this.enemyAll[i2].tankObj.lifeVal <= 0) {
              // 如果小于0删除敌人 将敌人的计时器清除
              isRemoveEnemy = true
              clearTimeout(v2.bulletId)
              clearTimeout(v2.turnToId)
              // 消灭敌人数 +1
              this.enemyVanishNum ++
              if (this.enemyVanishNum >= this.levelParams.enemyAmount) {
                this.dialog.alert( {
                  content:
                    this.level === 0 ? '胜利了,你可以开始下一关' :
                    this.level === 1 ? '你居然过了第二关,有点实力' :
                    this.level === 2 ? '牛啊,给你个大拇指' : '',
                  buttons: {
                    ok(){
                      return true;
                    },
                  }
                })
              }
              this.updateStatus()
            }
          } else {
            // 没撞上
            isRemoveEnemy = false
          }
          return !isRemoveEnemy
        })
      }
      else
      // 敌人子弹判断
      if (v1.color !== mc) {
        this.enemyAll = this.enemyAll.filter((v2, i2) => {
          // 不是主角坦克的会被过滤
          if (v2.tankObj.color !== mc) return v2
          // 敌人子弹对主角的碰撞检测
          let isHit = this.hitDetection(bulletHitObj, {
            x: v2.tankObj.tankX,
            y: v2.tankObj.tankY,
            w: v2.tankObj.tankW,
            h: v2.tankObj.tankH,
          })
          // 撞到了
          if (isHit) {
            // 坦克不在无敌状态才可以扣除生命值
            if (!v2.tankObj.isInvincible) {
              // 击中扣除生命值
              this.enemyAll[i2].tankObj.lifeVal -= 1
              // 主角扣除生命值会有1秒的无敌时间
              v2.tankObj.invincible(500)
            }
            isRemoveBullet = true
            this.updateStatus()
            // 游戏失败
            if (this.enemyAll[i2].tankObj.lifeVal <= 0) {
              this.isFinish = true
              isRemoveMyTanke = true
              this.dialog.alert( {
                content: '失败,坦克没了,大侠请重新来过',
                buttons: {
                  ok(){
                    return true;
                  },
                }
              })
            }
          }
          return !isRemoveMyTanke
        })
      }

      // 如果要删除子弹就在这个位置加上一个子弹碰撞特效
      if (isRemoveBullet) this.effectsAll.push({
        x: v1.x,
        y: v1.y,
        radius: 0,
        color: v1.color
      })
      // 将这个子弹过滤
      return !isRemoveBullet
    })
  }

子弹共同点

判断地形,如果撞到非障碍物地形该地形都会消失,撞到障碍物子弹会消失。

敌人子弹或者主角子弹撞到对方判断是否是无敌状态,如果是不会扣血

并且子弹消失之前会出现子弹爆炸特效

敌人子弹:

判断是否碰撞到主角,如果碰撞会扣除血量,血量为零时游戏失败

敌人子弹撞到主角会触发一个短暂的无敌时间

主角子弹:

判断是否碰撞到敌人,如果碰撞会扣除血量,血量为零时该敌人销毁

子弹爆炸特效

// 子弹碰撞效果绘制
  drawHitEffects() {
    // 半径递增
    this.effectsAll = this.effectsAll.map(v => {
      v.radius ++
      this.drawFires(v.x, v.y, 12, v.radius, v.color)
      return v
    })

    // 过滤半径超过某个值的
    this.effectsAll = this.effectsAll.filter(v => v.radius <= 13)
  }

  // 绘制烟花效果
  drawFires(x: number, y: number, count: number, radius: number, color: string) {
    const { ctx } = this

    for (let i1 = 0; i1 <= 2; i1 ++) {
      for (let i2 = 0; i2 < count; i2++) {
        // 渲染出当前数据
        let angle = 360 / (count / i1) * i2;
        let radians = angle * Math.PI / 180;
        let moveX = x + Math.cos(radians) * radius / i1
        let moveY = y + Math.sin(radians) * radius / i1
        // 开始路径
        ctx.beginPath();
        ctx.arc(moveX, moveY, 1.3, Math.PI * 2, 0, false);
        // 结束
        ctx.closePath();
        ctx.fillStyle = color
        ctx.fill();
      }
    }
  }

一个简单的烟花效果,当半径递增到一定程度会消失

地图类

地图结构

const mapObj = [
// @enemySeed 敌人速度
// @enemyCeiling 敌人上限(地图最多可以出现多少敌人)
// @enemyAmount 敌人数量
// @enemyLife 敌人生命
// @myTankLife 主角生命
// @enemyCreateSeed 敌人创建速度(毫秒)
    // 第一关
    {
        enemySeed: 2,
        enemyAmount: 10,
        enemyCeiling: 5,
        enemyLife: 2,
        myTankLife: 4,
        enemyCreateSeed: 1500,
        map: [
          // q = 墙 j = 家 z = 障碍
          '                                ',
          '                                ',
          '                                ',
          '                                ',
          '                                ',
          '                                ',
          '          q         q           ',
          '         q q       q q          ',
          '        q   q     q   q         ',
          '       q     q   q     q        ',
          'q     q       q q       q      q',
          'qq     q       q       q      qq',
          'qqq     q             q      qqq',
          'qqqq     q           q      qqqq',
          'qqqqq     q         q      qqqqq',
          '           q       q            ',
          '            q     q             ',
          '             q   q              ',
          '              q q               ',
          '               q                ',
          '    zzzzz              zzzzz    ',
          '    zzzzz              zzzzz    ',
          '                                ',
          '            qqqqqqq             ',
          '            qqjjjqq             ',
          '            qqjjjqq             ',
        ]
    }
]

这是一个关卡结构,每一关都有不用的地图,和敌人强度,主角血量的不同

切分地图

// 创建地图
create() {
    // 需要减去像素比
    this.barrierObj = []
    this.currentLevel = this.level
    this.levelParams = mapObj[this.level]
    // 当前地图
    const cm = mapObj[this.level].map
    // 绘画格子宽度
    const dw = this.cw / cm[0].length
    // 绘画格子高度
    const dh = this.ch / cm.length
    // 循环当前地图
    cm.forEach((v1, i1) => {
      // 遍历字符串
      for (let i2 = 0; i2 < v1.length; i2 ++) {
        const x = (dw * i2) / devicePixelRatio, y = (dh * i1) / devicePixelRatio
        if (v1[i2] !== ' ') {
          this.barrierObj.push({
            x,
            y,
            w: (dw / devicePixelRatio),
            h: (dh / devicePixelRatio),
            type: v1[i2]
          })
        }
      }
    })
    this.drawMap()
  }

根据关卡来选择是那个数组,取里面的地图

再根据canvas的宽度和高度来切分

然后遍历这个二维数组,根据里面的值来判断地形,存到一个对象里面。这个对象存放绘制地图的信息。

绘制地图

// 绘制地图
// 这个方法会被一直调用
  drawMap = () => {
    this.barrierObj.forEach(v => {
      v.type === 'q' ? this.drawWall(v.x, v.y, v.w, v.h, this.config.wallColor) : false
      v.type === 'j' ? this.drawFamily(v.x, v.y, v.w, v.h) : false
      v.type === 'z' ? this.drawBarrier(v.x, v.y, v.w, v.h) : false
    })
  }

  // 绘制墙壁
  // 该方法会根据canvas的宽高进行计算、并且平铺
  drawWall(x: number, y: number, w: number, h: number, color: string) {
    const { ctx } = this

    ctx.fillStyle = color
    // 墙的主体绘制
    ctx.beginPath()
    ctx.fillRect(x, y, w, h)
    ctx.closePath()

    // 墙里面的线绘制
    const num = h / 2
    ctx.strokeStyle = this.config.wallLineColor
    ctx.lineWidth = 2
    for (let i = 1; i <= 2; i ++) {
      if (i % 2 === 1) {
        // 这里 加1、减1是为了让线贴合到墙里面,不让它超出
        ctx.beginPath()
        ctx.strokeRect(x, y + (num * i) - num, (w - w / 2), num)
        ctx.moveTo((x) + (w - w / 2), y + num * i)
        ctx.lineTo(x + w, y + num * i)
        ctx.moveTo((x) + (w - w / 2), y + (num * i) - num)
        ctx.lineTo(x + w, y + (num * i) - num)
        ctx.stroke()
        ctx.closePath()
      } else {
        ctx.beginPath()
        ctx.moveTo(x + w / 4, y + (num * i))
        ctx.lineTo(x + w / 4, y + (num * i) - num)
        ctx.moveTo(x + (w / 2) + (w / 4), y + (num * i))
        ctx.lineTo(x + (w / 2) + (w / 4), y + (num * i) - num)
        ctx.stroke()
        ctx.closePath()
      }
    }
  }

  // 绘制家
  drawFamily(x: number, y: number, w: number, h: number) {
    const { ctx } = this
    ctx.beginPath()
    ctx.strokeStyle = 'red'
    ctx.font=`${w / 1.5}px Arial`;
    ctx.fillText('家',x,y + h)
    ctx.closePath()
  }

  // 绘制 障碍
  drawBarrier(x: number, y: number, w: number, h: number) {
    const { ctx } = this
    ctx.beginPath()
    ctx.save()
    ctx.fillStyle = '#fff'
    ctx.fillRect(x, y, w, h)
    ctx.restore()
    ctx.closePath()
  }

根据不同的地形来绘制

坦克类

坦克绘制

// 绘制坦克
  drawTank() {
    const x = this.tankX, y = this.tankY
    const { ctx, tankW, tankH } = this

    // 绘制左右坦轮
    ctx.beginPath()
    ctx.save()

    ctx.fillStyle = this.color
    // 根据方向旋转角度
    this.rotate(x, y, tankW, tankH, this.dir === '上' ? 0 : this.dir === '下' ? 180 : this.dir === '左' ? 270 : 90)
    ctx.fillRect(x, y, tankW / 4, tankH)
    ctx.fillRect(x + (tankW - tankW / 4), y, tankW / 4, tankH)

    ctx.strokeStyle = 'rgba(153,153,153,0.6)'

    // 一层遍历,将左右两边分开
    for (let i = 1; i <= 2; i ++) {
      ctx.lineWidth = 1
      // 绘制坦轮里面的横线
      for (let k = 1; k <= 5; k ++) {
        const currentY = y + (tankH / 5) * k
        switch (i) {
          // 左
          case 1: {
            ctx.moveTo(x, currentY)
            ctx.lineTo(x + tankW / 4, currentY)
          }
            break;
          default: {
            // 右
            ctx.moveTo(x + tankW - tankW / 4, currentY)
            ctx.lineTo(x + (tankW - tankW / 4) + tankW / 4, currentY)
          }
            break;
        }
      }
      ctx.stroke()
    }

    // 绘制坦身
    this.borderRect(x + (tankW / 2) - ((tankW / 2.6) / 2), y + ((tankH - (tankH / 1.4)) / 2), tankW / 2.6, tankH / 1.4)
    ctx.lineWidth = 1
    // 绘制炮管
    this.borderRect(x + ((tankW / 2) - ((tankW / 6) / 2)), y - 5, tankW / 6, tankH / 1.3)

    // 绘制无敌样式
    if (this.isInvincible) {
      ctx.beginPath()
      ctx.strokeStyle = 'rgba(255,130,0)'
      ctx.arc(x + (tankW / 2), y + (tankH / 2), tankW - 2, Math.PI * 2, 0)
      ctx.stroke()
      ctx.closePath()
    }
    ctx.restore()
    ctx.closePath()

    // 绘制裂痕
    if (
        // 低过半血就会出现裂痕
        // 判断是主角坦克还是敌人坦克,两种坦克血量不同
        this.lifeVal <= (this.color === this.config.myTankColor ?  this.levelParams.myTankLife / 2 : this.levelParams.enemyLife / 2)
    ) {
      ctx.beginPath()
      ctx.save()
      ctx.strokeStyle = '#000'
      ctx.lineWidth = 3
      ctx.moveTo(x + tankH / 4, y)
      ctx.lineTo(x + 5, y + tankH / 2)
      ctx.lineTo(x + tankH / 3, y + tankH / 2)
      ctx.lineTo(x + tankH / 4, y + tankH)
      ctx.moveTo(x + tankH - 5, y)
      ctx.lineTo(x + tankH - 5, y + tankH / 2)
      ctx.lineTo(x + tankH, y + tankH / 2)
      ctx.lineTo(x + tankH - 10, y + tankH)
      ctx.stroke()
      ctx.restore()
      ctx.closePath()
    }
  }

自己瞎画的,考虑到无敌状态的绘制、还有坦克血量过半的效果绘制

坦克移动

// 坦克移动
// 返回promise  reslove = 碰撞
  move = () => {
    return new Promise((reslove) => {
      let { canvas, tankW, tankH, hitDetection, tankX, tankY } = this
      const cw = canvas.width
      const ch = canvas.height
      const mapBottom = (ch - (tankH * devicePixelRatio)) / devicePixelRatio

      // 移动
      if (tankX > 0) this.dir === '左' ? tankX -= this.seed : false
      if (tankX + tankW < cw) this.dir === '右' ? tankX += this.seed : false
      if (tankY > 0) this.dir === '上' ? tankY -= this.seed : false
      if (tankY < mapBottom) this.dir === '下' ? tankY += this.seed : false

      // 遍历所以墙的位置 然后于坦克的位置进行碰撞检测
      const moveResult1 = this.barrierObj.find(v => {
        return hitDetection({
          x: tankX,
          y: tankY,
          w: tankW,
          h: tankH
        }, v)
      })

      // 撞到边界
      if ((tankX <= 0 || tankX + tankW >= cw || tankY <= 0 || tankY >= mapBottom) && (this.color !== this.config.myTankColor)) reslove(null)
      // 撞到墙了
      if (moveResult1) return reslove(null)

      // 没有障碍物
      this.tankX = tankX
      this.tankY = tankY
    })
  }

碰撞检测这里需要注意的是,一定要先减x、y的值,再检测,没有碰撞再赋值,不然会进入一个逻辑闭环

值得一起的是一个撞到边界,或者碰到障碍物,会返回一个回调的逻辑。这个是为了防止敌人坦克一直碰到边界不走的问题

无敌效果

// 开启无敌
// @time 无敌的时间(毫秒)
invincible(time: number) {
this.isInvincible = true
setTimeout(() => {
  this.isInvincible = false
}, time)
}

很简单的一个定时改状态逻辑

敌人类

创建敌人

 // 创建敌人
  create() {
    // 防止多次开计时器
    if (this.createEnemyId !== null) clearInterval(this.createEnemyId)
    // 上来就创建一个敌人
    this.createHandle()
    // 创建
    this.createEnemyId = setInterval(() => {
      // 限制地图上最大显示的敌人坦克数量
      // 并且限制 关卡敌人数量 - 消灭敌人数量
      if (
        this.enemyAll.length <= this.levelParams.enemyCeiling &&
        (this.levelParams.enemyAmount - this.enemyVanishNum) > this.enemyAll.length - 1
      ) this.createHandle()
    }, this.levelParams.enemyCreateSeed)
    this.move()
  }
  1. 开一个定时器,来批量创建敌人
  2. 根据关卡的参数限制敌人数量

敌人行为

敌人转向和创建敌人的具体操作

// 转向
  // @tankObj 要转向的坦克对象
  // @not 随机值不会随机到这个位置
  turnTo(tankObj: Tank, not?: string) {
    const arr = ['下', '上', '左', '右'].filter(v => not !== v)
    this.enemyAll = this.enemyAll.map((v) => {
      // 判断哪一个坦克需要转向
      if (tankObj === v.tankObj) {
        // 取随机值
        v.tankObj.dir = arr[Math.floor(Math.random() * arr.length)] as '上' | '下' | '左' | '右'
        return v
      }
      return  v
    })
  }

  // 创造敌人的操作
  createHandle() {
    // 时间间隔
    const arrLaunch = [1.2, 1.5, 1.8, 2, 2.2, 2.5]
    // 随机获取发射间隔的值
    const launchVal = arrLaunch[Math.floor(Math.random() * arrLaunch.length)] as 2 | 2.5 | 3 | 3.5 | 4
    // new 坦克对象
    const tankObj = new Tank(this.levelParams.enemySeed, 'e80000',this.levelParams.enemyLife, 100, 0, )
    // 发射子弹的定时器
    const bulletId = setInterval(() => {
      this.enemyBullet(tankObj)
    }, (launchVal * 1000) / 1.5)
    // 转向的定时器
    const turnToId = setInterval(() => {
      this.turnTo(tankObj)
    }, launchVal * 1000)
    // 创造敌人
    this.enemyAll.push({
      bulletId,
      turnToId,
      tankObj
    })
    this.draw()
  }

  // 绘制敌人
  draw = () => {
    this.enemyAll.forEach(v => {
      v.tankObj.drawTank()
    })
  }

绘制敌人直接调用Tank类里面的绘制方法

这里敌人行为是创建的时候开了两个计时器,一个控制发射子弹,一个控制转向。

转向时间是几个固定的时间间隔里面取随机值,发射子弹时间取的是转向时间的一半

这样的效果就是每个坦克行为都是独立的

敌人移动

// 敌人移动
  move = () => {
    this.enemyAll = this.enemyAll.map((v) => {
      // 这个判断成立代表这次遍历的坦克是主角,不需要移动
      if (v.tankObj.color === 'yellow') return v
      // 如果非主角需要移动
      v.tankObj.move().then(() => {
        this.turnTo(v.tankObj, v.tankObj.dir)
      })
      return v
    })
  }

这里直接调用Tank类里面的移动方法,参数是方向

碰撞到障碍物会返回一个promise,操作是直接转向,这样的效果就是坦克一直在移动,不会停止

敌人发射子弹

// 敌人发射子弹
  enemyBullet = (tankObj: Tank) => {
    const { tankW, tankH, tankX, tankY, dir } = tankObj
    this.bulletAll.push({
      dir,
      x: tankX + tankW / 2,
      y: tankY + tankH / 2,
      seed: 4,
      color: 'red'
    })
  }

很简单的添加逻辑

控制器

控制器是将多个类链接起的桥梁

还存放主角坦克和移动操作

主角坦克移动

// 键盘控制操作
  controllerHandle() {
    window.onkeydown = (e) => {
      switch (e.key) {
        case 'ArrowUp': {
          this.dir = '上'
          this.move()
        }
          break;
        case 'ArrowDown': {
          this.dir = '下'
          this.move()
        }
          break;
        case 'ArrowLeft': {
          this.dir = '左'
          this.move()
        }
          break;
        case 'ArrowRight': {
          this.dir = '右'
          this.move()
        }
          break;
        case ' ': {
          this.launchBullet()
        }
      }
    }

    // 键盘抬起
    window.onkeyup = (e) => {
      // 只有在移动状态下、并且抬起的上下左右四个按钮,才会执行该方法
      if (this.isMove && (e.key === 'ArrowDown' || e.key === 'ArrowRight' || e.key === 'ArrowLeft' || e.key === 'ArrowUp')) {
        this.isMove = false
      }
    }
  }

  // 控制移动
  move = () => {
    // 防止多次执行
    if (!this.isMove) {
      this.isMove = true
      this.moveHandle()
    }
  }
  
  // 移动操作
  moveHandle = () => {
    // 只有当键盘为按下状态的时候才会执行该方法
    if (this.isMove) {
      // 更改主角坦克的方向状态
      this.Tank.enemyAll = this.Tank.enemyAll.map(v => {
        // 判断主角坦克
        if (v.tankObj === this.Tank) {
          v.tankObj.dir = this.dir
          return v
        }
        return v
      })
      this.Tank.move()
      window.requestAnimationFrame(this.moveHandle)
    }
  }

先绑定键盘按下事件,判断上下左右

键盘按下会改变一个状态,递归调用一个方法,来进行移动操作

如果抬起会被监听到,改变这个状态,不会再进行移动

这里我是把主角坦克放到敌人坦克的数组里面去了,这样可以集体减。这么整叫enemyAll其实有不太合适了,但是用的地方有点多,懒得改了。

主角子弹发射

//  发射子弹
  launchBullet() {
    // 如果游戏状态结束不郧西发射子弹
    if (this.Tank.isFinish) return
    // 防抖
    if (this.bulletTimeID !== null) clearTimeout(this.bulletTimeID)
    this.bulletTimeID = setTimeout(() => {
      const { tankW, tankH, tankX, tankY, config } = this.Tank
      this.Bullet.bulletAll.push({
        dir: this.dir,
        x: tankX + tankW / 2,
        y: tankY + tankH / 2,
        seed: config.myBulletSeed,
        color: config.myTankColor
      })
    }, 100)
  }

还是简单的添加数据

这里做了一个防抖操作,是为了防止长按键盘执行,这样就不是子弹,是激光了

 // 重绘
  redraw = () => {
    const { ctx, canvas  } = this.Tank
    // 清除
    ctx.clearRect(0, 0, canvas.width, canvas.height)

    // 调用绘制方法
    this.CreateMap.drawMap()
    this.Enemy.move()
    this.Bullet.move()
    this.Enemy.draw()
    this.Bullet.drawHitEffects()

    window.requestAnimationFrame(this.redraw)
  }

很关键的步骤,这个每一刻都在调用,来一帧一帧绘制图像

canvas模糊问题解决

之前一直用台式机做的,分辨率很高,两千多大概吧。后来放到我笔记本上做我发现变的很模糊。

具体原因可以看这篇文章:Admin.net/post/706741…

就说一下我的解决方案

使用canvas+ts实现坦克大战小游戏

安装了一个包

但是还不够,绘制的时候还需要计算屏幕的devicePixelRatio

create() {
    this.barrierObj = []
    this.currentLevel = this.level
    this.levelParams = mapObj[this.level]
    // 当前地图
    const cm = mapObj[this.level].map
    // 绘画格子宽度
    const dw = this.cw / cm[0].length
    // 绘画格子高度
    const dh = this.ch / cm.length
    // 循环当前地图
    cm.forEach((v1, i1) => {
      // 遍历字符串
      for (let i2 = 0; i2 < v1.length; i2 ++) {
        // 计算像素比---------------
        const x = (dw * i2) / devicePixelRatio, y = (dh * i1) / devicePixelRatio
        if (v1[i2] !== ' ') {
          this.barrierObj.push({
            x,
            y,
            // 计算像素比------------
            w: (dw / devicePixelRatio),
            h: (dh / devicePixelRatio),
            type: v1[i2]
          })
        }
      }
    })
    this.drawMap()
  }

大致就这样,有兴趣的兄弟可以可以去看源码深入研究