#HarmonyOS NEXT体验官# 手把手教你适配微信小游戏-打飞机 原创 精华
前言
大家好,我是无言,有一段时间没有更新了。主要是这段时间在参与鸿蒙开源项目McCharts ,它是一个基于 ArkTS 语法封装的图表组件,使用方式高度类似Echarts
,而且可以运行在ArkTS 3+版本以上的任意版本当中,性能反馈非常不错,大家对鸿蒙开发感兴趣的可以去尝试一下。
本来在写这篇文章之前,我还想在鸿蒙中适配一些游戏引擎,例如pixiJs
、ThreeJs
,奈何现在鸿蒙Canvas
不支持 WebGL
,做了一些尝试,发现确实改动的地方太多了,就只有暂时搁置。
目的
通过本篇文章,小伙伴们能学到什么?我简单的总结了一下大概有以下几点。
- 对于将自己以前写过的一些小游戏适配到鸿蒙系统中,有了一些思路和方向。
- 了解在鸿蒙中
Canvas
绘制drawImage
图片的一些区别。 - 了解如何将鸿蒙中的触摸
Touch
事件传入到自己游戏逻辑里面。 - 了解鸿蒙中没有动画帧
requestAnimationFrame
,我们如何用其他方法代替。 - 了解在
Canvas
中如何确定自己点击的区域,然后触发对应的事件。 - 了解用
Canvas
实现游戏的一些基本逻辑,例如序列帧动画。
我们先来看看效果
实现步骤
一、准备工作
-
安装好最新DevEco Studio 开发工具,创建一个新的空项目, 然后创建并引入
Static Library
模块。具体详细信息可以参考我之前的一篇文章基于Echarts封装开发一个OpenHarmony三方库,我就不在重复讲了。 -
微信开发工具新建微信小游戏,然后选择第一个飞机游戏模板。
二、引入代码
将微信小游戏那边的js
代码引入到鸿蒙项目中的 Static Library
模块中,我这里将Library模块命名为airplane
,目录结构如下。
三、在主模块 entry
中引入并使用airplane
模块
- 修改 entry/oh-package.json5 中的
dependencies
属性。
{
"name": "entry",
"version": "1.0.0",
"description": "Please describe the basic information.",
"main": "",
"author": "",
"license": "",
"dependencies": {
"@wuyan/airplane": "file:../airplane"
}
}
修改完成后工具有提示,提示你重新加载,点击 Sync Now
即可。
- 修改 entry/src/main/ets/pages/Index.ets 文件 引入并使用
airplane
。
import {MainPage} from "@wuyan/airplane"
@Entry
@Component
struct Index {
build() {
RelativeContainer() {
MainPage();
}
.height('100%')
.width('100%')
}
}
- 运行预览一下看看效果(这里有个隐形的坑,必须选中
@Entry
页面,选组件或者其他js等文件是不能预览的)。
四、在模块 airplane
中引入加载游戏。
- 在 MainPage.ets 文件同级新建
game.js
,在这里我介绍一个鸿蒙中的类似window 的全局变量globalThis
,有了这个变量,我们传参就方便多了。
import Main from './js/main'
export class InitGame{
constructor(ctx,width,height) {
this.ctx =ctx;
globalThis.innerWidth=width//
globalThis.innerHeight=height
try {
this.gameMain= new Main(ctx)
} catch (err) {
console.log('err',err)
}
}
}
- 修改 MainPage.ets 添加
Canvas
组件 并在onReady
后将组件 Canvas的 context 和 宽 高 传入。
import {InitGame} from "./game"
@Component
export struct MainPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
myGame?:InitGame|null;
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor('#ffff00')
.onReady(() =>{
this.myGame = new InitGame(this.context,this.context.width,this.context.height)
})
.width('100%')
}.height('100%')
}
}
五、渲染我们的游戏背景
- 修改 js/main.js 文件 去掉里面的
canvas
相关的属性addEventListener
以及window 相关属性requestAnimationFrame
,注释其他业务逻辑,我们先把游戏背景加载出来。
// 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 = new DataBus()
/**
* 游戏主函数
*/
export default class Main {
constructor(ctx) {
this.ctx=ctx;
// 维护当前requestAnimationFrame的id
this.aniId = 0
this.restart()
}
restart() {
databus.reset()
// canvas.removeEventListener(
// 'touchstart',
// this.touchHandler
// )
this.bg = new BackGround(this.ctx)
// this.player = new Player(this.ctx)
// this.gameinfo = new GameInfo()
// this.music = new Music()
//
this.bindLoop = this.loop.bind(this)
this.hasEventBind = false
this.bindLoop();
// 清除上一局的动画
// 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 += 1
break
}
}
})
for (let i = 0, il = databus.enemys.length; i < il; i++) {
const enemy = databus.enemys[i]
if (this.player.isCollideWith(enemy)) {
databus.gameOver = true
break
}
}
}
// 游戏结束后的触摸事件处理逻辑
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() {
this.ctx.clearRect(0, 0, globalThis.innerWidth, globalThis.innerHeight)
this.bg.render(this.ctx)
databus.bullets
.concat(databus.enemys)
.forEach((item) => {
item.drawToCanvas(this.ctx)
})
this.player.drawToCanvas(this.ctx)
databus.animations.forEach((ani) => {
if (ani.isPlaying) {
ani.aniRender(this.ctx)
}
})
this.gameinfo.renderGameScore(this.ctx, databus.score)
// 游戏结束停止帧循环
if (databus.gameOver) {
this.gameinfo.renderGameOver(this.ctx, databus.score)
if (!this.hasEventBind) {
this.hasEventBind = true
this.touchHandler = this.touchEventHandler.bind(this)
// canvas.addEventListener('touchstart', this.touchHandler)
}
}
}
// 游戏逻辑更新主函数
update() {
if (databus.gameOver) return
this.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
// )
}
}
不出意外报错了window is not defined
- 继续修改js/runtime/background.js,将里面的宽高都替换一下。
import Sprite from '../base/sprite'
const BG_IMG_SRC = 'images/bg.jpg'
const BG_WIDTH = 512
const BG_HEIGHT = 512
/**
* 游戏背景类
* 提供update和render函数实现无限滚动的背景功能
*/
export default class BackGround extends Sprite {
constructor(ctx) {
super(BG_IMG_SRC, BG_WIDTH, BG_HEIGHT)
this.top = 0
this.render(ctx)
}
update() {
this.top += 2
if (this.top >= globalThis.innerHeight) this.top = 0
}
/**
* 背景图重绘函数
* 绘制两张图片,两张图片大小和屏幕一致
* 第一张漏出高度为top部分,其余的隐藏在屏幕上面
* 第二张补全除了top高度之外的部分,其余的隐藏在屏幕下面
*/
render(ctx) {
ctx.drawImage(
this.img,
0,
0,
this.width,
this.height,
0,
-globalThis.innerHeight + this.top,
globalThis.innerWidth,
globalThis.innerHeight
)
ctx.drawImage(
this.img,
0,
0,
this.width,
this.height,
0,
this.top,
globalThis.innerWidth,
globalThis.innerHeight
)
}
}
很奇怪运行结果,显示图片并没有渲染。
看了下官方文档drawImage
用到的图片是需要用new ImageBitmap() 而不是我们平时用的 new Image()
,而且type为"entry"和"feature"类型的Module,其图片加载路径的起点为当前Module的ets文件夹。所以只能把图片资源放在主模块中。
-
将微信小游戏那边图片资源文件,放入鸿蒙项目中的主模块中,目录结构如下。
-
修改 js/base/sprite.js 文件 用
new ImageBitmap()
替换new Image()
/**
* 游戏基础的精灵类
*/
export default class Sprite {
constructor(imgSrc = '', width = 0, height = 0, x = 0, y = 0) {
this.img = new ImageBitmap(imgSrc) //修改这里
// 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 / 2
const spY = sp.y + sp.height / 2
if (!this.visible || !sp.visible) return false
return !!(spX >= this.x
&& spX <= this.x + this.width
&& spY >= this.y
&& spY <= this.y + this.height)
}
}
运行一下 看看效果,背景图片是出来了,但是和我预期差距很大。
- 看起来应该是他背景拼接不对,以及图片大小识别单位尺寸有问题,所以重新修改一下 background.js 的
render
方法。
import Sprite from '../base/sprite'
const BG_IMG_SRC = 'images/bg.jpg'
const BG_WIDTH = 512
const BG_HEIGHT = 512
/**
* 游戏背景类
* 提供update和render函数实现无限滚动的背景功能
*/
export default class BackGround extends Sprite {
constructor(ctx) {
super(BG_IMG_SRC, BG_WIDTH, BG_HEIGHT)
this.top = 0
this.render(ctx)
}
update() {
this.top += 2
if (this.top >= globalThis.innerHeight) this.top = 0
}
/**
* 背景图重绘函数
* 绘制两张图片,两张图片大小和屏幕一致
* 第一张漏出高度为top部分,其余的隐藏在屏幕上面
* 第二张补全除了top高度之外的部分,其余的隐藏在屏幕下面
*/
render(ctx) {
ctx.drawImage(
this.img,
0,
this.top,
globalThis.innerWidth,
globalThis.innerHeight
)
ctx.drawImage(
this.img,
0,
-globalThis.innerHeight+ this.top,
globalThis.innerWidth,
globalThis.innerHeight
)
}
}
再运行一下看看效果,不错是我想要的效果
六、加载我们的战机
- 修改js/player/index.js 先把
initEvent
绑定事件去掉,用globalThis.innerWidth
和globalThis.innerHeight
替换宽高。
import Sprite from '../base/sprite'
import Bullet from './bullet'
import DataBus from '../databus'
// 玩家相关常量设置
const PLAYER_IMG_SRC = 'images/hero.png'
const PLAYER_WIDTH = 80
const PLAYER_HEIGHT = 80
const databus = new DataBus()
export default class Player extends Sprite {
constructor() {
super(PLAYER_IMG_SRC, PLAYER_WIDTH, PLAYER_HEIGHT)
// 玩家默认处于屏幕底部居中位置
this.x = globalThis.innerWidth / 2 - this.width / 2
this.y = globalThis.innerHeight - this.height - 30
// 用于在手指移动的时候标识手指是否已经在飞机上了
this.touched = false
this.bullets = []
// 初始化事件监听
this.initEvent()
}
/**
* 当手指触摸屏幕的时候
* 判断手指是否在飞机上
* @param {Number} x: 手指的X轴坐标
* @param {Number} y: 手指的Y轴坐标
* @return {Boolean}: 用于标识手指是否在飞机上的布尔值
*/
checkIsFingerOnAir(x, y) {
const deviation = 30
return !!(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 / 2
let disY = y - this.height / 2
if (disX < 0) disX = 0
else if (disX > globalThis.innerWidth - this.width) disX = globalThis.innerWidth - this.width
if (disY <= 0) disY = 0
else if (disY > globalThis.innerHeight - this.height) disY = globalThis.innerHeight - 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 = true
//
// this.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)
}
}
- 修改js/mian.js 中
restart
方法 放开this.player = new Player(this.ctx)
这行注释,其他代码我就省略了。
import Player from './player/index'
...
restart() {
databus.reset()
// canvas.removeEventListener(
// 'touchstart',
// this.touchHandler
// )
this.bg = new BackGround(this.ctx)
this.player = new Player(this.ctx) //修改这里
// this.gameinfo = new GameInfo()
// this.music = new Music()
//
this.bindLoop = this.loop.bind(this)
this.hasEventBind = false
this.bindLoop();
// 清除上一局的动画
// window.cancelAnimationFrame(this.aniId)
//
// this.aniId = window.requestAnimationFrame(
// this.bindLoop,
// canvas
// )
}
...
运行 效果如下,可以看到我们的战机 成功加载了。
七、加载敌方战机(NPC)
- 修改js/npc/enemy.js 将window属性替换成
globalThis
。
import Animation from '../base/animation'
import DataBus from '../databus'
const ENEMY_IMG_SRC = 'images/enemy.png'
const ENEMY_WIDTH = 60
const ENEMY_HEIGHT = 60
const __ = {
speed: Symbol('speed')
}
const databus = new DataBus()
function rnd(start, end) {
return Math.floor(Math.random() * (end - start) + start)
}
export default class Enemy extends Animation {
constructor() {
super(ENEMY_IMG_SRC, ENEMY_WIDTH, ENEMY_HEIGHT)
this.initExplosionAnimation()
}
init(speed) {
this.x = rnd(0, globalThis.innerWidth- ENEMY_WIDTH)
this.y = -this.height
this[__.speed] = speed
this.visible = true
}
// 预定义爆炸的帧动画
initExplosionAnimation() {
const frames = []
const EXPLO_IMG_PREFIX = 'images/explosion'
const EXPLO_FRAME_COUNT = 19
for (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 > globalThis.innerHeight + this.height) databus.removeEnemey(this)
}
}
- 因为敌方战机是随机生成的,所以我们需要将我们的游戏运行起来,因为不支持
requestAnimationFrame
所以我们先用setInterval
代替一下,修改main.js 主要代码如下,一些重复的代码我就省略了。
import Player from './player/index'
import Enemy from './npc/enemy'
import BackGround from './runtime/background'
...
restart() {
databus.reset()
// canvas.removeEventListener(
// 'touchstart',
// this.touchHandler
// )
this.bg = new BackGround(this.ctx)
this.player = new Player(this.ctx)
// this.gameinfo = new GameInfo()
// this.music = new Music()
//
this.bindLoop = this.loop.bind(this)
this.hasEventBind = false
if(this.aniId ){
clearInterval(this.aniId)
}
this.aniId =setInterval(()=>{
this.bindLoop();
},17)
// 清除上一局的动画
// window.cancelAnimationFrame(this.aniId)
//
// this.aniId = window.requestAnimationFrame(
// this.bindLoop,
// canvas
// )
}
// 全局碰撞检测
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 += 1
break
}
}
})
for (let i = 0, il = databus.enemys.length; i < il; i++) {
const enemy = databus.enemys[i]
if (this.player.isCollideWith(enemy)) {
databus.gameOver = true
if(this.aniId){
clearInterval(this.aniId) //去掉帧动画
}
break
}
}
}
...
运行效果如下,可以看到不仅敌方战机都出来了,而且我方战机子弹也跟着出来了,意外之喜。
八、加载游戏结算界面
- 修改 js/runtime/gameinfo.js ,修改点和上面其他地方差不多,主要替换
new Image()
和宽高等。
const atlas = new ImageBitmap('images/Common.png')
export default class GameInfo {
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, globalThis.innerWidth / 2 - 150, globalThis.innerHeight / 2 - 100, 300, 300)
ctx.fillStyle = '#ffffff'
ctx.font = '20px Arial'
ctx.fillText(
'游戏结束',
globalThis.innerWidth / 2 - 40,
globalThis.innerHeight / 2 - 100 + 50
)
ctx.fillText(
`得分: ${score}`,
globalThis.innerWidth / 2 - 40,
globalThis.innerHeight / 2 - 100 + 130
)
ctx.drawImage(
atlas,
120, 6, 39, 24,
globalThis.innerWidth / 2 - 60,
globalThis.innerHeight / 2 - 100 + 180,
120, 40
)
ctx.fillText(
'重新开始',
globalThis.innerWidth / 2 - 40,
globalThis.innerHeight / 2 - 100 + 205
)
/**
* 重新开始按钮区域
* 方便简易判断按钮点击
*/
this.btnArea = {
startX: globalThis.innerWidth / 2 - 40,
startY: globalThis.innerHeight / 2 - 100 + 180,
endX: globalThis.innerWidth / 2 + 50,
endY: globalThis.innerHeight / 2 - 100 + 255
}
}
}
游戏碰撞 结束后结算界面出来了,但是渲染雪碧图出现了问题,还有文字大小也不对。
- 重新修改一下
js/runtime/gameinfo.js
把文字单位px 换成vp,打印了一下图片获取到的宽度是170.66
的样子,然后重新找到这张图片可以发现这个图片的真实宽度是512,所以我们换算一下。
const atlas = new ImageBitmap('images/Common.png')
export default class GameInfo {
renderGameScore(ctx, score) {
ctx.fillStyle = '#ffffff'
ctx.font = '20vp Arial'
ctx.fillText(
score,
10,
30
)
}
renderGameOver(ctx, score) {
const scale = atlas.width/512
ctx.drawImage(atlas, 0*scale, 0*scale, 119*scale, 108*scale, globalThis.innerWidth / 2 - 150, globalThis.innerHeight / 2 - 100, 300, 300)
ctx.fillStyle = '#ffffff'
ctx.font = '20vp Arial'
ctx.fillText(
'游戏结束',
globalThis.innerWidth / 2 - 40,
globalThis.innerHeight / 2 - 100 + 50
)
ctx.fillText(
`得分: ${score}`,
globalThis.innerWidth / 2 - 40,
globalThis.innerHeight / 2 - 100 + 130
)
ctx.drawImage(
atlas,
120*scale, 6*scale, 39*scale, 24*scale,
globalThis.innerWidth / 2 - 60,
globalThis.innerHeight / 2 - 100 + 180,
120, 40
)
ctx.fillText(
'重新开始',
globalThis.innerWidth / 2 - 40,
globalThis.innerHeight / 2 - 100 + 205
)
/**
* 重新开始按钮区域
* 方便简易判断按钮点击
*/
this.btnArea = {
startX: globalThis.innerWidth / 2 - 40,
startY: globalThis.innerHeight / 2 - 100 + 180,
endX: globalThis.innerWidth / 2 + 50,
endY: globalThis.innerHeight / 2 - 100 + 255
}
}
}
修改后重新运行可以看到结束界面正常显示了。
九、把音乐加载上 (注意背景音乐只有在模拟器和真机上有效,预览无效)
- 在 MainPage.ets 同目录下新建 avPlayer.ets文件,利用 kit.MediaKit
media.createAVPlayer
播放背景音乐
import { media } from '@kit.MediaKit';
export class avPlayer {
avPlayer?:media.AVPlayer|null
constructor() {
}
async init(name:string){
// 创建avPlayer实例对象
let avPlayer:media.AVPlayer = await media.createAVPlayer();
// 创建状态机变化回调函数
avPlayer.on('stateChange', (state) => {
if (state == 'initialized') {
//监听播放器状态,当监听都处于初始化状态时,播放器调用prepare()变成就绪态
avPlayer?.prepare()
}
})
const fd = getContext().resourceManager.getRawFdSync(name)
// //给播放器设置播放资源,上图有参考资料,使用的是fsSrc资源,不是网络资源->。
// //网络资源用url.
avPlayer.fdSrc = { fd: fd.fd, offset: fd.offset, length: fd.length }
this.avPlayer=avPlayer
return avPlayer
}
//
async play() {
this.avPlayer?.on('stateChange', (val) => {
if (val == 'initialized') {
this.avPlayer?.prepare()
}
if (val == 'prepared') {
this.avPlayer?.play()
}
})
}
}
- 修改 MainPage.ets 文件 引入avPlayer, 在canvas
onReady
中将avPlayer
绑定到全局变量globalThis
上,其他代码和原来一样我就省略了。
import {avPlayer} from "./avPlayer"
...
.onReady(() =>{
globalThis.avPlayer=avPlayer //
this.myGame = new InitGame(this.context,this.context.width,this.context.height)
})
...
- 修改js/runtime/music.js
/**
* 统一的音效管理器
*/
export default class Music {
constructor() {
this.playBgm();
}
async playBgm() {
if(!this.bgmAudio){
this.bgmAudio = new globalThis.avPlayer()
await this.bgmAudio.init('bgm.mp3')
this.bgmAudio.loop=true
}
this.bgmAudio.play()
}
async playShoot() {
// this.shootAudio.currentTime = 0
if(!this.shootAudio){
this.shootAudio = new globalThis.avPlayer()
await this.shootAudio.init('bullet.mp3')
}
this.shootAudio.play()
}
async playExplosion() {
// this.boomAudio.currentTime = 0
if(!this.boomAudio){
this.boomAudio = new globalThis.avPlayer()
await this.boomAudio.init('boom.mp3')
}
this.boomAudio.play()
}
}
- 修改js/main.js 放开之前注释的 Music 相关代码即可。
十、绑定事件
- 修改player/index.js 修改 方法
initEvent
主要是替换addEventListener
逻辑 。
...
initEvent(name,e) {
if(name=='touchstart'){
const x = e.touches[0].x
const y = e.touches[0].y
//
if (this.checkIsFingerOnAir(x, y)) {
this.touched = true
this.setAirPosAcrossFingerPosZ(x, y)
}
}else if(name =='touchmove'){
const x = e.touches[0].x
const y = e.touches[0].y
if (this.touched) {
this.setAirPosAcrossFingerPosZ(x, y)
}
}else if(name =='touchend'){
this.touched = false
}
}
...
- 修改main.js 添加
bindEvent
方法,绑定事件。
...
bindEvent(name,event){
if(name=='touchstart'){
this.touchEventHandler(event) //处理点击重新开始事件
}
this.player.initEvent(name,event)
}
...
- 修改game.js 添加
initEvent
方法,绑定事件。
import { Main } from './js/main'
export class InitGame{
constructor(ctx,width,height,animator) {
this.ctx =ctx;
globalThis.innerWidth=width//
globalThis.innerHeight=height
this.gameMain= new Main(ctx,width,height,animator)
}
initEvent(event) {
const eventNames = [{
type: '0',
ecName: 'touchstart' //鼠标按下
}, {
type: '2',
ecName: 'touchmove' //移动
}, {
type: '1',
ecName: 'touchend' //离开
}, {
type: '1',
ecName: 'click' //点击
}];
eventNames.forEach(name => {
if(event.type==name.type){
this.gameMain.bindEvent(name.ecName,event)
}
});
}
}
- 修改 MainPage.ets 给Canvas 添加
onTouch
事件将事件信息传入到后续逻辑中。
...
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor('#ffff00')
.onTouch((event:TouchEvent) => {
if(this.myGame){
this.myGame.initEvent(event);
}
})
.onReady(() =>{
this.myGame = new InitGame(this.context,this.context.width,this.context.height)
})
.width('100%')
}.height('100%')
}
...
运行效果如下
优化性能
一、我们利用 animator 代替 requestAnimationFrame
- 修改 MainPage.ets文件 引入
Animator
模块 并将Animator
传入到后续业务中
import {Animator} from '@kit.ArkUI';
import {InitGame} from "./game"
import {avPlayer} from "./avPlayer"
@Component
export struct MainPage {
private settings: RenderingContextSettings = new RenderingContextSettings(true)
private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
myGame?:InitGame|null;
build() {
Column() {
Canvas(this.context)
.width('100%')
.height('100%')
.backgroundColor('#ffff00')
.onTouch((event:TouchEvent) => {
if(this.myGame){
this.myGame.initEvent(event);
}
})
.onReady(() =>{
globalThis.avPlayer=avPlayer
this.myGame = new InitGame(this.context,this.context.width,this.context.height,Animator)
})
.width('100%')
}.height('100%')
}
}
- 修改 game.js 绑定
Animator
到globalThis
...
constructor(ctx,width,height,Animator) {
this.ctx =ctx;
globalThis.innerWidth=width//
globalThis.innerHeight=height
globalThis.Animator=Animator // 修改这里
this.gameMain= new Main(ctx)
}
...
- 修改 main.js 将
setInterval
相关逻辑用 动画帧代替,将restart
方法中this.music = new Music()
剔出来避免每次重新开始游戏,会重新创建音频对象,浪费内存,改动点有点多所以我把完整代码贴出来。
import Player from './player/index'
import Enemy from './npc/enemy'
import BackGround from './runtime/background'
import Music from './runtime/music'
import GameInfo from './runtime/gameinfo'
import DataBus from './databus'
const databus = new DataBus()
/**
* 游戏主函数
*/
export default class Main {
constructor(ctx) {
this.ctx=ctx
// 维护当前requestAnimationFrame的id
this.aniId = 0
this.initAnimationFrame();
this.restart()
this.music = new Music()
}
restart() {
databus.reset()
this.bg = new BackGround(this.ctx )
this.player = new Player(this.ctx )
this.gameinfo = new GameInfo(this.ctx )
this.hasEventBind = false
this.animatorResult.finish();
this.animatorResult.play();
setInterval()
}
initAnimationFrame(){
let options = {
duration: 1500, //时长
easing: "linear", //动画线性变化
delay: 0, //延时播放时长
fill: "forwards",
direction: "normal", //动画正向循环播放。
iterations: -1, //-1时无限次播放。
begin: 0, //动画插值起点。
end: 0 //动画插值终点。
};
this.animatorResult= globalThis.Animator.create(options)
this.animatorResult.onFrame = (value)=> {
this.loop();
}
}
/**
* 随着帧数变化的敌机生成逻辑
* 帧数取模定义成生成的频率
*/
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 += 1
break
}
}
})
for (let i = 0, il = databus.enemys.length; i < il; i++) {
const enemy = databus.enemys[i]
if (this.player.isCollideWith(enemy)) {
databus.gameOver = true
this.animatorResult.finish();
break
}
}
}
// 游戏结束后的触摸事件处理逻辑
touchEventHandler(e) {
const x = e.touches[0].x
const y = e.touches[0].y
const area = this.gameinfo.btnArea
if(area){
if (x >= area.startX
&& x <= area.endX
&& y >= area.startY
&& y <= area.endY) {
this.restart()
}
}
}
/**
* canvas重绘函数
* 每一帧重新绘制所有的需要展示的元素
*/
render() {
this.ctx.clearRect(0, 0, globalThis.innerWidth, globalThis.innerHeight)
this.bg.render(this.ctx)
databus.bullets
.concat(databus.enemys)
.forEach((item) => {
item.drawToCanvas(this.ctx)
})
this.player.drawToCanvas(this.ctx)
databus.animations.forEach((ani) => {
if (ani.isPlaying) {
ani.aniRender(this.ctx)
}
})
this.gameinfo.renderGameScore(this.ctx, databus.score)
// 游戏结束停止帧循环
if (databus.gameOver) {
this.gameinfo.renderGameOver(this.ctx, databus.score)
if (!this.hasEventBind) {
this.hasEventBind = true
// canvas.addEventListener('touchstart', this.touchHandler)
}
}
}
bindEvent(name,event){
if(name=='touchstart'){
this.touchEventHandler(event)
}
this.player.initEvent(name,event)
}
// 游戏逻辑更新主函数
update() {
if (databus.gameOver) return
this.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
// )
}
}
最后重新运行游戏,特别是在模拟器上可以清楚的看到游戏不卡了,流畅了很多。
总结
本文详细介绍了关于在华为鸿蒙系统中适配微信小游戏“打飞机”的详细教程,主要是给大家小游戏适配到鸿蒙系统提供一些思路,以及处理游戏各元素(背景、战机、敌方战机、结算界面、音乐等)的加载和渲染、绑定事件以及优化性能等。
希望这篇文章能帮到你,最后我把完整代码放到了gitee上有需要的小伙伴可以自己拉下来去试一试。