第7章 投壶战斗:蓄力、抛物线、命中判定
「投壶之礼,主人奉矢,司射奉中,使人执壶。」——《礼记·投壶》
第 6 章结束时,你已经有一座可以自由行走的江南小镇。但一个能走路的角色还不算游戏——游戏需要「玩法」。本章我们给《酒魂》装上第一个真正能玩的东西:投壶。
投壶为什么排在最前面?因为它是战斗系统的降维实现。真正的动作战斗要考虑敌人移动、攻击前摇、受击硬直;而投壶把变量砍到只剩三个:靶子在动、准星在飘、你的手在抖。但「瞄准 → 蓄力 → 发射 → 命中判定 → 结算」这条链路,和第 8 章以后的动作战斗是同一套。今天把这条链路打通,第 8 章只需要换掉两头(敌人换靶子、近战换弓箭),中间那段原封不动。
本章要解决什么问题: 给 GameManager 补上「醉意值」这个全局变量和它的广播通道;搭一个 40×15 格的靶场;做三个可复用的预制件(靶壶 / 箭矢 / 醉意准星);做一个蓄力条 UI;写一个管开局、生成、发射、评分、福缘、结算的靶场管理器;最后把这套玩法作为叠加场景接进主游戏(第 4 章 4.2.3 预留的位置,今天兑现)。
本章你将得到: 《酒魂》的第一个可玩原型——走进小镇广场那块空地,按住鼠标蓄力,松手射出一支带抛物线弧线的箭,去打一只上下摆动、随醉意漂移的酒壶。项目完成度到达 25%。 这是第一个「可以拿给别人试玩」的版本。
本章的节奏和第 6 章一样是骨架先行:7.1~7.2 定设计与前置,7.3 按「实体 → UI → 逻辑」三段把靶场搭出来(全程可 F5、无报错),7.4 接进主游戏,7.5 交代本章给后面留下了什么。7.6 是进阶内容(追踪箭、蛇形摆动、粒子特效),只把玩法推到「能拿出去给人玩」的程度,不影响第 8 章,第一次读完全可以跳过。
7.1 投壶玩法设计
7.1.1 先破除一个误会:投壶不是「射箭小游戏」
上一章我们把 Y-Sort 和遮挡透视做完了,所以本章不用再修任何东西——直接从玩法开始。
很多教程做「投掷类小游戏」,做法是把靶子放正、准星锁死、比谁点得准。这套设计在键盘鼠标上有个致命问题:玩家第一次就能百发百中,然后就腻了。
《酒魂》的定位是「微醺」——主角是个喝了酒的酿酒人。所以我们反过来设计:
不追求「手稳」,追求「顺势」。 靶子自己在晃,准星自己在飘,玩家能做的是观察节奏、等待那个瞬间、在正确的时机松手。这不是精准度测试,这是节奏游戏。
7.1.2 三条设计选择
从「设计蓝图」到「代码」,第 3 章 3.4 讲过这条翻译链。具体到投壶,我们做了三次取舍,每一次都能对应到本章要写的某段代码:
| 设计选择 | 换成什么 | 对应本章 |
|---|---|---|
| 不追求精准瞄准 | 准星锚点跟着主角走,锚点周围做随机漂移——把「手稳」换成「读节奏」 | 7.3.5 醉意准星 |
| 把「醉」变成玩法,不是惩罚 | 醉意越高准星飘得越狠,但命中时机好会触发**【定风波】直接把醉意清零** | 7.2.1 醉意值 + 7.3.8 福缘 |
| 奖励要改规则,不要加数字 | 精准命中触发的是改这一局怎么打的增益,而不是「+50 分」这种看完就忘的飘字 | 7.3.8 福缘奖励 |
为什么「改规则」比「加分」高级? 加分只影响结算界面,玩家在游玩过程中感知不到;改规则会立刻改变玩家的下一手决策——「我还有三支福缘箭,可以赌一把远的」。第 16 章讲酿造词缀、第 20 章讲任务奖励时,这条原则会反复出现:奖励要改变行为,而不是改变数字。
7.1.3 四大核心机制
| 维度 | 《酒魂》的做法 | 玩家感受到的 |
|---|---|---|
| 靶子状态 | 纵向往复摆动,过中点最快、到两端最慢(短暂滞空) | 「等它停一下我再出手」 |
| 准星状态 | 跟随主角移动,在锚点周围随机漂移,漂移强度绑定醉意值 | 「手有点抖,但还能瞄」 |
| 操作方式 | 左右走位调整角度,按住左键蓄力,松手那一刻决定一切 | 「走位 → 等 → 放」 |
| 奖励方式 | 命中时机越好,触发规则级福缘的概率越高 | 「这局有惊喜」 |
玩法核心一句话:不强行瞄准,而是观察节奏、耐心等待、顺势出手。
7.2 全局醉意值与走位输入
动手之前先把三件「前置」处理好。它们都不长,但少了任何一件,7.3 的代码都跑不起来。
7.2.1 给醉意值一个正式的「家」
第 6 章 6.7.3 的 game_manager.gd 里,已经埋了这么一行:
var player_drunk_level: float = 0.0 # 全局醉意(第 7 章投壶使用,第 12 章扩展)
「第 7 章使用」——就是现在。但只放一个变量是不够的。醉意值的特点是:一份数据,很多人在看。现在只有准星在看;第 12 章会有攻击力、移动速度、打滑效果在看;第 14 章会有醉意条在看。如果每次都让它们自己去读 GameManager.player_drunk_level,那么第 12 章想加一个「醉意变化时自动播放晃屏」的反馈,就得回头去改每一处读取的地方。
正确做法:变量私有化在 GameManager 里,修改走唯一入口,变化走 EventBus 广播。
打开 scripts/game_manager.gd,在「全局状态」那一段下面补上:
# ---- 醉意值(第 7 章初版,第 12 章扩展为完整系统) ----
var player_drunk_level: float = 0.0 # 0.0 清醒 ~ 1.0 满醉
## 修改醉意的唯一入口:钳制范围 + 广播变化
## 所有人想改醉意都走这里,将来加监听者不用回来改 GameManager
func set_drunk(value: float) -> void:
player_drunk_level = clampf(value, 0.0, 1.0)
EventBus.drunk_level_changed.emit(player_drunk_level)
这几行里只有 clampf() 是新面孔(第 5 章算方向时用过它的整数版 wrapi),作用是把值卡在 0~1 之间——外面传 1.5 进来,存进去的也是 1.0。剩下两行就是「改值 + 广播」。
为什么不让大家直接改
player_drunk_level? 因为那样「改值」和「通知别人」就分家了——十处改值,就可能有五处忘了通知。把「怎么改」和「改完告诉谁」绑在同一个函数里,是第 4 章 4.4.3 讲的「一个系统只留一个入口」的又一次实践。本章的【定风波】福缘就是调set_drunk(0.0),一行。
调试建议:本章测试时,在 GameManager._ready() 里临时加一句 set_drunk(0.5),能立刻看出准星漂移的区别(7.8 会讲怎么收工)。
7.2.2 为什么这里不建鼠标输入映射
第 5 章我们花了一整节在「项目设置 → 输入映射」里建 left / right / up / down。所以你可能下意识想给鼠标左键也建一个 click 动作。
这次不用。 判断标准很简单:
| 情况 | 该不该建输入动作 |
|---|---|
| 一个功能要多个按键都能触发(比如「上」可以是 W 也可以是 ↑) | ✅ 建 |
| 玩家可能会自己改键(跳跃、攻击、移动) | ✅ 建 |
| 只有唯一一个来源,而且不需要改键(鼠标左键蓄力) | ❌ 直接判断事件类型 |
鼠标左键只有一个来源,也不需要让玩家改绑,所以本章直接判断事件类型——少一层配置,就少一处以后要维护的地方(这个判断在 6.1.2 讲「空壳节点是负债」时出现过同款思路)。
7.2.3 走位输入:复用 left / right
靶场里的主角只左右走位,上下不动(画面上方是靶子区)。所以直接复用第 5 章建好的 left / right,不新增任何输入动作。
up / down 不删——探索模式还要用。它们「在投壶模式里被忽略」,靠的是 7.3.7 的模式分流代码,而不是靠删输入映射。
7.3 搭建投壶战斗场景
第 6 章用「先搭骨架、后填逻辑」的方式做出了小镇。本章沿用同一套打法,而且更要如此——靶场里有 5 个脚本互相通过信号说话,如果先把逻辑写满再连线,一旦跑不起来,你根本不知道是哪个环节断的。
所以我们严格分三段走:先把「实体」立起来(7.3.27.3.4),再把「UI」摆上去(7.3.57.3.6),最后才写「逻辑」(7.3.7~7.3.8)。每一段结束都能 F5/F6 跑一次。
7.3.1 靶场全貌:先看清要建什么
动手前先看全貌,这一步别省。第 6 章 6.2.2 是这么做的,本章同样:
ArenaRoot (Node2D) ← 挂 arena_manager.gd(7.3.8 写)
├── GroundLayer (TileMapLayer) 靶场地面,复用 jiangnan_tileset,40×15 格
├── Player (player.tscn 实例) 主角(自带 Camera2D,7.3.2 调 Limit)
├── AimCursor (Sprite2D) 醉意准星(z_index = 10,永远在最上层)
├── PowerUI (CanvasLayer) 蓄力条容器
│ └── PowerBar (ProgressBar) 屏幕底部居中的进度条
└── Markers (Node2D)
└── SpawnPoint_Default (Marker2D) 主角出生点
| 文件 | 类型 | 作用 | 什么时候建 |
|---|---|---|---|
scenes/levels/level_arena.tscn |
场景 | 靶场舞台 | 7.3.2 |
scenes/object/target_pot.tscn |
场景 | 靶壶预制件 | 7.3.3 |
scenes/object/arrow.tscn |
场景 | 箭矢预制件 | 7.3.4 |
scripts/target_pot.gd |
脚本 | 靶壶:钟摆 + 被命中 | 7.3.3 |
scripts/arrow.gd |
脚本 | 箭矢:抛物线 + 命中/落地互斥 | 7.3.4 |
scripts/aim_cursor.gd |
脚本 | 准星:跟随 + 醉意漂移 | 7.3.5 |
scripts/power_ui.gd |
脚本 | 蓄力条:按住蓄力、松手发射 | 7.3.6 |
scripts/arena_manager.gd |
脚本 | 靶场管理器:生成/发射/评分/福缘/结算 | 7.3.8 |
先约定三件事,后面所有代码都遵守:
约定一:靶场尺寸 40×15 格 = 1280×480 px。 宽度是 640×480 视口的两屏,高度正好一屏。为什么不做成正好一屏(20×15 格)?因为「走位调整角度」是这个玩法的核心动作——横向 1280px 给了主角从「正对靶子」走到「斜 30 度」的余地,靶子会滑出画面,走两步又回来,这正是走位博弈的来源。相机怎么配合,7.3.2 讲。
约定二:物理层沿用 6.4.5 的五层。 本章用到两层:靶壶住在 layer 1 world(它是场景里静止的一份子),箭矢住在 layer 4 hitbox(它是主动「找靶子」的攻击物)。第 8 章的动作战斗会原样复用这套分工。
约定三:信号只往上走。 箭矢不知道有管理器,靶壶不知道有箭矢,它们只管广播自己身上发生了什么;谁关心谁去接。这是第 4 章 4.3.3 讲的信号哲学,本章是它第一次真正发挥威力。
素材准备: 本章需要三张小图——酒壶
assets/sprites/obj/pot.png、箭矢assets/sprites/obj/arrow.png、准星assets/sprites/obj/point.png,尺寸随意(32×32 或 48×48 都行,本章不依赖精确尺寸)。随书资源包里已备好(见附录 B)。暂时没有的话,随便拿三张 32×32 的图顶上也能跑通全部逻辑——先跑通,再换皮(第 2 章 2.1 的「完成比完美重要」)。
7.3.2 【实体】舞台:level_arena.tscn
1. 建场景骨架
- Ctrl+N 新建场景,根节点选 Node2D,重命名为
ArenaRoot; - Ctrl+S 保存到
scenes/levels/,文件名level_arena.tscn(和第 6 章的小镇做邻居); - 按下表把节点建出来:
ArenaRoot (Node2D)
├── GroundLayer (TileMapLayer) ← 铺地面用
├── Markers (Node2D) ← 先占位,放出生点
└── (Player 稍后从文件系统面板拖进来)
GroundLayer的Tile Set属性,选 加载(Load) →resources/jiangnan_tileset.tres——就用第 6 章做好的那一套图鉴,不用重新切图(这就是 6.4 把 TileSet 做成独立资源的好处);- 用第 6 章 6.5.2 的画笔工具,从
0,0起铺一块 40 格宽 × 15 格高 的地面。铺的时候用「随机瓦片」(6.5.2 的技巧),青石板才不会像瓷砖一样整齐划一。
2. 把主角请进靶场
- 从文件系统面板把
scenes/characters/player.tscn拖到ArenaRoot上松手,成为ArenaRoot/Player; - 在视口里把 Player 挪到靶场底部中间,位置设成 (320, 400)——靶子在画面上方,主角在下方朝上射,这是投壶的基本构图;
- 核对 Player 的
Scale是不是 (0.5, 0.5)——第 5 章 5.1.2 已经把它写进player.tscn的根节点,正常拖进来就是对的,这一步只是确认(为什么是 0.5、为什么不能填 0.4,见 5.1.2 与附录 C-17)。
3. 调相机:让它「只横向滚动」
第 5 章 5.1.1 说过:相机就在主角身上,不用新建。第 6 章 6.6 把它的 Limit 设成了小镇的尺寸。现在地图换了,相机只需要改 4 个数字:
- 展开 Player,选中它下面的
Camera2D; - 把
Limit改成靶场尺寸:Left = 0、Top = 0、Right = 1280、Bottom = 480; Position保持(0, -64)、Position Smoothing保持开启(Speed 8.0),都不用动。
改完想一想会发生什么:
- 横向:视口宽 640,地图宽 1280 → 相机可以左右滚 640px。主角走到靶场左半边,镜头停在最左;走到右半边,镜头跟着滚过去。
- 纵向:视口高 480,地图高 480 → 纵向可滚距离是 0。相机怎么都想动,但四个 Limit 把它焊死了,画面永远正好覆盖 0~480。
为什么要「只横向」? 因为靶子的摆动范围是人手调的(7.3.8 的
pot_min_y/pot_max_y),纵向留白没有意义;而横向滚动是走位玩法的天然要求。地图多大,Limit 就设多大——这条规则第 6 章 6.6.3 教过一次,本章只是换个数字再练一遍。
4. 补上出生点
- 在
Markers下新建 Marker2D,命名SpawnPoint_Default(严格按 6.9.2 的三段式命名); - 把它挪到
(320, 400),和 Player 现在的位置一致。
本章虽然是用叠加层装载靶场(7.4),不经过 GameManager.switch_level(),但出生点照样要建——理由 7.4.3 会讲:同一套骨架,两种装载方式都认,这是第 6 章给整个项目定下的规矩。
7.3.3 【实体】靶壶:会摆动的靶子
1. 建预制件
- Ctrl+N 新建场景,根节点选 Area2D,重命名为
TargetPot; - 加子节点 Sprite2D,
Texture加载assets/sprites/obj/pot.png; - 再加子节点 CollisionShape2D,
Shape新建 CircleShape2D,Radius设 20; - 保存到
scenes/object/target_pot.tscn。
为什么碰撞体用圆、半径还比图大一圈? 因为靶子的碰撞体不是「精确轮廓」,而是「能打中的范围」。圆是最省算力、判定最稳定的形状;半径放大一点是故意放宽命中——玩家的准星本来就在飘,如果判定还苛求像素级对齐,手感会变得很挫。判定可以比视觉宽容,这是动作游戏的通例(第 8 章的近战挥砍会用同一招)。
2. 物理层:靶壶不用改任何碰撞设置
这一节只有一句话,但值得单独成段:靶壶的 Layer 保持默认的 1(world),Mask 不用动。
因为按约定二,靶壶是「场景里静止的一份子」,它本来就住在 world 层。它从不主动检测别人(所以 Mask 无所谓),它只需要被别人检测到(所以 Monitorable 保持默认开启)。真正要改碰撞设置的是 7.3.4 的箭矢。
新手最容易在这里犯错: 「两边都要勾上才能撞上吧?」——不是。Godot 的规则是一句话:我 Mask 里有谁,我就检测谁(附录 C-13)。所以只要箭矢那边把 Mask 指向 world,就足够了。靶子这边什么都不用做。
3. 写脚本:scripts/target_pot.gd
新建脚本,绑定到 TargetPot 根节点:
extends Area2D
# target_pot.gd —— 靶壶:上下摆动,被箭射中后报信并自毁
class_name TargetPot
signal hit(pot: Node2D, arrow: Node2D) # 被射中时广播:谁被打中了、被谁打中
@export var amplitude: float = 60.0 # 摆动幅度(像素)
@export var frequency: float = 1.2 # 摆动频率(每秒多少弧度)
var center_y: float = 0.0 # 摆动中心:出生时的那条水平线
var elapsed_time: float = 0.0 # 已经摆了多久
var current_swing_velocity: float = 0.0 # 当前瞬时摆速(带正负号)
var _already_hit: bool = false # 命中锁:防止同一个靶壶被算两次
func _ready() -> void:
center_y = global_position.y # 记住出生高度,后面围着它上下摆
add_to_group("target") # 贴上「靶子」标签,箭矢靠这个标签认人
func _physics_process(delta: float) -> void:
elapsed_time += delta
# 简谐运动:位置 = 中心 + 幅度 × sin(频率 × 时间)
global_position.y = center_y + amplitude * sin(frequency * elapsed_time)
# 速度是位移的导数:v = 幅度 × 频率 × cos(频率 × 时间)
current_swing_velocity = amplitude * frequency * cos(frequency * elapsed_time)
## 给管理器算评分用:只取大小,不要方向
func get_current_velocity() -> float:
return absf(current_swing_velocity)
## 被箭射中:由箭矢调用(它自己的 _on_area_entered 里)
func on_hit(arrow: Node2D) -> void:
if _already_hit:
return
_already_hit = true
set_deferred("monitoring", false) # 先摘掉监听,防止同一帧第二支箭再打中它
hit.emit(self, arrow) # 广播出去,谁关心谁接
queue_free() # 靶壶消失
4. 为什么靶子是「正弦波」?
sin() 画出来的东西你可能不熟,但它的表现你一定见过:过中点时最快,到两端时最慢,两端还会「停一下」。
这正是我们想要的玩法:
- 两端滞空 → 靶子在那零点几秒里几乎不动,那就是「最佳出手时机」;
- 过中点最快 → 这时候打中,说明你是蒙的。
所以「打中」本身不难,难的是打中的时候靶子正好在慢——7.3.8 的评分就是拿这条瞬时速度算的(速度越小,分越高)。第 12 章讲醉意的「视觉反馈」时,也会用同一套「速度 → 手感」的思路。
sin/cos的关系不用记。 记住一句话就够:sin管位置,cos管速度;同一个角度,位置在两端时速度恰好为 0。 附录 F 的函数速查表里有两个函数的参数说明,卡住了去查。
5. 单独验收
先把 target_pot.tscn 单独跑一下(F6,第 5 章教过的「单跑当前场景」):
- 在
target_pot.tscn里选中TargetPot,检查器里把Frequency临时改成0.5; - F6 运行——你会在屏幕中心看到一个酒壶缓慢地上下浮动;
- 把
Amplitude改成120、Frequency改回1.2,再跑一次——摆得更远、更快了。
摆起来了,就把它拖到画面中间当参照(后面 7.3.8 会由管理器来生成它)。
7.3.4 【实体】箭矢:一条「看起来像抛物线」的直线
1. 建预制件
- Ctrl+N 新建场景,根节点选 Area2D,重命名为
Arrow; - 加子节点 Sprite2D(先建影子,后建的画在上面):
- 命名
Sprite2D,Texture加载assets/sprites/obj/arrow.png;
- 命名
- 再加一个 Sprite2D 命名
Shadow,Texture用同一张箭矢图,然后:Modulate设成 焦墨色(0.12, 0.11, 0.10, 0.35)——不要用纯黑(附录 E.3.3 的美术纪律:最暗只到焦墨#1E1C1A);
- 加子节点 CollisionShape2D,
Shape新建 RectangleShape2D,Size设 (36, 12); - 保存到
scenes/object/arrow.tscn,最后在节点面板里把Shadow拖到Sprite2D上面一行(先画影子,再画箭)。
Size的两个数照着你的箭矢图调:宽 ≈ 图宽,高 ≈ 箭杆粗细 + 一点余量。长条形的碰撞体比圆更贴合箭矢的形状,不会「擦着边就算命中」。
2. 物理层:这一步必须改
选中 Arrow 根节点,在检查器里:
| 属性 | 设成 | 读法 |
|---|---|---|
Collision → Layer |
只勾 4 (hitbox) | 「我是攻击物」 |
Collision → Mask |
只勾 1 (world) | 「我检测 world 层里的东西」 |
改完之后,箭矢和靶壶的关系就成立了:箭矢的 Mask(1) ∩ 靶壶的 Layer(1) ≠ 0 → 箭矢能看到靶壶。反过来靶壶什么都没勾,但不影响——因为检测是单向的(附录 C-13)。
为什么不干脆两边都勾、省得想? 因为靶场里还有别的东西。地面的 TileMap 在 world 层,但它是
StaticBody2D(6.4.6 配的),而area_entered只对 Area2D 生效,不会理它;可一旦以后场景里出现了别的 Area2D(比如第 15 章酒坊门口的触发区、第 18 章 NPC 的交互圈),「都勾上」就会让箭矢撞上一堆莫名其妙的东西。分层的目的就是让箭矢只看得见它该看见的。
3. 写脚本:scripts/arrow.gd
extends Area2D
# arrow.gd —— 箭矢:平面匀速直飞 + 一个「假高度」,合起来就是抛物线
class_name Arrow
signal arrow_landed # 箭矢退场(命中或落地都算)时广播
@export var base_speed: float = 500.0 # 基础水平初速(像素/秒)
@export var lift_speed: float = 120.0 # 起手向上的初速
@export var lift_gain: float = 260.0 # 蓄力满时额外获得的向上初速
@export var gravity: float = 800.0 # 重力(像素/秒²)
var velocity: Vector2 = Vector2.ZERO # 平面速度:真正改变位置的那一个
var height: float = 0.0 # 「假高度」:只用来画,不参与碰撞
var vertical_speed: float = 0.0
var is_moving: bool = false
var is_bonus_arrow: bool = false # 是不是福缘【连珠三箭】补射的箭
var _finished: bool = false # 收尾锁:保证「落地」和「命中」只有一个能生效
@onready var sprite: Sprite2D = $Sprite2D
@onready var shadow: Sprite2D = $Shadow
func _ready() -> void:
add_to_group("arrow") # 贴上标签:管理器靠它数「天上还有几支箭」
area_entered.connect(_on_area_entered) # 进入别人的碰撞范围时通知我
## 发射:power 是蓄力值 0~1,direction 是发射方向(已归一化)
## is_bonus = true 表示这是福缘补射的箭(它命中时不再触发新的福缘,防止滚雪球)
func launch(power: float, direction: Vector2, is_bonus: bool = false) -> void:
is_bonus_arrow = is_bonus
velocity = direction.normalized() * (base_speed + power * base_speed)
vertical_speed = lift_speed + power * lift_gain # 蓄得越满,飞得越高、越远
height = 0.0
_finished = false
is_moving = true
sprite.rotation = velocity.angle() # 箭头指向飞行方向
func _physics_process(delta: float) -> void:
if not is_moving:
return
# ① 平面位移:匀速直线
global_position += velocity * delta
# ② 假高度:先升后落,落回地面这一支箭就结束了
height += vertical_speed * delta
vertical_speed -= gravity * delta
if height <= 0.0:
_finish()
return
# ③ 把「高度」画出来:影子拉远 + 缩小变淡
sprite.position.y = -height * 0.15
var shrink := clampf(1.0 - height / 260.0, 0.35, 1.0)
shadow.scale = Vector2.ONE * shrink
shadow.modulate.a = 0.35 * shrink
var ground_offset := Vector2.ONE.normalized() * height * 0.5
shadow.position = ground_offset
shadow.rotation = sprite.rotation
## 命中靶壶:由引擎在「进入别人的碰撞范围」时调用
func _on_area_entered(area: Area2D) -> void:
if not is_moving:
return
if area.is_in_group("target") and area.has_method("on_hit"):
area.on_hit(self) # 让靶壶自己去报信(信号往上走)
_finish()
## 收尾:不管怎样都走这里,保证只广播一次、只销毁一次
func _finish() -> void:
if _finished:
return
_finished = true
is_moving = false
arrow_landed.emit()
queue_free()
4. 拆解:为什么这样就有「抛物线」了
新手看这段代码,最容易卡住的是这句——height 明明只是个变量,凭什么它一动,画面上就出现了弧线?
关键在于俯视角游戏的坐标系里,屏幕的 Y 轴同时承担了两件事:世界里的「前后」和画面上的「高度」。所以我们必须把它们拆开算:
| 变量 | 管什么 | 怎么变 |
|---|---|---|
global_position |
平面位置(真正决定命中谁) | 匀速直线,velocity * delta |
height |
离地高度(只决定画在哪) | 先加后减,和 7.3.3 的摆动一样是「初速 + 重力」 |
然后第 ③ 步把 height 翻译成三个视觉信号,让玩家「看得出来它在天上」:
- 箭矢上移:
sprite.position.y = -height * 0.15——高度越高,箭画得越靠上; - 影子拉远:影子留在原地附近,箭往上走,两者越分越开;
- 影子缩小变淡:
shrink最小到 0.35——离地越远,影子越小越淡。
这是一次有意的简化,你要知道它的代价: 命中判定只看平面位置,完全无视高度。也就是说,箭还在「天上」的时候,只要平面坐标压到靶壶身上,就算命中。
为什么还要这么干?因为俯视角 2D 做真三维弹道,收益极小、成本极高(要投影、要算交点、还要处理「打中了但高度不对」的挫败感)。把高度降级为「纯视觉」,玩家依然能感知到「这是一支飞出去的箭」,而难度全部来自靶子在动 + 准星在飘——这两样才是这个玩法的乐趣所在。第 8 章的动作战斗、第 13 章的酒魂技,走的都是同一条路。
5. 单独验收
arrow.tscn 没法单独跑(它一出生就一动不动的)。它的验收放在 7.3.9 的整场首跑里——那时候你会看到箭矢划出一道影子分离的弧线。
7.3.5 【UI】醉意准星:会飘的瞄准点
粗坯版先说结论:准星 = 一个锚点 + 一层随机漂移 + 一层回拉。
- 回到
level_arena.tscn,在ArenaRoot下新建子节点 Sprite2D,命名AimCursor; Texture加载assets/sprites/obj/point.png;Z Index设 10(确保它盖在所有游戏世界内容之上,包括后生成的箭矢和靶壶);- 新建脚本
scripts/aim_cursor.gd绑定上去。
extends Sprite2D
# aim_cursor.gd —— 醉意准星:锚点跟着主角走,锚点周围随机漂移
class_name AimCursor
@export var base_drift: float = 20.0 # 清醒时的漂移上限(像素)
@export var max_drift: float = 180.0 # 满醉时的漂移上限
@export var return_strength: float = 2.0 # 回正力度:偏离锚点后被拉回的速度
@export var smoothness: float = 6.0 # 跟随平滑:越大越灵敏
@export var follow_offset: Vector2 = Vector2(0, -100) # 锚点在主角头上多远
var target_offset: Vector2 = Vector2.ZERO # 漂移的目标偏移(想飘到哪)
var current_offset: Vector2 = Vector2.ZERO # 实际偏移(平滑地追过去)
@onready var player: CharacterBody2D = get_node("../Player")
func _process(delta: float) -> void:
var anchor := player.global_position + follow_offset
# 读全局醉意,算出「这一帧准星最多能飘多远」
var reach := base_drift + (max_drift - base_drift) * GameManager.player_drunk_level
# ① 随机方向走一小步(布朗运动)
target_offset += Vector2.RIGHT.rotated(randf() * TAU) * randf() * reach * delta * smoothness
# ② 往回拉一点,不然会越飘越远回不来
target_offset = target_offset.lerp(Vector2.ZERO, return_strength * delta)
# ③ 封顶:飘出上限就按最大半径拉回来
if target_offset.length() > reach * 1.5:
target_offset = target_offset.normalized() * reach * 1.5
# ④ 平滑跟上(用 1 - exp(-k·delta),这样帧率高低都不影响手感)
current_offset = current_offset.lerp(target_offset, 1.0 - exp(-smoothness * delta))
global_position = anchor + current_offset
三个新面孔,一次说清:
randf():返回 01 的随机小数。360° 的角度」;randf() * TAU就是「随机一个 0Vector2.RIGHT.rotated(角度):把向右的单位向量转过去。记它比记Vector2(cos, sin)直观——「朝右,转一个随机角度」;1.0 - exp(-k * delta):这是帧率无关的平滑插值系数。直接写lerp(a, b, 0.1)的话,帧率越高追得越快(60 帧和 144 帧手感不一样);用这个公式,无论帧率多少,每秒追上多少比例是固定的。
「布朗运动」是什么? 就是「每一步都朝随机方向走一小步」。单看一步毫无规律,连起来就是一团忽左忽右的抖动——这恰好就是人「手抖」的样子。比「用 sin 画一个规整的圆圈」真实得多:规整的晃动玩家三秒就能预判,随机的抖动才让人一直紧张。
F6 单跑靶场,把准星拖到视野里验收:主角左右走位时,准星跟着她平移,同时还在小幅无规律抖动。
7.3.6 【UI】蓄力条:按住左键、松手发射
- 在
ArenaRoot下新建子节点 CanvasLayer,命名PowerUI; - 在
PowerUI下新建子节点 ProgressBar,命名PowerBar; - 选中
PowerBar,按 Ctrl+A(或用检查器顶部的「锚点预设」)选 「底部居中」,然后设:Offset Left = -100、Offset Right = 100(宽 200px)Offset Top = -43、Offset Bottom = -16(高 27px,贴着屏幕下沿)Max Value = 1.0(我们的蓄力值就是 0~1)- 取消勾选
Show Percentage(数字飘在条上不好看)
- 新建脚本
scripts/power_ui.gd绑定到PowerUI(注意是 CanvasLayer,不是 ProgressBar)。
extends CanvasLayer
# power_ui.gd —— 蓄力条:按住鼠标左键蓄力,松手发射
class_name PowerUI
signal arrow_launched(power: float) # 松手时广播,power 是 0~1 的蓄力值
@export var charge_speed: float = 1.5 # 每秒涨多少(1.5 → 约 0.67 秒蓄满)
var current_power: float = 0.0
var is_charging: bool = false
var is_ready: bool = true # 对局结束后置为 false,不再接受输入
@onready var power_bar: ProgressBar = $PowerBar
func _input(event: InputEvent) -> void:
if not is_ready:
return
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed and not is_charging:
is_charging = true
current_power = 0.0
elif not event.pressed and is_charging:
is_charging = false
power_bar.value = 0.0
# 太轻的一按不算发射,避免误触
if current_power > 0.1:
arrow_launched.emit(current_power)
current_power = 0.0
func _process(delta: float) -> void:
if not is_charging:
return
current_power = minf(current_power + delta * charge_speed, 1.0)
power_bar.value = current_power
## 对局是否允许发射(结束时由管理器关掉)
func set_ready(state: bool) -> void:
is_ready = state
if not state:
is_charging = false
两个值得注意的细节:
is_charging这个布尔量不能省。 鼠标松开的事件只会在「从按下变松开」的那一帧到来;如果不用它标记状态,_process就不知道该不该继续攒蓄力值。「输入事件是瞬时的,游戏状态是持续的」——这是第 5 章移动、本章蓄力、第 8 章攻击共用的同一个模式。- 蓄力条本身的
value由代码推(power_bar.value = current_power),不是反过来读。UI 永远是数据的显示器,不是数据的持有者——第 9 章讲通用 UI 框架时会正式立这条规矩。
7.3.7 【逻辑】主角的投壶模式:一套脚本,两种人生
主角的脚本第 5 章就写好了:scripts/player.gd,负责八方向行走 + 动画 + 存档接口。现在要让同一个人在靶场里只能左右走。
最差的做法是复制一份 player_bowl.gd——那样以后改一个手感参数就得改两处。正确做法是给现有脚本加一个「模式开关」,这也是第 8 章完整状态机(8.2)的雏形。
改动一:新增投壶状态变量
在 player.gd 的 last_direction 那一行下面,加上:
# ---------- 投壶模式状态(第 7 章新增) ----------
# 探索地图不会用到这些变量,只有靶场管理器调用 enter_bowl_mode() 后才有意义
var bowl_mode: bool = false # 是否处于投壶模式
var bowl_min_x: float = 0.0 # 走位左边界
var bowl_max_x: float = 0.0 # 走位右边界
var bowl_power_ui: PowerUI = null # 蓄力 UI 的引用,由靶场管理器注入
var bowl_is_shooting: bool = false # 射箭动作播放期间锁住动画
bowl_power_ui的类型写成PowerUI而不是Node,靠的是 7.3.6 给脚本加的class_name PowerUI。给脚本起 class_name,就等于给这个类型发了身份证,别的地方就能用它做类型标注、还能享受编辑器的自动补全。本书后面每个「组件型」脚本都会这么办。
改动二:把 _physics_process 改成「分流器」
找到第 5 章写的那一段:
func _physics_process(_delta: float) -> void:
var direction := Input.get_vector("left", "right", "up", "down")
velocity = direction * move_speed
move_and_slide()
if direction != Vector2.ZERO:
last_direction = direction
update_animation("walk")
else:
update_animation("idle")
把它整段替换成下面三块:
func _physics_process(delta: float) -> void:
# 分流器:一个入口,两条路。第 8 章的完整状态机会把它换成 match
if bowl_mode:
_physics_process_bowl_mode(delta)
else:
_physics_process_explore_mode(delta)
## 探索模式:第 5 章的逻辑原样搬进来,一行没改
func _physics_process_explore_mode(_delta: float) -> void:
var direction := Input.get_vector("left", "right", "up", "down")
velocity = direction * move_speed
move_and_slide()
if direction != Vector2.ZERO:
last_direction = direction
update_animation("walk")
else:
update_animation("idle")
## 投壶模式:只左右走位,不需要物理碰撞
func _physics_process_bowl_mode(delta: float) -> void:
var input_dir := Input.get_axis("left", "right")
position.x = clampf(position.x + input_dir * move_speed * delta, bowl_min_x, bowl_max_x)
if bowl_is_shooting:
return # 射箭动作播放期间,不抢动画
if bowl_power_ui != null and bowl_power_ui.is_charging:
_update_bowl_aim_frame(bowl_power_ui.current_power) # 蓄力 → 驱动拉弓姿势
elif input_dir != 0.0:
update_animation("walk")
else:
update_animation("idle")
注意 _physics_process_bowl_mode 里没有调用 move_and_slide(),而是直接改 position.x。原因:
- 靶场里没有任何障碍物,物理碰撞无事可做;
move_and_slide()是按velocity做全方向位移的,我们只要横向——用它会引入一堆没必要的向量运算;- 代价是这个模式下主角会「穿过」任何东西。靶场里没东西可穿,所以这个代价是零。第 8 章回到动作战斗时会立刻换回
move_and_slide()。
update_animation()为什么直接用就行? 因为它在enter_bowl_mode()里已经通过last_direction = Vector2.UP把朝向钉成了「上」,所以update_animation("idle")拼出来就是idle_up——第 5 章那两个动画库自动就派上用场了,一行新素材都不用画。
改动三:新增四个方法
在脚本末尾(to_dict() 之前)加上:
# ============================================================
# 投壶模式(第 7 章)
# ============================================================
## 由靶场管理器在 _ready() 里调用,进入投壶模式
func enter_bowl_mode(min_x: float, max_x: float, power_ui: PowerUI) -> void:
bowl_mode = true
bowl_min_x = min_x
bowl_max_x = max_x
bowl_power_ui = power_ui
last_direction = Vector2.UP # 投壶时永远面朝靶子(画面上方)
velocity = Vector2.ZERO # 清掉探索时残留的速度
update_animation("idle")
bowl_power_ui.arrow_launched.connect(_on_bowl_arrow_launched)
sprite.animation_finished.connect(_on_bowl_animation_finished)
## 蓄力进度 → 拉弓动画的某一帧(不是播放,是「点播」到那一帧)
func _update_bowl_aim_frame(charge_ratio: float) -> void:
if not sprite.sprite_frames.has_animation("aim_up"):
return # 素材还没画,安静跳过,不报错
if sprite.animation != "aim_up":
sprite.play("aim_up")
sprite.stop() # 停住,让它听我们的
var last_frame: int = sprite.sprite_frames.get_frame_count("aim_up") - 1
sprite.frame = clampi(int(charge_ratio * last_frame), 0, last_frame)
func _on_bowl_arrow_launched(_power: float) -> void:
# ⚠ 必须把「锁定」这件事也放进 if 里面:
# 如果没有 shoot_up 动画却把 bowl_is_shooting 设成 true,
# 就永远等不到 animation_finished,角色会卡死在「射箭中」
if sprite.sprite_frames.has_animation("shoot_up"):
bowl_is_shooting = true
sprite.play("shoot_up")
func _on_bowl_animation_finished() -> void:
if sprite.animation == "shoot_up":
bowl_is_shooting = false
射箭动画:两条路,任选一条
路径 A:手上还没有射箭素材(本章默认)
你的素材包里现在只有 idle 和 walk 两套表,没有拉弓和射箭。所以上面两个方法都用了 has_animation() 做守卫——没有动画时安静跳过,不报错、不卡死。
这是本章一个刻意的教学选择:代码可以先写好,素材后补。但守卫必须写,否则你看到的就是「按了没反应」或者「角色卡住不动」(这两个坑都会进附录 C)。
路径 B:用随书资源包里的 bow.png(推荐)
随书资源包的 assets/sprites/player/ 里其实已经有这张表了:bow.png——1024×1024、8 列 × 8 行、每帧 128×128、行序与 idle/walk 完全相同(0右 1左 2下 3上 4右下 5左下 6右上 7左上,见附录 B.2.3)。
这意味着:第 5 章 5.2.3 的切帧代码一行都不用改,只要多喂一张表、多生成一组 bow_方向 动画。而它一行 8 帧记录的是一次完整的射箭过程:
| 帧号 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| 动作 | 满弦瞄准 | 瞄准 | 瞄准 | 放箭 | 收势 | 收势 | 持弓归位 | 持弓归位 |
一张表要干两件事(蓄力姿势 + 放箭),所以帧的用法要分开:
| 玩法阶段 | 用哪几帧 | 怎么播 |
|---|---|---|
| 蓄力中 | 0 ~ 2 | stop() 之后点播到 int(蓄力 × 2) 帧 |
| 松手放箭 | 3 ~ 7 | 从第 3 帧起播到结束,不循环 |
把上面两个方法换成:
## 蓄力进度 → 拉弓姿势(点播到「瞄准段」的某一帧)
func _update_bowl_aim_frame(charge_ratio: float) -> void:
if not sprite.sprite_frames.has_animation("bow_up"):
return
if sprite.animation != "bow_up":
sprite.play("bow_up")
sprite.stop() # 停住,让它听我们的
# 前 3 帧是「瞄准」段,蓄得越满帧越靠后
sprite.frame = clampi(int(charge_ratio * 2.0), 0, 2)
func _on_bowl_arrow_launched(_power: float) -> void:
if sprite.sprite_frames.has_animation("bow_up"):
bowl_is_shooting = true
sprite.play("bow_up") # 从头播……
sprite.frame = 3 # ……然后立刻跳到「放箭」那一帧
func _on_bowl_animation_finished() -> void:
if sprite.animation == "bow_up":
bowl_is_shooting = false
⚠ 为什么放箭时要手动跳到第 3 帧? 因为
play()永远从第 0 帧开始——不跳的话,角色会在你松手之后又瞄一遍(多花约 0.25 秒),画面上非常出戏。那这样还会正常触发
animation_finished吗?会。 Godot 官方文档写得很清楚:frame的 setter「只重置frame_progress」,不会中断播放;而animation_finished是「动画抵达末尾时发出」(前提是不循环)。所以跳帧只改变从哪儿开始,不改变「播完会报信」这件事——bowl_is_shooting照样能被解开。
名字必须对得上。 _update_bowl_aim_frame 里写死了动画名,方向后缀沿用第 5 章 5.2 的行序表:
| 路径 | 动画名 | 说明 |
|---|---|---|
| A(自绘两张表) | aim_up / shoot_up |
拉弓 4 |
B(用 bow.png) |
bow_up(一张表 8 帧) |
蓄力用 0 |
为什么投壶模式不需要额外的
walk_up/idle_up? 因为它们已经在了——第 5 章_build_animations()一次生成了 16 个动画,其中就有idle_up和walk_up。这就是当初「一次性切完 16 个动画」的回报:新玩法不需要新素材就能先跑起来。
7.3.8 【逻辑】靶场管理器:把所有人串起来
前面六个文件(靶壶、箭矢、准星、蓄力条、主角模式、舞台)都是「零件」,它们互相不认识。现在写那个唯一认识所有人的东西。
- 新建脚本
scripts/arena_manager.gd; - 绑定到
level_arena.tscn的根节点ArenaRoot; - 完整代码如下。
extends Node2D
# arena_manager.gd —— 靶场管理器
# 唯一「认识所有人」的节点:开局、生成靶壶、发射箭矢、评分、福缘、结算
# 它是第 8 章完整战斗的「岗位说明书」:以后换成敌人和血条,流程一模一样
class_name ArenaManager
const ARROW_SCENE: PackedScene = preload("res://scenes/object/arrow.tscn")
const POT_SCENE: PackedScene = preload("res://scenes/object/target_pot.tscn")
# ---------- 对局参数(全都能在检查器里调,不用改代码) ----------
@export var round_time_limit: float = 30.0 # 一局时长(秒)
@export var pot_total: int = 20 # 一局生成多少个靶壶
@export var player_min_x: float = 80.0 # 主角走位左边界
@export var player_max_x: float = 1200.0 # 主角走位右边界
@export var pot_min_x: float = 100.0 # 靶壶横向生成范围
@export var pot_max_x: float = 1180.0
@export var pot_min_y: float = 130.0 # 靶壶纵向生成范围
@export var pot_max_y: float = 230.0
@export var bonus_arrow_gap: float = 0.15 # 连珠三箭的发射间隔(秒)
# ---------- 运行时状态 ----------
var score: int = 0 # 总分
var hits: int = 0 # 命中次数
var shots: int = 0 # 出手次数
var pots_left: int = 0 # 场上还剩几个没被打中的靶壶
var elapsed: float = 0.0 # 这一局已经打了多久
var round_active: bool = false # 对局是否进行中
var score_multiplier: float = 1.0 # 福缘【醉仙翻倍】的乘数,用完即归 1
@onready var player: CharacterBody2D = $Player
@onready var aim_cursor: AimCursor = $AimCursor
@onready var power_ui: PowerUI = $PowerUI
func _ready() -> void:
player.enter_bowl_mode(player_min_x, player_max_x, power_ui) # ① 主角切成投壶模式
power_ui.arrow_launched.connect(_on_arrow_launched) # ② 松手 → 发射
GameManager.change_state(GameManager.GameState.BATTLE)
start_round()
func _process(delta: float) -> void:
if not round_active:
return
elapsed += delta
if elapsed >= round_time_limit:
_end_round() # 时间到,直接结算
# ============================================================
# 🚀 开局与生成
# ============================================================
func start_round() -> void:
score = 0
hits = 0
shots = 0
pots_left = 0
elapsed = 0.0
score_multiplier = 1.0
round_active = true
power_ui.set_ready(true)
for i in pot_total:
_spawn_pot()
print("[靶场] 开局!限时 ", round_time_limit, " 秒,靶壶 ", pot_total, " 个")
func _spawn_pot() -> void:
var pot: TargetPot = POT_SCENE.instantiate()
# 位置随机、摆动幅度和频率也随机 —— 每一局、每一个靶壶都不一样
pot.position = Vector2(randf_range(pot_min_x, pot_max_x), randf_range(pot_min_y, pot_max_y))
pot.amplitude = randf_range(40.0, 70.0)
pot.frequency = randf_range(1.1, 2.5)
add_child(pot) # 注意:先把位置设好,再加进场景树
pot.hit.connect(_on_pot_hit) # 靶壶被打中时会喊我们
pots_left += 1
# ============================================================
# 🏹 发射
# ============================================================
func _on_arrow_launched(power: float) -> void:
if not round_active:
return
# 方向 = 从主角指向准星当前位置(准星在飘,所以方向带一点随机)
var direction := (aim_cursor.global_position - player.global_position).normalized()
_fire_arrow(power, direction, false)
func _fire_arrow(power: float, direction: Vector2, is_bonus: bool) -> void:
var arrow: Arrow = ARROW_SCENE.instantiate()
add_child(arrow)
arrow.global_position = player.global_position + Vector2(0, -30) # 从胸口射出
arrow.arrow_landed.connect(_on_arrow_landed)
arrow.launch(power, direction, is_bonus)
shots += 1
## 一支箭退场(命中或落地都会来这儿)
func _on_arrow_landed() -> void:
_check_round_end()
# ============================================================
# 🎯 命中:评分 + 福缘
# ============================================================
func _on_pot_hit(pot: Node2D, arrow: Node2D) -> void:
if not is_instance_valid(pot) or pot.is_queued_for_deletion():
return
hits += 1
pots_left -= 1
# ---- 评分:靶子摆得越慢,分越高 ----
var swing_speed: float = pot.get_current_velocity() # 此刻的摆速
var max_speed: float = pot.amplitude * pot.frequency # 这个壶的最大摆速
var timing: float = 1.0 - clampf(swing_speed / maxf(max_speed, 0.01), 0.0, 1.0)
var quality: float = 0.6 + timing * 0.4 # 落在 0.6 ~ 1.0 之间
var gained: int = int(quality * 10.0 * score_multiplier)
score += gained
score_multiplier = 1.0 # 翻倍只对「这一箭」有效,用完就没了
print("[命中] 时机 %.2f → +%d 分(总分 %d,还剩 %d 壶)" % [timing, gained, score, pots_left])
# ---- 福缘:打漂亮了才有惊喜 ----
# ⚠ arrow.is_bonus_arrow:福缘补射的箭命中时不再触发新福缘,否则会滚雪球
if quality >= 0.85 and not arrow.is_bonus_arrow:
_roll_fortune()
_check_round_end()
## 福缘:把「打得漂亮」换成「这一局接下来怎么打」
func _roll_fortune() -> void:
var roll := randf()
if roll < 0.35:
_fire_bonus_arrows() # 【连珠三箭】
elif roll < 0.70:
score_multiplier = 2.0 # 【醉仙翻倍】
print("[福缘] 醉仙翻倍:下一箭得分 ×2")
else:
GameManager.set_drunk(0.0) # 【定风波】
print("[福缘] 定风波:醉意清零,准星立刻稳住")
## 【连珠三箭】:隔一点点时间补射三支箭,全中就能翻盘
func _fire_bonus_arrows() -> void:
print("[福缘] 连珠三箭!")
for i in 3:
await get_tree().create_timer(bonus_arrow_gap).timeout
if not round_active:
return # 对局已经结束,别再射了
var direction := (aim_cursor.global_position - player.global_position).normalized()
_fire_arrow(randf_range(0.15, 0.35), direction, true)
# ============================================================
# 📊 结算
# ============================================================
## 结束条件:靶壶全打完 + 天上没有还在飞的箭
func _check_round_end() -> void:
if round_active and pots_left <= 0 and get_tree().get_nodes_in_group("arrow").is_empty():
_end_round()
func _end_round() -> void:
if not round_active:
return
round_active = false
power_ui.set_ready(false) # 不再接受新箭
# ---- 清场:天上剩下的箭、场上剩下的壶 ----
for node in get_tree().get_nodes_in_group("arrow"):
node.queue_free()
for node in get_tree().get_nodes_in_group("target"):
node.queue_free()
# ---- 结算加分 ----
var remaining := maxf(0.0, round_time_limit - elapsed)
var time_bonus: int = int(remaining * 10.0) * 5 # 每剩 0.1 秒 +5 分
var accuracy := 0.0
if shots > 0:
accuracy = float(hits) / float(shots)
var hit_bonus: int = 100 if accuracy >= 1.0 else int(accuracy * 100)
score += time_bonus + hit_bonus
print("[靶场] 本局结束|得分 %d(时间 +%d,命中率 +%d)|出手 %d 次,命中 %d 次,命中率 %.1f%%"
% [score, time_bonus, hit_bonus, shots, hits, accuracy * 100.0])
# ---- 留一口气,再把结果广播出去 ----
await get_tree().create_timer(1.5).timeout # 让最后几支箭飞完、玩家看清
EventBus.battle_finished.emit({
"score": score, "hits": hits, "shots": shots, "accuracy": accuracy,
})
三个设计点,值得单独说
① 为什么用 const ARROW_SCENE = preload(...) 而不是 @export?
第 5 章的 player.gd 也是这么干的。preload 在编译期就把场景加载进来了,运行时不查磁盘;而 @export var arrow_scene: PackedScene 要靠你在检查器里手动拖一次——多一个可能忘掉、而且忘了是 null(不报错、只在运行时炸)的手工步骤。只有需要「同一份代码换不同素材」时才值得用 @export。本章不需要。
② 为什么 add_child(pot) 要放在设置 position 之后?
因为 TargetPot._ready() 里有一句 center_y = global_position.y——_ready() 是在 add_child() 那一刻触发的(第 6 章 C-16 讲过的时序)。所以必须先摆好位置,再进场景树,否则靶壶会把摆动中心记在 (0, 0),然后摆到画面外面去。
③ await 是什么?
await get_tree().create_timer(0.15).timeout 的意思是:「在这儿停一下,等 0.15 秒的计时器响了再往下走。」 它不阻塞游戏(这段时间里靶子照样在摆、箭照样在飞),只是把这个函数切成两段按顺序执行。
用它的好处极其直观:「隔 0.15 秒射一箭,共三次」就真的是这么写出来的,不用手工维护什么「第几支箭了、过了多少秒了」的状态变量。同一招第 13 章做技能特效、第 17 章做陈化计时都会再用。
await有一个必须知道的规矩: 等待期间如果这个节点被销毁了(比如玩家提前退出了靶场),函数不会继续往下跑——不会有报错,但后面的代码也不会执行。所以像_end_round()里那样,把「要不要继续」的判断写在await后面,是必须的习惯。
接线复核
保存后,逐项确认这三处引用没有一个是空的(这是本章最容易出错的地方):
| 节点 | 期望 | 怎么确认 |
|---|---|---|
arena_manager.gd 的 player |
指向 ArenaRoot/Player |
它应该是 $Player。如果你把主角放进了别的容器,这里要改路径 |
arena_manager.gd 的 aim_cursor |
指向 ArenaRoot/AimCursor |
同上,$AimCursor |
arena_manager.gd 的 power_ui |
指向 ArenaRoot/PowerUI |
同上,$PowerUI |
aim_cursor.gd 的 player |
get_node("../Player") |
AimCursor 必须是 ArenaRoot 的直接子节点,否则相对路径要改 |
为什么这里用
$节点名(@onready),而 7.3.8 之外的文件用@export拖拽绑定更常见? 因为@onready var x = $Path是写死在代码里的路径——路径错了一运行就报Node not found,错得很响;而@export忘了拖就是一个静默的null,要等某个功能失效才被发现(第 6 章 6.8 表里那条「出生点建成了 Node2D」就是这个坑)。对新手来说,宁可错得响,不要错得静。 等到需要「同一个场景换不同配置」时,再上@export。
7.3.9 F5 首跑:验收清单与故障排查
现在按 F6 单跑 level_arena.tscn(不用进小镇,直接测靶场),逐项打勾:
- 靶场地面显示出来,主角站在下方中央,没有报错;
- 按 A / D 主角左右走,走到两端被挡住(那是
player_min_x/max_x,不是墙); - 画面上方出现 20 只酒壶,各自以不同的幅度和频率上下摆动;
- 准星浮在主角上方,跟着她平移,同时小幅随机抖动;
- 按住鼠标左键,屏幕底部蓄力条上涨,约 0.67 秒蓄满;
- 松手,一支箭射出:箭往上偏、影子留在地上并被拉开距离,落地后消失;
- 箭飞过酒壶时,酒壶消失,输出面板打印
[命中] 时机 … → +… 分; - 打中时机好的(分高)时,偶尔看到
[福缘]字样的打印; - 30 秒后自动结算,打印
[靶场] 本局结束|…,之后左键不再发射。
常见故障排查:
| 现象 | 根因 | 解决方法 |
|---|---|---|
报错 Node not found: "Player" |
ArenaRoot 下没有 Player,或主角被放进了别的容器 |
核对 7.3.2 第 6 步;确保 player / aim_cursor / power_ui 三个 @onready 路径都对得上 |
| 主角不动 | 靶场管理器没挂在根节点上,或 enter_bowl_mode 没被调用 |
确认 arena_manager.gd 挂在 ArenaRoot(不是子节点);确认 _ready() 里三行都在 |
| 主角乱跑(上下也能动) | 改 _physics_process 时旧的那段没删干净 |
核对 7.3.7 改动二:_physics_process 里只剩分流三行 |
| 酒壶摆到画面外去了 | _spawn_pot() 里 add_child() 写在了设 position 之前 |
交换顺序:先设位置,再 add_child(7.3.8 设计点②) |
| 箭穿壶而过、毫无反应 | 箭矢的 Mask 没勾 1 (world),或靶壶的 Monitorable 被关了 |
回 7.3.4 第 2 步核对碰撞矩阵 |
| 箭一出生就没了 | _physics_process 里落地判断写成了 height <= 0 而没有 vertical_speed < 0 之类的前置条件 |
核对:起手 vertical_speed 必须是正数(往上),落地靠的重力把它变负 |
| 按了左键没反应 | PowerUI 的脚本挂错了节点(挂到了 PowerBar 上) |
脚本要挂在 CanvasLayer 上,PowerBar 只是它的子节点 |
| 蓄力条涨得飞快/几乎不动 | charge_speed 数值不对 |
默认 1.5(约 0.67 秒蓄满),在检查器里调 |
| 打中后角色卡住不动了 | bowl_is_shooting 被设成 true,但没有 shoot_up 动画来收尾 |
核对 7.3.7 改动三:锁定和播放必须在同一个 if 里 |
| 一局结束后还能继续射 | power_ui.set_ready(false) 没被调用 |
核对 _end_round() 第二行 |
本章最值钱的一句排错经验: 这五个脚本靠信号串联(
arrow_launched→ 发射;hit→ 评分;arrow_landed→ 收尾)。信号的特点是**「没连上就静默失效」**——没有报错,只是没人响应。所以卡住时,第一件事是回 7.3.8 的「接线复核」表把引用逐个确认,而不是盯着代码读。这是第 7 章和第 6 章排错方式最大的不同。
7.4 集成到主游戏
靶场单独能跑,但玩家进不去。这一节把它接进小镇。
7.4.1 兑现一张 4 章的欠条:给 Main 加 BattleArena
第 4 章 4.2.3 讲过一段话,当时我们刻意没有实现:
「触发投壶时,暂停当前关卡,把投壶场景动态加载到
Main下层叠加在画面上,结束后移除、恢复原状。」
第 6 章 6.1.2 又补了一句:
「为什么不趁现在多建几个空容器(比如 World、BattleArena)?因为空壳节点是负债不是资产……第 7 章做投壶时,会往
Main下加战斗叠加层;那时候加,理由才站得住。」
现在理由到了。打开 scenes/main.tscn,给 Main 加一个子节点:
Main (Node2D)
├── CurrentLevel (Node2D) 当前关卡(小镇、以后的酒坊……)
├── BattleArena (Node2D) ← 今天新增:独立玩法叠加层
└── UI (CanvasLayer) 全局界面层
BattleArena 先建成空壳,不挂任何脚本——它的所有行为都由 7.4.3 的 GameManager 驱动。它存在的唯一意义是给叠加场景一个固定的落脚点,让 GameManager 不必关心「往哪个节点加」。
为什么现在建就不算「负债」? 因为 6.1.2 反对的不是「建容器」,而是「在还没需求的时候建容器」。现在需求明确、位置明确、下一节就要往里塞东西——这叫按需落地。
7.4.2 触发区:在广场那块空地上
第 6 章 6.9.1 特意在小镇中央广场留了一块 10×8 格的无障碍空地,备注写着「第 7 章(投壶)+ 第 11 章(遭遇战)」。那块空地就是本节要放触发区的地方——注意,靶场本体是叠加上来的,所以不占小镇一寸地;广场上要放的只是一个看不见的感应圈。
- 打开
scenes/levels/level_town.tscn,在Markers下新建 Area2D,命名TriggerZone_Bowl; - 给它加子节点 CollisionShape2D,
Shape用 RectangleShape2D,Size设 (320, 96)(约 10×3 格,覆盖广场那块空地); - 把它摆到那块空地上(视口里拖,坐标随意,位置合适就行);
- 选中
TriggerZone_Bowl,按 6.9.1 的老规矩配碰撞:Layer勾 5 (item)、Mask勾 2 (player); - 新建脚本
scripts/battle_trigger.gd绑定上去:
extends Area2D
# battle_trigger.gd —— 投壶触发区:主角踩进来,就广播「该打靶了」
var _armed: bool = true # 「待触发」标记,防止来回踱步反复触发
func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _on_body_entered(_body: Node2D) -> void:
if not _armed:
return
_armed = false
EventBus.battle_requested.emit() # 广播:往事件总线上喊一声
func _on_body_exited(_body: Node2D) -> void:
_armed = true # 走开了,重新上膛
这里没有一个 if body.is_in_group("player")。 为什么?因为 Mask 已经只勾了 2 (player)——能触发这个 Area2D 的,只可能是住在 player 层的节点,再判一次是多余的。
⚠ 一个很多人会踩的坑: 网上大量教程写的是
if body.is_in_group("player")。如果你照着抄,但从没给主角贴过player这个分组,那这个判断永远为假——而且不报错,你会盯着代码怀疑人生。分组不是自动的,得手动add_to_group("player")。本书选择用碰撞层代替分组:层的配置在 6.4.5 已经立成规矩,不容易忘。
_armed 这个标记也别省。没有它,玩家站在触发区里会每帧触发一次(body_entered 在重新进入时会再响),叠加层被反复实例化,帧率瞬间掉到个位数。
7.4.3 叠加与恢复:GameManager 的最后一块
现在 EventBus.battle_requested 已经在喊了,但没人听。先给 EventBus 补两个信号(第 4 章 4.4 说过「后续章节会往这里陆续添加信号」):
打开 scripts/event_bus.gd:
signal battle_requested # 玩家踩进投壶触发区(第 7 章)
signal battle_finished(result: Dictionary) # 一局投壶结束(第 7 章;第 14 章做结算 UI 用)
battle_finished 带一个字典参数,而不是只报个分数。理由和第 3 章讲的「设计要留余地」一样:结算界面(第 14 章)想看命中率,任务系统(第 20 章)想看有没有达标,将来可能还想看连击数——广播一个结构,比广播一个数字活得久。
然后给 scripts/game_manager.gd 补上叠加层的进出(加在 switch_level() 后面):
# ---- 叠加层管理(第 7 章:投壶场地) ----
var overlay_container: Node = null # BattleArena 容器(惰性获取,和 level_container 一个路数)
var active_overlay: Node = null # 当前叠加的场景
## 进入投壶:相机让位 → 冻结小镇 → 叠加靶场
func enter_battle(arena_scene: PackedScene) -> void:
if active_overlay != null:
return # 已经在打了,别叠第二层
if overlay_container == null:
overlay_container = get_tree().current_scene.get_node("BattleArena")
# ① 相机让位:Godot 里同时只有一台 Camera2D 生效,先让小镇那台退休
var town_cam := _find_town_camera()
if town_cam != null:
town_cam.enabled = false
# ② 冻结 + 隐藏小镇:它还在内存里,但不跑逻辑、不渲染
if active_level != null:
active_level.process_mode = Node.PROCESS_MODE_DISABLED
active_level.visible = false
# ③ 把靶场叠上去
active_overlay = arena_scene.instantiate()
overlay_container.add_child(active_overlay)
# ④ 靶场自带相机接管画面(相机就在靶场的主角身上)
var arena_cam := active_overlay.get_node_or_null("Player/Camera2D") as Camera2D
if arena_cam != null:
arena_cam.make_current()
change_state(GameState.BATTLE)
## 退出投壶:拆靶场 → 唤醒小镇 → 相机交还
func exit_battle() -> void:
if active_overlay != null:
active_overlay.queue_free()
active_overlay = null
if active_level != null:
active_level.process_mode = Node.PROCESS_MODE_INHERIT
active_level.visible = true
var town_cam := _find_town_camera()
if town_cam != null:
town_cam.enabled = true
town_cam.make_current() # 显式抢回「当前相机」
change_state(GameState.EXPLORE)
## 找小镇主角身上那台相机(第 5 章 5.1.1 装的那台)
func _find_town_camera() -> Camera2D:
if active_level == null:
return null
return active_level.get_node_or_null("YSortWorld/Player/Camera2D") as Camera2D
这三步为什么一步都不能少
① 相机让位。 Godot 的视口同时只有一台生效的 Camera2D。如果不把小镇那台关掉,靶场加载进来之后就有两台都想当「当前相机」,谁生效取决于节点顺序和启用时机——这是那种「换台电脑就不一样」的玄学 bug。解法很土但绝对可靠:进靶场前先让小镇那台退休(enabled = false),出来时再显式抢回来(make_current())。
② 冻结小镇。 这里有个本章最容易被忽略的坑:靶场里有自己的一个主角实例。如果不冻结小镇,同一时刻就会有两个主角在跑——你按一下 D,两个人一起向右走;小镇那个虽然看不见(visible = false),但输入照收。
process_mode = PROCESS_MODE_DISABLED 是 Godot 提供的「整棵子树暂停」开关:它下面的所有节点都不再跑 _process / _physics_process、不再吃输入。比逐个节点 set_process(false) 干净得多,而且新增的子节点自动继承——以后第 11 章给小镇加了敌人,这一行照样管用。
③ 显式 make_current()。 两台相机谁生效这件事,不要指望引擎替你猜。make_current() 就是一句「这台,就是现在这台」——确定性永远比聪明重要。
为什么
_find_town_camera()要单独抽成一个函数? 因为「小镇的相机在哪」这个路径写了两遍(进和出各一次)。同一个路径写两遍,就有改一处忘一处的风险——哪天关卡结构调整了,你只会改到其中一处。抽成函数只多三行,省掉一次未来的事故。顺带注意这个路径名:
YSortWorld/Player/Camera2D——它在 6.2.2 建小镇、5.1.1 装相机的时候就注定了。本章一行都不用改小镇的场景结构,正是那两章把路走对的回报。
最后,让 main.gd 当那个「听广播的人」:
extends Node2D
# main.gd —— 游戏总入口:装小镇 + 调度叠加层
const ARENA_SCENE: PackedScene = preload("res://scenes/levels/level_arena.tscn")
func _ready() -> void:
EventBus.battle_requested.connect(_on_battle_requested)
EventBus.battle_finished.connect(_on_battle_finished)
GameManager.switch_level(GameManager.TOWN_SCENE)
func _on_battle_requested() -> void:
GameManager.enter_battle(ARENA_SCENE)
func _on_battle_finished(result: Dictionary) -> void:
print("[Main] 投壶结算:", result)
GameManager.exit_battle()
信号链此刻闭环了,把整条链路读一遍,你会发现每一环都只认识自己的邻居:
主角走进触发区
↓ body_entered(Area2D 信号)
battle_trigger.gd → EventBus.battle_requested(全局广播)
↓
main.gd → GameManager.enter_battle(ARENA_SCENE)
↓
靶场跑起来 …… 30 秒后 _end_round()
↓ EventBus.battle_finished(全局广播)
main.gd → GameManager.exit_battle()
↓
小镇醒来,主角还站在原地
有没有发现
battle_trigger.gd不知道靶场存在?main.gd不知道触发区存在? 触发区只喊了一句「要打靶了」,主场景只负责「把靶场装上」。两边各自只做一件事,中间的「谁去办」由事件总线牵线。 这就是第 4 章 4.3.3 那句「发广播的一方不需要知道谁在听」的实际收益:第 15 章要在酒坊门口加触发区,只需要在新触发区里喊一句EventBus.brewery_requested.emit(),main.gd加一行 connect——不用去动本章任何一行代码。
7.4.4 走一遍:从小镇走进靶场
F5 运行(跑整个游戏),按顺序验收:
- 小镇照常加载,主角出现在出生点,镜头平滑跟随;
- 走到广场那块空地,画面一切:小镇消失,靶场出现,主角站在靶场下方中央;
- 靶场里主角只能左右走;按住左键蓄力、松手射箭,一套流程可玩;
- 一局打完(约 30 秒或打完所有壶),输出面板打印结算,再等 1.5 秒;
- 小镇回来了,主角还在她走进触发区时的位置(不是出生点);
- 走开再走回来,可以再打一局。
集成阶段的常见故障:
| 现象 | 根因 | 解决方法 |
|---|---|---|
| 走进空地毫无反应 | 触发区的 Mask 没勾 2 (player),或 main.gd 里没 connect |
回 7.4.2 第 4 步 + 7.4.3 的 main.gd |
报错 Node not found: "BattleArena" |
main.tscn 里没建这个容器,或名字不一致 |
7.4.1,注意大小写 |
| 进了靶场,画面却还在小镇 | 小镇相机没让位,两台相机抢 | 核对 enter_battle() 的第 ① 步 |
| 按 A / D,看不见的主角也在动 | 小镇没冻结,两个主角同时吃输入 | 核对 enter_battle() 的第 ② 步 |
| 打完回不去小镇 | battle_finished 没人接 |
核对 main.gd 的 connect |
| 一回小镇就又被拉进靶场 | 触发区少了 _armed 标记 |
回 7.4.2 的脚本 |
| 一局结束后连着又开一局 | _end_round() 里没锁住,结束时又触发了一次 start_round |
核对 _end_round() 开头的 if not round_active: return |
7.5 从投壶到动作战斗:本章为后面留下了什么
第 8 章会把投壶收编为远程系武器的一件形制(8.7)。这不是「推倒重做」,而是「换两头」——本章写的东西几乎全部留在场上。
| 本章产出 | 建立位置 | 被后续哪些章节复用 |
|---|---|---|
醉意值 + set_drunk() + 广播 |
7.2.1 | 第 12 章(完整醉意系统)、第 14 章(醉意条) |
| 靶壶(会动的目标) | 7.3.3 | 第 11 章(敌人待机/受击的雏形) |
命中判定:碰撞层 + add_to_group |
7.3.4 | 第 8 章 8.3(Hitbox / Hurtbox 的直接前身) |
| 「假高度」投射物 | 7.3.4 | 第 8 章远程系、第 13 章酒魂技特效 |
蓄力 → power 值 |
7.3.6 | 第 8 章 8.7(远程系蓄力攻击态) |
| 评分 = 读目标的瞬时速度 | 7.3.8 | 第 12 章(醉意如何改变手感)、第 16 章(品质评分) |
| 福缘 = 改规则(不是加分) | 7.3.8 | 第 16 章词缀、第 20 章任务奖励 |
| 双模式分流 | 7.3.7 | 第 8 章 8.2(完整状态机的前身) |
叠加层机制 enter_battle/exit_battle |
7.4.3 | 第 9 章(UI 面板)、第 15 章(酿造面板)、第 19 章(对话) |
| 触发区 + 事件总线 | 7.4.2 / 7.4.3 | 第 15 章(酒坊门口)、第 18 章(NPC 交互) |
| 追踪箭(限速转向 + 蛇形摆动 + 尾焰粒子)(进阶) | 7.6.3 ~ 7.6.6 | 第 13 章(酒魂技的追踪型特效)、第 25 章(粒子性能优化) |
| 可中途叫停的协程写法 (进阶) | 7.6.7 | 第 8 章(攻击连段可被打断)、第 20 章(任务流程可中止) |
三个具体的「钩子」你已经埋好了,第 8 章张口就能用:
GameState.BATTLE已经切好了。 第 11 章的敌人 AI 会读它来决定「要不要追人」——你在靶场里就是BATTLE,走出来才是EXPLORE。EventBus.battle_finished(result)带的是字典。 第 14 章的战斗结算界面、第 20 章的「投壶连胜三次」任务,都是接这个信号,不用改本章任何代码。ArenaManager就是第 8 章的岗位说明书。 「开局 → 生成 → 等信号 → 评分 → 结算」这个骨架,换成「敌人 → 血条 → 伤害飘字」还是同一副架子。
本章最想让你带走的不是任何一段代码,而是一句话: 投壶之所以能被收编进动作战斗,是因为我们从一开始就把它当成**「战斗系统的最小实例」**来做,而不是「一个独立的小游戏」。设计新玩法时先问一句「它和主系统共用哪条链路」——共用得越多,将来拼装得越省力。(第 3 章 3.5 的「有意义的选择」讲的是玩家视角,这里是开发者视角的同一件事。)
7.6 (进阶·可选) 完整版投壶:追踪箭、蛇形摆动与粒子特效
走到这里,你的投壶已经能玩了:能瞄准、能蓄力、能命中、能结算、能进能出。但如果你把它拿给朋友玩五分钟,多半会收到一句评价——「挺好,但有点素。」
原因不难找。教学版里,7.3.8 的【连珠三箭】不过是「再直着射三支箭」,【定风波】是「醉意清零」。它们在规则上有效,但在屏幕上什么都看不见。而一个玩家判断「好不好玩」的时间只有三秒,全靠这三秒里的画面反馈。
本节把随书实盘工程里那份完整版拆开讲。它多出来的东西,恰好就是「教学版」和「能拿出去给人玩的版本」之间的差距。
📌 本节是进阶内容,完全可选。 它不新增任何全局设施,不影响第 8 章。第一次读建议先跳过,等主线跑通、玩腻了自己那版再回来。跳过的代价只有一个:你的福缘奖励看起来比较朴素。
7.6.1 先看清楚:完整版多了什么
| 模块 | 教学版(7.3) | 完整版(本节) | 玩家能感知到的差别 |
|---|---|---|---|
| 节点接线 | $Player 路径写死在代码里 |
@export 拖拽绑定 |
无(开发者体验) |
| 福缘【连珠三箭】 | 三支直飞的普通箭 | 三支会自动追目标的箭,还带蛇形摆动 | 「哇,它自己拐弯了!」 |
| 箭矢视觉 | 一张图 + 影子 | 图 + 影子 + 尾焰粒子,火焰橙 / 冰霜蓝随机 | 「这箭很贵」 |
| 目标提示 | 无 | 被锁定的酒壶上套一个脉冲光圈 | 「它在追那个壶」 |
| 协程中断 | await create_timer(),中途无法取消 |
逐帧轮询 + 三重保险 | 无(稳定性) |
| 结算 | 打印分数 | 分数 + 剩余时间奖励 + 命中率奖励 | 「打得快也有分」 |
下面按「先稳、后炫」的顺序讲:7.6.2 接线(地基)→ 7.6.3~7.6.5 追踪箭(核心)→ 7.6.6 粒子(最炫)→ 7.6.7~7.6.8 稳定性与结算(最容易被忽略、但最值钱)。
7.6.2 从 $Player 到 @export:把路径换成「插槽」
教学版的 arena_manager.gd 里有这么三行:
@onready var player: CharacterBody2D = $Player
@onready var aim_cursor: AimCursor = $AimCursor
@onready var power_ui: PowerUI = $PowerUI
它跑得没问题。可一旦你改了节点名(把 AimCursor 改成 Crosshair),或者把节点挪了位置(从 ArenaRoot 下挪进 ArenaRoot/UI 下),这三行立刻变成 Node not found 报错——而报错的地方离你刚才干的蠢事隔着好几百行代码。
完整版把它们换成 @export:
extends Node2D
# arena_manager.gd(完整版接线)
class_name ArenaManager
# ---------- 可拖拽的节点插槽 ----------
# @export 让变量出现在检查器里:回到 level_arena.tscn 选中 ArenaRoot,
# 你会看到这几个新插槽,把对应节点从左边的场景树里拖进去即可 —— 一行 $ 路径都不用写
@export var player: CharacterBody2D
@export var aim_cursor: AimCursor
@export var power_ui: PowerUI
@export var arrow_scene: PackedScene = preload("res://scenes/object/arrow.tscn")
@export var pot_scene: PackedScene = preload("res://scenes/object/target_pot.tscn")
func _ready() -> void:
# 顺序无所谓,因为引用已经在编辑器里接好了
player.enter_bowl_mode(player_min_x, player_max_x, power_ui)
power_ui.arrow_launched.connect(_on_arrow_launched)
GameManager.change_state(GameManager.GameState.BATTLE)
start_round()
两种写法到底差在哪,一张表说清:
@onready var x = $Path |
@export var x |
|
|---|---|---|
| 接线动作 | 代码里写路径 | 在编辑器里拖 |
| 改节点名 | 路径失效 → 运行时报错 | 不受影响(引用还在) |
| 节点换位置 | 路径失效 → 运行时报错 | 不受影响 |
| 忘了接 | 运行时才炸 null |
检查器里一眼看到空槽 |
| 一个脚本用在多个场景 | 场景树一变就得改代码 | 同一个脚本,各场景各自拖 |
| 上手成本 | 零(不用额外操作) | 每个场景多拖三下 |
那教学版为什么用
$? 因为它短、省事,读代码时不用来回翻编辑器。判断标准是「这个脚本会不会被复用到第二棵场景树」:只在一个场景里用,$挺好;一旦你要拿同一个管理器挂到第二个关卡上,@export就开始回本了。(这和 7.2.2 讲「只有唯一来源的输入就不必建映射」是同一种权衡——配置成本 vs 维护成本。)
7.6.3 追踪箭(一):先找最近的靶子
现在换脑筋:一支箭怎么才能「自己找目标」?
第一步是知道目标在哪。教学版里靶壶已经 add_to_group("target") 了(7.3.3),所以完整的答案只有一行:
get_tree().get_nodes_in_group("target")
但直接这么写会踩两个坑,我们把它们一起解决。
@export var target_lost_timeout: float = 2.0 # 目标丢失后还能靠记忆追多久(秒)
var target: Node2D = null
var target_lock_cooldown: float = 0.0
## 找离自己最近的那个 target —— 每 0.1 秒才真的遍历一次
func _find_closest_target(delta: float) -> void:
target_lock_cooldown -= delta
if target_lock_cooldown > 0.0:
return # 节流:还没到下一次搜的时机
target_lock_cooldown = 0.1
var candidates := get_tree().get_nodes_in_group("target")
if candidates.is_empty():
target = null
return
var closest_dist := INF
var closest_node: Node2D = null
for node in candidates:
if not is_instance_valid(node):
continue # 上一帧刚被射爆的壶还在组里,跳过
# 用「距离的平方」比较:省掉一次开方,反正比大小结果一样
var d := global_position.distance_squared_to(node.global_position)
if d < closest_dist:
closest_dist = d
closest_node = node
target = closest_node
if target != null:
lost_target_timer = 0.0
两个坑:
- 别每帧都搜。 一局里有 20~52 个壶(完整版
pot_num是 52),每帧遍历一遍并把每个壶的距离算一次,是纯粹白烧的算力。0.1 秒搜一次完全够用——箭飞那么快,目标又不会瞬移。这就是target_lock_cooldown的作用:一个「下次什么时候再搜」的倒计时。 is_instance_valid()不是可选项。 靶壶被射中后会queue_free(),但它要到本帧末尾才真正从组里消失。如果此刻正好有另一支箭在遍历,就会碰到一个「已经死掉的」节点——不加这句检查,你会在某个巧合的时刻收到Invalid access to property on a previously freed instance。
distance_squared_to()的小聪明: 我们只需要比大小,不需要真实距离。开方是纯浪费,而且距离越比越多的场景里它越浪费。凡是要「比远近」,先想能不能用平方。
7.6.4 追踪箭(二):限制转向速度,而不是「对着目标飞」
如果只是「每帧把速度方向设成指向目标」,你会得到一支激光——离弦瞬间就锁死目标,之后笔直撞上去。看起来像一个 bug,不像一个技能。
真正让追踪有「导弹感」的,是限制每帧能转多少度:
@export var homing_turn_speed: float = 6.0 # 每秒最多转 6 弧度(≈344°),越小越「笨重」
func _update_missile(delta: float) -> void:
# ---------- 第 1 步:目标锁定与丢失计时 ----------
if not is_instance_valid(target):
_find_closest_target(delta)
if not is_instance_valid(target):
lost_target_timer += delta # 一个目标都没有?先直飞,攒着计时
else:
lost_target_timer = 0.0
else:
lost_target_timer = 0.0
# 超时后还多宽限 1 秒再销毁 —— 留着让箭「飞完最后一段」,比当场消失好看
if lost_target_timer > target_lost_timeout + 1.0:
_destroy_arrow()
return
# ---------- 第 2 步:朝目标转,但每帧最多转这么多 ----------
var current_dir := velocity.normalized()
if is_instance_valid(target) and lost_target_timer <= target_lost_timeout:
var to_target := (target.global_position - global_position).normalized()
var angle_diff := current_dir.angle_to(to_target) # 带符号的最短夹角
var max_turn := homing_turn_speed * delta # 这一帧允许转的最大弧度
current_dir = current_dir.rotated(clampf(angle_diff, -max_turn, max_turn))
# ---------- 第 3 步:叠加蛇形摆动(下一小节讲) ----------
...
关键是 clampf(angle_diff, -max_turn, max_turn) 这一句。它在说:
「我算出了目标在哪个方向(
angle_diff),但这一帧我最多只准转max_turn。」
于是箭会画出一条弧线——先朝着发射方向冲出去,然后慢慢把身体扭过来,最后才咬住目标。它的「笨重感」是参数调出来的,不是玄学。
angle_to()和rotated()是一对好搭档。a.angle_to(b)返回「从a转到b需要转过的带符号最短角度」(范围(-π, π],正数是顺时针);v.rotated(θ)则把向量v旋转 θ 弧度。两者合起来,就是「朝目标转一点点」这个动作的完整表达。它们在第 8 章主角挥砍的方向计算、第 11 章敌人的转向里都会再出现。
实测手感参考(640×480 视口):
homing_turn_speed |
手感 | 建议 |
|---|---|---|
| 2.0 | 笨得像没头苍蝇,经常绕圈追不上 | 太钝 |
| 6.0 | 明显会拐弯,但看得出在「挣扎」 | ✅ 实盘取值 |
| 15.0 | 几乎瞬间对准,变回激光 | 太灵 |
| 999 | 完全等同于「直接设成指向目标」 | 别这么干 |
7.6.5 追踪箭(三):用正弦波走出「蛇形」,而且三支不重叠
弧线还是太规整。要让箭看起来「活着」,得在飞行方向的垂直方向上再叠一层摆动——这就是「蛇形 Z 跟踪」。
先在文件顶部加两个参数和两个变量:
@export var snake_amplitude: float = 60.0 # 横向摆动幅度(像素/秒)
@export var snake_frequency: float = 1.5 # 摆动频率(次/秒)
var snake_phase_offset: float = 0.0 # 随机相位:让三支箭各摇各的
var base_speed_mag: float = 0.0 # 发射瞬间记下的速度大小
再回到 _update_missile(),在 7.6.4 的「第 2 步」后面接上「第 3 步」与「第 4 步」:
func _update_missile(delta: float) -> void:
# ……(第 1 步「目标锁定与丢失计时」、第 2 步「限制转向」见 7.6.4)……
# ---------- 第 3 步:叠加蛇形摆动 ----------
var perpendicular := current_dir.orthogonal() # 垂直于飞行方向的那根轴
var t := Time.get_ticks_msec() / 1000.0 # 全局时间(秒)
var snake_offset := sin(t * snake_frequency * TAU + snake_phase_offset) * snake_amplitude
velocity = (current_dir * base_speed_mag + perpendicular * snake_offset).limit_length(base_speed_mag)
sprite.rotation = velocity.angle()
shadow.rotation = sprite.rotation
# ---------- 第 4 步:追踪箭不走弹道,高度恒为 0 ----------
height = 0.0
vertical_speed = 0.0
sprite.position.y = 0.0
shadow.scale = Vector2.ONE
shadow.modulate.a = 0.35
逐句拆:
| 代码 | 在做什么 |
|---|---|
current_dir.orthogonal() |
拿到垂直于飞行方向的向量——摆动的方向 |
sin(t * freq * TAU + phase) |
标准正弦波,范围 [-1, 1],来回摆动 |
perpendicular * snake_offset |
把「±1 的波形」放大成「±60 像素/秒的横向速度」 |
current_dir * base_speed_mag |
主体速度:沿飞行方向的那一份 |
.limit_length(base_speed_mag) |
总速度不许超过发射时的大小 |
最后那行
.limit_length()是本节最容易被忽略、但也最关键的一句。 如果没有它,横摆速度会在转弯时叠加到总速度上——箭一摆头就加速,摆得越猛飞得越快,三支箭的速度还不一样。limit_length把它们全部摁回同一个上限:蛇形只改变方向,不改变快慢。(这个思路和 5.3.3 的向量归一化一模一样:先分开算,再统一控总长。)
随机相位 snake_phase_offset 是灵魂。 在 launch() 里给追踪箭随机一个 randf_range(0.0, TAU):
func launch(power: float, direction: Vector2, is_missile: bool = false) -> void:
is_homing_missile = is_missile
is_bonus_arrow = is_missile # 追踪箭只来自福缘,命中时不再触发新福缘
var init_speed := base_speed + power * base_speed
velocity = direction.normalized() * init_speed
base_speed_mag = velocity.length() # 记住速度大小,追踪时用它限速
vertical_speed = lift_speed + power * lift_gain
height = 0.0
homing_time = 0.0
lost_target_timer = 0.0
target_lock_cooldown = 0.0
target = null
if is_homing_missile:
snake_phase_offset = randf_range(0.0, TAU) # ← 每支箭起点相位不同
if randf() > 0.5:
particles.modulate = Color(1.0, 0.4, 0.0) # 火焰橙
else:
particles.modulate = Color(0.2, 0.6, 1.0) # 冰霜蓝
else:
snake_phase_offset = 0.0
sprite.rotation = velocity.angle()
is_moving = true
不加这个随机相位会怎样?三支箭的 sin 曲线完全同步——它们会像编队飞行一样保持整齐的间距,看起来像一段录好的动画,而不是三支各自有脾气的箭。加了之后,三支箭各摇各的,偶尔交错、偶尔分开,「活的」感觉立刻出来了。
这是一个反复出现的设计手法:同样的公式 + 不同的初始相位 = 「一群有差异的个体」。 第 12 章敌人的巡逻路线、第 16 章酿造词缀的随机、第 25 章粒子特效,用的都是这一招。想让一堆东西看起来不整齐,先看看能不能只改它们的「起点」。
7.6.6 粒子尾焰:让箭看起来「很贵」
追踪箭走的是「福缘」路线,视觉上就该和普通箭拉开距离。最省力的做法是给它加一串尾焰。
节点怎么搭(在 arrow.tscn 里):
Arrow (Area2D)
├── Shadow (Sprite2D) ← 影子
└── Sprite2D ← 箭头本体
└── GPUParticles2D ← 尾焰(挂在 Sprite2D 下!)
为什么粒子要挂在
Sprite2D下,而不是Arrow下? 因为 7.3.4 里我们是靠旋转Sprite2D来让箭头指向飞行方向的。粒子挂在它下面,就会跟着一起转——尾焰永远朝箭尾喷。挂在Arrow根节点下的话,尾焰的方向就固定在初始方向,箭一拐弯,尾焰就横着喷出去了。
GPUParticles2D 的检查器设置(够用就行,不必追求华丽):
| 属性 | 值 | 说明 |
|---|---|---|
Amount |
12 | 同时最多 12 个粒子,够糊住箭尾 |
Lifetime |
0.5 | 每个粒子活 0.5 秒 |
One Shot |
关 | 持续发射,不是一次性爆炸 |
Emitting |
开 | |
Process Material |
新建 ParticleProcessMaterial | 粒子行为都在这里面 |
↳ Direction |
(1, 0) |
朝「箭尾方向」喷(因为挂在 Sprite2D 下,(1,0) 就是局部坐标的箭尾) |
↳ Spread |
25 | 散开角度,别像一根针 |
↳ Initial Velocity |
60 ~ 120 | |
↳ Scale Amount Curve |
1 → 0 | 粒子越飞越小,这是「尾焰」的关键 |
CanvasItem → Material → Blend Mode |
Add | 加法混合,粒子叠加处更亮——像素游戏的「发光」靠它 |
在脚本里拿到它、并且记得在销毁前熄火:
@onready var particles: GPUParticles2D = $Sprite2D/GPUParticles2D
## 统一销毁入口:命中、落地、超时、飞出屏幕 —— 全走这里
func _destroy_arrow() -> void:
if _finished:
return
_finished = true
# ① 先熄火,再销毁
if is_instance_valid(particles):
particles.emitting = false
particles.queue_free()
# ② 断信号,避免销毁过程中又触发一次回调
if area_entered.is_connected(_on_area_entered):
area_entered.disconnect(_on_area_entered)
# ③ 退出 "arrow" 组(管理器靠这个组数「天上还有几支箭」)
remove_from_group("arrow")
arrow_landed.emit()
queue_free()
## 兜底:节点被外部销毁(比如靶场整体卸载)时,也要把粒子收拾干净
func _exit_tree() -> void:
if is_instance_valid(particles):
particles.emitting = false
particles.queue_free()
if area_entered.is_connected(_on_area_entered):
area_entered.disconnect(_on_area_entered)
⚠ 为什么「熄火」这件事要写两遍? 因为箭矢有两条死法:
① 自己走的(命中 / 落地 / 超时)→ 会经过
_destroy_arrow(),第一遍熄火在那里; ② 被别人带走的(靶场叠加层被卸载、GameManager.exit_battle()把整棵树queue_free)→ 根本不会调用_destroy_arrow(),箭矢是被父节点连坐销毁的。第二种情况如果没兜住,粒子会在你已经离开靶场之后继续喷零点几秒,屏幕上凭空多出一串火——而且因为父节点已经没了,它连位置都是错的。
_exit_tree()是 Godot 提供的「不管谁弄死我,我都会走这一趟」的钩子,凡是有粒子 / 音频 / 计时器的节点,都该在这里做一次清理。
7.6.7 让「连珠三箭」能被中途叫停
回到教学版的 _fire_bonus_arrows()。它长这样:
# 教学版的写法
for i in range(3):
_fire_arrow(randf_range(0.1, 0.3), direction, true)
if i < 2:
await get_tree().create_timer(bonus_arrow_gap).timeout
await create_timer() 简洁漂亮,但它有一个致命属性:这 0.15 秒是「不可打断」的。 协程在这里睡下之后,外界没有任何办法叫醒它——如果对局正好在这 0.15 秒里结束了(时间到、壶被打光),它睡醒之后照样接着射剩下的箭。玩家已经看到结算界面了,天上还在飞箭。
完整版的改法是:把「睡一觉」换成「每帧醒一次、攒够时间再走」。
var is_barrage_active: bool = false # 连珠三箭正在跑(防止重复触发)
var _barrage_should_stop: bool = false # 外部强制叫停的旗标
func _fire_bonus_arrows() -> void:
if is_barrage_active:
return
is_barrage_active = true
_barrage_should_stop = false
print("【连珠三箭】!")
for i in range(3):
# 每次循环前先问一句:还该继续吗?
if _should_abort_barrage():
break
_on_arrow_launched(randf_range(0.1, 0.3), true) # 发射一支追踪箭
if i < 2:
var waited := 0.0
while waited < bonus_arrow_gap:
if _should_abort_barrage():
break
await get_tree().process_frame # ← 只等「一帧」,不是等 0.15 秒
waited += get_process_delta_time()
if _should_abort_barrage():
break
is_barrage_active = false
print("【连珠三箭】发射完毕")
## 三重保险:任何一条成立,就该收手
func _should_abort_barrage() -> bool:
if _barrage_should_stop: # ① 外部(结算)叫停
return true
if not round_active: # ② 对局已经结束
return true
if not is_instance_valid(self): # ③ 我自己已经被销毁了
return true
return false
配套的,_end_round() 里第一件事就是举旗:
func _end_round() -> void:
if not round_active:
return
round_active = false
_barrage_should_stop = true # ① 举旗:让协程在下次醒来时收手
is_barrage_active = false # ② 立刻解锁,免得下一局被上一次卡住
power_ui.set_ready(false)
...
为什么是「三重」,一条不够吗? 三条各自挡的是不同的事:
检查 挡的是什么 _barrage_should_stop对局正常结束(时间到 / 壶打光)——由结算主动举旗叫停 not round_active状态字段被别处改了(比如 start_round()重置过状态)not is_instance_valid(self)靶场整个被卸载( exit_battle()把树拆了)——self随时可能变成空壳第三条是兜底中的兜底。节点被
queue_free()之后并不会立刻消失,协程还可能被唤醒一两次;这两次里如果去读round_active,就有可能撞上「在已释放的实例上读取属性」的报错。先问一声「我还活着吗」,再去碰别的东西,是所有异步代码里最便宜的一份保险。这也是本章 7.3.8 那句「等待期间节点可能已被销毁,所以『要不要继续』的判断必须写在
await之后」最完整的落地。
7.6.8 两个小但值钱的收尾
① call_deferred:别在信号回调里直接改场景树。
if quality >= 0.85 and not arrow.is_bonus_arrow:
call_deferred("_fire_bonus_arrows") # ← 不是 _fire_bonus_arrows()
_on_pot_hit() 是一个信号回调,此刻引擎可能正处在「遍历碰撞结果」的过程中。而补射要往场景树里 add_child() 新的箭矢——在遍历的过程中修改集合,属于典型的「能跑,但偶尔会出怪事」。
call_deferred() 的意思是「把这次调用排到本帧所有物理步走完之后再执行」。代价是晚一两毫秒,收益是再也不会出现「某局福缘莫名不触发」这种没法复现的 bug。
教学版里直接调用通常也能跑——但那是运气好。凡是「在信号回调里增删节点」的地方,
call_deferred都值得优先考虑。第 8 章 Hitbox 的命中广播、第 11 章敌人的死亡移除,都会用到它。
② 结算不只看总分,还看「怎么打的」。
教学版的结算只打印分数。完整版多算两笔账:
func _end_round() -> void:
...
# ---- 奖励 1:剩余时间。每剩 0.1 秒 +5 分 ----
var remaining := maxf(0.0, round_time_limit - elapsed)
var time_bonus := int(remaining * 10) * 5
score += time_bonus
# ---- 奖励 2:命中率。打多少中多少,就补多少分;全中额外给满 ----
var hit_rate := 0.0
if shots > 0:
hit_rate = float(hits) / float(shots)
var hit_bonus := 100 if hit_rate >= 1.0 else int(hit_rate * 100.0)
score += hit_bonus
print("=== 本局结束 ===", "得分 ", score)
print("剩余时间 %.1f 秒 → 时间奖励 +%d" % [remaining, time_bonus])
print("命中率 %.1f%% → 命中奖励 +%d" % [hit_rate * 100.0, hit_bonus])
这正好呼应 7.1.2 那张表里的第三条设计选择:「奖励要改规则,不要加数字」。 但请注意,这里的两个奖励不冲突:
- 游戏进行中给的是规则级奖励(连珠三箭 → 改变你接下来怎么打);
- 游戏结束后给的是数字级奖励(时间 + 命中率 → 改变你的复盘,和「想再来一把」的冲动)。
过程中改行为,结束后给评价。 这是奖励系统的通用分工——第 16 章的酿造评分、第 20 章的任务结算会原样复用这套分工。
7.6.9 完整版代码(可直接整段替换)
把前面几节的碎片拼起来,arrow.gd 的完整版如下。没讲到的部分和教学版一致,可以只替换有变化的方法。
extends Area2D
# arrow.gd(完整版)—— 普通箭走弹道,追踪箭会拐弯 + 蛇形 + 尾焰
# 与教学版共用的部分:velocity / height / base_speed / lift_speed / lift_gain / gravity
# class_name、signal、_ready() 均不变(只多一个 particles 引用)
class_name Arrow
signal arrow_landed
# ---------- 弹道参数(教学版已有) ----------
@export var base_speed: float = 500.0
@export var lift_speed: float = 120.0
@export var lift_gain: float = 260.0
@export var gravity: float = 800.0
# ---------- 追踪箭参数(新增) ----------
@export var homing_turn_speed: float = 6.0
@export var snake_amplitude: float = 60.0
@export var snake_frequency: float = 1.5
@export var target_lost_timeout: float = 2.0
# ---------- 状态 ----------
var velocity: Vector2 = Vector2.ZERO
var height: float = 0.0
var vertical_speed: float = 0.0
var is_moving: bool = false
var is_bonus_arrow: bool = false
var is_homing_missile: bool = false
var target: Node2D = null
var base_speed_mag: float = 0.0
var lost_target_timer: float = 0.0
var target_lock_cooldown: float = 0.0
var snake_phase_offset: float = 0.0
var _finished: bool = false
const DESTROY_MARGIN: float = 100.0 # 飞出屏幕多远才算真的「没用了」
@onready var sprite: Sprite2D = $Sprite2D
@onready var shadow: Sprite2D = $Shadow
@onready var particles: GPUParticles2D = $Sprite2D/GPUParticles2D
@onready var viewport_size: Vector2 = get_viewport().get_visible_rect().size
func _ready() -> void:
add_to_group("arrow")
area_entered.connect(_on_area_entered)
func launch(power: float, direction: Vector2, is_missile: bool = false) -> void:
is_homing_missile = is_missile
is_bonus_arrow = is_missile
var init_speed := base_speed + power * base_speed
velocity = direction.normalized() * init_speed
base_speed_mag = velocity.length()
vertical_speed = lift_speed + power * lift_gain
height = 0.0
lost_target_timer = 0.0
target_lock_cooldown = 0.0
target = null
_finished = false
if is_homing_missile:
snake_phase_offset = randf_range(0.0, TAU)
if randf() > 0.5:
particles.modulate = Color(1.0, 0.4, 0.0) # 火焰橙
else:
particles.modulate = Color(0.2, 0.6, 1.0) # 冰霜蓝
else:
snake_phase_offset = 0.0
sprite.rotation = velocity.angle()
is_moving = true
func _physics_process(delta: float) -> void:
if not is_moving or _finished:
return
if is_homing_missile:
_update_missile(delta)
else:
_update_ballistic(delta)
if _out_of_bounds():
_destroy_arrow()
return
global_position += velocity * delta
## 普通箭:平面匀速直飞 + 假高度(与教学版一致)
func _update_ballistic(delta: float) -> void:
height += vertical_speed * delta
vertical_speed -= gravity * delta
if height <= 0.0:
_destroy_arrow()
return
sprite.position.y = -height * 0.15
var shrink := clampf(1.0 - height / 260.0, 0.35, 1.0)
shadow.scale = Vector2.ONE * shrink
shadow.modulate.a = 0.35 * shrink
shadow.position = Vector2.ONE.normalized() * height * 0.5
shadow.rotation = sprite.rotation
## 追踪箭:限制转向 + 蛇形摆动 + 无重力
func _update_missile(delta: float) -> void:
if not is_instance_valid(target):
_find_closest_target(delta)
if not is_instance_valid(target):
lost_target_timer += delta
else:
lost_target_timer = 0.0
else:
lost_target_timer = 0.0
if lost_target_timer > target_lost_timeout + 1.0:
_destroy_arrow()
return
var current_dir := velocity.normalized()
if is_instance_valid(target) and lost_target_timer <= target_lost_timeout:
var to_target := (target.global_position - global_position).normalized()
var angle_diff := current_dir.angle_to(to_target)
var max_turn := homing_turn_speed * delta
current_dir = current_dir.rotated(clampf(angle_diff, -max_turn, max_turn))
var perpendicular := current_dir.orthogonal()
var t := Time.get_ticks_msec() / 1000.0
var snake_offset := sin(t * snake_frequency * TAU + snake_phase_offset) * snake_amplitude
velocity = (current_dir * base_speed_mag + perpendicular * snake_offset).limit_length(base_speed_mag)
sprite.rotation = velocity.angle()
shadow.rotation = sprite.rotation
height = 0.0
vertical_speed = 0.0
sprite.position.y = 0.0
shadow.scale = Vector2.ONE
shadow.modulate.a = 0.35
func _find_closest_target(delta: float) -> void:
target_lock_cooldown -= delta
if target_lock_cooldown > 0.0:
return
target_lock_cooldown = 0.1
var candidates := get_tree().get_nodes_in_group("target")
if candidates.is_empty():
target = null
return
var closest_dist := INF
var closest_node: Node2D = null
for node in candidates:
if not is_instance_valid(node):
continue
var d := global_position.distance_squared_to(node.global_position)
if d < closest_dist:
closest_dist = d
closest_node = node
target = closest_node
if target != null:
lost_target_timer = 0.0
func _out_of_bounds() -> bool:
var p := global_position
return p.x < -DESTROY_MARGIN or p.x > viewport_size.x + DESTROY_MARGIN \
or p.y < -DESTROY_MARGIN or p.y > viewport_size.y + DESTROY_MARGIN
func _on_area_entered(area: Area2D) -> void:
if not is_moving or _finished:
return
if area.is_in_group("target") and area.has_method("on_hit"):
area.on_hit(self)
_destroy_arrow()
func _destroy_arrow() -> void:
if _finished:
return
_finished = true
is_moving = false
if is_instance_valid(particles):
particles.emitting = false
particles.queue_free()
if area_entered.is_connected(_on_area_entered):
area_entered.disconnect(_on_area_entered)
remove_from_group("arrow")
arrow_landed.emit()
queue_free()
func _exit_tree() -> void:
if is_instance_valid(particles):
particles.emitting = false
particles.queue_free()
if area_entered.is_connected(_on_area_entered):
area_entered.disconnect(_on_area_entered)
注意
_out_of_bounds()只对普通箭生效。 追踪箭在追不到目标时会自己超时销毁,不需要屏幕边界这一层;而普通箭一旦飞出去(比如蓄力满、靶子又刚被打光),不把它收掉就会永远留在「arrow」组里——管理器的结束条件get_nodes_in_group("arrow").size() <= 0就永远不成立,这一局永远结束不了。这是一个非常隐蔽的死锁,务必写上。另外
viewport_size用get_viewport().get_visible_rect().size而不是写死 640×480——将来第 26 章改了分辨率,这里不用跟着改。
target_lock.gd(锁定光圈,可选)一并附上,它挂在被锁定的酒壶下面:
class_name TargetLock extends Node2D
# target_lock.gd —— 追踪箭的「锁定中」光圈:跟着酒壶一起摆动的脉冲圆环
@export var color: Color = Color(1.0, 0.35, 0.1, 0.9)
@export var radius: float = 26.0
var _elapsed: float = 0.0
func _process(delta: float) -> void:
_elapsed += delta
queue_redraw() # 请求重绘,配合下面的 _draw() 做出脉冲动画
func _draw() -> void:
var pulse := 1.0 + sin(_elapsed * 6.0) * 0.15 # 半径周期性胀缩
draw_arc(Vector2.ZERO, radius * pulse, 0.0, TAU, 32, color, 3.0)
## 供管理器调用:把光圈颜色调成和追踪箭一致的火焰橙 / 冰霜蓝
func set_color(new_color: Color) -> void:
color = new_color
_draw()是 Godot 的「立即模式绘图」:你不需要创建任何节点,只要实现_draw()并调用queue_redraw()请求重绘,引擎就会把draw_arc/draw_line/draw_rect这些命令画出来。做像素游戏的指示器、血条、光效轮廓时它比堆节点省事得多(第 9 章做 UI 时还会用它)。
7.6.10 收尾:完整版值得抄哪几条
本节讲了九个东西,但你不需要全部照搬。按「性价比」排个序:
| 优先级 | 内容 | 理由 |
|---|---|---|
| ⭐⭐⭐ 必做 | 7.6.7 三重保险 + _barrage_should_stop |
不做就是「结算了还在射箭」的偶发 bug,最难查 |
| ⭐⭐⭐ 必做 | 7.6.5 的 .limit_length() |
少这一句,蛇形箭会忽快忽慢 |
| ⭐⭐⭐ 必做 | 7.6.6 的 emitting = false 再 queue_free() |
少这一句,箭消失了粒子还在喷 |
| ⭐⭐ 推荐 | 7.6.3 is_instance_valid() 与 0.1 秒节流 |
一局 20~52 个壶,性能与稳定性都受益 |
| ⭐⭐ 推荐 | 7.6.4 clampf 限制转向速度 |
追踪箭好不好玩,八成靠这个参数 |
| ⭐⭐ 推荐 | 7.6.6 _exit_tree() 兜底 |
靶场卸载时的清场保险 |
| ⭐ 看情况 | 7.6.5 随机相位 | 三支箭重叠时特别明显,只中一两支时看不出来 |
| ⭐ 看情况 | 7.6.8 call_deferred |
没遇到过怪 bug 就先不加,遇到了记得回来看 |
| ⭐ 看情况 | 7.6.2 @export 接线 |
只在你要复用到第二个关卡时才划算 |
最后一句提醒:这些都不是「正确答案」,而是「实盘工程的现状」。 写游戏的乐趣之一就是——同一段追踪箭,有人觉得 6.0 转向太钝,有人觉得 15.0 太滑。参数该不该调,只由一件事决定:你按 F5 打一局,爽不爽。
7.7 本章总结
交付物
scenes/levels/level_arena.tscn:40×15 格靶场 + 主角 + 准星 + 蓄力条 + 出生点;scenes/object/target_pot.tscn:会摆动的靶壶(可复用为任何「会动的目标」);scenes/object/arrow.tscn:带影子与假高度的投射物(第 8 章远程系的弹药);scripts/arena_manager.gd:靶场管理器——开局 / 生成 / 发射 / 评分 / 福缘 / 结算;scripts/battle_trigger.gd:可复用的触发区模板(喊一声就走);GameManager的两个新能力:醉意的唯一入口set_drunk()、叠加层进出enter_battle() / exit_battle();EventBus的两条新信号:battle_requested/battle_finished。
(进阶)如果把 7.6 也做完了,你还会多出:追踪箭(限速转向 + 蛇形摆动)、尾焰粒子、可中途叫停的连珠三箭、以及时间与命中率两项结算奖励。它们属于锦上添花,不进后续章节的依赖链。
知识点
- 信号解耦:五个脚本互不引用,靠
hit/arrow_landed/arrow_launched三条信号串起来。信号没连上不会报错,所以「接线复核」要成为你的肌肉记忆(7.3.9 末尾那句话); - 碰撞层是单向的:只要攻击方的
Mask指过去就行,靶子什么都不用改(附录 C-13); - 俯视角的「假高度」:平面位置管命中、
height变量管好看——用最少的算力换「看得出来」; - 帧率无关的平滑:
1.0 - exp(-k * delta),比裸lerp稳; await与协程:「等 0.15 秒再射下一支」可以真的这么写,但等待期间节点可能已死,所以「要不要继续」的判断要写在await之后;- 模式分流 vs 状态机:
if bowl_mode:是状态机的前身,第 8 章 8.2 会把它升级成enum + match; - 叠加层的两条纪律:相机只能有一台生效、被盖住的关卡必须冻结——否则就是「两台相机抢镜头」和「两个主角一起走」;
- 「能跑」和「稳得住」是两件事(7.6.7):
await等 0.15 秒很简洁,但这段等待不可打断;要能中途叫停,就得改成「每帧醒来、累加时间」的轮询,并且三重判断缺一不可(外部旗标 / 状态字段 /is_instance_valid(self))。同理,凡是要「在信号回调里增删节点」,call_deferred()都值得优先考虑(7.6.8)。
玩法设计心得
- 不给玩家「必中」的东西。 准星会飘是故意的,它把玩法从「手稳不稳」转成了「节奏读得准不准」;
- 难度要来自玩家能理解的东西。 靶子摆动有规律(两端慢),准星漂移没规律——一乱一稳,玩家才会产生「我看懂了」的错觉和「再来一次」的冲动;
- 奖励改规则,不改数字。 【连珠三箭】让玩家重新评估剩余时间,【定风波】直接把醉意清空——每一个都在改变玩家的下一手决策。
7.8 本章产出:《酒魂》的可玩战斗原型(完成度:25%)
本章你得到了什么
一个可以拿给别人试玩的版本:《酒魂》从「能走路的 demo」变成了「有小玩法的 demo」。玩家走进广场空地,打一局 30 秒的投壶,出来接着逛小镇——一个完整的小循环跑通了。
按目录的里程碑,这是全书的第一个大关卡:
| 完成度 | 里程碑 | 状态 |
|---|---|---|
| 15% | 第一张地图 + 摄像机 | 第 6 章 ✅ |
| 25% | 第一个可玩原型(投壶) | 本章 ✅ |
| 60% | 战斗系统完整可玩 | 第 14 章 |
| 75% | 酿造系统完整可玩 | 第 17 章 |
| 100% | 闭环打通,完整可玩 | 第 24 章 |
这些设施会被谁复用?
见 7.5 的复用表——那张表就是本章的「资产清单」。有一点值得强调:本章没有新建任何全局设施,GameManager 和 EventBus 都是第 4 章/第 6 章就立好的,本章只是往里加了两条信号和三个方法。这就是骨架先行的回报:项目越往后走,新章节该写的东西越少。
收尾:打上本章的存档点
git add .
git commit -m "第7章完成:投壶战斗原型(蓄力、抛物线、命中判定)、醉意值初版、叠加层机制"
git push origin master
git tag ch07
git push origin ch07
交给你自己的三个可选练习
做完主线的读者,可以按兴趣挑一个(都不影响后续章节):
- 把准星还给自己(进阶·可选):删掉
aim_cursor.gd的漂移,改成鼠标位置直接决定方向——然后对比一下,哪个更好玩? 这是理解「设计取舍」最有效的一课; - 给靶壶加一条命(进阶·可选):把
TargetPot.on_hit()改成「扣血、血空了才queue_free」,_already_hit换成hp。这一步做完,你其实已经做出了第 11 章敌人基类的雏形; - 把福缘做成「追踪箭」(进阶·可选):
launch()已经留了is_bonus参数。把它换成「发射后自己找最近的靶壶飞过去」——这需要你给Arrow加一个target变量和一个「每帧微调方向」的逻辑(第 12 章 12.2 的「策略模式」讲的正是这类可替换的行为)。
下一步: 投壶是「靶子不动、你也不动」的降维版本。第 8 章我们要把维度加回来——给主角装上完整的状态机(待机 / 移动 / 攻击 / 受击 / 死亡),并让她能切换三种武器:近战、远程、酒魂。你在本章写的那支箭,会成为「远程系」的第一件形制;而本章那个简陋的
if bowl_mode:,会升级成一个真正能扛住五种状态的骨架。
延伸阅读
以下资源不是必读,卡住或想深入时再看:
- Godot 官方文档(中文):Area2D:https://docs.godotengine.org/zh_CN/stable/classes/class_area2d.html ——
monitoring/monitorable/area_entered的完整语义,本章 7.3.4 只用了其中一小半; - Godot 官方文档(中文):Camera2D:https://docs.godotengine.org/zh_CN/stable/classes/class_camera2d.html ——
make_current()、enabled与「多相机」的行为说明,7.4.3 那两条纪律的官方依据; - Godot 官方文档(中文):SceneTreeTimer 与
await:https://docs.godotengine.org/zh_CN/stable/tutorials/scripting/gdscript/gdscript_basics.html#awaiting-for-signals-or-coroutines —— 协程的官方说明,7.3.8「连珠三箭」用的就是它;7.6.7 讲的「不可打断」也在这页的语义里; - Godot 官方文档(中文):2D 粒子系统:https://docs.godotengine.org/zh_CN/stable/tutorials/2d/particle_systems_2d.html ——
GPUParticles2D与ParticleProcessMaterial的完整参数,7.6.6 的尾焰只用了其中几个; - Godot 官方文档(中文):2D 自定义绘图:https://docs.godotengine.org/zh_CN/stable/tutorials/2d/custom_drawing_in_2d.html ——
_draw()与queue_redraw(),7.6.9 的锁定光圈就是它; - Godot 官方文档(中文):
Object.call_deferred():https://docs.godotengine.org/zh_CN/stable/classes/class_object.html —— 7.6.8 讲的「推迟到物理步之后再执行」; - 附录 C:本章相关的坑(触发区分组、多相机、动画锁定卡死、
await不可打断)会随你的实跑反馈持续补入; - 附录 E.2.1:《酒魂》投壶玩法的完整设计参数(数值、评分公式、福缘池)。
评论0
暂时没有评论