知识点
- 难点一:如何渲染到canvas平台上
解决方案:利用vue3的custom renderer并且配合pixi.js来解决
- 难点二:如何实现页面切换
解决方案:利用vue3的组件通信控制组件切换
- 难点三:如何进行碰撞检测
解决方案:利用数学思想的反证法
- 难点四:如何做到地图无限滚动的效果
解决方案:利用障眼法(类比轮播图的实现)
- 难点五:如何做到飞机移动、发射子弹效果
解决方案:监听键盘事件
- 难点六:如何构建项目目录
解决方案:可根据页面级进行一次拆分、可根据工具函数进行一次拆分、可根据组件级进行一次拆分等等方式来构建项目目录,可以方便阅读查找。
- 难点七:如何对代码进行重构
解决方案:合理的代码组织和逻辑复用
- 难点八:如何实时的去检测是否碰撞
解决方案:使用pixi.js的api并且配合Vue3的生命周期(类比setInterval)
- 难点九:如何操纵canvas配合实现打飞机游戏
解决方案:学习pixi.js的简单使用
前言回顾
- 针对如何使用Vue3配合pixi.js自定义渲染到canvas平台上,我已经在之前写的的文章写过了,这是实现该项目的必备基础知识,需要的朋友可以先去看一下。点击跳转至该文章 vue3的新特性custom renderer
- 该项目实现自定义渲染到canvas平台上的结构目录如图所示:
- 下面是
main.js文件的代码:
import { createApp } from "./runtime-canvas";
import { getRootContainer } from "./game";
import App from "./App.vue";
//清除警告
console.warn = () => {};
createApp(App).mount(getRootContainer());
- 下面是
game/index.js文件的代码:
import { Application } from "pixi.js";
export const game = new Application({
width: 750,
height: 1080,
});
document.body.append(game.view);
export function getRootContainer() {
return game.stage;
}
- 下面是
runtime-canvas/index.js文件的代码:
import { createRenderer } from "vue";
import { Container, Texture, Sprite, Text } from "pixi.js";
const renderer = createRenderer({
createElement(type) { // 容器 以及 图片
let element;
switch (type) {
case "container":
element = new Container();
break;
case "sprite":
element = new Sprite();
break;
}
return element;
},
insert(el, parent) {
if (el) {
parent.addChild(el);
}
},
parentNode(node) {
// 获取当前 node 的父级节点
return node.parent;
},
remove(el) {
// 当删除一个元素的时候 进行调用
if (el && el.parent) {
// removeChild(el)
el.parent.removeChild(el);
}
},
patchProp(el, key, prevValue, nextValue) {
switch (key) {
case "texture":
// 响应赋予图片src地址
el.texture = Texture.from(nextValue);
break;
case "onClick":
// 响应点击事件
el.on("pointertap", nextValue);
break;
default:
// 响应移动位置以实时更新视图
el[key] = nextValue;
break;
}
},
// 必须要的
createText(text) {
return new Text(text);
},
nextSibling() {},
createComment() {},
});
export function createApp(rootComponent) {
return renderer.createApp(rootComponent);
}
- 到此为止,我们已经利用Vue3的custom renderer配合pixi.js实现了自定义渲染到canvas平台上,如图所示:
实现页面切换
- 该项目中的页面切换逻辑如下图:
- 该项目中的页面切换逻辑的案例图演示:
- 该项目中页面切换的实现原理:
- 下面是App.vue的核心代码:
<template>
<container>
<currentPage
@change-page="handleChangePage"></currentPage>
<!--
可以被动态切换为
<GamePage></GamePage>
<StartPage></StartPage>
<EndPage></EndPage>
默认是显示<StartPage></StartPage>
-->
</container>
</template>
<script>
//引入三个页面级组件
import StartPage from "./pages/StartPage";
import GamePage from "./pages/GamePage.vue";
import EndPage from "./pages/EndPage";
//引入用到的依赖
import { computed, ref } from "vue";
export default {
name: "App",
components: {
// GamePage,
// StartPage,
},
setup() {
// 利用计算属性来做 默认显示开始页面
let currentPageName = ref("StartPage");
// let currentPageName = ref("GamePage");
// let currentPageName = ref("EndPage");
//通过计算属性更换视图 切换页面组件
const currentPage = computed(() => {
if (currentPageName.value === "StartPage") {
return StartPage;
} else if (currentPageName.value === "GamePage") {
return GamePage;
} else if (currentPageName.value === "EndPage") {
return EndPage;
}
});
//当子组件通知父组件更换视图页面时触发
const handleChangePage = (page) => {
currentPageName.value = page;
};
return {
currentPage,
handleChangePage,
};
},
};
</script>
- 接着我们来看在开始页面是如何通过点击开始按钮通知App.vue来切换为游戏页面的,核心代码和逻辑图如下:
>>>这里是StartPage.vue文件的代码
<template>
<container>
<sprite
:texture="startPageImg"
ref="startPage"></sprite>
<!--
两张背景图
下面的这一张是点击开始按钮图
x和y是为了设置点击开始按钮图的初始位置偏移量
interactive是pixi.js的语法使得图片可点击
-->
<sprite
:texture="startBtnImg"
x="223"
y="515"
@click="toGame"
:interactive="true"
></sprite>
</container>
</template>
<script>
import startPageImg from "../assets/start_page.jpg";
import startBtnImg from "../assets/startBtn.png";
import { ref, onMounted } from "vue";
export default {
setup(props, { emit }) {
function toGame() {
console.log("点击开始游戏按钮后触发自定义事件");
console.log("类比vue2语法中的this.$emit");
console.log("vue3没有setup中this为undefined");
// 触发父组件上的自定义事件 通知更换相应的视图
emit("change-page", "GamePage");
}
const startPage = ref(null);
onMounted(() => {
console.log(startPage);
});
return {
startPage,
startPageImg,
startBtnImg,
toGame,
};
},
};
</script>
- 接着我们来看在结束页面是如何通过点击再来一次按钮通知App.vue来切换为游戏页面的,核心代码和逻辑图如下:
<template>
<container>
<sprite
:texture="endPageImg"
ref="startPage"
></sprite>
<sprite
:texture="restartBtnImg"
x="250"
y="520"
@click="toGame"
:interactive="true"
></sprite>
</container>
</template>
<script>
import endPageImg from "../assets/end_page.jpg";
import restartBtnImg from "../assets/restartBtn.png";
import { ref, onMounted } from "vue";
export default {
setup(props, { emit }) {
function toGame() {
emit("change-page", "GamePage");
}
//为了获取组件实例
const startPage = ref(null);
onMounted(() => {
console.log(startPage);
});
return {
startPage,
endPageImg,
restartBtnImg,
toGame,
};
},
};
</script>
- 接着我们来看在游戏页面是如何通过实时监听我方飞机与敌方飞机是否发生碰撞,如果发生碰撞通知App.vue来切换为结束页面的,逻辑图如下(
代码先省略后续揭晓):
做到地图无限滚动的效果
- 该项目中的地图无限滚动的案例图演示:
- 该项目中的地图无限滚动的逻辑分析:(类比轮播图的实现方式)
- 我们以功能划分将地图抽离为一个组件,地图滚动做为地图组件的逻辑写在地图组件中,而地图组件又被归为游戏的一部分,由游戏页面(
GamePage.vue)引入此子组件使用,关系图和目录结构图如下: - 接着我们来看实现地图无限滚动的逻辑核心代码:
- 下面是GamePage.vue文件的代码
>>>这里是GamePage.vue文件的代码
<template>
<container>
<Map></Map>
</container>
</template>
<script>
import Map from "../components/Map";
import { onMounted, onUnmounted } from "vue";
import { game } from "../game";
export default {
components: {
Map
},
}
</script>
- 下面是Map.vue文件的代码
>>>这里是Map.vue文件的代码
<template>
<container>
<sprite :texture="mapImg" :y="mapY1"></sprite>
<sprite :texture="mapImg" :y="mapY2"></sprite>
</container>
</template>
<script>
import { ref, onMounted, onUnmounted } from "vue";
import mapImg from "../assets/map.jpg";
import { game } from "../game";
export default {
setup() {
const viewHeight = 1080;
const mapY1 = ref(0);
const mapY2 = ref(-viewHeight);
const speed = 8;
function handleTicker() {
mapY1.value += speed;
mapY2.value += speed;
if (mapY1.value >= viewHeight) {
mapY1.value = -viewHeight;
}
if (mapY2.value >= viewHeight) {
mapY2.value = -viewHeight;
}
}
onMounted(() => {
game.ticker.add(handleTicker);
});
onUnmounted(() => {
game.ticker.remove(handleTicker);
});
return {
mapImg,
mapY1,
mapY2,
};
},
};
</script>
- 写到这里,地图无限滚动的逻辑已经完成了,但是我们需要重构一下Map.vue文件的代码,重构后的代码如下:
<template>
<container>
<sprite :texture="mapImg" :y="mapY1"></sprite>
<sprite :texture="mapImg" :y="mapY2"></sprite>
</container>
</template>
<script>
import { ref, onMounted, onUnmounted } from "vue";
import mapImg from "../assets/map.jpg";
import { game } from "../game";
export default {
setup() {
//这里封装了地图无限滚动的逻辑
let {mapY1,mapY2,}=MapInfiniteScrolling()
return {
mapImg,
mapY1,
mapY2,
};
},
};
function MapInfiniteScrolling(){
const viewHeight = 1080;
const mapY1 = ref(0);
const mapY2 = ref(-viewHeight);
const speed = 8;
function handleTicker() {
mapY1.value += speed;
mapY2.value += speed;
if (mapY1.value >= viewHeight) {
mapY1.value = -viewHeight;
}
if (mapY2.value >= viewHeight) {
mapY2.value = -viewHeight;
}
}
onMounted(() => {
game.ticker.add(handleTicker);
});
onUnmounted(() => {
game.ticker.remove(handleTicker);
});
return {
mapY1,
mapY2,
}
}
</script>
进行碰撞检测
- 利用数学思想的反证法,逻辑图如下:
- 我们将碰撞检测封装为工具函数单独放在工具文件夹
utils/index.js下,核心代码实现如下:
>>>这里是utils/index.js文件的代码
// 矩形碰撞 四种情况下取反 &&的用法
// 只要其中一种情况的发生就表示没有碰撞返回false
// 返回true代表着四种不碰撞的情况都没有发生即发生了碰撞
export function hitTestObject(rectA, rectB) {
return (
rectA.x + rectA.width >= rectB.x &&
rectB.x + rectB.width >= rectA.x &&
rectA.y + rectA.height >= rectB.y &&
rectB.y + rectB.height >= rectA.y
);
}
做到飞机移动效果
- 先来梳理一下实现该功能的简易流程图,如下:
- 下面是Plane.vue的核心实现代码:(
先省去实现发射子弹效果的代码)
<template>
<container>
<sprite :texture="planeImg"></sprite>
</container>
</template>
<script>
import planeImg from "../assets/plane.png";
import { reactive, onMounted, onUnmounted } from "vue";
export default {
setup() {
return {
planeImg,
};
},
};
/*
在Plane.vue我们还特别利用一个usePlane()函数
将planeInfo即我方飞机的数据信息导出
供GamePage.vue导入使用
*/
export function usePlane() {
const planeInfo = reactive({
x: 150,
y: 520,
width: 258,
height: 364,
});
const speed = 10;
function handleMove(e) {
switch (e.code) {
case "ArrowUp":
planeInfo.y -= speed;
break;
case "ArrowDown":
planeInfo.y += speed;
break;
case "ArrowLeft":
planeInfo.x -= speed;
break;
case "ArrowRight":
planeInfo.x += speed;
break;
default:
break;
}
}
onMounted(() => {
window.addEventListener("keyup", handleMove);
});
onUnmounted(() => {
window.removeEventListener("keyup", handleMove);
});
return { planeInfo };
}
</script>
- 写到这里,该Plane.vue已经实现了飞机的移动功能效果,如果我们要在加入飞机攻击发射子弹的功能效果,会发现此时的逻辑代码会出现耦合,需要针对此对以上的代码再做一次重构,重构后的代码如下:
<script>
import planeImg from "../assets/plane.png";
import { reactive, onMounted, onUnmounted } from "vue";
export default {
setup() {
return {
planeImg,
};
},
};
export function usePlane() {
const planeInfo = reactive({
x: 150,
y: 520,
width: 258,
height: 364,
});
function move() {
const speed = 10;
function handleMove(e) {
switch (e.code) {
case "ArrowUp":
planeInfo.y -= speed;
break;
case "ArrowDown":
planeInfo.y += speed;
break;
case "ArrowLeft":
planeInfo.x -= speed;
break;
case "ArrowRight":
planeInfo.x += speed;
break;
default:
break;
}
}
onMounted(() => {
window.addEventListener("keyup", handleMove);
});
onUnmounted(() => {
window.removeEventListener("keyup", handleMove);
});
}
//关于飞机移动的逻辑写在上面并在这里调用
//功能性的封装
//重构后有利于阅读与维护
move();
//先省略飞机攻击发射子弹的逻辑代码
//function attack(){//doto...}
//关于飞机攻击发射子弹的逻辑写在上面并在这里调用
//attack();
return { planeInfo };
}
</script>
- 写到这里,该Plane.vue已经实现了飞机的移动功能效果并对代码进行了重构,可以看到最终通过
return { planeInfo }返回了我方飞机的信息,于是可以把关注点转移到GamePage.vue中去,让我方飞机基于数据呈现在视图之上,GamePage.vue的核心实现代码如下:
<template>
<container>
<Map></Map>
<Plane :x="planeInfo.x" :y="planeInfo.y"></Plane>
</container>
</template>
<script>
import Map from "../components/Map";
import Plane, { usePlane } from "../components/Plane";
import { onMounted, onUnmounted } from "vue";
import { game } from "../game";
export default {
components: {
Map,
Plane,
},
setup() {
// 我方飞机 为了在视图中基于数据呈现
const { planeInfo } = usePlane();
return {
planeInfo
};
}
}
</script>
- 写到这里,让我们看一下现在基于数据显示的我方飞机视图,如图:
- 注意点:我们在GamePage.vue中书写的
<Plane :x="planeInfo.x" :y="planeInfo.y"></Plane>这一句代码,其中x和y的响应式数据会被添加到子组件Plane.vue的根节点<container>...</container>上(即<container :x="planeInfo.x" :y="planeInfo.y">...</container>),该根节点是pixi.js的容器元素,随着x和y的数据变化,该容器元素也会实时响应(看到的效果是实时更新视图位置)。关于Attribute继承可以参考官网点击进入官网 - 注意点:pixi.js中的容器
(container)是一个整体,改变容器的位置,容器内的东西(图片、矩形等等)也会跟着一起移动
做到飞机攻击发射子弹效果
- 我们来回顾一下我方飞机的功能实现简图,到这里为止已经完成了飞机移动功能,还需要实现飞机攻击发射子弹效果
- 下面是Bullet.vue的核心实现代码:(
需要先构建子弹的视图并封装子弹发射后向上移动的逻辑) - 注意点:在Bullet.vue中除了导出
Bullet.vue子弹组件,还通过导出useBullets()方法向外暴露了addBullet(添加子弹的方法)和bullets(装有子弹的数组数据)
<template>
<container>
<sprite :texture="bulletImg"></sprite>
</container>
</template>
<script>
import { reactive, onMounted, onUnmounted } from "vue";
import bulletImg from "../assets/bullet.png";
import { game } from "../game";
export default {
setup() {
return {
bulletImg,
};
},
};
export function useBullets() {
const width = 61;
const height = 99;
const bullets = reactive([]);
function addBullet({ x, y }) {
bullets.push({ x, y, width, height });
}
function move() {
const speed = 10;
const handleTicker = () => {
bullets.forEach((bullet,index) => {
bullet.y -= speed;
if(bullet.y<=0){
bullets.splice(index,1)
}
});
};
onMounted(() => {
game.ticker.add(handleTicker);
});
onUnmounted(() => {
game.ticker.remove(handleTicker);
});
}
move();
return {
addBullet,
bullets,
};
}
</script>
<style lang="scss" scoped></style>
- 注意点:通信中关于回调函数的运用之基础图:
- 如何做到飞机攻击发射子弹效果的实现逻辑图如下:
- 下面是GamePage.vue文件的代码:
<template>
<container>
<Map></Map>
<Plane :x="planeInfo.x" :y="planeInfo.y"></Plane>
<Bullet
v-for="(bullet, index) in bullets"
:key="index"
:x="bullet.x"
:y="bullet.y"
></Bullet>
</container>
</template>
<script>
import Map from "../components/Map";
import Plane, { usePlane } from "../components/Plane";
import Bullet, { useBullets } from "../components/Bullet";
import { onMounted, onUnmounted } from "vue";
import { game } from "../game";
export default {
components: {
Map,
Bullet,
Plane,
},
setup() {
// 向Plane.vue中导出的usePlane方法里传递回调函数
// 在Plane.vue中的usePlane方法中通过解构得到该回调函数
// 并在Plane.vue中通过监听键盘事件触发该回调函数
const { planeInfo } = usePlane({
onAttack(position) {
//该回调函数的逻辑是增加子弹
addBullet(position);
},
});
// 子弹的数组数据和添加子弹数据的方法
const { bullets, addBullet } = useBullets();
return {
planeInfo,
bullets,
};
},
};
</script>
<style></style>
- 下面是Plane.vue的核心实现代码:
<script>
import planeImg from "../assets/plane.png";
import { reactive, onMounted, onUnmounted } from "vue";
export default {
setup() {
return {
planeImg,
};
},
};
//解构出传递过来的回调函数onAttack
export function usePlane({ onAttack }) {
const planeInfo = reactive({
x: 150,
y: 520,
width: 258,
height: 364,
});
function move(){//上方已实现不在重复书写}
//关于飞机移动的逻辑写在上面并在这里调用
//功能性的封装
//重构后有利于阅读与维护
move();
function attack(){
function handleMove(e) {
if (e.code === "Space") {
onAttack &&
onAttack({
x: planeInfo.x + 100,
y: planeInfo.y,
});
}
}
onMounted(() => {
// 当按下空格的时候
window.addEventListener("keyup", handleMove);
});
onUnmounted(() => {
window.removeEventListener("keyup", handleMove);
});
}
//关于飞机攻击发射子弹的逻辑写在上面并在这里调用
attack();
return { planeInfo };
}
</script>
- 直至到此已经做到飞机攻击发射子弹效果,效果如图:
随机出现可移动的敌方飞机
- 如何做到随机出现敌方飞机逻辑图如下:
- 下面是EnemyPlane.vue的核心实现代码:
<template>
<container>
<sprite :texture="enemyImg"></sprite>
</container>
</template>
<script>
import { reactive, onMounted, onUnmounted } from "vue";
import enemyImg from "../assets/enemy.png";
import { game } from "../game";
export default {
setup() {
return {
enemyImg,
};
},
};
export function useEnemyPlane() {
const width = 398;
const height = 207;
const enemyPlanes = reactive([
{
x: 55,
y: 55,
width,
height,
}
]);
// eslint-disable-next-line no-unused-vars
function move() {
const speed = 10;
const handleTicker = () => {
enemyPlanes.forEach((enemy, index) => {
enemy.y += speed;
if (enemy.y >= 1080) {
enemyPlanes.splice(index, 1);
}
});
};
onMounted(() => {
game.ticker.add(handleTicker);
});
onUnmounted(() => {
game.ticker.remove(handleTicker);
});
}
move();
function createRandomEnemyPlane(){
setInterval(()=>{
enemyPlanes.push({
x: Math.ceil(Math.random()*352),
y:55,
width,
height,
})
},1000)
}
createRandomEnemyPlane()
return {
enemyPlanes,
};
}
</script>
- 下面是GamePage.vue的核心实现代码:
<template>
<container>
<Map></Map>
<!-- <Plane :x="planeInfo.x" :y="planeInfo.y"></Plane> -->
<EnemyPlane
v-for="(enemy, index) in enemyPlanes"
:key="index"
:x="enemy.x"
:y="enemy.y"
></EnemyPlane>
<!-- <Bullet
v-for="(bullet, index) in bullets"
:key="index"
:x="bullet.x"
:y="bullet.y"
></Bullet> -->
</container>
</template>
<script>
import Map from "../components/Map";
import EnemyPlane, { useEnemyPlane } from "../components/EnemyPlane";
import { onMounted, onUnmounted } from "vue";
import { game } from "../game";
export default {
components: {
Map,
EnemyPlane,
},
setup() {
const { enemyPlanes } = useEnemyPlane();
return {
enemyPlanes,
};
},
};
</script>
<style></style>
- 直至到此已经做到随机出现可移动的敌方飞机效果,效果如图:
碰撞检测我方飞机与敌方飞机、子弹与敌方飞机
- 如何做到碰撞检测我方飞机与敌方飞机、子弹与敌方飞机逻辑图如下:
- 下面是GamePage.vue的核心实现代码:
<template>
<container>
<Map></Map>
<Plane :x="planeInfo.x" :y="planeInfo.y"></Plane>
<EnemyPlane
v-for="(enemy, index) in enemyPlanes"
:key="index"
:x="enemy.x"
:y="enemy.y"
></EnemyPlane>
<Bullet
v-for="(bullet, index) in bullets"
:key="index"
:x="bullet.x"
:y="bullet.y"
></Bullet>
</container>
</template>
<script>
import Map from "../components/Map";
import Plane, { usePlane } from "../components/Plane";
import EnemyPlane, { useEnemyPlane } from "../components/EnemyPlane";
import Bullet, { useBullets } from "../components/Bullet";
//单独将战斗逻辑抽离为文件引入使用
import {useFighting} from '../utils/useFighting';
export default {
components: {
Map,
Bullet,
Plane,
EnemyPlane,
},
setup(props, { emit }) {
// 我方飞机
const { planeInfo } = usePlane({
onAttack(position) {
addBullet(position);
},
});
// 敌军飞机
const { enemyPlanes } = useEnemyPlane();
// 子弹
const { bullets, addBullet } = useBullets();
// 核心战斗逻辑
// 我方飞机与敌方飞机 我方子弹与敌方飞机
// 实时检测
useFighting({ planeInfo, enemyPlanes, bullets, emit });
return {
planeInfo,
enemyPlanes,
bullets,
};
},
};
</script>
<style></style>
- 下面是
'../utils/useFighting.js'文件的核心实现代码:
import { onMounted, onUnmounted } from "vue";
import { game } from "../game";
import { hitTestObject } from "../utils/index";
export function useFighting({ enemyPlanes, planeInfo, bullets, emit }) {
const handleTicker = () => {
// 实时碰撞检测
// 我方飞机与敌方飞机的检测
enemyPlanes.forEach((enemy) => {
if (hitTestObject(enemy, planeInfo)) {
emit("change-page", "EndPage");
}
});
// 我方子弹和敌军的检测
// 代码的可读性一开始的时候是大于性能
// 一定要记住宽 和 高
enemyPlanes.forEach((enemy, enemyIndex) => {
bullets.forEach((bullet, bulletIndex) => {
if (hitTestObject(enemy, bullet)) {
// 销毁
enemyPlanes.splice(enemyIndex, 1);
bullets.splice(bulletIndex, 1);
}
});
});
};
onMounted(() => {
game.ticker.add(handleTicker);
});
onUnmounted(() => {
game.ticker.remove(handleTicker);
});
}
案例演示
- 本文基于vue3实现了打飞机游戏,在此基础上讲解项目中运用到的核心知识点和构建思路