4. 球的运动与碰撞
球的运动与碰撞
4.1 球与球碰撞
球与球的碰撞是桌球游戏中最核心的物理现象。当母球被击出后,它会在桌面上滚动,碰到目标球,然后两个球都会改变运动方向和速度。
弹性碰撞原理
生活化比喻
想象两个冰球在冰面上相撞。因为冰面很滑(几乎没有摩擦),碰撞后两个冰球会各自朝不同方向滑出去。这就是弹性碰撞——碰撞过程中没有能量损失(理想情况),动量守恒。
桌球球与球之间的碰撞就非常接近理想的弹性碰撞,因为桌球是硬质材料做的,碰撞时几乎没有形变。
弹性碰撞的物理规律
当两颗球碰撞时,遵循以下规律:
| 规律 | 说明 |
|---|---|
| 动量守恒 | 碰撞前两球动量之和 = 碰撞后两球动量之和 |
| 能量守恒(近似) | 碰撞前总动能 ≈ 碰撞后总动能 |
| 碰撞方向 | 碰撞力沿着两球连心线方向传递 |
好消息:物理引擎帮你算!
你不需要手动计算弹性碰撞的公式!Godot的物理引擎会自动处理所有碰撞计算。你只需要:
- 给球设置正确的物理参数(质量、弹性系数等)
- 让物理引擎去计算碰撞结果
所以这一章的重点不是"怎么计算碰撞",而是"怎么检测碰撞"和"怎么响应碰撞"。
碰撞响应方式
Godot中有两种处理碰撞响应的方式:
| 方式 | 方法 | 适用场景 |
|---|---|---|
| 信号方式 | 连接 body_entered 信号 | 简单的碰撞检测 |
| 脚本方式 | 重写 _BodyEntered 方法 | 需要获取碰撞信息的场景 |
| Contact方式 | 使用 body_shape_entered | 需要精确碰撞形状信息 |
碰撞信号连接
在球体脚本中,我们可以通过连接信号来检测碰撞:
// C# - 在 _Ready 中连接碰撞信号
public override void _Ready()
{
BodyEntered += OnBodyEntered;
}
private void OnBodyEntered(Node body)
{
// body 就是被撞到的物体
GD.Print($"球 #{BallNumber} 碰到了 {body.Name}");
}# GDScript - 在 _Ready 中连接碰撞信号
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node) -> void:
# body 就是被撞到的物体
print("球 #%d 碰到了 %s" % [ball_number, body.name])4.2 球与边框碰撞
球撞到球桌边框后应该反弹回来。这个反弹效果完全由物理引擎处理,但我们可能需要在反弹时做一些额外的逻辑(比如播放声音)。
边框碰撞检测
球与边框碰撞的处理方式和球与球碰撞类似,但我们需要区分"撞到球"和"撞到边框"。
判断碰撞对象类型
使用Group来区分物体类型
在Godot中,我们可以给节点添加"组"(Group)标签,方便在代码中识别。比如:
- 所有球添加到
balls组 - 所有边框添加到
rails组 - 所有球袋添加到
pockets组
这样在碰撞时,我们就可以判断碰到的是什么类型的物体。
创建组
在场景中选中节点,在右侧检查器中找到 Node → Groups,点击添加:
| 节点 | 组名 |
|---|---|
| 所有球 | balls |
| 所有边框 | rails |
| 所有球袋 | pockets |
| 母球 | cue_ball |
碰撞类型判断代码
using Godot;
/// <summary>
/// 碰撞响应脚本 - 处理球的各种碰撞事件
/// </summary>
public partial class CollisionHandler : RigidBody3D
{
/// <summary>碰撞音效播放器</summary>
private AudioStreamPlayer3D _collisionAudio;
/// <summary>球碰撞音效</summary>
[Export] public AudioStream BallHitSound { get; set; }
/// <summary>边框碰撞音效</summary>
[Export] public AudioStream RailHitSound { get; set; }
public override void _Ready()
{
// 创建音效播放器
_collisionAudio = new AudioStreamPlayer3D();
AddChild(_collisionAudio);
// 连接碰撞信号
BodyEntered += OnBodyEntered;
}
/// <summary>
/// 碰撞发生时的处理
/// </summary>
private void OnBodyEntered(Node body)
{
// 根据碰撞对象的组来区分碰撞类型
if (body.IsInGroup("balls"))
{
OnBallCollision(body);
}
else if (body.IsInGroup("rails"))
{
OnRailCollision(body);
}
else if (body.IsInGroup("pockets"))
{
OnPocketCollision(body);
}
}
/// <summary>
/// 球与球碰撞
/// </summary>
private void OnBallCollision(Node otherBall)
{
var ballScript = otherBall as BallPhysics;
if (ballScript == null) return;
GD.Print($"球 #{BallNumber} 碰到了球 #{ballScript.BallNumber}");
// 根据碰撞力度调整音量
float impactForce = LinearVelocity.Length();
float volume = Mathf.Clamp(impactForce / 8.0f, 0.1f, 1.0f);
// 播放碰撞音效
if (BallHitSound != null)
{
_collisionAudio.VolumeDb = Mathf.LinearToDb(volume);
_collisionAudio.Stream = BallHitSound;
_collisionAudio.Play();
}
}
/// <summary>
/// 球与边框碰撞
/// </summary>
private void OnRailCollision(Node rail)
{
GD.Print($"球 #{BallNumber} 碰到了边框 {rail.Name}");
// 播放边框碰撞音效
if (RailHitSound != null)
{
float impactForce = LinearVelocity.Length();
float volume = Mathf.Clamp(impactForce / 8.0f, 0.1f, 1.0f);
_collisionAudio.VolumeDb = Mathf.LinearToDb(volume);
_collisionAudio.Stream = RailHitSound;
_collisionAudio.Play();
}
}
/// <summary>
/// 球与球袋碰撞(进袋)
/// </summary>
private void OnPocketCollision(Node pocket)
{
GD.Print($"球 #{BallNumber} 靠近球袋 {pocket.Name}");
// 进袋逻辑由球袋的Area3D处理
}
/// <summary>
/// 获取球的编号
/// </summary>
private int BallNumber
{
get
{
if (GetParent() is BallPhysics bp) return bp.BallNumber;
return -1;
}
}
}## 碰撞响应脚本 - 处理球的各种碰撞事件
extends RigidBody3D
## 碰撞音效播放器
var _collision_audio: AudioStreamPlayer3D
## 球碰撞音效
@export var ball_hit_sound: AudioStream
## 边框碰撞音效
@export var rail_hit_sound: AudioStream
func _ready() -> void:
# 创建音效播放器
_collision_audio = AudioStreamPlayer3D.new()
add_child(_collision_audio)
# 连接碰撞信号
body_entered.connect(_on_body_entered)
## 碰撞发生时的处理
func _on_body_entered(body: Node) -> void:
# 根据碰撞对象的组来区分碰撞类型
if body.is_in_group("balls"):
_on_ball_collision(body)
elif body.is_in_group("rails"):
_on_rail_collision(body)
elif body.is_in_group("pockets"):
_on_pocket_collision(body)
## 球与球碰撞
func _on_ball_collision(other_ball: Node) -> void:
var ball_script = other_ball as BallPhysics
if not ball_script:
return
print("球 #%d 碰到了球 #%d" % [get_parent().ball_number, ball_script.ball_number])
# 根据碰撞力度调整音量
var impact_force = linear_velocity.length()
var volume = clampf(impact_force / 8.0, 0.1, 1.0)
# 播放碰撞音效
if ball_hit_sound:
_collision_audio.volume_db = linear_to_db(volume)
_collision_audio.stream = ball_hit_sound
_collision_audio.play()
## 球与边框碰撞
func _on_rail_collision(rail: Node) -> void:
print("球碰到了边框 %s" % rail.name)
# 播放边框碰撞音效
if rail_hit_sound:
var impact_force = linear_velocity.length()
var volume = clampf(impact_force / 8.0, 0.1, 1.0)
_collision_audio.volume_db = linear_to_db(volume)
_collision_audio.stream = rail_hit_sound
_collision_audio.play()
## 球与球袋碰撞(进袋)
func _on_pocket_collision(pocket: Node) -> void:
print("球靠近球袋 %s" % pocket.name)
# 进袋逻辑由球袋的Area3D处理4.3 球进袋检测
球进袋是桌球游戏中最关键的判定之一。当球滚入球袋区域时,需要准确检测并做出响应。
Area3D的工作原理
简单理解Area3D
Area3D 就像一个"隐形传感器"。它不参与物理碰撞(不会挡住球),但它能检测到"有东西进来了"和"有东西出去了"。
你可以把它想象成一个"红外线报警器"——有人经过时它不会阻止你,但会发出警报(信号)。
球袋的信号
Area3D提供以下有用的信号:
| 信号 | 触发时机 | 用途 |
|---|---|---|
body_entered | 物理体进入区域 | 球进入球袋 |
body_exited | 物理体离开区域 | 球离开球袋(一般不会发生) |
area_entered | 另一个Area3D进入 | 很少用到 |
area_exited | 另一个Area3D离开 | 很少用到 |
球袋检测脚本
using Godot;
/// <summary>
/// 球袋检测脚本 - 检测球是否进入球袋
/// </summary>
public partial class PocketDetector : Area3D
{
/// <summary>球袋编号</summary>
[Export] public int PocketId { get; set; }
/// <summary>球袋名称</summary>
[Export] public string PocketName { get; set; } = "球袋";
/// <summary>进袋音效</summary>
[Export] public AudioStream PocketSound { get; set; }
/// <summary>音效播放器</summary>
private AudioStreamPlayer3D _audioPlayer;
/// <summary>球进袋事件</summary>
[Signal] public delegate void BallPocketedEventHandler(int pocketId, int ballNumber, Node ball);
public override void _Ready()
{
// 设置碰撞层:只检测球
CollisionLayer = 0;
CollisionMask = 1; // 层1:球
// 设置监控
Monitoring = true;
Monitorable = false;
// 创建音效播放器
_audioPlayer = new AudioStreamPlayer3D();
AddChild(_audioPlayer);
// 连接信号
BodyEntered += OnBallEntered;
}
/// <summary>
/// 球进入球袋区域
/// </summary>
private void OnBallEntered(Node body)
{
// 确认是球
if (!body.IsInGroup("balls")) return;
var ballScript = body.FindChild("BallPhysics") as BallPhysics;
if (ballScript == null) return;
GD.Print($"球 #{ballScript.BallNumber} 进入了{PocketName} (#{PocketId})");
// 播放进袋音效
if (PocketSound != null)
{
_audioPlayer.Stream = PocketSound;
_audioPlayer.Play();
}
// 让球"进袋"(从桌面上消失)
ballScript.Pocket();
// 发出进袋信号
EmitSignal(SignalName.BallPocketed, PocketId, ballScript.BallNumber, body);
}
}## 球袋检测脚本 - 检测球是否进入球袋
extends Area3D
## 球袋编号
@export var pocket_id: int = 0
## 球袋名称
@export var pocket_name: String = "球袋"
## 进袋音效
@export var pocket_sound: AudioStream
## 音效播放器
var _audio_player: AudioStreamPlayer3D
## 球进袋事件
signal ball_pocketed(pocket_id: int, ball_number: int, ball: Node)
func _ready() -> void:
# 设置碰撞层:只检测球
collision_layer = 0
collision_mask = 1 # 层1:球
# 设置监控
monitoring = true
monitorable = false
# 创建音效播放器
_audio_player = AudioStreamPlayer3D.new()
add_child(_audio_player)
# 连接信号
body_entered.connect(_on_ball_entered)
## 球进入球袋区域
func _on_ball_entered(body: Node) -> void:
# 确认是球
if not body.is_in_group("balls"):
return
var ball_script = body.find_child("BallPhysics") as BallPhysics
if not ball_script:
return
print("球 #%d 进入了%s (#%d)" % [ball_script.ball_number, pocket_name, pocket_id])
# 播放进袋音效
if pocket_sound:
_audio_player.stream = pocket_sound
_audio_player.play()
# 让球"进袋"(从桌面上消失)
ball_script.pocket()
# 发出进袋信号
ball_pocketed.emit(pocket_id, ball_script.ball_number, body)4.4 球运动减速
在真实桌球中,球不会永远滚下去——桌面的布料摩擦力会让球逐渐减速,最终停下来。在Godot中,我们通过以下方式模拟这个效果:
减速机制
| 机制 | 说明 | 设置方式 |
|---|---|---|
| 线性阻尼 | 模拟空气阻力和滚动摩擦 | LinearDamp = 0.5 |
| 角速度阻尼 | 模拟旋转摩擦 | AngularDamp = 0.5 |
| 物理材质摩擦 | 碰撞面之间的摩擦 | Friction = 0.2 |
| 休眠系统 | 速度极低时停止模拟 | Can Sleep = true |
检测所有球是否停止
在每一回合中,玩家必须等到所有球都停下来才能进行下一步操作。我们需要一个方法来检测"所有球都停了吗"。
using Godot;
using System.Collections.Generic;
/// <summary>
/// 球运动管理器 - 追踪和检测所有球的运动状态
/// </summary>
public partial class BallMovementManager : Node
{
/// <summary>所有球(不包括已进袋的)</summary>
private List<BallPhysics> _activeBalls = new();
/// <summary>是否有球在运动中</summary>
public bool IsAnyBallMoving { get; private set; } = false;
/// <summary>所有球停止时发出的事件</summary>
[Signal] public delegate void AllBallsStoppedEventHandler();
/// <summary>速度阈值(低于此值认为球已停止)</summary>
[Export] public float StopThreshold { get; set; } = 0.01f;
/// <summary>检查间隔(秒)</summary>
[Export] public float CheckInterval { get; set; } = 0.1f;
private double _checkTimer = 0;
public override void _Ready()
{
GD.Print("球运动管理器初始化完成");
}
/// <summary>
/// 注册球到管理器
/// </summary>
public void RegisterBall(BallPhysics ball)
{
if (!_activeBalls.Contains(ball))
{
_activeBalls.Add(ball);
ball.BallStopped += OnBallStopped;
ball.BallPocketed += OnBallPocketed;
}
}
public override void _Process(double delta)
{
_checkTimer += delta;
// 每隔一段时间检查一次
if (_checkTimer >= CheckInterval)
{
_checkTimer = 0;
CheckAllBallsStopped();
}
}
/// <summary>
/// 检查是否所有球都停了
/// </summary>
private void CheckAllBallsStopped()
{
bool anyMoving = false;
foreach (var ball in _activeBalls)
{
if (ball.IsPocketed) continue;
if (ball.IsBallMoving())
{
anyMoving = true;
break;
}
}
bool wasMoving = IsAnyBallMoving;
IsAnyBallMoving = anyMoving;
// 从"有球在动"变为"所有球都停了"
if (wasMoving && !IsAnyBallMoving)
{
GD.Print("所有球都已停止!");
EmitSignal(SignalName.AllBallsStopped);
}
}
/// <summary>
/// 单个球停止时的回调
/// </summary>
private void OnBallStopped()
{
// 单个球停止不触发事件,等所有球都停了再触发
}
/// <summary>
/// 球进袋时的回调
/// </summary>
private void OnBallPocketed(int ballNumber)
{
GD.Print($"球 #{ballNumber} 已从活动列表中移除");
}
/// <summary>
/// 强制停止所有球(用于犯规后重置)
/// </summary>
public void StopAllBalls()
{
foreach (var ball in _activeBalls)
{
if (ball.IsPocketed) continue;
ball.Sleeping = true;
ball.LinearVelocity = Vector3.Zero;
ball.AngularVelocity = Vector3.Zero;
}
IsAnyBallMoving = false;
GD.Print("所有球已强制停止");
}
}## 球运动管理器 - 追踪和检测所有球的运动状态
extends Node
## 所有球(不包括已进袋的)
var _active_balls: Array[BallPhysics] = []
## 是否有球在运动中
var is_any_ball_moving: bool = false
## 所有球停止时发出的事件
signal all_balls_stopped
## 速度阈值(低于此值认为球已停止)
@export var stop_threshold: float = 0.01
## 检查间隔(秒)
@export var check_interval: float = 0.1
var _check_timer: float = 0.0
func _ready() -> void:
print("球运动管理器初始化完成")
## 注册球到管理器
func register_ball(ball: BallPhysics) -> void:
if not ball in _active_balls:
_active_balls.append(ball)
ball.ball_stopped.connect(_on_ball_stopped)
ball.ball_pocketed.connect(_on_ball_pocketed)
func _process(delta: float) -> void:
_check_timer += delta
# 每隔一段时间检查一次
if _check_timer >= check_interval:
_check_timer = 0.0
_check_all_balls_stopped()
## 检查是否所有球都停了
func _check_all_balls_stopped() -> void:
var any_moving = false
for ball in _active_balls:
if ball.is_pocketed:
continue
if ball.is_ball_moving():
any_moving = true
break
var was_moving = is_any_ball_moving
is_any_ball_moving = any_moving
# 从"有球在动"变为"所有球都停了"
if was_moving and not is_any_ball_moving:
print("所有球都已停止!")
all_balls_stopped.emit()
## 单个球停止时的回调
func _on_ball_stopped() -> void:
# 单个球停止不触发事件,等所有球都停了再触发
pass
## 球进袋时的回调
func _on_ball_pocketed(ball_number: int) -> void:
print("球 #%d 已从活动列表中移除" % ball_number)
## 强制停止所有球(用于犯规后重置)
func stop_all_balls() -> void:
for ball in _active_balls:
if ball.is_pocketed:
continue
ball.sleeping = true
ball.linear_velocity = Vector3.ZERO
ball.angular_velocity = Vector3.ZERO
is_any_ball_moving = false
print("所有球已强制停止")4.5 回合轮次判定
当所有球停下来后,需要判定当前回合的结果,然后决定是继续打还是换人。
判定流程
所有球停下
↓
检查本轮进了哪些球
↓
┌─── 进了自己的球?──→ 是 ──→ 继续当前玩家回合
│
├─── 进了对方的球?──→ 是 ──→ 换人
│
├─── 母球进袋?────→ 是 ──→ 犯规,换人+自由球
│
├─── 什么都没进?────→ 换人
│
└─── 打进了8号球?──→ 检查是否合法 → 胜负判定回合状态机
桌球游戏的状态可以用一个简单的状态机来管理:
| 状态 | 说明 | 玩家可以做什么 |
|---|---|---|
AIMING | 瞄准中 | 移动鼠标瞄准 |
CHARGING_POWER | 蓄力中 | 按住鼠标调整力度 |
BALLS_MOVING | 球在运动 | 等待所有球停下 |
JUDGING | 判定回合结果 | 系统自动判定 |
PLACING_CUE_BALL | 放置母球 | 点击桌面放置母球(自由球) |
GAME_OVER | 游戏结束 | 显示结果,重新开始 |
什么是状态机?
状态机就像红绿灯——在某个时刻只能处于一个状态(红灯、黄灯或绿灯),状态之间按照规则切换。游戏状态机也是如此:在某个时刻游戏只能处于一个状态(瞄准、球在动、判定中等),每个状态下玩家的操作和系统的行为都不同。
4.6 碰撞调优技巧
常见碰撞问题
| 问题 | 原因 | 解决方法 |
|---|---|---|
| 球穿过另一颗球 | 物理步长太大 | 提高Solver Iterations |
| 碰撞后球速度异常 | 弹性系数设置不当 | 调整Bounce到0.9 |
| 球卡在边框里 | 碰撞形状不匹配 | 确保边框碰撞体无间隙 |
| 球"抖动"不停 | 速度阈值太小 | 提高StopThreshold |
| 进袋检测不准 | 球袋区域太小 | 适当增大球袋Area3D |
调试技巧
调试物理碰撞的三个方法
- 开启物理调试:在调试菜单中开启"碰撞形状"显示,可以看到每个物体的碰撞区域
- 打印碰撞日志:在碰撞回调中打印信息,了解碰撞发生的时机和对象
- 放慢物理速度:临时降低物理帧率,慢动作观察碰撞过程
4.7 小结
在本章中,我们实现了球的运动和碰撞系统:
- 球与球碰撞:通过物理引擎自动处理弹性碰撞,用信号检测碰撞事件
- 球与边框碰撞:边框用StaticBody3D,碰撞时播放音效
- 球进袋检测:球袋用Area3D检测球进入,触发进袋逻辑
- 球运动减速:通过阻尼和摩擦力让球自然停下来
- 回合判定:所有球停下后判定回合结果
- 状态机:管理游戏的不同状态
碰撞系统是桌球游戏的灵魂。当球在桌面上滚动、碰撞、反弹、进袋的时候,整个游戏就"活"了!
