使用 Vue3 + Vite + Canvas + Worker 实现简单版的 "雷霆战机"

今日份分享:飞机小游戏

使用 Vue3 + Vite + Canvas + Worker 实现简单版的 "雷霆战机"

先看看效果

在敲代码时遇到一些问题:

  1. 在vue3+vite的项目中直接用 const worker = new Worker('./worker.js');的写法会报错,也不用去配置什么,vite已经给我们配置好了

    正确用法:

    @/workers/worker.js

    js 复制代码
    addEventListener('message', e=>{
        const {data} = e;
        console.log(data);
        setTimeout(()=>{
            return postMessage('线程完成')
        })
    })
    export default {}

    在 .vue文件里使用

    js 复制代码
    import Worker from "@/workers/worker.js?worker";
    let worker = new Worker();
    worker.postMessage({msg: 'start'});
    worker.onmessage = (e)=>{
        console.log(e, 'onmessage--------');
    }
  1. 在切换浏览器窗口后,setInterval 定时器会发生异常

    解决方案:

    A.使用window.onblur和window.onfocus来解决

    代码如下:

    js 复制代码
    window.addEventListener('blur', ()=>{
      ...
    })
    window.addEventListener('focus', ()=>{
      ...
    })

    B.使用浏览器页面可见性 API visibilitychange事件解决 (推荐)

    当浏览器最小化窗口或切换到另一个选项卡时就会触发visibilitychange事件,我们可以在事件中用Document.hidden或者Document.visibilityState判断当前窗口的状态,来决定除定时器后者重新开始定时器

    代码如下

    js 复制代码
      document.addEventListener('visibilitychange', () => {
        if (document.hidden === true) {
          ...
        } else {
          ...
        }
      })

代码:

bullet.js

js 复制代码
​
// 子弹
function Bullet({ canvas, image, width=20, height= 20 }) {
    canvas.style.cssText = `
      background-color: none;
    `
    this.ctx = canvas.getContext('2d');
    // 子弹的大小
    this.width = width;
    this.height = height;
    // 画布的大小
    this.CanvasWidth = canvas.width;
    this.CanvasHeight = canvas.height;
    // 子弹运行速度
    this.speed = 5;
    this.move = 0;
    // 是否存在
    this.show = true;
    this.image = image;
    // 子弹坐标
    this.x = 0;
    this.y = 0;
    this.stop = false;
    // 坐标记录 用于暂停游戏
    this.points = null;
}
Bullet.prototype = {
    draw(startPoint) {
        this.points = startPoint;
        if(this.stop){
            return
        }
        let { ctx, width, height, image } = this;
        // 清除子弹上一个位置
        ctx.clearRect(
            startPoint.x - this.width/2, 
            startPoint.y - this.height/2 - this.move, 
            width, 
            height
        );
        if(!this.show){
            return
        }
        if (startPoint.y - 16 - this.move > -this.height) {
            this.move += this.speed;
            this.x = startPoint.x - this.width/2;
            this.y = startPoint.y - this.height/2 - this.move;
        } else {
            this.show = false;
            return
        }
        ctx.drawImage(image, this.x, this.y, width, height);
​
        requestAnimationFrame(() => {
            setTimeout(()=>{
                this.isHit();
                this.draw(startPoint);
            },10)
        })
    },
    isHit(){
        if(!this.show){
            return
        }
        if(!this.game || !this.game.airships){
            console.log(this);
            return
        }
        
        this.game.airships.forEach((item,index)=>{
            if(!item.show){
                return
            }
            let minX = this.x - item.width - 5;
            let maxX = this.x + 5;
            if(
                item.x <= maxX && 
                item.x >= minX && 
                item.y >= this.y - item.height /2
            ){
                this.game.airships[index].show = false;
                this.show = false;
                this.game.airships.splice[index, 1];
                this.game.score += item.scoreValue;
            }
        })
        
    },
    // 销毁
    destroy(){
        this.show = false;
    },
    getScore(){
        return this.game.score;
    }
​
}
export default Bullet;

plane.js

js 复制代码
let Bullet = null; 
/**
 * @canvas  目标canvas画布 
 * @images  飞机和子弹的图片资源
 * @width   飞机的宽
 * @height  飞机的高
 * */ 
// 飞机元素
function Plane({ canvas, images, width=50, height= 30 }) {
    this.width = width;
    this.height = height;
    this.ctx = canvas.getContext('2d');
    this.CanvasWidth = canvas.width;
    this.CanvasHeight = canvas.height;
    this.images = images;
    this.planeImage = images.plane;
    this.bulletImage = images.bullet;
    // this.planeImage = images.plane;
    this.bullets = [];
    this.points = [];
    this.bulletCanvas = null;
    // 飞机坐标
    this.x = 0;
    this.y = 0;
    this.shootSpeed = 5
    this.init();
}
​
Plane.prototype = {
    async init() {
        if(!Bullet){
            Bullet = (await import(/* webpackChunkName: 'Bullet' */'./bullet.js')).default;
        }
        for (let i = 0; i < 10; i++) {
            try {
                this.createBullets();
            } catch (error) {
                console.log(error);
            }
        }
    },
    createBullets() {
        if (this.bullets.length < 10) {
            let bullet = new Bullet({ canvas: this.bulletCanvas, image: this.bulletImage, width:15, height:15 });
            bullet.speed = this.shootSpeed;
            this.bullets.push(bullet);
        }
    },
    draw(points) {
        let { CanvasWidth, CanvasHeight, ctx, planeImage } = this;
        this.x = points.x - this.width/2;
        this.y = points.y - this.height/2;
        ctx.clearRect(0, 0, CanvasWidth, CanvasHeight);
        ctx.drawImage(planeImage, this.x, this.y);
        this.points = points;
    },
    shoot() {
        if (this.bullets.length == 0) {
            this.init();
            return
        }
        let bullet = this.bullets.shift();
        requestAnimationFrame(() => {
            bullet.draw(this.points);
        })
        return bullet;   
​
    },
}
​
export default Plane;

airship.js

js 复制代码
import createRandom from '@/ulits/random.js';
// 飞船
function Airship({ canvas, images, width=50, height=50 }) {
    this.ctx = canvas.getContext('2d');
    this.width = width;
    this.height = height;
    this.CanvasWidth = canvas.width;
    this.CanvasHeight = canvas.height;
    let scoreValue = createRandom(3);
    this.image = images[ scoreValue ];
    this.scoreValue = (scoreValue + 1) * 3;
    this.move = 0;
    // 飞船运动速度
    this.speed = 2;
    // 是否存在
    this.show = true;
    this.x = 0;
    this.y = 0;
    this.stop = false;
    this.points = null;
}
Airship.prototype = {
    draw(points){
        this.points = points;
        if(this.stop){
            return
        }
        let {ctx, CanvasWidth, CanvasHeight, width, height} = this;
​
        ctx.clearRect(
            points.x - this.width/2, 
            points.y + this.move - this.height/2, 
            width, 
            height
        );
        
        if(!this.show){
            return
        }
        if(points.y + this.move < CanvasHeight){
            this.move += this.speed;
            // this.x = points.x;
            // this.y = points.y + this.move;
            this.x = points.x - this.width/2;
            this.y = points.y + this.move - this.height/2;
        }else{
            this.show = false;
            return
        }
        ctx.drawImage(this.image, this.x, this.y, width, height );
        requestAnimationFrame(()=>{
            this.draw(points);
        })
    },
    // 销毁
    destroy(){
        this.show = false;
    },
}
export default Airship;

createAirship.js

js 复制代码
let Airship = null;
// 创建飞船
function CreateAirship({ canvas, total, images }) {
    this.total = total;
    this.airships = [];
    this.num = 5;
    this.canvas = canvas;
    this.images = images;
}
CreateAirship.prototype = {
    async create() {
        if(!Airship){
            Airship = (await import(/* webpackChunkName: 'Airship' */'./airship.js')).default
        }
        // 没有飞船了
        if(this.total<=0){
            return
        }
        // 每次生产的飞船数量
        let cnt = this.num >= this.total ? this.total : this.num;
        for (let i = 0; i < cnt; i++) {
            let airship = new Airship({
                canvas: this.canvas,
                images: this.images
            });
            this.airships.push(airship)
        }
        this.total -= this.num;
    },
    // 出击
    hitOut() {
        let airship = this.airships.shift();
        if (this.airships.length == 0) {
            this.create();
        }
        return airship;
    },
}
export default CreateAirship;

createGame.js

js 复制代码
import Plane from '@/game/plane.js';
import CreateAirship from '@/game/createAirship.js';
// 随机数
import createRandom from '@/ulits/random.js';
class CreateGame {
    constructor(){
        
    }
    myPlane = null;// 飞机对象
    gameImages = {};// 游戏图片
    airshipImages = [];// 飞船图片集合
    // 敌军舰队定时器
    airshipInit = null;
    airFleet = null; //敌军飞船集合
    // 子弹自动攻击定时器
    autoShootInit = null;
    // 飞机的坐标
    planePoints = {
        // x: canvasWidth / 2, y: canvasHeight - 20
    }
    // 暂停时 保存飞机坐标
    lastPlanePoint = null
    canvas = null
    bulletCanvas = null
    game = null
    over = false
    shootSpeed = 5
    airshipTotal = 30
    // 监听游戏结束
    onGameOver() {
​
    }
    // 暂停游戏
    stop() {
        this.stopShoot();
        this.airshipStopMove();
    }
    // 开始游戏
    begin() {
        this.autoShootInit = true;
        this.airshipInit = true;
        this.autoShoot();
        this.airshipAutoMove();
    }
    getGameStatus(){
        return this.game
    }
    init() {
        this.createPlane();
        this.drawPlane();
        this.createAirFleet();
    }
    // 创建飞机
    createPlane() {
        this.myPlane = new Plane({
            canvas: this.canvas,
            images: this.gameImages,
        })
        console.log(this.bulletCanvas, 'this.bulletCanvas------');
        // 子弹的画布
        this.myPlane.bulletCanvas = this.bulletCanvas;
    }
​
    // 画飞机
    drawPlane() {
        this.myPlane.draw(this.planePoints);
    }
    // 停止射击 清除定时器
    stopShoot() {
        this.autoShootInit = false;
    }
    // 自动射击
    autoShoot() {
        this.autoShootInterval();
    }
    // 
    autoShootInterval(delay=400) {
        if(!this.autoShootInit){
            return
        }
        // console.log(this.canvas, 'this.canvas----');
        let { game } = this;
        const bullet = this.myPlane.shoot();
        
        if (bullet) {
            game.bullets.push(bullet);
            game.airships = game.airships.filter(item => item.show);
            bullet.game = game;
        }
        setTimeout( this.autoShootInterval.bind(this),delay)
    }
​
​
    // 创建舰队
    createAirFleet() {
        this.airFleet = new CreateAirship({
            canvas: this.airshipCanvas,
            total: this.airshipTotal,// 舰队总数
            images: this.airshipImages, // 飞船图片集合
        });
    }
    // 飞船停止移动 清除定时器
    airshipStopMove() {
        this.airshipInit = false;
    }
    // 飞船自动移动
    airshipAutoMove() {        
        this.airshipAutoMoveInterval();
    }
​
    airshipAutoMoveInterval(delay=1500) {
        if(this.over){
            return
        }
        if(!this.airshipInit){
            return
        }
        let { airFleet, game } = this
        let airship = airFleet.hitOut();
        if (airFleet.total <= 0 && airFleet.airships.length == 0) {
            let existAirship = game.airships.filter(item => item.show);
            // 最后一架飞船消失后 游戏结束
            if (!existAirship.length) {
                this.airshipInit = null
                // 游戏结束
                // gameOver.value = true;
                // gameStop.value = true;
                // maskLayerShow.value = true;
                this.stop();
                console.log('onGameOver', this.over);
                this.onGameOver();
                return
            }
        }
        if (airship) {
            game.airships.push(airship);
            airship.draw({
                x: createRandom(450, 50),
                y: 0
            })
        }
        setTimeout( this.airshipAutoMoveInterval.bind(this),delay)
    }
​
}
​
export default CreateGame;

game.vue

js 复制代码
​
<template>
  <div class="container-box">
    <div class="operation-box">
      <div class="score-displayer">{{ game.score }}</div>
      <div class="begin-btn" v-if="!gameOver" @click="handleStopGame">{{ gameStop ? "开始" : "暂停" }}</div>
      <div class="begin-btn" v-if="gameOver" @click="handleNextGame">冲击下一关</div>
    </div>
​
    <div class="canvas-box">
      <div class="mask-layer" v-show="maskLayerShow">
        {{ gameOver ? "游戏结束" : "暂停" }}
      </div>
      <canvas id="game-canvas"></canvas>
      <canvas id="bullet-canvas"></canvas>
      <canvas id="airship-canvas"></canvas>
    </div>
  </div>
</template>
​
<script setup>
import { ref, reactive, onMounted } from 'vue';
import Worker from "@/workers/worker.js?worker";
import CreateGame from "./js/createGame.js"
// 画布
let canvasBox = null;
let canvas = null;
let bulletCanvas = null;
let airshipCanvas = null;
// 画布尺寸
let canvasWidth = 500;
let canvasHeight = 500;
​
​
​
let gameImages = {};// 游戏图片
let airshipImages = [];// 飞船图片集合
let airshipTotal = 30
// 飞机的坐标
let planePoints = {
  x: canvasWidth / 2, y: canvasHeight - 20
}
// 暂停时 保存飞机坐标
let lastPlanePoint = null
// 遮罩层标志位
let maskLayerShow = ref(true);
// 游戏暂停标志位
let gameStop = ref(true);
// 游戏结束标志位
let gameOver = ref(false);
// 游戏得分对象
let game = reactive({
  bullets: [],
  airships: [],
  score: 0
})
​
let worker = new Worker();
worker.postMessage({ type: 'init', planePoints });
let CGame = new CreateGame();
const onGameOver = ()=>{
  console.log('over');
  CGame.over = true;
  CGame.stop();
​
  gameOver.value = true;
  gameStop.value = true;
  maskLayerShow.value = true;
}
CGame.onGameOver = onGameOver
/**
 * @canvas        包裹画布的容器
 * @airshipCanvas 敌军飞船画布
 * @bulletCanvas  子弹画布
 * @gameImages    游戏图片资源集合
 * @planePoints   飞机坐标
 * @game          游戏管理对象
 * @airshipImages 飞船图片资源集合
 * @shootSpeed    控制子弹速度
 * @airshipTotal  控制敌军飞船总数
 * */ 
function initGame({
  canvas,
  airshipCanvas,
  bulletCanvas, 
  gameImages, 
  planePoints, 
  game, 
  airshipImages,
  shootSpeed,
  airshipTotal
}){
  CGame.canvas = canvas;
  CGame.airshipCanvas = airshipCanvas;
  CGame.bulletCanvas = bulletCanvas;
​
  CGame.gameImages = gameImages;
  CGame.planePoints = planePoints;
  CGame.game = game;
  CGame.airshipImages = airshipImages;
  CGame.shootSpeed = shootSpeed || 400
  CGame.airshipTotal = airshipTotal || 30;
  
  CGame.init();
  // CGame.begin();
}
onMounted(() => {
  canvasBox = document.querySelector('.canvas-box')
  canvas = document.getElementById('game-canvas');
  bulletCanvas = document.getElementById('bullet-canvas');
  airshipCanvas = document.getElementById('airship-canvas');
  // 所有画布统一宽高配置
  [canvas, bulletCanvas, airshipCanvas].forEach(item => {
    item.width = canvasWidth;
    item.height = canvasHeight;
  })
​
  worker.onmessage = (e) => {
    // return
    let { data } = e;
    (data && data.images) && data.images.forEach((item) => {
      gameImages[item.name] = item.image
    })
    // 飞船数据收集
    let { airship1, airship2, airship3 } = gameImages
    airshipImages = [airship1, airship2, airship3];
    // 游戏界面是否隐藏
    initGame({
      canvas,
      airshipCanvas,
      bulletCanvas, 
      gameImages, 
      planePoints, 
      game, 
      airshipImages,
      shootSpeed: 12,
      airshipTotal: airshipTotal
    })
    windowIsHidden();
  }
  canvasBox.addEventListener('mousedown', mousedown)
  canvasBox.addEventListener('mouseup', mouseup)
})
​
// 窗口是否隐藏
function windowIsHidden() {
  document.addEventListener('visibilitychange', () => {
    if (document.hidden === true || gameStop.value) {
      // 遮罩层存在 就不继续执行了
      if(maskLayerShow.value){
        return
      }
      CGame.stop();
      maskLayerShow.value = true;
      gameStop.value = true;
      let existAirship = CGame.game.airships.filter(item=>item.show);
      let existBullet = CGame.game.bullets.filter(item=>item.show);
      existAirship.forEach(item=>{
        item.stop = true;
      })
      existBullet.forEach(item=>{
        item.stop = true;
      })
    } else {
​
    }
  })
}
​
​
​
function mousedown(e) {
  // 游戏暂停出现遮罩层
  if (maskLayerShow.value) {
    return
  }
  canvasBox && canvasBox.addEventListener('mousemove', mousemove);
  CGame.drawPlane();
}
function mousemove(e) {
  // 
  if (maskLayerShow.value) {
    canvasBox && canvasBox.removeEventListener('mousemove', mousemove)
    return
  }
  let { offsetX, offsetY } = e;
  planePoints = {
    x: offsetX,
    y: offsetY
  }
  CGame.planePoints = planePoints
  CGame.drawPlane();
}
function mouseup(e) {
  canvasBox && canvasBox.removeEventListener('mousemove', mousemove)
}
​
​
const handleStopGame = () => {
  gameStop.value = !gameStop.value;
  if (gameStop.value) {
    lastPlanePoint = { ...planePoints };
    CGame.stop();
    maskLayerShow.value = true;
  } else {
    if(gameOver.value){
      return
    }
    CGame.begin();
    maskLayerShow.value = false;
  }
  CGame.game.airships.filter(item=>item.show).forEach(item => {
    // stop 标志位 控制所有 元素运行或停止
    item.stop = gameStop.value;
    if (!gameStop.value) {
      item.draw(item.points)
    }
  })
  CGame.game.bullets.filter(item=>item.show).forEach(item => {
    if(!item){
      return
    }
    item.stop = gameStop.value;
    if (!gameStop.value) {
      item.points && item.draw(item.points)
    }
  })
}
const handleNextGame = () => {
  
  canvasBox = document.querySelector('.canvas-box')
  canvas = document.getElementById('game-canvas');
  bulletCanvas = document.getElementById('bullet-canvas');
  airshipCanvas = document.getElementById('airship-canvas');
  console.log(canvas, 'canvas-------');
  gameOver.value = false;
  gameStop.value = false;
  maskLayerShow.value = false;
  CGame = new CreateGame()
  CGame.onGameOver = onGameOver
  CGame.over = false;
  setTimeout(()=>{
    initGame({
      canvas,
      airshipCanvas,
      bulletCanvas, 
      gameImages, 
      planePoints, 
      game, 
      airshipImages,
      shootSpeed: 12,
      airshipTotal: airshipTotal
    })
    CGame.begin();
  })
    
}
</script>
​
​
<style scoped>
@font-face {
  font-family: Number;
  src: url(@/assets/font/calculatorFontBold.TTF);
}
​
.container-box {
  width: 500px;
  height: 100vh;
  margin: 0 auto;
​
}
​
.operation-box {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 10px;
}
​
.begin-btn {
  padding: 5px 0;
  font-size: 1.5rem;
  cursor: pointer;
  margin-bottom: 10px;
  margin-right: 10px;
  padding: 5px 15px;
  background-color: #1AA034;
  color: #fff;
  border-radius: 5px;
}
​
.score-displayer {
  color: blue;
  user-select: none;
  display: inline-block;
  padding: 5px 0;
  width: 150px;
  font-size: 2rem;
  font-family: Number;
  background-color: #fff;
  margin-bottom: 10px;
  text-align: center;
}
​
#airship-canvas,
#bullet-canvas,
#game-canvas {
  position: absolute;
  top: 0;
  left: 0;
}
​
#game-canvas {
  background-color: #fff;
  z-index: 100;
}
​
#airship-canvas {
  z-index: 200;
}
​
#bullet-canvas {
  z-index: 200;
}
​
.canvas-box {
  position: relative;
  width: 500px;
  height: 500px;
  margin: 0 auto;
}
​
​
.mask-layer {
  position: absolute;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  z-index: 1000;
  /* width: 550px;
  height: 550px; */
  background-color: rgba(0, 0, 0, 0.5);
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 4rem;
  font-weight: bold;
  color: #fff;
}
​
</style>
​
相关推荐
徐子颐9 分钟前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭21 分钟前
如何理解HTML语义化
前端·html
jump68044 分钟前
url输入到网页展示会发生什么?
前端
诸葛韩信1 小时前
我们需要了解的Web Workers
前端
brzhang1 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu1 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花1 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js
十二春秋1 小时前
场景模拟:基础路由配置
前端
六月的可乐2 小时前
实战干货-Vue实现AI聊天助手全流程解析
前端·vue.js·ai编程
一 乐2 小时前
智慧党建|党务学习|基于SprinBoot+vue的智慧党建学习平台(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·学习