1. 项目概述与需求拆解
1.1 这个“平行与垂直”到底在做什么
先聊聊这个项目标题里的两个关键词:平行与垂直。放在光线射击游戏里,这可不是单纯描述两条线的方向,而是整个游戏机制的核心坐标系。
简单说,游戏场景里会有一个由玩家控制的光源,通常是一个反射镜或者一块可旋转的棱镜。场景中会有一条平行光束从画面一侧扫过,玩家需要在光束经过自己的可控区域时,通过调整部件状态,让光束以垂直方向射向目标靶点。这里“平行”代表系统自动扫描的光带,“垂直”代表玩家干预后产生的反射光路。两个方向一结合,就构成了“接光、转光、射光”的完整玩法循环。
这类游戏在射击玩法里算是个细分方向,核心体验不是比手速,而是比几何空间感和节奏判断。玩家要做的不是瞄准敌人,而是在一个不断变化的二维平面里,反复理解“入射角等于反射角”这个初中物理概念,并把它用在每一帧的实时操作里。
我最早接触这个玩法思路时,第一个感觉是,这东西放在HarmonyOS的ArkUI框架里做,特别合适。因为它的画面逻辑不复杂,但交互状态很多,非常适合用ArkUI的状态驱动模型来管理。你不需要依赖重型游戏引擎,一个Canvas组件加一组状态变量就能跑出完整的游戏逻辑。
1.2 面向哪些开发者和场景
这个实例适合两类人参考。
一类是刚完成鸿蒙基础组件学习、想找一个“有点交互复杂度但又不至于失控”的练习项目的开发者。做计算器、待办清单这类应用,可能练不到布局状态和动画的配合;直接上完整游戏项目,又容易在渲染循环、资源管理上被劝退。光线射击游戏正好卡在中间。
另一类是对ArkUI自定义组件和Canvas绘制感兴趣的开发者。这个游戏需要的所有视觉元素,光束、靶心、反射镜、背景网格,全部可以通过Canvas的2D绘图API实现,不需要引入外部图片资源。这就意味着你可以把精力完全集中在“状态怎么驱动渲染”这个核心问题上,而不是在资源和素材上浪费时间。
从技术覆盖面上看,这个项目能练到的东西很完整:页面路由、自定义组件封装、状态管理装饰器、Canvas绘制、动画驱动、传感器(可选)、甚至简单的物理几何计算。任何一个点拆出来,都够单独写一篇笔记,组合在一起就是一个有真实体感的完整应用。
2. 游戏机制设计与方案选型
2.1 为什么用“平行入射+垂直反射”作为核心规则
我见到很多自制的射击小游戏,一上来就陷入“子弹、敌人、血量、分数”这套传统框架。但放在鸿蒙应用开发的教学场景里,这种框架的问题在于,它太依赖外部美术资源和复杂的碰撞物理,反而掩盖了应用架构本身的学习重点。
“平行与垂直”的设计灵感直接来源于光栅扫描显示器和经典的反射谜题。把光束的入射方向固定为水平平行扫描,把玩家需要达成的光路方向固定为垂直反射,就天然构造出了一个明确的“目标状态”。
玩家旋转反射镜时,光束的方向会跟着改变。当反射方向接近垂直(也就是目标方向)时,游戏给予高亮反馈;当偏差在允许范围内时,判定为命中。这个机制的巧妙之处在于,它不需要玩家理解复杂的角度公式,只需要反复尝试“转到哪个位置能看到光路变绿”,通过视觉反馈就能形成直觉。
从这个角度看,这个游戏本质上是一个“状态对准游戏”,和传统的“躲避射击”有本质区别。它的核心较量发生在玩家的空间感知和操作精准度之间,而不是反应速度。
2.2 技术选型:ArkUI状态驱动 vs 传统游戏循环
在HarmonyOS应用开发里做游戏,通常有两种技术路线。
一种是使用方舟引擎或者集成游戏框架,类似于在应用里嵌入一个完整的游戏运行时,渲染、物理、音频全部由引擎接管。这种方式适合复杂的3D游戏或重度2D游戏,但对于这种单个页面的轻量交互游戏来说,属于杀鸡用牛刀。
另一种是ArkUI原生的状态驱动模式。你定义组件的状态数据,当状态变化时,框架自动重新渲染相关界面部分。光线射击游戏就是第二种路线的绝佳示例:一帧光束的位置、旋转角度、命中判定结果,都是清晰的状态值,可以直接映射到界面。
我在这个项目里坚定不移地选择了原生方案。原因有三个:
第一,状态管理清晰。游戏中的“当前光带横坐标”“反射镜旋转角度”“是否命中”全部可以设计为组件状态变量。调试时只需要关注状态值,不需要在渲染循环里打断点。
第二,动画系统原生可控。ArkUI的Animator和属性动画可以驱动视图的旋转、透明度、位置变化,用来做光束扫过的效果和命中后的辉光反馈,比在Canvas里手动插值计算帧更高效。
第三,跨设备适配方便。HarmonyOS应用天然支持多设备形态。手机、平板甚至折叠屏上,只要布局用好了栅格和自适应单位,游戏画面可以自动调整尺寸,不需要重写渲染逻辑。
2.3 选型时要避开的坑
如果完全照搬Flutter或者Web开发的经验,会在一个点上栽跟头:Canvas的坐标系和ArkUI布局坐标系之间的换算。
在Web里,Canvas的左上角坐标原点通常就是页面的(0,0),坐标对应关系简单直接。但在鸿蒙的ArkUI中,Canvas组件可以嵌入在任何布局位置,可能有外边距、内边距、偏移。如果你直接用Canvas内部的坐标来绘制,省事;但如果需要让某个UI组件(比如一个按钮)覆盖在Canvas的指定逻辑位置上,就必须手动计算偏移量。
我见过有人在这个问题上卡了一整天,最后绕道用“绝对定位像素值”来硬编码,结果在不同分辨率设备上全部错位。正确做法是:在Canvas的onReady回调里拿到组件的实际宽高,把所有的游戏逻辑坐标都做一次归一化处理,也就是转换成0到1之间的比例坐标,绘制时再乘以实际宽高。
3. 核心机制拆解与实现思路
3.1 平行光带扫描机制
游戏开始后,一条平行光束会从场景左边缘开始,以一个匀速向右移动,直到右边缘后重置回左边缘。这个过程是自动的,不需要玩家干预,它的作用是“逼迫”玩家在光带经过自己的可控区域时完成接光和转向操作。
实现上,光带的X坐标可以用一个状态变量来维护。使用ArkUI的帧回调能力,在每一帧刷新时更新这个坐标值。帧回调不适合用setInterval实现,因为setInterval的最小间隔不稳定,而且和界面刷新不同步。推荐使用ArkUI提供的DisplaySync或类似的动画帧驱动机制,保证更新频率和屏幕刷新率一致。
代码结构大致是:
@State beamX: number = 0; private beamSpeed: number = 0.002; // 每帧移动的比例距离 onFrame(deltaTime: number) { this.beamX += this.beamSpeed * deltaTime; if (this.beamX > 1.0) { this.beamX = 0; // 回到起点,开始下一轮 } }这里的beamX是归一化坐标,0代表左边缘,1代表右边缘。归一化的好处后面细说。
光带的视觉表现是两条平行线中间夹着一层半透明光晕,形成一个“光带”而不是一条单线。这个设计既有视觉效果上的考虑,也有游戏机制上的考虑:如果是一条单线,玩家很难判断“光带中心”到底在哪里;宽光带则给了玩家一定的容错空间。
3.2 垂直反射机制与对称反馈
当光带扫过屏幕中部的反射镜区域时,画面会进入“瞄准阶段”。反射镜默认朝向一个方向,玩家可以通过滑动或点击按钮来调整反射镜的旋转角度。反射镜的几何中心是固定的,旋转轴也固定,玩家能改变的只有镜面法线的方向。
反射计算逻辑遵循经典的光学反射定律:入射光方向已知,法线方向由反射镜旋转角度决定,反射光方向等于入射方向关于法线的对称向量。这里需要特别注意,游戏里的反射计算全部在二维平面内完成,所以可以把向量运算简化为角度运算。
具体来说,入射光方向固定为向右,也就是0度方向。反射镜的可旋转角度范围设定为-45度到45度,那么反射光的出射方向就是入射方向关于镜面法线的对称方向。如果我们规定镜面法线角度为镜面转角加90度,那么反射角度的计算就是一个简单的换算:
calculateReflectAngle(mirrorAngle: number): number { // 入射角为0度(向右),法线角度为mirrorAngle + 90 let normalAngle = mirrorAngle + 90; // 反射角 = 2 * 法线角 - 入射角 let reflectAngle = 2 * normalAngle - 0; return normalizeAngle(reflectAngle); }这里的normalizeAngle把角度值限制在0到360度之间,避免负角度或大于360的角度出现。
判断命中的条件,并不是反射角恰好等于90度,而是允许一个容差范围。我最初设计的容差是正负5度,实测发现这个范围对大多数新手玩家来说过于严格,光带扫过的时间窗口本身就短,还要同时调整角度,很容易挫败。后来把容差放宽到了正负10度,游戏体验明显改善。
命中后,系统给出一段垂直向下的高亮光束视觉反馈,然后重新开始下一轮扫描。
3.3 游戏状态机设计
整个游戏流程可以抽象为三个状态:搜索阶段、瞄准阶段、射击阶段。
搜索阶段是光带自动扫描,玩家看到屏幕上的光带从左向右移动,反射镜区域处于待命状态。这个阶段不需要玩家操作,玩家要做的是观察和预判。
当光带中心进入反射镜的可响应区域范围时,进入瞄准阶段。画面上的反射镜高亮,提示玩家可以旋转调整。此时如果玩家没有在限定时间内完成角度调整,光带会继续前进离开区域,状态回到搜索阶段。
如果玩家在限定时间内完成了角度调整并且反射角落在容差范围内,进入射击阶段。垂直光束从反射镜位置射出,命中屏幕底部的靶心,播放命中动画,得分增加,然后重置回搜索阶段。
三个状态用TypeScript的枚举类型来管理。每个状态对应一组UI表现和交互响应逻辑,状态之间通过明确的边界条件迁移。这样设计的好处是,游戏逻辑和渲染逻辑完全解耦,出问题时可以分别排查。
4. 实操过程与关键代码实现
4.1 搭建项目骨架与页面结构
创建一个新的HarmonyOS应用项目,语言选择ArkTS。项目创建完成后,动工前先把目录结构理好,不要把所有代码堆在一个页面文件里。
我的目录规划是这样的:
- entry/src/main/ets/entryability:入口Ability,保持默认。
- entry/src/main/ets/pages:页面目录,放置主游戏页面Index.ets。
- entry/src/main/ets/model:游戏数据模型,定义光束状态、游戏阶段枚举。
- entry/src/main/ets/common:公共组件,封装反射镜和靶心自定义组件。
页面结构上,我使用一个垂直方向的布局栈,底层是Canvas画布绘制游戏背景、光带、反射镜和靶心,上层覆盖一个半透明的操作面板,展示分数和操作提示。这里使用Stack布局最合适,能让游戏画面和操作面板处于同一个视觉层叠空间。
4.2 用Canvas绘制游戏元素
ArkUI的Canvas组件通过CanvasRenderingContext2D对象提供绘图能力。这个对象和Web Canvas的API高度相似,有moveTo、lineTo、stroke、fill这些基础方法,熟悉Web前端的人可以无缝迁移。
先画背景网格。网格的视觉价值不只是好看,它能给玩家提供空间参照,帮助判断光带位置和角度关系。横向和纵向每隔固定像素画一条浅色线,形成网格纹理。注意网格间距不用归一化,直接用像素绘制即可,因为背景不需要响应游戏逻辑变化。
drawGrid(context: CanvasRenderingContext2D, width: number, height: number) { const step = 40; context.strokeStyle = '#1F2A3A'; context.lineWidth = 1; for (let x = 0; x <= width; x += step) { context.beginPath(); context.moveTo(x, 0); context.lineTo(x, height); context.stroke(); } for (let y = 0; y <= height; y += step) { context.beginPath(); context.moveTo(0, y); context.lineTo(width, y); context.stroke(); } }光带的绘制包含三层:半透明的宽光晕、较窄的中间光、一条高亮的中心线。这样层层叠加才有“光束”的质感,而不是一根干巴巴的线段。透明度可以通过context.globalAlpha来控制,每帧更新时,光晕层的透明度可以做一个轻微的波动,模拟光闪烁的效果。
反射镜的绘制稍复杂,因为需要表现旋转。绘制前先保存当前上下文,然后把坐标原点平移到反射镜中心,旋转当前角度,再绘制一个矩形代表镜面。绘制完成后调用restore恢复上下文,避免旋转状态干扰后续绘制。
4.3 状态变量与数据绑定
游戏的核心状态定义在一个类里,作为页面的成员变量。ArkUI的响应式装饰器负责把这些状态绑定到UI:
- @State修饰的变量,用于页面内部可变的UI状态。
- @Prop用于父组件向子组件传递的单项数据流。
- @Link用于父子组件之间的双向同步。
在这个游戏里,反射镜子组件需要知道当前的镜面旋转角度,同时把角度变化同步回父页面。这种场景用@Link最合适。
@Component export struct MirrorComponent { @Link mirrorAngle: number; build() { Column() { Text(`当前角度: ${this.mirrorAngle.toFixed(0)}°`) .fontSize(14) .fontColor('#FFFFFF'); } .onTouch((event: TouchEvent) => { if (event.type === TouchType.Move) { // 根据手指的水平移动距离调整角度 this.mirrorAngle += event.touches[0].x * 0.1; this.mirrorAngle = Math.max(-45, Math.min(45, this.mirrorAngle)); } }) } }触摸调整角度时,把手指移动的像素值乘以一个系数后累加到角度值上。这个系数的选择很关键:系数太大会导致旋转过快、难以微调;太小则操作迟钝。我实测下来,0.1度/像素比较合适,玩家可以用一个较长的滑动手势覆盖整个45度范围,同时精调时也比较稳定。
4.4 动画与命中反馈
命中反馈是游戏体验的高光时刻,一定要做足。垂直光束命中靶心后,我设计了三层反馈:
- 靶心位置产生一个向外扩散的环形波纹,持续约300毫秒。
- 屏幕底部出现一个短暂的色块闪烁提示,表示得分增加。
- 靶心本身经历一次缩放动画,从正常大小迅速放大再缩回,产生“中弹震动”的感觉。
用ArkUI的animateTo方法实现这些动画非常方便。animateTo接受一个参数对象描述动画持续时间和曲线,内部发生状态变化后,系统会自动用动画过渡而不是瞬间跳变。命中时,我让靶心的scale状态从1变为1.4再回到1,配合animateTo就生成了缩放动画。
这里有一点要提醒,animateTo需要配合状态变化一起使用才会生效。如果你改了状态但没包在animateTo里,界面就是瞬变的;包了才有过渡。命中后的波纹扩散动画,我是在Canvas里用请求动画帧的方式实现的,因为波纹的位置和半径需要实时计算,不适合用状态驱动。
4.5 完整的碰撞判定逻辑
命中判定不依赖Canvas绘制,而是每帧在逻辑层计算。反射镜的位置是固定的,反射镜中心坐标是已知的。光带的Y坐标等于反射镜中心Y坐标时,就可以认为光带到达了反射镜的可响应区域。
但用户看到的“光带中心”和“可响应区域”之间要留出足够的余量。我设定的判定条件是:光带中心线距离反射镜中心线在水平方向上的偏差小于光带宽度的一半时,触发瞄准状态。
反射光线的碰撞判定更简单:光带到达反射镜位置后,根据当前反射角判断其方向。如果反射角在85度到95度之间(对应容差正负5度的更严格模式),同时光带未离开响应区域,就判定为命中。这里的角度容差我加了两个档位:简单模式正负10度,困难模式正负5度,玩家可以在设置里切换。
checkHit(beamX: number, mirrorAngle: number): boolean { // 光带是否在响应区域内 if (Math.abs(beamX - mirrorCenterX) > beamWidth / 2) { return false; } // 反射角是否在容差范围内 let reflectAngle = this.calculateReflectAngle(mirrorAngle); return Math.abs(reflectAngle - 90) <= this.tolerance; }5. 问题排查与经验复盘
5.1 光带移动的帧率不稳定
第一个遇到的问题是光带移动不流畅。在部分设备上,光带看起来一顿一顿的,持续一段时间后会突然跳一下。排查发现,是我最初用了setInterval来实现每帧更新,间隔设置为16毫秒。但实际上setInterval的触发并没有和屏幕刷新同步,而且当主线程有其他任务时会被延迟。
解决方法是改用ArkUI的帧回调能力,通过requestAnimationFrame类似的接口(具体到鸿蒙ArkUI,可以使用DisplaySync接口)来驱动光带位置更新。帧回调会跟随屏幕刷新率自动触发,保证动画流畅度。
5.2 光带与反射镜错位
在游戏测试过程中,出现过光带明明已经经过反射镜位置,但画面上的反射镜没有进入高亮状态的情况。这说明光带中心的判断坐标和反射镜的实际绘制坐标不在同一个坐标系里。
原因是我在绘制光带时使用了归一化坐标乘以Canvas宽度,而反射镜的位置定义用了布局中的百分比定位。两种定位方式的基准不同,导致换算误差。修复方式是把所有游戏元素的坐标统一为归一化坐标系,在绘制时统一乘以Canvas的宽高。这样既保证了逻辑一致,也为后续适配不同屏幕比例打好了基础。
5.3 旋转角度累计误差导致反射角漂移
如果玩家持续向同一个方向旋转反射镜,角度值会不断增大或减小,超过我设置的-45到45度的范围时,反射角计算会出现漂移。
我最初的处理方式是直接对触摸事件加的角度不设上下限,结果反射镜转了几圈后,角度值变成了几百,计算反射角时虽然做了归一化,但因为浮点运算的精度问题,产生了细微的角度漂移,导致明明看起来对的镜面角度,判定结果却不命中。
修复方案是在每次角度更新后立即做范围限制:
this.mirrorAngle = Math.max(-45, Math.min(45, this.mirrorAngle));这个限制不仅保证了计算正确性,也保证了玩家每次游戏的操作范围一致,反射镜不会出现“越转越乱”的情况。
5.4 性能问题:光带半透明层重叠导致闪烁
在低端设备上,光带绘制时因为多层半透明叠加,移动过程中出现了明显的闪烁和噪点。这是因为半透明层在每帧重绘时,旧的绘制痕迹未能完全清除,加上设备的GPU合成能力有限,产生了视觉伪影。
解决的办法有两个。一是在每帧绘制前调用clearRect清空画布,不要依赖Canvas自动清理。二是降低光带半透明层的层数,从三层减为两层,视觉上差别不大,但性能提升非常明显。实测下来,帧率从大约50帧提升到了接近满帧。
5.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 光带移动卡顿 | setInterval帧率不稳定 | 改用帧回调驱动 |
| 光带不进入瞄准状态 | 坐标系不统一 | 全部逻辑坐标改为归一化 |
| 旋转角度越转越乱 | 角度未限幅 | 每次更新后强制限制范围 |
| 低端设备闪烁 | 半透明层过多或未清空 | 减少半透明层、每帧clearRect |
| 命中判定偶发失败 | 容差范围过小 | 调整容差档位或优化角度计算 |
| 不同设备布局错位 | 硬编码像素坐标 | 使用自适应布局和归一化坐标 |
6. 进一步扩展方向与个人心得
6.1 玩法扩展:从单光束到多光束
目前的游戏一次只扫描一条光带,节奏相对简单。若想提升趣味性和难度,可以考虑多光束机制:多条平行光带以不同速度、不同间隔扫描,玩家需要依次匹配不同容差角度。每一条光带的处理需要独立状态,公用同一个反射镜,这就要求玩家在极短时间内连续完成多次角度调整,对操作精度和时间分配提出更高要求。
实现多光束在状态管理上并不复杂,只需要把单一光束状态变量替换为一个数组,循环遍历处理每个光束的移动与命中判定。这个扩展用到的知识点还是那些,但综合度上了一个台阶。
6.2 交互扩展:传感器控制反射镜
手机上通常集成陀螺仪传感器。把这个项目中的滑动操作改为“倾斜手机控制反射镜角度”,游戏体验会有一个质的飞跃。通过HarmonyOS的传感器接口获取设备姿态角,将绕Z轴的旋转角映射到反射镜的旋转角度。玩家不再用手指滑动屏幕,而是像握着一块实体镜子一样,通过转动手机来调整光路。
这类交互方式在游戏里不算新鲜,但放在鸿蒙应用开发练习里很有价值,能让开发者接触到传感器生命周期管理、数据频率调节、角度数据平滑滤波等知识点。其中角度数据的平滑处理尤其关键,直接用原始传感器数据会出现明显的抖动,需要在代码里加一个低通滤波,把高频噪声滤掉,玩家操作才跟手。
6.3 个人心得:状态驱动思维的价值
做完这个项目,最深的体会是:在ArkUI框架下做游戏,架构思维比写代码本身更重要。传统开发里“每帧重绘一切”的惯性思维,在这里需要被“状态变化驱动局部刷新”取代。
比如光束移动,我用状态变量记录位置,每帧更新这个状态,框架自动去刷新Canvas。命中反馈的动画,我用状态变量触发,框架自动播放过渡动画。这种响应式编程思路在学习时会有短暂的不适应,但一旦熟练,写出来的代码逻辑清晰、可维护性极高,远比在传统游戏循环里维护一堆全局标志位舒服。
把项目里的每一个状态转换都梳理清楚,你会发现,这已经不只是一个游戏了,而是一个充分运用声明式UI、状态管理和Canvas绘图能力的完整架构案例。依照这套模式,后续做更多有互动的应用场景,比如可视化编辑器、手势绘图板、实时仪表盘等,底层思路都是相通的。这个“平行与垂直”的光线射击游戏,价值不只在游戏本身,而在它帮你建立的那套鸿蒙开发思维方式。