1. 项目概述:Cocos Creator 飞刀小游戏到底在练什么
Cocos Creator 的经典飞刀小游戏,说简单是真的简单:一个旋转的圆盘当靶子,玩家在正确的时机点一下屏幕,手里的飞刀就出手,插到圆盘边缘就算成功,接下来这把刀会跟着圆盘一起转。如果运气不好,下一把刀插到了上一把刀的刀身上,游戏结束。这个玩法从最早的一批休闲游戏一路火到现在,各大小游戏平台都能见到它。
但这个项目一点都不“新手白给”。我自己带着学生做这个练习时,发现它几乎把 Cocos Creator 2D 开发里最常用的知识点全部串起来了:节点的层级关系、坐标系转换、本地坐标与世界坐标的理解、触摸事件、预制体复用、碰撞检测到底是“物理对了”还是“数学对了”,以及关卡节奏怎么调才让人想一直点下去。
所以这篇文章不是给你贴一段能跑的代码就算完,而是把我实际做这个项目时踩过的最深的坑、反复改过的设计逻辑,以及几个值得反复琢磨的细节全部写出来。不管你是刚接触 Cocos Creator 的编程新手,还是想用这个小游戏练手、转头做成求职作品的人,照着这份思路走,你至少能少走三四个晚上的弯路。
1.1 核心玩法里的三个隐藏知识点
先拆一下这个看似简单的玩法。一个完整的飞刀游戏环节,背后其实是三条独立运行的逻辑链。
第一条是“圆盘旋转”。这不是让背景图转,而是要让所有已经插上去的飞刀也跟着同步转。如果用手一张张控制每把刀的位置,那代码会写得非常痛苦。
第二条是“飞刀出手”。玩家点屏幕的时候,刀刃要沿着一个方向飞出去,在极短时间内落到圆盘边缘上。这里要考虑的并不是物理引擎,而是纯粹的坐标计算:手指点到哪、刀往哪个方向飞、什么时候算“到达圆盘”。
第三条是“命中判定”。刀到了圆盘边缘,得检查一下那个位置附近有没有已经插在圆盘上的刀。这里如果直接上碰撞体,往往会陷入各种奇怪的边界问题。更合适的做法是用角度差来判断,在代码里用一个数组把每把刀的角度存起来,然后做差,小于某个阈值就算撞刀。
这三条逻辑环环相扣,但耦合度又不高。你完全可以先把旋转做出来,再单独做投掷,最后再拼命中判定。这也是我推荐用它做练手项目的原因:模块化天然清晰,每一步都能看到成果。
1.2 为什么我推荐你先绕开物理引擎
很多人第一次做这个项目时,第一反应是“给刀加刚体,给圆盘加碰撞体,让物理引擎去处理”。我一开始也这么想过,但实际动手以后,马上发现问题。
圆盘本身是旋转的,刀插上去之后如果作为圆盘的子节点,那么刀的碰撞体就会跟着父节点转。物理引擎计算碰撞时,会把这把刀的碰撞体角度和位置算进去,这样确实能做到“刀碰到刀就失败”,但你要面对很多物理参数:摩擦力、恢复系数、碰撞分组、传感器开关。调着调着,你会发现游戏都还没做完,光调物理参数就花了一晚上。
更关键的是,这个游戏本质上是一个“离散判定”,它不需要连续物理模拟。刀出手到插盘这个过程只有零点几秒,你只要在刀到达圆盘的瞬间判断一下角度差,就足够了。用数学做判定,比用物理引擎做判定,在玩法的精确度和手感控制上要直观十倍。
所以我建议这么分工:圆盘旋转用 tween 或直接改节点的 eulerAngle,刀的飞行用 update 里线性移动,命中判定用角度数组。物理引擎在这个项目里完全不需要引入。
2. 场景搭建与资源准备:节点树设计比写代码更重要
飞刀游戏这种项目,场景里节点层级设计得好不好,直接决定后面脚本写起来顺不顺手。我见过很多人把刀直接挂在 Canvas 根节点下面,然后在脚本里频繁地改节点的 parent,最后代码里全是查找节点和坐标转换,出一堆莫名其妙的 bug。
先给出一套我长期使用的节点结构,你可以直接照着搭建。
2.1 一套够用的节点层级结构
Canvas ├── GameRoot(空节点,挂总控脚本) │ ├── BackGround(背景图,纯装饰) │ ├── WheelRoot(空节点,锚点设置在圆心) │ │ ├── WheelBody(圆盘图片) │ │ └── KnifeContainer(空节点,所有插到盘上的刀都挂这里) │ ├── ReadyKnife(当前待发射的刀,初始位置在圆盘正下方) │ ├── SpawnArea(飞刀出手点,可以作为隐藏节点) │ └── UI │ ├── ScoreLabel(当前得分) │ ├── ResultPanel(游戏结束面板,默认隐藏) │ └── RestartButton(重启按钮)这里面的精髓在于WheelRoot和KnifeContainer。
WheelRoot是圆盘旋转的载体,我让它的锚点正好对应圆盘图片的中心。让它每帧旋转,圆盘就会绕中心转。KnifeContainer是WheelRoot的子节点,当一把刀成功插上去以后,我就把这个刀节点挂到KnifeContainer下面。因为父节点在转,刀跟着转,所以我不需要在每帧 update 里手动去同步每一把已经插上的刀的位置和角度。这就是层级设计带来的最大红利。
而ReadyKnife单独放在外面,好处是玩家出手前可以看清下一把刀的样子,点击屏幕时我直接复制ReadyKnife的模板生成新的飞刀,出手之后再把ReadyKnife隐藏。等命中成功后再重新生成一把,并播放一个“刷新新刀”的动画。
2.2 坐标系问题:为什么你的刀总是不在圆盘边缘
这是飞刀项目里第一个容易把人绕晕的地方。Cocos Creator 的节点位置,默认用的是父节点坐标系。当刀是 Canvas 根节点的子节点时,节点坐标是以 Canvas 左下角或中心点为原点来计算的,而根据你的设计锚点不同,结果也不同。可圆盘的世界坐标、玩家的触摸坐标,又是另一套参考系。
我建议在预处理阶段就统一坐标系,别在代码里东换算一个地方西换算一个地方。
首先,在初始化时拿到圆盘圆心的世界坐标。Cocos Creator 3.x 里可以直接用node.worldPosition获取,这在运行时是可以用node.getWorldPosition()拿到的。然后触摸位置用事件对象的getUILocation()获取,它会返回 UI 坐标。UI 坐标和世界坐标在默认 Canvas 不发生缩放位移时是可以直接做向量运算的,但如果你的场景有缩放、有安全区域适配,我建议你直接统一换算到同一坐标空间,最简单的方法是使用Canvas下的节点做UI.transform.相关转换。
我们最后要算的东西,是“从圆盘中心指向触摸点”的方向向量。有了这个方向,刀就从这个方向的远处飞向圆盘边缘。方向向量的计算本身不复杂,真正的坑在于:如果你在onLoad里记录圆盘中心位置,而圆盘在适配时发生位移,后面就会偏。所以我每次在投掷函数里现取圆盘中心的世界坐标,绝不缓存。
还有个小技巧:把圆盘的点击热区就设置在WheelRoot节点上,触摸事件不要挂在整个 Canvas 上。这样玩家必须点到圆盘附近才能出手,游戏节奏会更紧凑。当然,如果你希望玩家点屏幕任意位置都能扔刀,那就把事件挂到 Canvas 上。
2.3 素材准备与参数设置:美术越简单越好
做程序开发的时候,不要一开始就纠结精美美术。飞刀游戏的整套视觉用最简单的几何图形就能表达:圆盘用一张 256x256 的图片,刀身用一张 128x128 或更细长的矩形图片即可。没有美术基础的话,直接在代码里用Graphics控件画圆盘和刀身也行,但为了省事,我还是推荐用几张纯色圆图。
重点是圆盘的旋转中心和图片中心对齐。如果你用图片编辑器自动生成的九宫格或者锚点不是 0.5, 0.5,那么圆盘中心就会偏。检查方法很简单:把WheelBody的位置设为 (0, 0),然后看它是不是完全覆盖在WheelRoot的空节点上。
参数方面,先给个建议值:圆盘半径R = 120,飞刀的长度56,旋转速度初始为40 度/秒,飞刀飞行速度800 像素/秒。这些值不是拍脑袋定的,而是按照常见移动端屏幕尺寸(大约 750x1334 设计分辨率)验证过的手感。圆盘太大,玩家很容易看出破绽,太小则判定过于苛刻。40 度每秒的初始旋转速度让玩家在感受到压力之余还能保持稳定命中,800 像素每秒的飞行速度让投掷反馈足够干脆。
你后续加难度时,不需要改这个基础结构,只要调高旋转速度或者增加圆盘半径变化节奏就行。
3. 核心脚本实现:投掷、命中与旋转,三块代码一次讲透
接下来进入实战。我会用 Cocos Creator 3.x + TypeScript 来写,思路在 2.x 的 JavaScript 版本里同样适用,只是 API 名称略有差异。
3.1 让圆盘转起来:越简单越稳
我把旋转逻辑独立成一个小脚本,挂在WheelRoot上。它的作用只有一个:每帧累计角度,然后设置节点的旋转。
import { _decorator, Component, Node, math } from 'cc'; const { ccclass, property } = _decorator; @ccclass('Wheel') export class Wheel extends Component { @property speed = 40; // 角度/秒 private _angle = 0; public setSpeed(v: number) { this.speed = v; } update(dt: number) { this._angle += this.speed * dt; if (this._angle > 360) { this._angle -= 360; } this.node.setRotationFromEuler(0, 0, this._angle); } }这段代码里有一个容易被忽略的细节:我用setRotationFromEuler而不是直接设置node.rotation。因为rotation在 Cocos Creator 3.x 里是一个Quaternion,如果你直接对它操作,很容易跟旋转、缩放搞混。用欧拉角设置,可读性好,也避免把角度加到 360 以上时出现奇怪的翻转。
更新逻辑是每帧累加_angle,但这只是个内部值,实际展示靠的是setRotationFromEuler。如果你需要让圆盘在某个时刻突然加速,直接调用setSpeed(80)就行。这样设计的好处是,后续做关卡难度递增的时候,你不需要改旋转脚本,只需要在外部传参数。
3.2 飞刀出手:计算方向,创建实例
飞刀的出手逻辑是这个项目的核心,我写成总控脚本GameRoot.ts里的函数。
先说明一下思路:玩家点击屏幕,我拿到点击位置的世界坐标和圆盘中心坐标,算出一个单位向量作为飞刀飞行方向。然后从圆盘外部某个点生成飞刀,让它沿着这个方向朝圆盘移动。当它到达圆盘边缘半径附近时,停止飞行,执行“插入”操作。
这里我会用一个预制体KnifePrefab来表示飞刀。这个预制体的根节点是一个空节点,下面挂刀身图片和刀刃碰撞区域。根节点的锚点和位置都取中心即可。
private spawnAndThrow(targetPos: Vec3) { const dir = new Vec3(); // 方向 = 目标位置 - 圆心 Vec3.subtract(dir, targetPos, this.wheelCenter); dir.z = 0; dir.normalize(); // 生成飞刀的位置:圆心加上方向向量乘以 (圆盘半径 + 预留距离) const spawnPos = new Vec3( this.wheelCenter.x + dir.x * (this.radius + 60), this.wheelCenter.y + dir.y * (this.radius + 60), 0 ); const knife = instantiate(this.knifePrefab); knife.setParent(this.spawnAreaParent); knife.setPosition(spawnPos); // 根据方向旋转飞刀,让刀尖指向圆心方向 const angle = Math.atan2(dir.y, dir.x) * 180 / Math.PI; knife.setWorldRotationFromEuler(0, 0, angle - 90); this._flyingKnife = knife; this._flyingDir = dir; this._isFlying = true; }这一段有几个值得展开讲的细节。
第一,normalize()之前我先把z轴设为 0,否则方向向量里带 z 分量,飞行轨迹就是斜的。
第二,初始生成位置在“圆心 + 方向乘以半径 + 60 像素”处,这个 60 像素是飞出起点和圆盘边缘的间隔。这个间隔太小,玩家会看不清刀的轨迹;太大则让响应变慢。60 像素在常见移动端分辨率下是比较舒服的。
第三,刀尖方向应该对准圆心。刀身图片默认是竖直朝上的,我们要让它的朝向和飞行方向一致。先算方向向量的反正切值,得到弧度转成角度,然后再减去 90 度,是因为图片默认的刀尖是向上,也就是 90 度方向,图片节点的旋转零度对应其自身坐标系的 x 轴正方向。这里只要调整一次就能明白,不用死记。
第四,飞刀实例化后先挂在spawnAreaParent下。这个spawnAreaParent是一个空白节点,建议放在 Canvas 根节点下,保证飞刀在初始阶段不受到WheelRoot旋转的影响。
3.3 飞行中的逻辑:位移与到达判定
在GameRoot的update里,我会判断当前是否存在正在飞行的飞刀,如果有,就让它沿着方向向量每帧移动。
这里用世界坐标系来移动。之所以不用Translate接口,是因为它在某些版本里会有父节点旋转干扰的问题。我们直接用setPosition加一个偏移量,最可控。
update(dt: number) { if (this._isFlying && this._flyingKnife) { const oldPos = this._flyingKnife.position.clone(); const stepX = this._flyingDir.x * this.knifeFlySpeed * dt; const stepY = this._flyingDir.y * this.knifeFlySpeed * dt; this._flyingKnife.setPosition(oldPos.x + stepX, oldPos.y + stepY, 0); // 到达判定:飞刀距圆心的距离 <= 圆盘半径 const knifepos = this._flyingKnife.position; const dx = knifepos.x - this.wheelCenter.x; const dy = knifepos.y - this.wheelCenter.y; const dist = Math.sqrt(dx * dx + dy * dy); if (dist <= this.radius) { this._isFlying = false; this.onKnifeHitWheel(); } } }“到达判定”的判断条件是飞刀位置到圆心的距离小于等于圆盘半径。在圆盘半径内,一刀下去应该能插到边缘。但这个判定有个前提:飞刀生成的位置应该在圆盘外部。如果生成位置已经在圆盘内部,就会在出生的一瞬间触发命中判定,那游戏就没法玩了。
另一个细节是,飞行速度不能太快也不能太慢。太快,玩家点击出手后几乎看不到飞行过程,反馈不清晰。太慢,玩家就会觉得刀在飘。我设的默认速度是 800 像素每秒,圆周半径 120 像素,所以飞刀飞行过程大概是一帧 13 像素,整个飞行时间约 0.2 秒。这个节奏在休闲游戏里是很合适的。
3.4 命中的关键:插入圆盘并计算角度
飞刀飞到圆盘边缘后,不能直接消失,而是要“插上去”。这个操作在视觉上最容易出问题的,就是插入位置的局部坐标转换。
当一个飞刀作为 Canvas 根节点的子节点时,它的position是在 Canvas 坐标系下的。但当我们把它挂到WheelRoot的子节点KnifeContainer下面后,它的坐标就要变成相对于旋转节点坐标系的坐标。如果我们直接用原来的世界坐标作为子节点坐标,会得到错误结果,因为旋转节点的原点并不在 Canvas 原点。
这里我的做法是:在命中判定的瞬间,计算出“圆心指向飞刀位置”的角度,然后让飞刀作为KnifeContainer的子节点,并以这个角度计算出半径位置,作为它的本地坐标。
private onKnifeHitWheel() { const knifePos = this._flyingKnife.position.clone(); const dx = knifePos.x - this.wheelCenter.x; const dy = knifePos.y - this.wheelCenter.y; const hitAngle = Math.atan2(dy, dx) * 180 / Math.PI; // 这一帧的圆盘旋转角度 const wheelAngle = this.wheelNode.eulerAngles.z; // 判定是否撞到已有的刀 if (this.checkRepeat(hitAngle)) { this.gameOver(); return; } // 将飞刀挂到 KnifeContainer 下 this._flyingKnife.setParent(this.knifeContainer); // 在 KnifeContainer 的本地坐标系中,设置刀的本地位置 const localRadius = this.radius * 0.9; // 让刀稍微插入圆盘内部 const rad = hitAngle * Math.PI / 180; const localPos = new Vec3(Math.cos(rad) * localRadius, Math.sin(rad) * localRadius, 0); this._flyingKnife.setPosition(localPos); // 由于 KnifeContainer 跟随 WheelRoot 旋转,刀也会跟着转 this.knifeAngles.push(hitAngle); // 生成下一把待发射的刀 this.refreshReadyKnife(); }这里要反复琢磨的是hitAngle的含义。hitAngle是飞刀在命中时,相对圆盘中心的角度。它是在世界坐标里根据atan2(dy, dx)计算出来的。当飞刀成为KnifeContainer的子节点后,它的本地位置就是这个半径加上角度对应的点。只要KnifeContainer还在旋转,这个刀就会跟着转。
但是,这里有一个非常容易踩的坑:KnifeContainer本身也在旋转,所以它的子节点坐标也会跟着旋转。当我用世界坐标的角度算出localPos再设为本地位置时,实际上这个角度已经被旋转过一次了。比如,如果KnifeContainer当前旋转了 20 度,我设置一个 30 度的本地位置,那么在界面上看到的角度是 50 度,而不是 30 度。这样飞刀插入的位置就会鬼畜地偏了。
解决办法是:计算hitAngle时,要把圆盘当前旋转的角度减掉,转成相对于圆盘本地坐标的角度。
const wheelAngle = this.wheelNode.eulerAngles.z; const localHitAngle = hitAngle - wheelAngle;然后把localHitAngle转换成弧度,算出本地坐标,这样飞刀插入后,在世界坐标里看到的最终角度才是玩家点击时那一瞬间的方向。否则,每次出手都偏差固定值,玩起来非常别扭,你会觉得“刀插得好像还对准了,但就是差那么几度”。
这个坐标换算是本项目的第一个“反直觉点”,建议你写完后一定要用控制台打印一下hitAngle、wheelAngle和localHitAngle三个值,确认它们之间的关系。理解了这个,你后面做任何旋转容器下的动态子节点都不会慌。
3.5 判定撞刀:角度差,而不是碰撞体
真正决定游戏胜负的“撞刀判定”,我用的是一个角度数组knifeAngles,里面存的是每一把已插入刀相对于圆盘本地的角度。
private checkRepeat(newAngle: number): boolean { for (let i = 0; i < this.knifeAngles.length; i++) { let diff = Math.abs(newAngle - this.knifeAngles[i]); // 处理角度环绕,比如 350 度和 10 度,其实只差 20 度 if (diff > 180) { diff = 360 - diff; } if (diff < 8) { return true; } } return false; }这个判定比用物理碰撞要简单可靠得多,因为它避开了刀身形状的问题。在经典飞刀游戏里,一把刀插在圆盘上之后,刀刃是有宽度的,第二把刀只要碰到第一把刀的任何一个像素点就算失败。如果用碰撞体,就需要精确匹配刀身的形状和角度。而用角度差,我们可以直接用刀身宽度换算出一个合适的角度阈值,比如一刀宽度为 6 像素、圆周半径 120 像素时,角度阈值大约是2*atan(3/120) = 2.8度。但为了手感宽容一点,我取 8 度。你可以根据你实际用的飞刀图片宽度调整这个值。
还有一点要注意:角度环绕问题。假设第一把刀在 350 度,第二把刀在 10 度,它们之间的绝对值差是 340 度,但实际物理距离只有 20 度。如果不处理这个,你会看到两把刀明明几乎重叠,却判定没有撞刀。上面代码里if (diff > 180) diff = 360 - diff;就是为了修正这个问题。这种边界情况在游戏开发里非常典型,几乎所有的循环角度比较都会遇到。
4. 手感优化与玩法扩展:让玩家愿意多玩几把
一个飞刀游戏做到现在,核心玩法已经可以跑了。但离“好玩”还差不少。用一个字形容现在这个版本就是“僵”:点击、飞刀、插入、下一个,循环太干巴。要让玩家觉得有意思,至少要加三样东西:更清晰的操作反馈、随机起伏的难度、以及一个失败后的“再来一次”的节奏。
4.1 每一把刀都值得一个“出手动画”
视觉反馈是休闲游戏里最便宜但最有效的提升手段。我给ReadyKnife加了一个很简单的“抖动”动画:在玩家没有点击的时候,待命飞刀每 0.8 秒做一个上下位移的小幅度循环。这样玩家的视线会自然被吸引到那把你还没扔出去的刀上。
当玩家点击出手后,我立即生成飞刀实例,然后隐藏ReadyKnife。等命中判完,再重新生成一把新刀。为了让这个“补充新刀”的过程更带感,我会让新刀从 Canvas 底部用 tween 移动到待命位置,而不是瞬间出现。
this.readyKnife.active = true; this.readyKnife.setPosition(-200, -300, 0); tween(this.readyKnife) .to(0.2, { position: new Vec3(0, -260, 0) }) .start();这段代码里有一个隐含的设计:待命位置我放在圆盘正下方,所以新刀出现时会从屏幕底部上滑。玩家看到这个动作,就知道上一刀已经稳稳插上去了,下一刀已经备好,节奏感就出来了。
4.2 加速机制:要让玩家感受到“越来越难”
经典的飞刀游戏并不是匀速的。玩家每插上五把刀,圆盘转速就提高一档。这个递进不能用简单的随机数,否则玩家会感觉难度突然跳升,从而产生挫败感。
我的设计是:每插入一把刀,圆盘旋转速度增加 1.5%。这样前二十刀的速度增长缓慢,对新手很友好;二十刀之后,速度明显提升,玩家的反应时间被压缩,紧张感自然出现。
实现上也非常简单:命中回调里加上一行this.wheel.setSpeed(this.wheel.speed * 1.015);
还有另一个加分设计:每当玩家成功插入一刀,就播放一个非常短暂、约 30 毫秒的“停顿”。这个停顿不是卡死,而是让插入的那一瞬间在高速旋转的画面里被凸现出来。由于是旋转速度乘以一个很小的系数,玩家几乎察觉不到减速,但视觉上会有一个“插稳了”的瞬间。
4.3 游戏结束与重新开始的节奏
游戏失败的条件是撞刀。撞刀瞬间,我会立刻停止圆盘旋转,播放一个非常短促的失败音效,然后弹出结算面板。
结算面板不要用模态对话框把屏幕完全遮住,我习惯在面板里保留一部分游戏画面,让玩家看到最后那把导致失败的刀。然后给一个“重开”按钮,按钮点击后直接调用director.loadScene重载当前场景。这是最朴素但也最稳定的方式:重载场景会把所有节点状态清空,避免手动复位一堆状态变量。
更精细的开发者可能会做一个“从失败现场产生新一局”的软重置,但休闲游戏玩家更在意的是“再来一次”的速度,不是“看起来多优雅的过渡”。直接重载场景,就是玩家体验最好的方案。
5. 常见问题与排错:我在实际调试中踩过的坑
这一段集中写我在给这个项目做测试时遇到的问题,排名分先后,几乎都会碰到。
5.1 双击触发两次投掷,飞刀被分身
这是最常见的 bug。玩家快速点屏幕两下,第一下出手后飞刀还没飞到圆盘,第二下又触发了一次生成。结果屏幕上同时出现两把飞刀,其中一把的判定可能还没来得及做,就会产生各种诡异情况。
我的处理方式是在出手函数最前面加一个状态锁:
if (this._isFlying) { return; }另外,触摸事件类型也要注意。如果用Node.EventType.TOUCH_START,那么在手指按下的瞬间就会触发,很容易连点。如果你希望玩家必须点一下,而不是“按住不放就疯狂扔刀”,你需要在逻辑里加一个冷却时间。最简单的是在出手后 0.15 秒内禁止再次出手。0.15 秒这个数值比飞刀飞行时间短得多,所以你几乎感觉不到它存在,但又能完美拦截双击。
5.2 刀插到圆盘上角度偏移,像是“斜着插”
这个问题我在 3.4 里已经重点写了,就是没有减去wheelAngle。排查的时候可以先把飞刀的本地坐标打印出来,然后手动算一下应该的角度。比较快的验证方法是:让圆盘停在 0 度,点一次屏幕,看刀是不是正好插在你点的方向。如果 0 度时正常,几十度时偏移,那就说明一定是旋转容器下的子节点坐标没有做角度补偿。
5.3 真机测试时触摸位置偏移
编辑器里跑得好好的,一上真机,刀总是朝某个固定方向偏。这个问题通常在 Canvas 适配或者屏幕安全区间上。Cocos Creator 默认的getUILocation()返回的是屏幕 UI 坐标,但它在某些手机上会受到分辨率适配和屏幕缩放的影响。建议你统一使用世界坐标:在触摸事件的回调里,通过this.node.getComponent(UITransform).convertToNodeSpaceAR(event.getUILocation())换算成节点本地坐标,再用UITransform的convertToWorldSpaceAR或者其他接口转世界坐标。别嫌麻烦,这一步能够保证你在各种分辨率下用同一套逻辑。
如果还是偏移,就要检查 Canvas 的适配模式。我用的设计分辨率是 750x1334,适配策略选择SHOW_ALL或FIXED_WIDTH。SHOW_ALL在部分长屏手机上会出现左右黑边,但飞刀圆盘始终居中,操作逻辑不会有偏差。FIXED_WIDTH在全面屏手机上表现更好,但需要额外处理上下安全区。小游戏项目我建议先用SHOW_ALL跑通逻辑,再换适配策略调细节。
5.4 撞刀判定偶尔漏判
角度数组方案在绝大多数情况下是准的,但如果你把每把刀都记录成一个固定角度,会觉得“明明刀挨得很近了,怎么还没判输”。这是因为刀的物理宽度不止是角度,还有刀身在径向方向上的厚度。当圆盘半径较大时,角度阈值要适当放宽。我默认给了 8 度,你可以根据实际刀身宽度换算:刀宽 / 圆盘半径 再取反正切,然后乘一个 1.2 的容错系数。如果刀身宽 6 像素、半径 120,那角度就是2*atan(3/120)=2.86度,乘 1.2 约 3.4 度。但我实际测试下来 8 度更舒服,因为玩家的手指操作误差往往远大于刀身宽度,太严格的判定会导致挫败感。
下面整理一份我在测试过程中经常参考的排错表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 点击后没有飞刀生成 | 触摸事件没有挂到正确节点 | 检查节点是否启用了触摸事件监听,Canvas 下子节点需要点击穿透时要检查UITransform的尺寸 |
| 刀生成但朝反方向飞 | 方向向量计算错 | 确认圆心坐标是圆盘节点的worldPosition,不是局部位置 |
| 刀明明插中了圆盘边缘但显示偏移 | 没有做旋转角度补偿 | 参考 3.4 节,用localHitAngle = hitAngle - wheelAngle |
| 多把刀叠在同一位置 | 角度数组没有正确记录或重复 push | 打印knifeAngles,检查是否在失败时也 push 了角度 |
| 真机测试触摸偏移 | Canvas 适配问题 | 统一转换为世界坐标,适配模式改为SHOW_ALL先跑通 |
| 圆盘旋转速度越来越快但停不下来 | speed 叠加逻辑写在了 update 里 | 只在命中回调里递增 speed,不要在 update 里因为每帧调用而翻倍 |
| 重开按钮点了没反应 | 场景名字写错或场景没有加入构建列表 | 检查director.loadScene的字符串是否和构建面板里的场景名字完全一致,包括大小写 |
5.5 还有一个容易被忽略的细节:刀身的锚点
刀身的图片资源我建议统一把锚点设置为图片中心偏下的位置,也就是刀柄位置。因为当你把刀作为KnifeContainer的子节点并设置位置时,这个节点位置其实是刀柄所在的位置。如果锚点在刀尖,那么刀会整体往圆心方向偏移,看起来像刀深深扎进了圆盘;如果锚点在刀柄,那么刀尖伸出圆盘边缘的长度才会是正常范围。这个问题在美术资源上调整最省事,不用动代码。
6. 最后想分享的几点经验
这个项目我前前后后带过好几轮练习生,每次都能在不同的地方发现新问题。最深的体会是:Cocos Creator 这类可视化引擎把大量底层封装好了,但如果你不理解节点层级和坐标变换,封装反而会成为你排查 bug 时最大的障碍。
我个人建议在做飞刀小游戏时,不要一开始就急着写代码。先在纸上把节点树画出来,把“刀出生之后归谁管”“插到盘上之后归谁管”“哪个节点在旋转”这三个问题想清楚,再动手写。这个项目的核心难点从来不是逻辑复杂度,而是你对场景结构的心智模型是否清晰。
另外一个经验就是:每写完一个功能,就真机测一次。飞刀游戏对触摸响应非常敏感,编辑器里的模拟触摸和真机的手感完全是两回事。第一次真机测试时你可能会被那个触摸坐标偏移吓一跳,但早发现,早修早安心。
如果你打算把这个项目继续扩展,可以试着在现有结构上加“飞刀数量限制”“限时挑战模式”“随机应变轮盘大小”等玩法。每一个扩展都不会破坏原有结构,因为你已经把最关键的“角度判断 + 旋转容器子节点”这一套逻辑牢牢握在手里了,这是这一类旋转判定游戏通用的地基。