如果你想在HarmonyOS应用开发里找一个能把状态管理、Canvas绘图、触摸交互和物理运动全部串起来的练手项目,我非常推荐从“抛物线篮球投篮模拟”开始。这个案例看起来只是让一颗球飞向篮筐,但真正动手后你会发现,ArkUI的声明式界面、Canvas自绘、TouchEvent响应、定时动画、物理建模、碰撞判定这些核心能力全都会被逼着过一遍。你拖拽角度和力度,球就会按斜抛运动飞向篮板和篮筐,系统判断是否命中并计分,整个过程直观又有游戏感,做完了也非常适合拿去和同事朋友演示。
这个项目适合刚把ArkTS基础语法过完、但对 ArkUI 自定义绘制还不太熟的人,也适合那些想在一个稍完整的工程里理解“动画到底该怎么驱动”“Canvas 为什么需要手动清屏”的开发者。下面我按自己从零实现这个项目时的完整过程来写,包含物理模型的选择、绘制方案、投篮交互设计、命中判定以及真机调试里踩过的坑。整个过程不依赖第三方库,一个标准工程就能跑通,代码量也不大,但能帮你在HarmonyOS应用开发这条路上形成一套自己的“综合项目手感”。
1. 抛物线模型:先把坐标系、重力加速度和初速度这三件事定下来
1.1 侧视图坐标系下的斜抛公式
做投篮模拟,第一件事不是写代码,而是把坐标系和公式统一好。HarmonyOS的Canvas和绝大多数图形系统一样,坐标系原点在画布左上角,x轴向右,y轴向下。这和我们在数学课本里默认的“y轴向上”是相反的,如果你直接照抄物理公式,球大概率会往反方向飞。
侧视角度的篮球运动,本质是斜抛运动。假设出手点是 (x0, y0),初速度为 v,出手方向与水平方向夹角为 θ,那么水平方向的初速度是 vx = v * cos(θ),竖直方向的初速度是 vy = v * sin(θ)。在屏幕坐标系里,竖直初速度的方向是朝上的,但y轴向下,所以竖直位移公式要写成:
typescript复制// t 是从出手开始经过的时间,单位秒
let x = x0 + vx * t;
let y = y0 - vy * t + 0.5 * g * t * t;
注意这个式子里,-vy * t 让球先向上飞,后面的 + 0.5 * g * t * t 又让球慢慢被重力拉回来。如果写成 +vy * t,球就会一出手就往下掉,这是新手最容易犯的错误。
这里的 g 并不是真实物理世界里的 9.8 m/s²。因为屏幕空间使用的是vp(虚拟像素)单位,g 应该取一个和你画布尺寸匹配的经验值。我自己常用的是 g = 320,在常见的手机画布尺寸下,球的飞行速度看起来比较像真实投篮,不至于慢得像在月球上飘,也不至于快得像子弹。
1.2 基于时间的参数化,而不是每帧累加位移
很多刚接触动画开发的人会习惯每帧这么算:
typescript复制// 不推荐的写法
ball.x += vx * deltaTime;
ball.y += vy * deltaTime;
vy += g * deltaTime;
这种“递推式”的思路非常符合直觉,每帧先算位移,再更新速度,循环往复。但它有一个很麻烦的问题:只要某一帧卡顿或者定时器不准确,球的位移就偏了,而且一旦开始偏差,后面每一步都错。也就是说,同样的代码在60帧设备上跑出来的弧线,和30帧设备上跑出来的是两条不同的抛物线。
我更推荐使用“基于时间的参数化公式”。每次投篮时记录 launchTime,球的位置直接用当前时间相对于 launchTime 的差值来计算,不依赖“上一帧球在哪里”。这样即使定时器偶尔抖动甚至丢帧,只要刷新到某一帧,它计算出的位置仍然是这条抛物线理论上的准确位置。
一个完整的“飞行子片段”可以设计成下面这样:
typescript复制interface ShotSegment {
startX: number;
startY: number;
vx: number; // 当前阶段的水平速度,vp/s
vy: number; // 当前阶段的竖直速度,vp/s(朝上为正)
beginTime: number; // 片段开始时间
}
function getPosition(segment: ShotSegment): { x: number; y: number } {
let t = (Date.now() - segment.beginTime) / 1000;
return {
x: segment.startX + segment.vx * t,
y: segment.startY - segment.vy * t + 0.5 * 320 * t * t
};
}
有人会问,用 t 直接算,那篮板反弹之后怎么办?其实很简单。篮板碰撞发生时,我们不需要在这一帧改变“全局时间”,只需要把当前的位置当作新的 startX、startY,反弹后的水平速度当作新的 vx,然后把 beginTime 重置为当前时间。这样一次完整的投篮路径,也可以理解为由“出手片段”“碰板反弹片段”“入筐后下落片段”拼接而成,每一段都还是干净利落的抛物线。
1.3 出手参数的估算方法:让球能落在篮筐附近
投篮能不能进,本质上是出手角度和初速度的组合是否合适。假如画布里发球点与篮筐的水平距离是200vp左右,发球点比篮筐低200vp左右,那想让球飞行到筐口附近,角度通常需要落在40°到65°之间,初速度大约在350vp/s到650vp/s之间。这个区间不需要靠瞎猜,可以从运动学公式大致推出来:
斜抛运动的水平方向是匀速直线运动,所以从出手到飞到筐口正上方的时间约等于 t = 水平距离 / (v * cos(θ))。竖直方向在这个时间里需要从发球点上升到接近筐口的高度,所以需要满足:高度差 = v * sin(θ) * t - 0.5 * g * t²。
把 t 的表达式带进去,可以看到任意一组角度和速度,都能算出一条轨迹在多少时间点到达哪个高度。做“拖拽投篮”时,玩家是没办法精确控制到1°和1vp/s的,所以要给一个宽松的“合理区间”。如果拖拽映射出来的出手速度落在300以下,球很容易飞不到篮筐;超过700以后,球常常直接砸向篮板或被判定“打铁”。我最终把初速度范围限制在[280, 720],角度下限设为15°,防止玩家瞎拉导致球往背后飞。
后面我会把重力、速度范围和篮板位置都抽成常量,真机调试时直接改常量比到处找公式里的魔法数字方便得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Canvas场景怎么画才不违和:静态场景与动态篮球分层处理
2.1 画篮球:一个圆加几条线,关键在于径向渐变和自旋
画布准备好以后,接下来要解决的是“它看起来像一场投篮”的问题。第一个绘制对象是篮球本身。如果只画一个橙色圆形加两条直线,虽然能看出是球,但会显得很扁平,缺少体积感。Canvas里做一个篮球可以分成三步:
第一步,用径向渐变填充球的底色。我选择在球心稍偏左上的位置创建一个浅色中心,让球的右下方看起来暗一点,模拟侧上方光照效果。这样球还没画纹路就已经有了立体感。
第二步,画篮球表面的纹路。篮球的标志性外观是垂直于地面的两条大弧线和一条横向的赤道线。但要注意,因为视角是侧面,而且球会自旋,纹路画死板了会非常假。我最开始直接把“横线”画成了一条从左侧到右侧的水平直线,结果球一旦旋转,那条线就像一根棍子插在球里一样别扭。
更合理的做法是把绘制中心临时平移到球心,再旋转画布。每次刷新时让球围绕圆心多转一个角度,这样纹路跟着一起转动,视觉上就是一个有自旋的球了。
typescript复制context.save();
context.translate(ball.x, ball.y);
context.rotate(ball.rotation);
// 此时以(0, 0)为球心绘制半径r的球
context.beginPath();
context.arc(0, 0, ballRadius, 0, Math.PI * 2);
context.fillStyle = gradient;
context.fill();
// 用弧线画球纹
context.strokeStyle = '#5b2f13';
context.lineWidth = 2;
context.beginPath();
context.arc(0, 0, ballRadius * 0.95, -Math.PI * 0.65, Math.PI * 0.65);
context.stroke();
context.beginPath();
context.arc(0, 0, ballRadius * 0.95, Math.PI * 0.35, Math.PI * 1.65);
context.stroke();
// 赤道线:实际是一条过圆心的水平短弧
context.beginPath();
context.moveTo(-ballRadius * 0.9, 0);
context.lineTo(ballRadius * 0.9, 0);
context.stroke();
context.restore();
这里用 context.translate + context.rotate 的意义,是把球从“用绝对坐标绘制的物体”变成“在自身局部坐标系里绘制的物体”。这样打球位置变化和球自旋互不干扰,你只需要改变 translate 目标位置和 rotate 旋转角度。
2.2 画球架:侧视视角的篮板、筐口和网
投篮画面的第二个主角是球架。我用的是完全侧视视角——篮筐从篮板边缘向左伸出来,玩家从画面左侧投篮。在这个视角下,篮板本身垂直立在地面上,筐口是一条横着伸出来的“臂”,球网则是筐口下方的一组斜线。
具体坐标建议抽成一组常量,不要散落在绘制函数里:
typescript复制const HOOP = {
boardX: 300, // 篮板所在的水平位置
boardTop: 160,
boardBottom: 420,
hoopY: 260, // 筐口所在的垂直高度
hoopLeft: 230, // 筐口最左端
hoopRight: 292 // 筐口与篮板连接处
};
绘制篮板时,先用浅灰色矩形表示整个矩形板面,再用白色细线画出边框。筐口我用一个橙色细矩形表示,从 hoopRight 延伸到 hoopLeft,高度大约是8vp。网则是从筐口两端向下方汇拢的一组折线,重复画几条斜线就足够暗示“这里有网”了。
需要注意,在侧视视角下,篮板和篮筐的遮挡关系必须处理对:球从左侧飞过来,应该先经过筐口,然后才可能碰到篮板。如果绘制顺序反了,就会出现“球从篮板里面穿过去再进球”的诡异画面。绘制顺序建议是:先画背景,再画篮网(后侧),画筐口,画篮板,最后画篮球。但为了简化,把网和筐画在一起、篮板画在最里层也问题不大,因为场景元素很少,视觉层次的容错范围很高。
2.3 为什么每帧都要先清屏并重绘静态场景
我在第一次实现时犯了一个很典型的错误:没有在绘制篮球前把画布清干净,结果每隔几十毫秒就在画布上“盖”一个球,最后整条抛物线变成了一串逐渐消失的残影。后来在每帧刷新函数里第一行加了:
typescript复制context.clearRect(0, 0, canvasWidth, canvasHeight);
残影消失了,但另一个问题又来了:每帧把画布清成透明后,篮板和篮筐也没了。于是我需要把“绘制静态球架”也放进每一帧的刷新链路里,先重画球场,再画球。很多人问这样每个帧都画静态元素是不是浪费性能?对于这个场景来说完全不必担心,因为球架只有七八个矩形和折线,几十次绘制在一帧内的开销可以忽略不计。
这里其实体现了一个开发思路:如果一个画面里的“背景”和“动态物体”不复杂,就不需要急着引入离屏Canvas或者缓存图层这种优化手段,清晰、简单的重绘逻辑反而是最稳妥的起点。只有等到背景很复杂、GPU开销明显上升时,再考虑把静态内容缓存起来。
3. 拖拽式投篮:从按下手指到松手触发,这条链路怎么设计
3.1 投篮交互,我选择了“后拉蓄力”而不是点击投篮
最开始我做的版本是“点击屏幕任意位置,篮球自动随机一个角度投出去”。测试之后感觉非常无聊,因为玩家对结果没有任何掌控感,进不进全靠系统心情。后来就改成了类似弹弓的后拉式拖拽:手指按住屏幕上的篮球区域,向左下方拖拽,松手后球就沿着相反方向——也就是右上方——飞出去。
这个方向的直觉逻辑是:你想让球往右上方飞,就要向左下方“拉”它。实际计算出手向量时,不是用“触摸点指向球”,而是用“球位置减去触摸点位置”:
typescript复制// dragX、dragY 代表玩家拖拽产生的向量
let dragX = ballStartX - touchX;
let dragY = ballStartY - touchY;
// 出手初速度 = 拖拽向量方向 * 力度系数
let angle = Math.atan2(dragY, dragX);
let distance = Math.min(Math.sqrt(dragX * dragX + dragY * dragY), MAX_DRAG_DISTANCE);
let speed = minSpeed + (maxSpeed - minSpeed) * (distance / MAX_DRAG_DISTANCE);
let vx = speed * Math.cos(angle);
let vy = speed * Math.sin(angle);
这里限制了最大拖拽距离。如果不限制,玩家可以拉出屏幕外部,形成上万vp/s的初速度,球会一瞬间飞出视野。限制距离后,力度被映射到 [minSpeed, maxSpeed] 区间,体验稳定很多。
3.2 TouchEvent坐标与Canvas坐标对应的细节
使用Canvas做触摸交互,最容易栽的坑是坐标不一致。在ArkUI的Canvas上注册onTouch事件,拿到的是TouchEvent对象,里面的touch.x和touch.y代表触摸点相对组件左上角的坐标。只要你的Canvas没有做滚动偏移、没有嵌套在奇怪的容器里导致额外位移,这些坐标可以直接用来和你的Canvas绘制坐标比较。
我在做多指操作时踩过一个坑。onTouch事件里面有个touches数组,用户如果同时放上两个手指,touches[0]不一定是玩家最想用的那只手。最开始的代码在TouchType.Move里永远取第一个触摸点,一旦另一根手指不小心碰到了屏幕,瞄准线就会瞬间跳走。后来改成每次只跟踪第一根手指的touchId,如果它不是之前跟踪的那根手指,就忽略这次事件。对于投篮这样单指操作完全够用的场景,我建议直接禁用掉多余指针干扰。
3.3 用瞄准辅助线降低玩家试错成本
纯拖拽却不显示任何辅助线,玩家第一轮基本会乱投几个球,全靠试错来感受力度映射。为了把试错成本降下来,我加了一个很简单的反馈:拖拽过程中,从篮球位置出发,沿着与拖拽方向相反的方向画一条虚拟瞄准线,线的长度和拖拽距离成正比,线的颜色从绿色渐变到红色,暗示当前力度大小。
typescript复制if (this.dragging) {
// 当前拖拽向量
this.ctx.strokeStyle = '#ffffff';
this.ctx.lineWidth = 3;
this.ctx.setLineDash([6, 6]);
this.ctx.beginPath();
this.ctx.moveTo(ballStartX, ballStartY);
this.ctx.lineTo(ballStartX + dragX * 0.6, ballStartY + dragY * 0.6);
this.ctx.stroke();
this.ctx.setLineDash([]);
}
这里有另一个小细节:如果直接用完整拖拽距离去画辅助线,线会很长,甚至超出画布范围,看起来并不舒服。我按 0.6 的比例缩短了显示长度,同时物理计算时仍然用完整拖拽距离换算初速度,保证显示和实际作用力方向一致,但视觉更克制。
松开手指后,状态从“待机”切到“飞行中”,投篮状态机开始推进。
4. 进球判定与篮板反弹:这几行判断最容易写出“看起来进了却不判分”
4.1 用上一帧坐标和当前帧坐标做穿越判断
进球判定是整个项目里最微妙的部分。如果只说“球的最终位置在筐口上方”,这个判定是不够的,因为球的移动速度可能很快,上一帧还在筐口左边,下一帧已经飞到筐口右边。它从筐口左端上方“跳”了过去,这时候你只看当前帧坐标,会错误地认为它没碰到筐。
所以我采用的是“线段穿越检测”。每一帧拿到球的位置以后,和上一帧的位置进行比较,判断球的轨迹是否在这一帧的时间间隔内,穿过了筐口左端的竖直检测线:
typescript复制if (!this.scored && prevX < hoopLeft && currentX >= hoopLeft) {
let horDis = Math.abs(currentY - hoopY);
if (horDis <= ballRadius + HOOP_TOLERANCE) {
this.score++;
this.scored = true;
// 进入入筐后的坠落阶段
}
}
这里的判定不是用“当前坐标是否刚好等于某个点”,而是给一个高度容忍度 HOOP_TOLERANCE。我设的是12vp,这样就算球心稍微高一点或低一点,只要篮球本体的边缘有相当一部分越过了筐口线,系统就判定进球。如果把它设成0,玩家必须精确到像素级才能进球,挫败感会极强。实际手感调下来,8到12vp是比较理想的宽容范围。
4.2 篮板反弹可以只反转水平速度
当球没有直接进筐,而是先碰到篮板时,我们需要模拟一个反弹。最简化的模型是:保持垂直方向速度不变,只把水平方向速度取反,并乘以一个0.7左右的阻尼系数,模拟碰撞时的能量损失。
反弹检测同样建议用“穿越检测”而不是简单判断当前位置是否重叠,否则球在高速飞行时可能直接“嵌入”篮板里穿过。碰撞发生后的位置要立刻修正到篮板边缘之外,防止它下一帧还在板内,造成连续触发反弹:
typescript复制if (!this.bounced && prevX + ballRadius <= boardX && currentX + ballRadius >= boardX ) {
// 球横跨了篮板所在的竖直线
if (currentY > boardTop - ballRadius && currentY < boardBottom + ballRadius) {
this.bounced = true;
this.segment.vx = -this.segment.vx * 0.7;
this.segment.startX = boardX - ballRadius;
this.segment.startY = currentY;
this.segment.beginTime = Date.now();
}
}
这个简化没有考虑球碰到篮板时的旋转变化和微弱的竖直方向反弹损失,但对投篮模拟而言已经够了。加了打板反弹以后,玩家会发现自己能打出那种“先砸板、再弹进筐”的真实进球,成就感比单纯空心入筐还高。
4.3 投篮流程状态机与得分更新
投篮过程不用做得太复杂,一个三态状态机已经足够:
- idle:球在发球点等待拖拽
- flying:球在空中运动,可能命中、可能碰板、可能落地
- settled:本轮结束,正在复位回发球点
在 flying 状态里,每一帧都要:
- 根据当前 ShotSegment 计算球的新位置
- 根据上一帧位置和当前位置,判断是否穿过筐口左端线,如果穿过并满足高度条件则得分
- 判断是否碰到篮板,如果碰到就反转水平速度并开启打板标志
- 判断是否落到地面以下的 y 或超出左右边界,如果是则进入 settled
得到分数以后,再用一个 @State 装饰的 score 变量驱动显示层更新。不要把 score 声明成普通变量,除非你愿意在每次得分后手动调用 UI 刷新。@State 在ArkUI里就是干这个的:
typescript复制@State score: number = 0;
注意一点:每次新一轮投篮开始时,要清掉上一轮的 bounced 和 scored 标志,否则可能会出现“上一轮已经打板了,这一轮刚出手就触发一次无效得分”的诡异Bug。
5. 实测中踩过的坑:残影、帧率、定时器泄漏和真机手感失真
5.1 ClearRect清屏时的坐标范围
开发中最先遇到的问题就是残影。当时我只清了一个很小的矩形区域,导致篮球轨迹上的旧内容没有被完全移除。后来把 clearRect 的宽高改成画布真实宽高才解决。这里花了点时间排查,因为代码里的宽高是从常量读出的,跟渲染区域的实际尺寸差了大概不到10vp,肉眼看起来已经是一层淡淡的拖影。
如果发现Canvas上出现了类似“流星尾巴”的效果,不要怀疑是绘制逻辑有问题,先检查clearRect是不是清空了整块画布。
5.2 定时器间隔并不是越小越流畅
动画主要靠定时器驱动。我一开始图流畅,把定时器间隔设成了10ms,结果实际运行并不流畅,而且CPU占用偏高。原因是系统定时器实际触发频率有上限,过小的间隔不仅
