第7章 投壶战斗:蓄力射箭与福缘奖励
「射者,仁之道也。射求正诸己,己正而后发,发而不中,则不怨胜己者,反求诸己而已矣。」——《礼记·射义》
本章我们将开发《酒魂》核心玩法——投壶战斗。实现蓄力射箭、抛物线飞行、动态酒壶、醉意准星、命中评分、福缘奖励全套功能,让游戏拥有完整的休闲战斗体系。
本章核心目标
- 理解《酒魂》投壶核心设计:顺势而为、可控预判、随机惊喜的国风微醺玩法逻辑;
- 实现动态交互组件:纵向钟摆摇摆酒壶、随醉意漂移的跟随式准星;
- 完成投壶核心操作:主角区域内左右走位、鼠标蓄力投掷、箭矢抛物线飞行;
- 掌握 Godot 信号机制:实现箭矢命中、落地判定,搭建评分+福缘奖励战斗循环;
- 让福缘奖励真正"看得见":连珠三箭触发火/冰属性追踪箭,自动追踪命中当前酒壶并额外加分;
- 完成场景集成:将投壶战斗接入主场景,实现探索场景与战斗场景切换。
7.1 投壶玩法设计
在编写投壶玩法代码前,我们先梳理玩法核心设计逻辑,理解设计思路与底层理念,明确代码开发的核心目标,避免盲目编码。
7.1.1 核心理念:顺势而为
《酒魂》投壶玩法深度贴合「酒与微醺」的核心主题,主打动态预判、顺势而为的游玩体验,完美还原醉酒状态下「不完全可控,但充满意外惊喜」的独特质感。动态摇摆的酒壶、随机漂移的准星,让每一次投掷都无法完全复刻,杜绝机械化瞄准操作。玩法核心不在于对抗不确定性、强行追求精准命中,而是顺应动态规律、借力而为。玩家需要通过走位调整最佳投掷站位,观察酒壶摆动节奏,在壶身摆至端点、速度归零的滞空窗口期出手,将「顺势而为」的东方哲学深度融入游戏核心机制,贴合整体国风醉酒玩法基调。
玩法结构拆解:投壶玩法可拆解为「输入层 → 决策层 → 反馈层」三层结构。输入层负责接收玩家的走位与蓄力操作;决策层由动态酒壶、醉意准星共同构成"不确定性来源",迫使玩家在观察与等待中做出判断;反馈层则通过命中评分、福缘奖励、视觉特效形成完整的情绪闭环。三层相互独立又彼此联动,任何一层的改动都会影响整体手感,这也是后续代码模块划分的依据。
| 维度 | 《酒魂》投壶(动态预判) |
|---|---|
| 靶子 | 像钟摆一样纵向(前后)摇摆,模拟绳牵壶身忽近忽远的动态效果 |
| 准星 | 随醉意产生布朗运动随机漂移,模拟醉酒手抖状态 |
| 投掷位置 | 主角可在投掷线内自由左右走位,灵活调整投掷角度 |
| 操作逻辑 | 站对位置、卡准时机,等待准星飘过壶口的瞬间松手投掷 |
| 核心体验 | 主打走位博弈、节奏把控,营造顺势而为的沉浸式心流 |
| 与醉意联动 | 醉意数值越高,准星漂移幅度、速度越强,操作不确定性越高 |
7.1.2 四大核心机制
投壶玩法包含四大专属机制,相互联动形成独特的游玩体验。
- 动态随机酒壶(纵向钟摆运动 + 随机刷新):酒壶模拟绳牵物理效果,沿屏幕纵深前后往复摆动,呈现忽近忽远的视觉效果;每一轮游戏都会随机刷新场地位置,不固定单点。酒壶摆动至两端端点时会短暂滞空静止,是玩家投掷的最佳时机。
- 主角自由走位投掷:玩家可在指定投掷区域内左右移动,箭矢始终从主角实时站位发射,站位不同,箭矢飞行角度与轨迹也会变化,丰富操作策略。
- 醉意动态准星(跟随式布朗运动):准星锚点始终跟随主角移动,无法被玩家手动控制,呈现无规律的随机漂移效果,模拟醉酒状态。游戏醉意值越高,准星漂移幅度、速度越大,操作难度越高。
- 福缘规则奖励(机制级增益):精准预判投掷可触发随机福缘奖励,判定标准为:酒壶接近静止时出手、箭矢落点贴近壶心。奖励并非单纯积分加成,而是改变本局游戏规则、且视觉上真正看得见的实质性增益,包含连珠三箭(火/冰追踪箭)、积分翻倍等等效果。
7.2 全局醉意值与走位输入
7.2.1 全局醉意值
投壶玩法的核心变量 准星漂移强度 与玩家醉意深度强绑定:醉意越高,准星布朗运动越剧烈,投掷不确定性越强,完美贴合「微醺失控」的游戏主题。
本项目全局数值统一由全局单例 GameManager 管理(第四章已完成全局自动加载配置),我们只需校验并保留对应醉意变量即可。打开项目脚本文件:scripts/game_manager.gd,确认文件内存在如下全局醉意变量:
var player_drunk_level: float = 0.0 # 范围 0.0 ~ 1.0
变量说明:
- 数值区间固定为
0.0 ~ 1.0,0 为完全清醒、无漂移,1.0 为满醉、漂移最大化; - 该变量由后续第十章酿酒、饮酒系统动态修改,本章仅做读取使用;
- 为方便开发调试投壶漂移效果,可临时将默认值设置为
0.3,保证初始有轻微醉意、可直观看到准星漂移效果。
7.2.2 左右走位
投壶玩法支持玩家在投掷区域内左右自由走位,以此调整投掷起点与射击角度。为统一全游戏操作手感、减少冗余配置,投壶走位逻辑完全复用第五章已配置的方向输入,无需新增任何输入映射。
第五章 5.3.2 节已全局配置完成四向输入动作:left、right、up、down,并可通过 Input.get_vector() 统一读取方向输入。
投壶专属规则:
- 投壶战斗模式下,仅启用 left、right 左右走位,屏蔽上下移动;
- 整套按键与大世界探索完全一致,玩家无需适应新操作;
- 玩家角色脚本中会增加「投壶模式状态判断」,进入投壶场景自动切换走位规则,隔离探索移动与投壶移动逻辑(见 7.3.7 节)。
7.3 搭建投壶战斗场景
本节分三个阶段完成投壶战斗场景的全部搭建:
- 第一阶段(7.3.1–7.3.3)核心实体:创建酒壶和箭矢两个可复用的独立场景,理解简谐运动与抛物线物理;
- 第二阶段(7.3.4–7.3.6)场景与交互组件:搭建 BowlGame 主场景,添加摄像机、地面、醉意准星和蓄力 UI;
- 第三阶段(7.3.7–7.3.9)逻辑串联:实例化主角并追加投壶模式,编写 BowlGameManager 把所有零件串成完整战斗循环,最后在检查器里完成节点赋值。
第一阶段:核心实体
7.3.1 创建动态酒壶 TargetPot
Ctrl+N新建场景,根节点选Area2D,重命名TargetPot。- 添加子节点
Sprite2D,加载assets/sprites/obj/pot.png。 - 添加子节点
CollisionShape2D,Shape新建CircleShape2D,半径根据酒壶图片适当调整。 Ctrl+S保存为scenes/object/target_pot.tscn。- 附加脚本
target_pot.gd,保存到scripts/:
class_name TargetPot extends Area2D
signal hit(sender:Node2D,arrow:Node2D) #自定义信号 "hit",当靶子被击中时发射
@export var amplitude:float =60.0 #幅度
@export var frequency:float = 1.2 #摆动频率
var center_y:float=0.0 #初始位置的 Y 轴坐标,作为上下摆动的中心点
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 #获取并记录当前全局坐标系下的 Y 轴位置,作为摆动中心
add_to_group("target") # 将该节点添加到名为 "target" 的组中,方便箭矢识别命中目标
func _physics_process(delta: float) -> void:
elapsed_time +=delta
# 简谐运动核心公式:位置 = 中心 + 幅度 × sin(频率 × 时间)
var offset_y = amplitude * sin(frequency*elapsed_time)
global_position.y = center_y + offset_y
# 瞬时速度是位移对时间的导数:v = amplitude * frequency * cos(frequency * elapsed_time)
current_swing_velocity = amplitude * frequency * cos(frequency * elapsed_time)
# 获取当前摆动速度的绝对值(只取大小,忽略方向),供管理器算命中评分用
func get_current_velocity() -> float:
return abs(current_swing_velocity)
# 被箭矢命中时调用(由箭矢的碰撞检测触发)
func on_hit(arrow:Node2D) -> void:
if _already_hit:
return
_already_hit = true
set_deferred("monitoring", false)
hit.emit(self,arrow)
queue_free()

7.3.2 深度解析:摆动背后的数学与物理
1. 核心变量:振幅与频率
振幅(Amplitude):决定了物体离开平衡位置的最大距离。代码中 amplitude = 60 意味着酒壶最高偏离中心 60 像素。它控制了摆动的"强弱"。
频率(Frequency):决定了单位时间内摆动的次数。frequency = 1.2 意味着摆动的节奏。它控制了摆动的"急缓"。
2. 位置公式:为什么是 sin(频率 × 时间)?
代码中的核心公式是:
位置 = 中心 + 振幅 × sin(频率 × 时间)
为什么是正弦(sin)? 因为 sin 的值在 -1 到 1 之间平滑循环。这使得靶子在两端(最高/最低点)自然减速至静止,在中间时加速通过。这就是物理学中的简谐运动,完美模拟了"荡秋千"或"弹簧"的质感,避免了机械生硬的直线弹跳。
为什么是"频率 × 时间"? sin 函数默认周期约为 6.28 秒。通过乘以频率系数,我们可以自由控制摆动的速度。频率越大,sin 值变化越快,酒壶摆动得越急促。
3. 速度公式:为什么出现余弦(cos)?
代码中还有一个关键变量 current_swing_velocity,它通过余弦函数计算:
速度 = 振幅 × 频率 × cos(频率 × 时间)
- 为什么要算速度? 这用于命中评分。当酒壶在两端(速度 = 0)时最容易射中;在中间(速度最大)时射中则说明技术更高。
- 为什么是余弦(cos)? 这就引出了微积分中的核心概念——导数。
- 导数就是"变化率"。例如:位置的导数 = 速度(位置变化的快慢)。
- 在数学上,
sin函数的导数恰好就是cos函数。位移(sin)对时间求导,自然得到速度(cos)。 - 直观理解:画一条正弦波。在波峰(最高点)时波形是平的(斜率为 0,速度为 0),此时
sin=±1而cos=0;在中间点(零点)时波形最陡(斜率最大,速度最快),此时sin=0而cos=±1。完美对应!
4. 代码逻辑串联
- 初始化:记录中心点 Y 坐标,并将自身加入"靶子组"。
- 每帧更新:
- 累计时间 → 用
sin算偏移量 → 更新位置。 - 用
cos算瞬时速度 → 存储备用。
- 累计时间 → 用
- 命中响应:发射信号,销毁自身。
| 概念 | 物理意义 | 代码体现 | 数学本质 |
|---|---|---|---|
| 振幅 | 摆动幅度(强弱) | amplitude |
波峰高度 |
| 频率 | 摆动快慢(急缓) | frequency |
波形疏密 |
| 正弦(sin) | 计算当前位置 | sin(freq * time) |
输出 -1 到 1 的循环值 |
| 余弦(cos) | 计算当前速度 | cos(freq * time) |
位置(sin)的导数 |
| 导数 | 瞬间变化率 | 位置求导得速度 | 曲线在一点的斜率 |
记住:
sin管你在哪,cos管你多快;而导数,就是连接"位置"与"速度"的那座数学桥梁。理解了这些,你就彻底掌握了动态酒壶的运动逻辑。
7.3.3 创建箭矢 Arrow
Ctrl+N新建场景,根节点选Area2D,重命名Arrow。- 添加子节点
Sprite2D(保持默认名Sprite2D),加载assets/sprites/arrow.png。 - 添加子节点
CollisionShape2D,Shape新建CircleShape2D,大小根据箭矢的大小调整。 - 再添加一个子节点
Sprite2D,重命名Shadow,加载assets/sprites/arrow_shader.png,箭矢的影子。 - 在
Sprite2D添加子节点GPUParticles2D,重命名Trail(将其作为Sprite2D的子节点,这样特效会跟随箭矢移动而移动),对应设置其属性:-
Amount = 16,Lifetime = 0.3;
-
Process Material→ 新建ParticleProcessMaterial:Gravity = (0,0,0),Initial Velocity → Min/Max设为2040,Scale → Min/Max设为0.51.0;

-
6. Ctrl+S 保存为 scenes/object/arrow.tscn。
7. 附加脚本 arrow.gd,保存到 scripts/:
class_name Arrow extends Area2D
# =========================
# 节点引用
# =========================
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var shadow: Sprite2D = $Shadow
@onready var gpu_particles_2d: GPUParticles2D = $Sprite2D/GPUParticles2D
var is_bonus_arrow: bool = false # 默认普通箭矢
# =========================
# 信号
# =========================
signal arrow_landed
signal bonus_landed(value: int)
# =========================
# 基础物理
# =========================
@export var arrow_gravity: float = 800.0
@export var is_homing_missile: bool = false
# =========================
# 导弹跟踪参数
# =========================
@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 basespeed: float = 500.0
var target: Node2D = null
var homing_time: float = 0.0
var base_speed_mag: float = 0.0
var viewport_size: Vector2 = Vector2.ZERO
var lost_target_timer: float = 0.0
var already_destroy: bool = false
var target_lock_cooldown: float = 0.0
var snake_phase_offset: float = 0.0 # 随机相位(仅导弹使用)
var bonus_score_value: int = 0 # 追踪箭的额外加分值
# =========================
# 常量:销毁边界缓冲(仅普通箭矢)
# =========================
const DESTROY_MARGIN: float = 100.0
# =========================
# 初始化
# =========================
func _ready() -> void:
add_to_group("arrow")
area_entered.connect(_on_area_entered)
viewport_size = get_viewport().get_visible_rect().size
rotation = 0.0
sprite_2d.position = Vector2.ZERO
shadow.position = Vector2.ZERO+Vector2(10,15)
lost_target_timer = 0.0
target_lock_cooldown = 0.0
already_destroy = false
# =========================
# 物理主循环
# =========================
func _physics_process(delta: float) -> void:
if not is_instance_valid(self) or not is_moving or already_destroy:
return
if is_homing_missile:
_update_missile(delta)
else:
_update_physics(delta)
global_position += velocity * delta
# 边界销毁:仅对普通箭矢生效
if not is_homing_missile and _is_out_of_bounds():
_destroy_arrow()
# =========================
# 检查箭矢是否完全离开屏幕(含缓冲区域)
# =========================
func _is_out_of_bounds() -> bool:
var pos = global_position
return (pos.x < -DESTROY_MARGIN or pos.x > viewport_size.x + DESTROY_MARGIN or
pos.y < -DESTROY_MARGIN or pos.y > viewport_size.y + DESTROY_MARGIN)
# =========================
# 销毁箭矢(统一销毁方法)
# =========================
func _destroy_arrow() -> void:
if already_destroy:
return
already_destroy = true
# 1. 停止并释放粒子系统
if gpu_particles_2d:
gpu_particles_2d.emitting = false
gpu_particles_2d.queue_free()
# 2. 断开所有信号连接
if area_entered.is_connected(_on_area_entered):
area_entered.disconnect(_on_area_entered)
# 3. 从组中移除
remove_from_group("arrow")
emit_signal("arrow_landed")
queue_free()
# =========================
# 发射接口
# =========================
func launch(power: float, direction: Vector2, ismissile: bool = false) -> void:
is_homing_missile = ismissile
is_bonus_arrow = ismissile
var init_speed = basespeed + power * basespeed
velocity = direction.normalized() * init_speed
base_speed_mag = velocity.length()
vertical_speed = 120.0 + power * 260.0
height = 0.0
homing_time = 0.0
lost_target_timer = 0.0
target_lock_cooldown = 0.0
target = null
# --- 仅当为导弹时生成随机相位,普通箭矢置0 ---
if is_homing_missile:
snake_phase_offset = randf_range(0.0, TAU)
var is_fire = randf() > 0.5
if is_fire:
gpu_particles_2d.modulate = Color(1.0, 0.4, 0.0) # 火焰橙红
else:
gpu_particles_2d.modulate = Color(0.2, 0.6, 1.0) # 冰蓝
else:
snake_phase_offset = 0.0
sprite_2d.rotation = velocity.angle()
is_moving = true
# =========================
# 设置追踪箭(福缘奖励调用)
# =========================
func setup_bonus(p_target: Node2D, p_bonus_score: int, p_element: String) -> void:
target = p_target
bonus_score_value = p_bonus_score
is_homing_missile = true
is_bonus_arrow = true
snake_phase_offset = randf_range(0.0, TAU)
# 染色 + 打开粒子拖尾
if gpu_particles_2d:
gpu_particles_2d.emitting = true
if p_element == "fire":
gpu_particles_2d.modulate = Color(1.0, 0.4, 0.0) # 火焰橙红
else:
gpu_particles_2d.modulate = Color(0.2, 0.6, 1.0) # 冰霜蓝
# =========================
# 搜寻最近目标
# =========================
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 = null
for node in candidates:
if not is_instance_valid(node):
continue
var dist = global_position.distance_squared_to(node.global_position)
if dist < closest_dist:
closest_dist = dist
closest_node = node
target = closest_node
if target != null:
lost_target_timer = 0.0
# =========================
# 普通箭矢重力弹道(含落地判断)
# =========================
func _update_physics(delta: float) -> void:
if not is_instance_valid(self) or already_destroy:
return
height += vertical_speed * delta
vertical_speed -= arrow_gravity * delta
# 落地判断
if height <= 0.0 and vertical_speed < 0.0:
height = 0.0
vertical_speed = 0.0
_destroy_arrow()
return
var visual_offset = -height * 0.15
sprite_2d.position.y = visual_offset
var shrink = clamp(1.0 - height / 260.0, 0.35, 1.0)
shadow.scale = Vector2.ONE * shrink
shadow.modulate.a = shrink
var shadow_ground_offset = Vector2(1.0, 1.0).normalized() * height * 0.5
shadow.position = Vector2(10, 15) + shadow_ground_offset
shadow.rotation = sprite_2d.rotation
# =========================
# 导弹跟踪物理(蛇形Z跟踪)
# =========================
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
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
var turn_angle = clamp(angle_diff, -max_turn, max_turn)
current_dir = current_dir.rotated(turn_angle)
# 3. 叠加蛇形摆动(使用随机相位)
var perpendicular = current_dir.orthogonal()
var time = Time.get_ticks_msec() / 1000.0
var snake_offset = sin(time * snake_frequency * TAU + snake_phase_offset) * snake_amplitude
var new_velocity = (current_dir * base_speed_mag) + (perpendicular * snake_offset)
velocity = new_velocity.limit_length(base_speed_mag)
# 4. 更新箭头和阴影旋转
sprite_2d.rotation = velocity.angle()
shadow.rotation = sprite_2d.rotation
# 5. 导弹无重力,重置高度
height = 0.0
vertical_speed = 0.0
sprite_2d.position.y = 0.0
gpu_particles_2d.position.y = 0.0
shadow.scale = Vector2.ONE
shadow.modulate.a = 1.0
# =========================
# 碰撞检测
# =========================
func _on_area_entered(area: Area2D) -> void:
if not is_moving or not is_instance_valid(self) or already_destroy:
return
if area.is_in_group("target"):
if area.has_method("on_hit"):
area.on_hit(self)
if is_homing_missile:
emit_signal("bonus_landed", bonus_score_value)
_destroy_arrow()
func _exit_tree() -> void:
# 确保粒子系统被释放
if gpu_particles_2d and is_instance_valid(gpu_particles_2d):
gpu_particles_2d.emitting = false
gpu_particles_2d.queue_free()
# 断开所有信号
if area_entered.is_connected(_on_area_entered):
area_entered.disconnect(_on_area_entered)

第二阶段:场景与交互组件
7.3.4 创建场景、摄像机与草地地面
- 新建场景:
Ctrl+N,根节点选 Node2D,重命名为BowlGame,Ctrl+S保存为scenes/levels/bowl_game.tscn。 - 添加摄像机:选中
BowlGame,添加子节点 Camera2D,位置设为场地几何中心(577, 325)。如果运行后酒壶或主角有一部分在视野外,微调摄像机位置或缩放。 - 添加草地图层:添加子节点
TileMapLayer,命名GroundLayer,Tile Set加载resources/jiangnan_tileset.tres,用绘制模式铺满一片草地和石板路,作为投壶场地地面。
7.3.5 添加醉意准星 AimCursor
在 BowlGame 场景中。
- 添加子节点
Sprite2D,重命名AimCursor,加载assets/sprites/cursor.png,Z Index设为10。 - 附加脚本
aim_cursor.gd,保存到scripts/:
class_name AimCursor extends Node2D
@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 # 当前实际偏移量(准星当前相对于锚点的位置,会通过插值逐渐接近 target_offset)
@onready var player: CharacterBody2D = get_parent().get_node("Player")
func _process(delta: float) -> void:
var anchor_position = player.global_position + follow_offset
var drunk = 0.3
if GameManager:
drunk = GameManager.player_drunk_level
var current_max = base_drift + (max_drift - base_drift) * drunk
var random_angle = randf() * TAU
var random_step = randf() * current_max * delta * smoothness
target_offset += Vector2(cos(random_angle), sin(random_angle)) * random_step
target_offset = target_offset.lerp(Vector2.ZERO, return_strength * delta)
if target_offset.length() > current_max * 1.5:
target_offset = target_offset.normalized() * current_max * 1.5
current_offset = current_offset.lerp(target_offset, 1.0 - exp(-smoothness * delta))
global_position = anchor_position + current_offset
7.3.6 添加蓄力 UI PowerUI
在 BowlGame 场景中。
-
添加子节点
CanvasLayer,重命名PowerUI;在它下面添加子节点ProgressBar,命名PowerBar,Min Value=0、Max Value=1、Step=0.01,size=(200, 27),用Layout → Bottom Wide固定在屏幕底部。 -
附加脚本
power_ui.gd,保存到scripts/:
class_name PowerUI extends CanvasLayer
signal arrow_launched(power: float) #当玩家松开鼠标发射箭矢时发出,参数 power 为蓄力大小(0~1)
@export var charge_speed: float = 1.5
var current_power: float = 0.0
var is_charging: bool = false
var is_ready: bool = true
@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
if current_power > 0.1:
arrow_launched.emit(current_power)
current_power = 0.0
power_bar.value = 0.0
func _process(delta: float) -> void:
if not is_charging:
return
current_power += delta * charge_speed
if current_power > 1.0:
current_power = 1.0
power_bar.value = current_power
func reset_power() -> void:
current_power = 0.0
power_bar.value = 0.0
is_charging = false
func set_ready(state: bool) -> void:
is_ready = state
if not state:
is_charging = false
第三阶段:逻辑串联
7.3.7 实例化 Player,追加投壶模式
- 回到
bowl_game.tscn,选中BowlGame,实例化Player.tscn,保持默认名Player,位置设为(400, 520)。 - 打开
player.gd(第五章写好的主角脚本),探索模式原有的移动/动画代码不用动,追加下面这些内容,让Player能在探索地图和投壶场景里复用同一份脚本,目前脚本代码修改如下:
extends CharacterBody2D
@onready var player_ghost: AnimatedSprite2D = $PlayerGhost
@export var speed: float = 200.0
# 记录角色最后移动的方向向量,默认为向下(0, 1)
var last_direction:Vector2 =Vector2.DOWN
# ============================================================
# 🏹 投壶模式相关状态(新增)
# 探索地图不会用到这些变量,只有进入投壶场景后才会被设置
# ============================================================
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,由 BowlGameManager 传入
var bowl_is_shooting: bool = false # 射箭动画播放期间,暂时不切换其他动画
@onready var animated: AnimatedSprite2D = $AnimatedSprite2D
#当节点首次进入场景树时自动调用
func _ready() -> void:
player_ghost.visible=false
update_idle_animation()# 一开始就播放默认方向的待机动画
func _physics_process(delta: float) -> void:
if bowl_mode:
_physics_process_bowl_mode(delta)
else:
_physics_process_explore_mode(delta)
func _physics_process_explore_mode(delta: float) -> void:
# 获取八方向输入(自动归一化,斜向速度不超标)
var direction = Input.get_vector("left", "right", "up", "down")
velocity = direction * speed
move_and_slide()
if direction.length() > 0:
last_direction = direction
play_walk_animation(direction)
else:
update_idle_animation()
func _physics_process_bowl_mode(delta: float) -> void:
var input_dir := Input.get_axis("left", "right")
position.x += input_dir * speed * delta
position.x = clamp(position.x, bowl_min_x, bowl_max_x)
if bowl_is_shooting:
return
if bowl_power_ui and bowl_power_ui.is_charging:
_update_bowl_aim_frame(bowl_power_ui.current_power)
elif input_dir != 0.0:
$AnimatedSprite2D.play("walk_up") # 面朝酒壶方向走位,复用已有的八方向动画之一
else:
$AnimatedSprite2D.play("idle_up")
# 蓄力拉弓:不自动播放,直接按蓄力比例"点播"到对应的那一帧
func _update_bowl_aim_frame(charge_ratio: float) -> void:
var anim: AnimatedSprite2D = $AnimatedSprite2D
if not anim.sprite_frames or not anim.sprite_frames.has_animation("aim"):
return
if anim.animation != "aim":
anim.animation = "aim"
anim.stop()
var frame_count = anim.sprite_frames.get_frame_count("aim")
anim.frame = clampi(int(charge_ratio * (frame_count - 1)), 0, frame_count - 1)
# 由 BowlGameManager 在 _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
bowl_power_ui.arrow_launched.connect(_on_bowl_arrow_launched)
$AnimatedSprite2D.animation_finished.connect(_on_bowl_animation_finished)
func _on_bowl_arrow_launched(_power: float) -> void:
bowl_is_shooting = true
if $AnimatedSprite2D.sprite_frames.has_animation("shoot"):
$AnimatedSprite2D.play("shoot")
func _on_bowl_animation_finished() -> void:
if $AnimatedSprite2D.animation == "shoot":
bowl_is_shooting = false
func exit_bowl_mode() -> void:
bowl_mode = false
# 根据 last_direction 更新待机动画
func update_idle_animation():
var anim_name = get_animation_name(last_direction, "idle")
animated.play(anim_name)
func play_walk_animation(dir: Vector2):
var anim_name = get_animation_name(dir, "walk")
animated.play(anim_name)
func get_animation_name(dir:Vector2,action:String)->String:
var x = dir.x
var y = dir.y
var suffix:String
if x==0 and y==0:
suffix = "right"
else:
if abs(x)>abs(y):
suffix = "right" if x>0 else "left"
elif abs(y)>abs(x):
suffix = "down" if y>0 else "up"
else:
if x>0 and y<0:
suffix ="up_right"
elif x>0 and y>0:
suffix = "down_right"
elif x < 0 and y < 0:
suffix = "up_left"
else:
suffix = "down_left"
return action+"_"+suffix
bowl_mode默认false,不影响探索地图:_physics_process里第一行判断就会走_physics_process_explore_mode()这个分支——探索地图上的Player实例永远不会调用enter_bowl_mode(),这些新增变量和函数对它完全没有影响。
aim/shoot这两个动画怎么办: 这两个是投壶专属的新动画,需要在SpriteFrames资源里补充;aim只需要"由松到紧"的几张渐进关键帧(4~6 帧足够),因为最终显示第几帧由蓄力比例决定。如果暂时没做,_update_bowl_aim_frame()和_on_bowl_arrow_launched()都做了存在性检查,不会报错,只是拉弓、射箭时不会有额外动作。
7.3.8 创建游戏管理器 BowlGameManager
这是把所有零件串起来的一节,同时实现两件事:酒壶在场地里随机位置动态生成,以及**"连珠三箭"福缘奖励真正射出三支带火/冰特效的追踪箭,命中额外加分**,而不再是单纯打印文字。
选中根节点 BowlGame,附加脚本 bowl_game_manager.gd:
class_name BowlGameManager extends Node2D
#对外变量
@export var arrow_scene: PackedScene = preload("res://scenes/object/arrow.tscn") #箭矢场景
@export var pot_scene: PackedScene = preload("res://scenes/object/target_pot.tscn") #酒壶场景
@export var player: CharacterBody2D #主角
@export var aim_cursor: AimCursor #瞄准星
@export var power_ui: PowerUI #力量控制
#角色移动范围
@export var player_min_x: float = 80.0
@export var player_max_x: float = 1200.0
#酒壶出现范围
@export var pot_spawn_min_x: float = 100.0
@export var pot_spawn_max_x: float = 1100.0
@export var pot_spawn_min_y: float = 80.0
@export var pot_spawn_max_y: float = 220.0
@export var round_time_limit: float = 30.0 #游戏时间限制
@export var score_multiplier:float = 1.0
# 福缘奖励"连珠三箭"相关参数
@export var bonus_arrow_score: int = 3 # 每支追踪箭命中额外加多少分
@export var bonus_arrow_delay: float = 0.15 # 三支箭之间的发射间隔(秒)
var pot_num:int =52
var current_pot_num:int =0 #场景中酒壶数量
var score: int = 0 #分数
var hits: int = 0 #销毁酒壶数量
var elapsed_round_time: float = 0.0 #共玩了多长时间
var round_active:int=0 #开局状态,0表示未开局,1表示正在开局 2表示结束
var arrowCount:int =0
var is_barrage_active: bool = false # 防止连珠三箭重复触发
var _barrage_should_stop: bool = false #强制中断连发协程
func _ready() -> void:
power_ui.arrow_launched.connect(_on_arrow_launched)
player.enter_bowl_mode(player_min_x, player_max_x, power_ui)
start_round()
func _process(delta: float) -> void:
elapsed_round_time += delta
if elapsed_round_time >= round_time_limit or current_pot_num<=0:
if get_tree().get_nodes_in_group("arrow").size()<=0:
_end_round()
# ============================================================
# 🚀 开局 / 生成酒壶
# ============================================================
func start_round() -> void:
score = 0
hits = 0
elapsed_round_time = 0.0
round_active = 1
power_ui.set_ready(true)
for i in range(pot_num):#生成酒钢数量
_spawn_pot()
update_ui()
print("--- 新的一局!", "限时 " + str(round_time_limit) + " 秒", " ---")
#------酒壶生成函数----------
func _spawn_pot() -> void:
current_pot_num += 1
var pot: TargetPot = pot_scene.instantiate()
pot.position = Vector2(randf_range(pot_spawn_min_x, pot_spawn_max_x),randf_range(pot_spawn_min_y, pot_spawn_max_y))
pot.amplitude = randf_range(60,100)
pot.frequency = randf_range(1.1,2.5)
add_child(pot)
pot.hit.connect(_on_target_hit)
# ============================================================
# 🏹 发射
# ============================================================
func _on_arrow_launched(power: float,is_bost:bool=false) -> void:
var direction = (aim_cursor.global_position - player.global_position).normalized()
var arrow = arrow_scene.instantiate()
add_child(arrow)
arrow.global_position = player.global_position+Vector2(0,-30)
arrow.launch(power, direction,is_bost)
arrow.arrow_landed.connect(_on_arrow_landed)
arrowCount +=1 #发射键的数量
update_ui()
#箭矢销毁
func _on_arrow_landed() -> void:
_advance_pot()
#酒壶被射中
func _on_target_hit(current_pot:Node2D,arrow:Node2D) -> void:
# 增加有效性检查,防止回调时酒壶已被销毁
if not is_instance_valid(current_pot) or current_pot.is_queued_for_deletion():
return
hits += 1
current_pot_num -=1
#计算分值
var swing_velocity = 0.0
var max_speed = 1.0
swing_velocity = current_pot.get_current_velocity() #获取当前摆动速度的绝对值
max_speed = current_pot.amplitude * current_pot.frequency #幅度*摆动频率
var timing_score = 1.0 - clamp(swing_velocity / max(max_speed, 0.01), 0.0, 1.0)
var total_score = 0.6 + timing_score * 0.4
var final_score = total_score * score_multiplier
score_multiplier =1.0
if final_score > 0.8 and not is_barrage_active and not arrow.is_bonus_arrow:
call_deferred("_fire_bonus_arrows")
score += int(final_score*10)
if not arrow.is_bonus_arrow:
score += int(final_score*10)*3
_advance_pot()
func _advance_pot() -> void:
if not round_active:
return
update_ui()
#_spawn_next_pot()
# ============================================================
# 📊 结算与 UI
# ============================================================
func _end_round() -> void:
if round_active==2:
return
round_active = 2
_barrage_should_stop = true #强制停止连发协程
is_barrage_active = false # 防止继续触发
power_ui.set_ready(false) #禁止再发射新箭
# ---------- 统计额外加分 ----------
# 1. 剩余时间奖励:每0.1秒 +5 分(取整秒数)
var remaining_time = max(0.0, round_time_limit - elapsed_round_time)
var time_bonus = int(remaining_time * 10) * 5 # 向下取整到0.1秒,每0.1秒5分
# 2. 命中率奖励
var hit_rate = 0.0
if arrowCount > 0:
hit_rate = float(hits) / float(arrowCount)
var hit_bonus = 0
if hit_rate >= 1.0:
hit_bonus = 100 # 100% 命中加 100 分
else:
hit_bonus = int(hit_rate * 100) # 命中率百分之多少就加多少分
# 累加到总分
score += time_bonus + hit_bonus
# 打印最终统计信息
update_ui()
print("=== 本局结束 ===","得分",score)
print("剩余时间: %.1f 秒, 时间奖励: +%d 分" % [remaining_time, time_bonus])
print("命中率: %.1f%%, 命中奖励: +%d 分" % [hit_rate * 100, hit_bonus])
func update_ui() -> void:
print("酒壶: ",current_pot_num,"/",pot_num, " 得分: ", score, " 命中: ", hits, "/", arrowCount)
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 _barrage_should_stop or round_active != 1 or not is_instance_valid(self):
break
# 每支箭的力量随机在 0.1 ~ 0.3 之间
var power = randf_range(0.1, 0.3)
# 发射追踪箭(is_bost = true)
_on_arrow_launched(power, true)
if i < 2:
var elapsed = 0.0
while elapsed < bonus_arrow_delay:
if _barrage_should_stop or round_active != 1 or not is_instance_valid(self):
break
await get_tree().process_frame
elapsed += get_process_delta_time()
# 如果是因为停止条件退出的,则不再继续发射后续箭矢
if _barrage_should_stop or round_active != 1 or not is_instance_valid(self):
break
is_barrage_active = false
print("🍶 连珠三箭发射完毕!")
7.3.9 检查器赋值确认表
场景搭建完成后,在 BowlGameManager 节点的检查器中确认以下属性已正确赋值:
| 属性名 | 应该拖入/填入的内容 |
|---|---|
Arrow Scene / Pot Scene |
已通过 preload 自动赋值,不用管 |
Player |
场景树中的 Player 实例 |
Aim Cursor |
场景树中的 AimCursor 节点 |
Power UI |
场景树中的 PowerUI 节点 |
Player Min X / Player Max X |
默认 220/580,对应 7.3.4 节的草地范围 |
Pot Spawn Min X / Max X / Y |
默认 260/540/180,需要落在摄像机可见范围内 |
End Mode |
下拉框选择"按数量结束"或"按时间结束" |
Total Pots |
按数量结束时生效,默认 5 |
Round Time Limit |
按时间结束时生效,默认 30 秒 |
Bonus Arrow Score / Bonus Arrow Delay |
默认 3 分 / 0.15 秒,可按手感调整 |

7.4 集成到主游戏
投壶场景开发完成后,需要把它接入主游戏 main.tscn。我们在第六章已经预留了 BattleArena 容器。
7.4.1 将投壶场景添加到 BattleArena
- 打开
main.tscn。 - 选中
BattleArena节点(位于根节点Main下)。 - 右键 → 实例化子场景,选择
bowl_game.tscn。 - 将实例化的
BowlGame节点的Visible属性取消勾选(默认隐藏,只有进入战斗时才显示)。
7.4.2 添加触发区域(进入战斗)
在小镇地图上放置一个触发区域,当玩家走进时自动进入投壶战斗:
-
打开
level_town.tscn。 -
在根节点
TownRoot下,添加子节点 Area2D,命名为BattleTrigger。 -
添加子节点
CollisionShape2D,形状选RectangleShape2D,覆盖小镇广场的一块区域。 -
添加sprite2D,命名为potlist,添加酒壶素材

-
添加脚本
battle_trigger.gd:
extends Area2D
func _ready():
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D):
if body.is_in_group("player"):
GameManager.start_battle()
修改game_manager.gd代码如下
extends Node
# ---- 游戏状态枚举 ---- 自由移动\参与战斗\特定于某种机制,可能指制作药水、炼金术、混合原料、烹饪或酿造\与NPC对话\游戏完全暂停
enum GameState { EXPLORE, BATTLE, BREWING, DIALOGUE, PAUSED }
# ---- 核心变量 ----
var current_state: GameState = GameState.EXPLORE
var player_drunk_level: float = 0.3 # 全局醉意值
# ---- 地图切换相关 ----
@export var town_scene: PackedScene = preload("res://scenes/levels/level_town.tscn")
var active_level: Node2D = null
@onready var level_container: Node = get_node("/root/Main/World/CurrentLevel")
@onready var battle_container: Node = get_node("/root/Main/BattleArena")
# ---- 战斗动态加载(使用路径,避免编译时预加载占用内存) ----
@export var battle_scene_path: String = "res://scenes/levels/bowl_game.tscn"
var current_battle_instance: Node = null # 用来记录当前战斗实例
# ---- 初始化 ----
func _ready() -> void:
print("[GameManager] 游戏启动,当前状态: ", GameState.keys()[current_state])
battle_container.visible = false
# 加载默认地图(江南小镇)
switch_level(town_scene, Vector2(600, 150))
# ---- 地图切换核心函数 ----
func switch_level(target_scene: PackedScene, spawn_pos: Vector2) -> void:
if active_level != null:
active_level.queue_free()
active_level = null
await get_tree().process_frame # ★★★ 关键:等一帧,让 Godot 彻底处理完销毁 ★★★
active_level = target_scene.instantiate()
level_container.add_child(active_level)
# 假设地图中有 Player 和 GroundLayer
var player = active_level.get_node("YSortWorld/Player")
if player:
player.position = spawn_pos
var camera = player.get_node("Camera2D")
var ground = active_level.get_node("GroundLayer")
if camera and ground and camera.has_method("setup_limits_from_tilemap"):
camera.setup_limits_from_tilemap(ground)
else:
push_warning("[GameManager] 地图中没有找到 Player 节点,位置未设置")
# ---- 状态切换(保留原逻辑,并添加针对地图切换的处理) ----
func change_state(new_state: GameState) -> void:
if current_state == new_state:
return
print("[GameManager] 状态变更: ", GameState.keys()[current_state], " -> ", GameState.keys()[new_state])
current_state = new_state
match new_state:
GameState.EXPLORE:
get_tree().paused = false
GameState.PAUSED:
get_tree().paused = true
GameState.BREWING, GameState.DIALOGUE:
get_tree().paused = true # UI 弹出时暂停世界,但 UI 层需设为不受暂停影响
GameState.BATTLE:
# ARPG 战斗不暂停世界
pass
func start_battle() -> void:
# 防止重复进入战斗
if current_battle_instance != null:
push_warning("战斗已存在,请勿重复触发")
return
await get_tree().physics_frame
print("[GameManager] 开始加载战斗场景...")
var player = active_level.get_node("YSortWorld/Player")
if player:
var player_camera = player.get_node("Camera2D")
if player_camera and player_camera is Camera2D:
# 必须使用 set_deferred,防止物理冲突报错
player_camera.set_deferred("current", false)
print("[GameManager] 已强制关闭玩家相机 (current = false)")
# 1. 同步加载场景资源(如果场景 50MB 以上觉得卡,看下面进阶异步方案)
var battle_scene = load(battle_scene_path)
if battle_scene == null:
push_error("战斗场景加载失败,请检查路径: ", battle_scene_path)
return
# 2. 实例化并放入容器
current_battle_instance = battle_scene.instantiate()
battle_container.add_child(current_battle_instance)
# 3. 显示容器,隐藏世界(因为是平级,互不干扰)
battle_container.set_deferred("visible", true)
level_container.set_deferred("visible", false)
# 4. 切换游戏状态
change_state(GameState.BATTLE)
print("[GameManager] 战斗场景已加载并显示")
# ---------- 🏕️ 结束战斗(核心优化点:用完即焚) ----------
func end_battle() -> void:
if current_battle_instance == null:
push_warning("没有正在进行的战斗")
return
print("[GameManager] 正在销毁战斗场景,释放内存...")
# 1. 从树上移除并彻底销毁(引用归零,Godot 自动卸载纹理/音效)
current_battle_instance.queue_free()
current_battle_instance = null
# 2. 隐藏容器,显示世界
battle_container.set_deferred("visible", false)
level_container.set_deferred("visible", true)
# 2.1 重新启用小镇 player 相机(战斗开始时被禁用了)
var player = active_level.get_node("YSortWorld/Player")
if player:
var player_camera = player.get_node("Camera2D")
if player_camera and player_camera is Camera2D:
player_camera.set_deferred("current", true)
# 3. 切回探索状态
change_state(GameState.EXPLORE)
print("[GameManager] 战斗内存已释放")
7.5 本章总结
7.5.1 本章完整交付物
| 文件/成果 | 路径 | 说明 |
|---|---|---|
bowl_game.tscn(含 Camera2D、GroundLayer 草地瓦片、Player.tscn 实例) |
scenes/object/ |
投壶战斗主场景 |
target_pot.tscn |
scenes/object/ |
动态酒壶场景(纵向摇摆,位置由管理器动态指定) |
arrow.tscn(含 Shadow 影子精灵、Trail 火/冰粒子拖尾) |
scenes/object/ |
箭矢场景,修复了发射速度换算与弧线落地判定 |
player.gd(新增"投壶模式") |
scripts/ |
第五章的主角脚本追加 enter_bowl_mode() 等函数,探索/投壶两种模式共用同一份脚本 |
target_pot.gd |
scripts/ |
酒壶纵向钟摆摇摆脚本 |
arrow.gd |
scripts/ |
箭矢俯视角飞行脚本,含修复后的命中判定与追踪箭逻辑 |
aim_cursor.gd |
scripts/ |
醉意准星漂移脚本,锚点每帧跟随主角位置 |
power_ui.gd |
scripts/ |
蓄力UI控制脚本 |
bowl_game_manager.gd |
scripts/ |
游戏管理器:动态生成酒壶、按数量/限时双模式结算、真正生效且带特效的福缘奖励 |
battle_trigger.gd |
scripts/ |
战斗触发脚本 |
7.5.2 核心知识点总结
| 知识点 | 应用场景 | 章节中的体现 |
|---|---|---|
| 蓄力比例与实际速度分离 | 修复"箭矢几乎不动"问题 | launch() 内部把 0~1 的蓄力比例换算成 200~400 的像素速度,而不是直接拿比例当速度用 |
| 抛体运动的符号约定 | 修复弧线落地判定错误 | 约定 height 正值=离地、vertical_speed 正值=上升,重力持续做减法,落地条件是"高度归零且正在下降" |
| Area2D 之间的命中判定 | 命中/落地互斥 | 箭矢监听自己的 area_entered 信号,命中后立即 queue_free(),不会和落地判定冲突 |
| 转向速度限制的追踪逻辑 | 连珠三箭的自动追踪 | 每帧用 angle_to() 算出偏差角度,clamp 到最大转向速度内再旋转速度向量,而不是瞬间对准目标 |
| GPUParticles2D 拖尾特效 | 福缘奖励的视觉表现 | 追踪箭染色 + 打开粒子发射,让"连珠三箭"从一句打印文字变成真正看得见的战斗表现 |
| 奖励系统防止自我叠加 | 避免箭矢刷屏 | barrage_active 标记位,连珠三箭进行中不会再次触发同一个奖励 |
| 动态实例化 + 随机位置 | 酒壶不固定出现在一个点 | BowlGameManager 用 pot_scene.instantiate() 在指定范围内随机生成酒壶 |
| 按数量/按时间的双结束模式 | 适配不同的关卡节奏需求 | @export_enum 做一个下拉选择,两种模式共用同一套命中/落地事件处理 |
| 准星锚点跟随移动目标 | 让漂移效果和角色位置联动 | AimCursor 每帧用 player.global_position + follow_offset 重新计算锚点 |
| 简谐运动(正弦波) | 物体来回摆动 | 酒壶的纵向钟摆摇摆:y = center + amplitude * sin(frequency * time) |
| 同一角色的多模式切换 | 复用同一份场景/脚本 | player.gd 用 bowl_mode 开关区分探索移动和投壶走位 |
| 蓄力比例驱动动画帧 | 拉弓等需要"进度感"的动作 | 关掉自动播放,直接用 charge_ratio 换算 anim.frame |
| 信号机制 | 节点间解耦通信 | arrow_launched、hit、arrow_landed、bonus_landed 信号 |
| launch 与 setup_bonus 职责分离 | 避免追踪箭初始化重复染色 | launch() 只管物理初速度,setup_bonus() 管追踪模式+特效染色,二者不重叠 |
7.5.3 玩法设计心得
这一版投壶最大的两处修复:一是让箭矢真正"看得见地飞出去"——蓄力比例和飞行速度是两个不同量纲的数字,混用会导致"看起来什么都没发生";二是让福缘奖励从纯粹的数字变化,变成真正影响画面的战斗表现——"连珠三箭"不再只是控制台里一行加分记录,而是三支带着火焰或寒冰拖尾、会自动追踪的箭矢真的飞了出去。这个思路会贯穿后续章节:数值上生效只是及格线,能被玩家亲眼看到、感受到,才是真正完整的游戏反馈。
7.6 下一章预告与延伸阅读
下一章预告
第8章《战斗升级:状态机与攻击动画》将把本章的远程投壶与近战攻击结合,引入 Hitbox/Hurtbox 体系,并开始构建主角的战斗状态机(待机/移动/攻击/受击/死亡)。届时,投壶将作为主角的"远程技能"融入完整的战斗系统中。
延伸阅读参考
- Godot 官方文档 - 物理介绍:Physics introduction — 理解 Area2D 之间
area_entered信号的触发条件; - Godot 官方文档 - 信号与事件系统:Using signals — 巩固本章大量使用的信号驱动通信方式;
- Godot 官方文档 - GPUParticles2D 类参考:了解粒子发射器的
Amount、Lifetime、ProcessMaterial等核心属性; - Godot 官方文档 - Camera2D 类参考:了解
Zoom、Position等属性如何影响可视范围; - 简谐运动可视化:搜索 "pendulum simulation" 理解正弦波运动;抛体运动物理原理(中文百科词条"抛体运动"):巩固水平匀速、竖直匀加速的基础公式推导;布朗运动:随机游走模型在游戏AI和特效中的广泛应用。
评论0
暂时没有评论