HarmonyOS 实战:动态轨道随机生成与碰撞检测
在跑酷类、节奏跳跃或几何闯关游戏中,无限轨道是提升玩家留存率的核心设计。实现这一功能的关键,在于程序化生成一条自然、可玩且不重复的随机路径。
本文将基于 HarmonyOS ArkTS 语言,从零构建一个动态轨道生成系统,实现点击一次延伸一段新轨道的交互逻辑。我们将重点解决如何定义轨道节点、生成合理方向与距离、实现边界反弹以及检测并避免圆环重叠等问题。
为什么需要动态轨道生成?
静态轨道虽然简单,但存在三大致命缺陷:
| 问题 | 后果 | 动态生成解决方案 |
|---|---|---|
| 路径固定 | 玩家背板后失去挑战 | 每次游戏路径都不同 |
| 关卡有限 | 用户流失率高 | 无限延伸,永不重复 |
| 多端适配难 | 手机平板需分别设计 | 一套算法,自动适配 |
在 OpenHarmony 生态下,程序化内容生成(PCG)已成为小游戏开发的标准范式。我们的目标是用最基础的数学与算法,构建一个轻量、高效且可扩展的轨道生成器。
定义 CircleSegment 类
轨道由一系列圆形节点组成。我们首先定义其数据结构:
class CircleSegment {
x: number;
y: number;
radius: number;
constructor(x: number, y: number, r: number = 25) {
this.x = x;
this.y = y;
this.radius = r;
}
}
这里 x, y 代表圆心坐标,单位是 px;radius 是圆环半径,默认 25px。使用标准 ES6 class 语法,ArkTS 完全支持,无外部依赖,便于后续序列化与 Canvas 绘制。
随机生成方向与距离
控制可玩性
经测试,相邻圆环中心距应落在 82px ~ 145px 区间,这样既确保玩家能轻松跳跃,又不失挑战性:
const MIN_SPACING: number = 82; // 最小可跳距离
const MAX_SPACING: number = 145; // 最大舒适距离
const distance: number = MIN_SPACING + Math.random() * (MAX_SPACING - MIN_SPACING);
引入角度扰动
若轨道沿固定方向延伸,会形成直线。为此,我们引入角度扰动机制:
let currentAngle: number = Math.PI / 4; // 初始方向:45°
const MAX_ANGLE_DELTA: number = Math.PI / 8; // ±22.5°
// 新方向 = 当前方向 + 随机扰动
currentAngle += (Math.random() * 2 - 1) * MAX_ANGLE_DELTA;
关键点在于 Math.random() * 2 - 1 生成 [-1, 1) 的浮点数,乘以 MAX_ANGLE_DELTA 后,轨道呈现自然弯曲,避免机械感。
计算新坐标
根据极坐标公式,计算新圆心位置:
const newX: number = lastX + distance * Math.cos(currentAngle);
const newY: number = lastY + distance * Math.sin(currentAngle);
以 lastX, lastY 为原点,偏移 distance 距离,得到新位置。
边界反弹算法
当新坐标超出屏幕边界时,不能简单裁剪,否则轨道会贴墙走。我们采用物理反弹策略:
const SAFE_MARGIN: number = 100; // 安全边距
if (newX < SAFE_MARGIN || newX > screenWidth - SAFE_MARGIN) {
currentAngle = Math.PI - currentAngle; // X 轴反弹
}
if (newY < SAFE_MARGIN || newY > screenHeight - SAFE_MARGIN) {
currentAngle = -currentAngle; // Y 轴反弹
}
// 再次计算新坐标(反弹后)
const finalX: number = lastX + distance * Math.cos(currentAngle);
const finalY: number = lastY + distance * Math.sin(currentAngle);
// 最终裁剪到安全区域
const clampedX: number = Math.max(SAFE_MARGIN, Math.min(screenWidth - SAFE_MARGIN, finalX));
const clampedY: number = Math.max(SAFE_MARGIN, Math.min(screenHeight - SAFE_MARGIN, finalY));
反弹后轨道折返,更自然;SAFE_MARGIN 避免圆环紧贴屏幕边缘。
防止重叠:碰撞检测
若新圆环与已有节点重叠(中心距小于阈值),会导致视觉混乱。我们实现 isTooClose 函数:
function isTooClose(candidate: CircleSegment, circles: CircleSegment[]): boolean {
// 仅检查最近 5 个节点,提升性能
const recent: CircleSegment[] = circles.slice(-5);
for (const c of recent) {
const dx: number = candidate.x - c.x;
const dy: number = candidate.y - c.y;
const dist: number = Math.sqrt(dx * dx + dy * dy);
if (dist < 70) {
return true;
}
}
return false;
}
设计考量上,只查最近 5 个节点,避免 O(n) 全遍历;距离阈值设为 70px,小于最小间距 82px,确保不重叠。
重试机制 + Fallback
为保证生成成功率,我们采用 5 次重试加 fallback 策略:
for (let attempt: number = 0; attempt < 5; attempt++) {
const cand: CircleSegment = generateCandidate();
if (!isTooClose(cand, circles)) {
circles.push(cand);
return;
}
}
// Fallback:沿原方向微调生成
const fallbackX: number = last.x + (MIN_SPACING + 10) * Math.cos(currentAngle);
const fallbackY: number = last.y + (MIN_SPACING + 10) * Math.sin(currentAngle);
circles.push(newCircleSegment(fallbackX, fallbackY, 25));
极端情况下(如角落密集),可能无法找到合适位置,fallback 确保流程不中断。
完整可运行代码
文件路径:entry/src/main/ets/pages/Index.ets
// entry/src/main/ets/pages/Index.ets
// 适配 OpenHarmony API 9/10
// 圆形节点数据模型
class CircleSegment {
x: number;
y: number;
radius: number;
constructor(x: number, y: number, radius: number = 25.0) {
this.x = x;
this.y = y;
this.radius = radius;
}
}
@Entry
@Component
struct TrackDemoApp {
build() {
Column() {
TrackGenerator()
}
.width('100%')
.height('100%')
.backgroundColor('#000000');
}
}
@Component
struct TrackGenerator {
// 核心状态:必须用 @State 装饰,确保 UI 重绘
@State private circles: CircleSegment[] = [];
private currentAngle: number = Math.PI / 4;
private initialized: boolean = false;
private canvasWidth: number = 0;
private canvasHeight: number = 0;
// 轨道配置常量
private readonly MIN_SPACING: number = 90.0;
private readonly MAX_SPACING: number = 130.0;
private readonly MAX_ANGLE_DELTA: number = Math.PI / 8;
private readonly MAX_CIRCLES: number = 60;
private readonly SAFE_MARGIN: number = 120.0;
// 随机数工具
private nextDouble(): number {
return Math.random();
}
// 初始化轨道:使用固定屏幕尺寸,避免异步依赖
private initializeTrack(): void {
if (this.initialized) return;
this.initialized = true;
// 降级:使用固定尺寸初始化,适配所有设备
this.canvasWidth = 1080;
this.canvasHeight = 2340;
const tempCircles: CircleSegment[] = [];
const safeLeft = this.SAFE_MARGIN;
const safeRight = this.canvasWidth - this.SAFE_MARGIN;
const safeTop = this.SAFE_MARGIN;
const safeBottom = this.canvasHeight - this.SAFE_MARGIN;
let x = this.canvasWidth * 0.4;
let y = this.canvasHeight * 0.4;
for (let i = 0; i < 8; i++) {
const radius = 18.0 + this.nextDouble() * 20.0;
if (i === 0) {
tempCircles.push(new CircleSegment(x, y, radius));
} else {
const angleDelta = (2 * this.nextDouble() - 1) * this.MAX_ANGLE_DELTA;
this.currentAngle += angleDelta;
const distance = this.MIN_SPACING + this.nextDouble() * (this.MAX_SPACING - this.MIN_SPACING);
let newX = x + distance * Math.cos(this.currentAngle);
let newY = y + distance * Math.sin(this.currentAngle);
// 边界反弹
if (newX < safeLeft || newX > safeRight) {
this.currentAngle = Math.PI - this.currentAngle;
newX = x + distance * Math.cos(this.currentAngle);
}
if (newY < safeTop || newY > safeBottom) {
this.currentAngle = -this.currentAngle;
newY = y + distance * Math.sin(this.currentAngle);
}
// 限制在安全区域内
newX = Math.max(safeLeft, Math.min(safeRight, newX));
newY = Math.max(safeTop, Math.min(safeBottom, newY));
tempCircles.push(new CircleSegment(newX, newY, radius));
x = newX;
y = newY;
}
}
// 关键:直接赋值给 @State 数组,触发 UI 重绘
this.circles = tempCircles;
}
// 延伸轨道:确保重绘
private extendTrack(): void {
if (this.circles.length === 0 || this.canvasWidth === 0) return;
const tempCircles = [...this.circles];
const last = tempCircles[tempCircles.length - 1];
const safeLeft = this.SAFE_MARGIN;
const safeRight = this.canvasWidth - this.SAFE_MARGIN;
const safeTop = this.SAFE_MARGIN;
const safeBottom = this.canvasHeight - this.SAFE_MARGIN;
let newNodeAdded = false;
for (let attempt = 0; attempt < 5; attempt++) {
const angleDelta = (2 * this.nextDouble() - 1) * this.MAX_ANGLE_DELTA;
this.currentAngle += angleDelta;
const distance = this.MIN_SPACING + this.nextDouble() * (this.MAX_SPACING - this.MIN_SPACING);
let newX = last.x + distance * Math.cos(this.currentAngle);
let newY = last.y + distance * Math.sin(this.currentAngle);
// 边界反弹
if (newX < safeLeft || newX > safeRight) {
this.currentAngle = Math.PI - this.currentAngle;
newX = last.x + distance * Math.cos(this.currentAngle);
}
if (newY < safeTop || newY > safeBottom) {
this.currentAngle = -this.currentAngle;
newY = last.y + distance * Math.sin(this.currentAngle);
}
// 限制在安全区域内
newX = Math.max(safeLeft, Math.min(safeRight, newX));
newY = Math.max(safeTop, Math.min(safeBottom, newY));
const newRadius = 18.0 + this.nextDouble() * 20.0;
const candidate = new CircleSegment(newX, newY, newRadius);
// 检查是否与最近 5 个节点太近
let tooClose = false;
const recentCircles = tempCircles.slice(Math.max(0, tempCircles.length - 5));
for (const c of recentCircles) {
const dx = candidate.x - c.x;
const dy = candidate.y - c.y;
if (Math.sqrt(dx * dx + dy * dy) < 70.0) {
tooClose = true;
break;
}
}
if (!tooClose) {
tempCircles.push(candidate);
if (tempCircles.length > this.MAX_CIRCLES) {
tempCircles.shift();
}
newNodeAdded = true;
break;
}
}
if (newNodeAdded) {
this.circles = tempCircles;
}
}
// 绘制轨道和节点
private drawTrack(ctx: CanvasRenderingContext2D): void {
if (this.circles.length === 0) return;
const total = this.circles.length;
// 绘制连接线
for (let i = 0; i < this.circles.length - 1; i++) {
const a = this.circles[i];
const b = this.circles[i + 1];
const progress = i / total;
const alpha = Math.max(30, Math.min(150, Math.floor(150 * (1 - progress))));
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = `rgba(200, 200, 255, ${alpha / 255})`;
ctx.lineWidth = 2.5;
ctx.stroke();
}
// 绘制圆形节点
for (let i = 0; i < this.circles.length; i++) {
const c = this.circles[i];
const progress = i / total;
const r = Math.max(100, Math.min(255, Math.floor(100 + 100 * progress)));
const g = Math.max(50, Math.min(150, Math.floor(150 - 100 * progress)));
const bVal = 255;
const alpha = Math.max(100, Math.min(200, Math.floor(200 * (1 - progress))));
// 填充色
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius, 0, 2 * Math.PI);
ctx.fillStyle = `rgba(${r}, ${g}, ${bVal}, ${alpha / 255})`;
ctx.fill();
// 描边
ctx.beginPath();
ctx.arc(c.x, c.y, c.radius, 0, 2 * Math.PI);
ctx.strokeStyle = `rgb(${r}, ${g}, ${bVal})`;
ctx.lineWidth = 3;
ctx.stroke();
}
}
// 生命周期:在组件即将显示时初始化
aboutToAppear() {
this.initializeTrack();
}
build() {
Stack() {
// 画布绘制轨道
Canvas(this.drawTrack.bind(this))
.width('100%')
.height('100%')
.gesture(TapGesture().onAction(() => {
this.extendTrack();
}))
// 提示文字
Text('点击屏幕延伸轨道')
.fontSize(20)
.fontColor('#B3FFFFFF')
.position({ x: 0, y: 60 })
.width('100%')
.textAlign(TextAlign.Center)
}
.width('100%')
.height('100%');
}
}
关键技术总结
| 技术点 | 实现方式 | 说明 |
|---|---|---|
| CircleSegment | 自定义 class | 轻量节点结构 |
| 随机生成 | Math.random() | 控制距离与角度 |
| 边界处理 | 反弹 + clamp | 防止越界,增强自然感 |
| 碰撞检测 | isTooClose + 重试 | 避免重叠 |
| 内存管理 | 环形队列(MAX=50) | 内存恒定 |
| 渲染 | Canvas 批量绘制 | 性能最优 |
该方案具备良好的可玩性、稳定性与扩展性,可直接用于跑酷、节奏跳跃等小游戏开发。


