#HarmonyOS NEXT体验官# 手把手教你适配微信小游戏-打飞机 原创 精华

无言de对话
发布于 2024-7-27 08:51
浏览
0收藏

前言

大家好,我是无言,有一段时间没有更新了。主要是这段时间在参与鸿蒙开源项目McCharts ,它是一个基于 ArkTS 语法封装的图表组件,使用方式高度类似Echarts,而且可以运行在ArkTS 3+版本以上的任意版本当中,性能反馈非常不错,大家对鸿蒙开发感兴趣的可以去尝试一下。

本来在写这篇文章之前,我还想在鸿蒙中适配一些游戏引擎,例如pixiJsThreeJs,奈何现在鸿蒙Canvas 不支持 WebGL,做了一些尝试,发现确实改动的地方太多了,就只有暂时搁置。

目的

通过本篇文章,小伙伴们能学到什么?我简单的总结了一下大概有以下几点。

  • 对于将自己以前写过的一些小游戏适配到鸿蒙系统中,有了一些思路和方向。
  • 了解在鸿蒙中 Canvas 绘制drawImage图片的一些区别。
  • 了解如何将鸿蒙中的触摸Touch事件传入到自己游戏逻辑里面。
  • 了解鸿蒙中没有动画帧requestAnimationFrame,我们如何用其他方法代替。
  • 了解在 Canvas中如何确定自己点击的区域,然后触发对应的事件。
  • 了解用Canvas实现游戏的一些基本逻辑,例如序列帧动画。

我们先来看看效果

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

实现步骤

一、准备工作
  • 安装好最新DevEco Studio 开发工具,创建一个新的空项目, 然后创建并引入Static Library模块。具体详细信息可以参考我之前的一篇文章基于Echarts封装开发一个OpenHarmony三方库,我就不在重复讲了。

  • 微信开发工具新建微信小游戏,然后选择第一个飞机游戏模板。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

二、引入代码

将微信小游戏那边的js 代码引入到鸿蒙项目中的 Static Library 模块中,我这里将Library模块命名为airplane,目录结构如下。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

三、在主模块 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 即可。
 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

  • 修改 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等文件是不能预览的)。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

四、在模块 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

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

  • 继续修改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
    )
  }
}

很奇怪运行结果,显示图片并没有渲染。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

看了下官方文档drawImage用到的图片是需要用new ImageBitmap() 而不是我们平时用的 new Image(),而且type为"entry"和"feature"类型的Module,其图片加载路径的起点为当前Module的ets文件夹。所以只能把图片资源放在主模块中。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

  • 将微信小游戏那边图片资源文件,放入鸿蒙项目中的主模块中,目录结构如下。
     #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

  • 修改 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)
  }
}

运行一下 看看效果,背景图片是出来了,但是和我预期差距很大。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

  • 看起来应该是他背景拼接不对,以及图片大小识别单位尺寸有问题,所以重新修改一下 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
    )
  }
}

再运行一下看看效果,不错是我想要的效果

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

六、加载我们的战机
  • 修改js/player/index.js 先把 initEvent 绑定事件去掉,用globalThis.innerWidthglobalThis.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
  // )
}
...

运行 效果如下,可以看到我们的战机 成功加载了。
 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

七、加载敌方战机(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
    }
  }
}
...

运行效果如下,可以看到不仅敌方战机都出来了,而且我方战机子弹也跟着出来了,意外之喜。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

八、加载游戏结算界面
  • 修改 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
    }
  }
}

游戏碰撞 结束后结算界面出来了,但是渲染雪碧图出现了问题,还有文字大小也不对。
 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

  • 重新修改一下 js/runtime/gameinfo.js 把文字单位px 换成vp,打印了一下图片获取到的宽度是170.66的样子,然后重新找到这张图片可以发现这个图片的真实宽度是512,所以我们换算一下。

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区




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
    }
  }
}

修改后重新运行可以看到结束界面正常显示了。
 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

九、把音乐加载上 (注意背景音乐只有在模拟器和真机上有效,预览无效)
  • 在 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%')

}
...

运行效果如下

 #HarmonyOS NEXT体验官#  手把手教你适配微信小游戏-打飞机-鸿蒙开发者社区

优化性能

一、我们利用 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 绑定 AnimatorglobalThis
...
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上有需要的小伙伴可以自己拉下来去试一试。

©著作权归作者所有,如需转载,请注明出处,否则将追究法律责任
已于2024-8-4 12:24:48修改
1
收藏
回复
举报
回复
    相关推荐