
使用鸿蒙Next复刻一个上古小游戏:《是男人就坚持100秒》 原创
- 介绍
- 环境搭建
- 代码结构解读
- canvas交互开发原理简介
- 构建应用界面
- canvas(游戏核心功能)组件的实现
- 绑定TextTimer的时间信息
- 订阅角度传感器
- 数据持久化存储
- 总结
介绍
本篇Codelab将介绍如何使用canvas结合sensor(手机传感器服务),实现一个简单的小游戏。要求完成以下功能:
游戏内容是玩家操作一个小球(黄色小球,上方有红色箭头作为标记)在屏幕中移动,躲避其他敌人小球的追击,如果触碰到敌人小球则游戏结束,在菜单界面显示本次的生存时间与最高纪录的生存时间。
游戏包含四个不同的难度,同屏的敌人小球的数量上限与移动速度根据难度依次递增,不同难度下的最高纪录相互独立,玩家可以反复游玩,以挑战更久的生存时长记录。
主要学习内容为:
- 学习如何基于变化的参数开发简单的canvas交互动画效果。
- 学习如何使用TextTimer组件显示计时器时间,并将时间传递给其他变量。
- 学习如何订阅传感器,获取传感器监听信息。
- 通过持久化存储保存数据。
相关概念:
- Canvas:提供画布组件,用于自定义绘制图形。
- CanvasRenderingContext2D:使用RenderingContext可以在Canvas组件上进行绘制,绘制对象可以是矩形、文本、图片等。
- TextTimer:通过文本显示计时信息并控制其计时器状态的组件。
- 方向传感器:可以提供手机在xyz轴方向的旋转角度信息,订阅方向传感器数据,系统返回相关数据。
- setInterval:(定时器接口)重复调用一个函数,在每次调用之间具有固定的时间延迟。通过定时器与canvas擦除重绘的结合就能实现类似动画的图像效果。
- PersistentStorage:持久化存储UI状态 。state与appstorage保存的变量在应用重启后会丢失。将PersistentStorage与appStorage协同使用,就可以方便实现变量的持久化存储。【适用于数据结构简单的轻量级数据,否则可能会数据持久化失败】
相关权限
无
环境搭建
我们首先需要完成HarmonyOS开发环境搭建,可参照如下步骤进行。
软件要求
DevEco Studio版本:DevEco Studio API13 及以上。
HarmonyOS SDK版本:HarmonyOS NEXT API13 及以上。
硬件要求
设备类型:华为手机。
HarmonyOS系统:HarmonyOS NEXT API13 及以上。
环境搭建
安装DevEco Studio,详情请参考下载和安装软件。
设置DevEco Studio开发环境,DevEco Studio开发环境需要依赖于网络环境,需要连接上网络才能确保工具的正常使用,详情请参考配置开发环境。
开发者可以参考以下链接,完成设备调试的相关配置:
使用真机进行调试
代码结构解读
本篇Codelab只对核心代码进行讲解,对于完整代码,我们会在源码下载中提供。
本codelab的有效功能代码结构如下:
entry/src/main/ets // 代码区
├──utils
│ └──StepsUtil.ets // 小球的属性类、角度管理类
└──pages
└──Index.ets // 应用代码
- 1.
- 2.
- 3.
- 4.
- 5.
canvas交互开发原理简介:
canvas工作原理简介:
canvas的本质是一个画板,在绑定了CanvasRenderingContext2D对象或OffscreenCanvasRenderingContext2D对象以后,可以通过输入指令的方式往画板上绘制内容,绘制的内容类型可以是基础形状、文本、图片等。
在代码逻辑中增加对指令的选择,就可以差异化的生成各种不同的画面,并且由于canvas内容的绘制是基于坐标系来定位的,我们可以对这个画板进行像素级的内容定制,。
Canvas提供画布组件,用于自定义绘制图形,
开发者使用CanvasRenderingContext2D对象和OffscreenCanvasRenderingContext2D对象在Canvas组件上进行绘制,。
canvas动画的原理:
动画的本质是连续播放的图像,通过定时器与canvas的结合,不断擦除并重绘图像,就能做出动画效果。
开发者使用CanvasRenderingContext2D对象和OffscreenCanvasRenderingContext2D对象在Canvas组件上进行绘制。
canvas坐标系:
canvas中的元素大小和位置是以canvas坐标系为基础确定的。
在默认情况下,canvas画布的左上角未坐标原点(0,0)。水平方向为X轴(向右为正值),垂直方向为Y轴(向下为正值)。
使用clearRect(x: number, y: number, w: number, h: number)清除指定区域内的绘制内容。
本案例中使用的canvas指令汇总:
fillStyle 指定绘制的填充色。
clearRect(x: number, y: number, w: number, h: number): void 删除指定区域内的绘制内容。
translate(x: number, y: number): void 移动当前坐标系的原点。
beginPath(): void 创建一个新的绘制路径。
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, counterclockwise?: boolean): void 绘制弧线路径。
closePath(): void 结束当前路径形成一个封闭路径。
stroke(path?: Path2D): void 进行边框绘制操作。
fill(fillRule?: CanvasFillRule): void 对封闭路径进行填充。
moveTo(x: number, y: number): void 路径从当前点移动到指定点。
lineTo(x: number, y: number): void 从当前点到指定点进行路径连接。
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
构建应用界面
应用界面以stack组件为根节点,依次堆叠四个组件构成,分别是:canvas(游戏核心功能)组件、游戏计时器器组件、(开始)菜单组件、暂停菜单组件。
build() {
Stack(){
//canvas(游戏核心功能)组件
Canvas(this.context).width("100%").height("100%").onReady(() => {
this.isGameOver=true
this.canvasReady()
this.randomBackBalls()
})
.border({width:1,color:Color.Red})
.flexGrow(1)
.onClick(()=>{
this.gamePause()
})
//游戏计时器器组件
TextTimer({ isCountDown: false, controller: this.textTimerController })
.format('mm:ss.SS')
.fontColor(Color.Black)
.position({x:0,y:0})
.fontSize(30)
.onTimer((utc: number, elapsedTime: number) => {
console.info('textTimer notCountDown utc is:' + utc + ', elapsedTime: ' + elapsedTime)
this.liveTime[this.level]=elapsedTime
if(this.maxLiveTime[this.level]<this.liveTime[this.level]){
this.maxLiveTime[this.level]=this.liveTime[this.level]
}
})
if (this.isGameOver == true) {
//开始菜单的代码
Column() {
Column({space:15}) {
Text(`最高纪录:${Math.floor(this.maxLiveTime[this.level] / 1000)}:${Math.floor(this.maxLiveTime[this.level] % 1000 / 10)}`)
Text(`本次生存时长:${Math.floor(this.liveTime[this.level] / 1000)}:${Math.floor(this.liveTime[this.level] % 1000 / 10)}`)
Text(`难度:${this.difficultyName[this.level]}`)
List(){
ForEach(this.difficultyName,(res:string,index)=>{
ListItem(){
Text(res)
.fontColor(this.level==index?Color.White:Color.Black)
.backgroundColor(this.level==index?Color.Blue:Color.Gray)
.padding(5)
}
.onClick(()=>{this.level=index})
})
}
.listDirection(Axis.Horizontal)
.height(30)
Button('开始游戏')
.onClick(() => {
this.gameRestart()
})
}
.width('80%')
.backgroundColor(Color.White)
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.padding(10)
}
.height('100%')
.width('100%')
.backgroundColor('rgba(0,0,0,0.4)')
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.border({width:1,color:Color.Orange})
}
if(this.gamePausing==true){
...//暂停菜单的代码
}
}.width("100%").height("100%").alignContent(Alignment.Center)
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
- 41.
- 42.
- 43.
- 44.
- 45.
- 46.
- 47.
- 48.
- 49.
- 50.
- 51.
- 52.
- 53.
- 54.
- 55.
- 56.
- 57.
- 58.
- 59.
- 60.
- 61.
- 62.
- 63.
- 64.
- 65.
- 66.
- 67.
- 68.
- 69.
- 70.
- 71.
- 72.
- 73.
- 74.
开发canvas(游戏核心功能)组件
初始化canvas画布
清理画布并将canvas画布的中心点设置为坐标系原点
canvasReady() {//初始化canvas画布
this.context.clearRect(-this.context.width, -this.context.height,this.context.width*2,this.context.height*2)
this.context.translate(this.context.width/2, this.context.height/2)
}
- 1.
- 2.
- 3.
- 4.
生成随机背景
为了增加用户体验,在游戏处于菜单界面时,生成30个随机运动的小球作为背景。
randomBackBalls(){//游戏等待时间随机生成的背景动画
//确认小球数量
if(this.backgroundList.length!=30){
this.backgroundList=[]
for (let index = 0; index < 30; index++) {
let X=Math.floor(this.context.width/2-Math.random()*this.context.width)
let Y=Math.floor(this.context.height/2-Math.random()*this.context.height)
let size=Math.ceil(Math.random()*10)
let XSpeed=Math.ceil(Math.random()*10)
let YSpeed=Math.ceil(Math.random()*10)
this.backgroundList.push(new ballInfo(X,Y,size,XSpeed,YSpeed))
}
}
//定时自动更新画布
this.readyTimerId=setInterval(()=>{
//计算元素位置
for (let index = 0; index < 30; index++) {
this.backgroundList[index].x+=this.backgroundList[index].XSpeed
this.backgroundList[index].y+=this.backgroundList[index].YSpeed
if(this.backgroundList[index].x>=this.context.width/2 || this.backgroundList[index].x<=-this.context.width/2){
this.backgroundList[index].XSpeed = -this.backgroundList[index].XSpeed
}
if(this.backgroundList[index].y>=this.context.height/2 || this.backgroundList[index].y<=-this.context.height/2){
this.backgroundList[index].YSpeed = -this.backgroundList[index].YSpeed
}
}
//清理画布
this.context.clearRect(-this.context.width, -this.context.height,this.context.width*2,this.context.height*2)
//依次绘制元素
for (let index = 0; index < this.backgroundList.length; index++) {
this.context.fillStyle = 'rgba(90,60,90,1)';
this.context.beginPath()
this.context.arc(this.backgroundList[index].x, this.backgroundList[index].y, this.backgroundList[index].size, 0, 6.28)
this.context.closePath()
this.context.stroke()
this.context.fill()
}
},100)
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
- 40.
计算游戏中下一帧中每个元素所处的位置
玩家小球根据角度传感器传来的方向信息进行移动,敌人小球不断地向玩家小球所处的位置靠近。
NextFrame(){//单帧的移动
this.player.x +=this.player.XSpeed;
this.player.y +=this.player.YSpeed
for (let index = 0; index < this.enemyList.length; index++) {
if (this.enemyList[index].x > this.player.x) {
if(this.enemyList[index].x-1*(this.level+1)<= this.player.x){
this.enemyList[index].x=this.player.x
}else{
this.enemyList[index].x -= 1*(this.level+1)
}
} else if (this.enemyList[index].x < this.player.x) {
if(this.enemyList[index].x+ 1*(this.level+1)>= this.player.x){
this.enemyList[index].x=this.player.x
}else{
this.enemyList[index].x += 1*(this.level+1)
}
}
if (this.enemyList[index].y > this.player.y) {
if(this.enemyList[index].y-1*(this.level+1)<= this.player.y){
this.enemyList[index].y=this.player.y
}else{
this.enemyList[index].y -= 1*(this.level+1)
}
} else if (this.enemyList[index].y < this.player.y) {
if(this.enemyList[index].y+ 1*(this.level+1)>= this.player.y){
this.enemyList[index].y=this.player.y
}else{
this.enemyList[index].y += 1*(this.level+1)
}
}
if ((this.enemyList[index].x - this.player.x)**2 + (this.enemyList[index].y - this.player.y)**2 < (this.enemyList[index].size + this.player.size)**2) {
this.gameOver()
}
}
this.redrawCanvas()//将当前帧绘制到画布上
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
- 33.
- 34.
- 35.
- 36.
- 37.
- 38.
- 39.
刷新页面
将画布的内容全部清除并根据数据画出下一帧的内容
redrawCanvas(){//游戏过程中每一帧的更新内容
//清理画布
this.context.clearRect(-this.context.width, -this.context.height,this.context.width*2,this.context.height*2)
//绘制玩家小球
if(this.player.size!=0){
this.context.fillStyle = 'rgba(255,215,0,1)'; // 设置玩家操纵小球为金色
this.context.beginPath()
this.context.arc(this.player.x, this.player.y, this.player.size, 0, 6.28)
this.context.closePath()
this.context.stroke()
this.context.fill()
this.context.fillStyle = 'rgba(250,0,0,1)'; // 指示玩家位置的红色箭头
this.context.beginPath()
this.context.moveTo(this.player.x, this.player.y-8)
this.context.lineTo(this.player.x-5, this.player.y-15)
this.context.lineTo(this.player.x+5, this.player.y-15)
this.context.closePath()
this.context.stroke()
this.context.fill()
}
//绘制敌人
for (let index = 0; index < this.enemyList.length; index++) {
this.context.fillStyle = 'rgba(90,60,90,1)'; // 敌人的颜色
this.context.beginPath()
this.context.arc(this.enemyList[index].x, this.enemyList[index].y, this.enemyList[index].size, 0, 6.28)
this.context.closePath()
this.context.stroke()
this.context.fill()
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
- 22.
- 23.
- 24.
- 25.
- 26.
- 27.
- 28.
- 29.
- 30.
- 31.
- 32.
绑定TextTimer的时间信息
TextTimer是文本形式的计时器组件,通过start/pause/reset来控制计时器的启停。可在onTimer事件中获取计时器的最新时间数据,同步给其他变量使用。
TextTimer({ isCountDown: false, controller: this.textTimerController })
.format('mm:ss.SS')
.fontColor(Color.Black)
.position({x:0,y:0})
.fontSize(30)
.onTimer((utc: number, elapsedTime: number) => {//文本jishuqi
console.info('textTimer notCountDown utc is:' + utc + ', elapsedTime: ' + elapsedTime)
this.liveTime[this.level]=elapsedTime
if(this.maxLiveTime[this.level]<this.liveTime[this.level]){
this.maxLiveTime[this.level]=this.liveTime[this.level]
}
})
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
订阅角度传感器
通过订阅获取到计步传感器数据,解析后将beta/gamma两个参数用于游戏控制。
sensor.on(sensor.SensorId.ORIENTATION, (data: sensor.OrientationResponse) => {
if(this.originalDirection.x==0){
this.originalDirection.x=data.alpha//沿侧边的旋转//本案例中不使用
}
if(this.originalDirection.y==0){
this.originalDirection.y=data.beta//沿屏幕垂直方向的旋转
}
if(this.originalDirection.z==0){
this.originalDirection.z=data.gamma//沿屏幕水平方向的旋转
}
this.player.YSpeed=(this.originalDirection.y-data.beta)/3
this.player.XSpeed=(this.originalDirection.z-data.gamma)/3
if(this.player.x+this.player.XSpeed>=this.context.width/2 || (this.player.x+this.player.XSpeed)<=-this.context.width/2){
this.player.XSpeed=0
}
if(this.player.y+this.player.YSpeed>=this.context.height/2 || (this.player.y+this.player.YSpeed)<=-this.context.height/2){
this.player.YSpeed=0
}
console.info('Succeeded in invoking on. Scalar quantity: ' + data.alpha);
}, { interval: 100000 });
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
- 13.
- 14.
- 15.
- 16.
- 17.
- 18.
- 19.
- 20.
- 21.
数据持久化存储
在使用AppStorage之前调用PersistentStorage.persistProp,可从设备中获取到之前持久化的对应变量。
之后修改同名的appStorage变量,即可将最新的变量值持久化存储到本地磁盘。
PersistentStorage.persistProp<number[]>('maxLiveTime',[0,0,0,0])
@Entry
@Component
export struct miniGame{
@StorageLink('maxLiveTime') maxLiveTime:number[]=[0,0,0,0]//历史分数
...
build{
...
}
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
- 8.
- 9.
- 10.
- 11.
- 12.
总结
您已经完成了本次Codelab的学习,并了解到以下知识点:
- 学习如何基于变化的参数开发简单的canvas交互动画效果。
- 学习如何使用TextTimer组件显示计时器时间,并将时间传递给其他变量。
- 学习如何订阅传感器,获取传感器监听信息。
- 通过持久化存储保存数据。
作者:陈胜歌
华为HDE,鸿蒙先锋,开发者达人,南京星梦之舟联合创始人,坚果派成员
