6. 规则判定
2026/4/13大约 12 分钟
规则判定
6.1 美式8球规则回顾
在上一章中我们实现了瞄准和击球系统,现在需要加入游戏规则,让桌球从一个"物理模拟器"变成一个"完整的游戏"。
规则系统的作用
如果说物理引擎是桌球游戏的"身体"(让球能动),那么规则系统就是桌球游戏的"大脑"(决定该谁打、犯规没有、谁赢了)。没有规则系统,桌球就像一个没有裁判的比赛——虽然球能动、能碰、能进袋,但没有人告诉你这些意味着什么。
核心规则流程图
游戏开始
↓
开球(三角形排列)
↓
第一次有效进球 → 确定双方花色
↓
┌─── 正常回合循环 ───┐
│ 当前玩家瞄准击球 │
│ ↓ │
│ 所有球停下 │
│ ↓ │
│ 判定回合结果 │
│ ┌──┼──┐ │
│ 进球 没进 犯规 │
│ │ │ │ │
│ 继续 换人 换人+罚 │
│ └──┴──┘ │
└─────────┘
↓
打完7球 → 打8号球 → 胜负判定6.2 球的种类和颜色
在美式8球中,15颗目标球分为两组:
| 球号 | 颜色 | 组别 | 说明 |
|---|---|---|---|
| 1-7号 | 纯色球(Solid) | 纯色组 | 整颗球是一种颜色 |
| 8号 | 黑色 | 特殊球 | 最后打的目标 |
| 9-15号 | 花色球(Stripe) | 花色组 | 球中间有一条白色带 |
颜色定义表
| 球号 | 颜色名 | 色值 | 组别 |
|---|---|---|---|
| 1 | 黄色 | #FFD700 | 纯色 |
| 2 | 蓝色 | #0047AB | 纯色 |
| 3 | 红色 | #DC143C | 纯色 |
| 4 | 紫色 | #6B3FA0 | 纯色 |
| 5 | 橙色 | #FF6600 | 纯色 |
| 6 | 绿色 | #006B3C | 纯色 |
| 7 | 栗色 | #8B0000 | 纯色 |
| 8 | 黑色 | #000000 | 特殊 |
| 9 | 黄花 | #FFD700 + 白条 | 花色 |
| 10 | 蓝花 | #0047AB + 白条 | 花色 |
| 11 | 红花 | #DC143C + 白条 | 花色 |
| 12 | 紫花 | #6B3FA0 + 白条 | 花色 |
| 13 | 橙花 | #FF6600 + 白条 | 花色 |
| 14 | 绿花 | #006B3C + 白条 | 花色 |
| 15 | 栗花 | #8B0000 + 白条 | 花色 |
如何区分纯色和花色?
- 纯色球:整颗球都是同一种颜色(除了一个白色小圆圈里写着球号)
- 花色球:球的上半部分和下半部分是白色,中间一条宽带是彩色(就像穿了一件彩色马甲)
在我们的3D游戏中,可以通过不同的材质来区分——纯色球用纯色材质,花色球用"上下白色+中间彩色"的材质。
6.3 开球——三角形排列
游戏开始时,15颗目标球需要摆成三角形。8号球必须放在三角形的中心(第三排中间)。
三角形排列规则
第1排: ● (1颗球)
第2排: ● ● (2颗球)
第3排: ● ⑧ ● (3颗球,8号在中间)
第4排: ● ● ● ● (4颗球)
第5排: ● ● ● ● ● (5颗球)开球排列的其他规则
- 8号球必须在三角形中心
- 三角形最前面(第1排)的那颗球可以随机
- 两个底角(第5排两端)的球必须一个是纯色,一个是花色
开球位置计算
三角形排列的顶点在球桌的三分之二处(从开球方算起),三角形的底边朝向开球方。
| 参数 | 值 | 说明 |
|---|---|---|
| 三角形顶点X | 0 | 球桌中心线上 |
| 三角形顶点Z | 0.51 | 球桌远端 |
| 球间距 | 0.057 | 球直径(2 x 半径) |
6.4 玩家数据模型
我们需要一个数据结构来存储每位玩家的信息。
玩家信息
| 属性 | 类型 | 说明 |
|---|---|---|
| 玩家编号 | int | 1或2 |
| 玩家名称 | string | 显示名称 |
| 花色分配 | enum | 纯色/花色/未分配 |
| 已进球列表 | int[] | 已打进的目标球号 |
| 剩余球数量 | int | 还需要打进几颗球 |
6.5 规则管理器
规则管理器是整个游戏规则的"裁判",负责判定每一回合的结果。
C
using Godot;
using System;
using System.Collections.Generic;
/// <summary>
/// 游戏状态枚举
/// </summary>
public enum GameState
{
WaitingToStart, // 等待开始
Breaking, // 开球
Playing, // 正常游戏
PlacingCueBall, // 放置母球(自由球)
GameOver // 游戏结束
}
/// <summary>
/// 花色类型枚举
/// </summary>
public enum BallType
{
Unassigned, // 未分配
Solid, // 纯色球(1-7号)
Stripe, // 花色球(9-15号)
EightBall // 8号球
}
/// <summary>
/// 回合结果枚举
/// </summary>
public enum TurnResult
{
Continue, // 继续当前玩家回合
SwitchPlayer, // 换人
Foul, // 犯规(换人+自由球)
Win, // 当前玩家获胜
Lose // 当前玩家失败
}
/// <summary>
/// 玩家数据
/// </summary>
public class PlayerData
{
public int PlayerId { get; set; }
public string PlayerName { get; set; }
public BallType AssignedType { get; set; } = BallType.Unassigned;
public List<int> PocketedBalls { get; set; } = new();
public bool CanShootEight { get; set; } = false;
public int RemainingBalls
{
get
{
if (AssignedType == BallType.Unassigned) return 7;
return 7 - PocketedBalls.Count;
}
}
}
/// <summary>
/// 规则管理器 - 桌球游戏的核心规则引擎
/// </summary>
public partial class RuleManager : Node
{
/// <summary>当前游戏状态</summary>
public GameState CurrentState { get; private set; } = GameState.WaitingToStart;
/// <summary>当前玩家编号</summary>
public int CurrentPlayerId { get; private set; } = 1;
/// <summary>两位玩家的数据</summary>
public PlayerData Player1 { get; private set; } = new() { PlayerId = 1, PlayerName = "玩家1" };
public PlayerData Player2 { get; private set; } = new() { PlayerId = 2, PlayerName = "玩家2" };
/// <summary>花色是否已分配</summary>
public bool IsTypeAssigned { get; private set; } = false;
/// <summary>回合结果事件</summary>
[Signal] public delegate void TurnResultEventHandler(TurnResult result, string message);
/// <summary>游戏结束事件</summary>
[Signal] public delegate void GameOverEventHandler(int winnerId, string message);
/// <summary>回合切换事件</summary>
[Signal] public delegate void TurnChangedEventHandler(int newPlayerId);
/// <summary>状态变化事件</summary>
[Signal] public delegate void StateChangedEventHandler(GameState newState);
/// <summary>本轮进袋的球列表</summary>
private List<int> _turnPocketedBalls = new();
/// <summary>本轮是否犯规</summary>
private bool _turnFoul = false;
/// <summary>本轮母球是否先碰到了合法球</summary>
private bool _cueBallHitValidBall = false;
/// <summary>本轮母球是否进袋</summary>
private bool _cueBallPocketed = false;
public override void _Ready()
{
GD.Print("规则管理器初始化完成");
}
/// <summary>
/// 开始新游戏
/// </summary>
public void StartGame()
{
// 重置所有数据
Player1 = new() { PlayerId = 1, PlayerName = "玩家1" };
Player2 = new() { PlayerId = 2, PlayerName = "玩家2" };
CurrentPlayerId = 1;
IsTypeAssigned = false;
CurrentState = GameState.Breaking;
GD.Print("新游戏开始!玩家1开球");
EmitSignal(SignalName.StateChanged, GameState.Breaking);
EmitSignal(SignalName.TurnChanged, 1);
}
/// <summary>
/// 记录球进袋(在球运动过程中调用)
/// </summary>
public void RecordPocketedBall(int ballNumber)
{
if (ballNumber == 0)
{
// 母球进袋 = 犯规
_cueBallPocketed = true;
GD.Print("犯规!母球进袋!");
return;
}
_turnPocketedBalls.Add(ballNumber);
GD.Print($"球 #{ballNumber} 进袋(本轮记录)");
}
/// <summary>
/// 记录母球碰到的第一颗球
/// </summary>
public void RecordFirstContact(int ballNumber)
{
if (_cueBallHitValidBall) return; // 已经记录过了
var currentPlayer = GetCurrentPlayer();
if (currentPlayer.AssignedType == BallType.Unassigned)
{
// 未分色时,碰到任何目标球都算合法
_cueBallHitValidBall = true;
}
else if (currentPlayer.CanShootEight)
{
// 该打8号球了,必须先碰到8号球
_cueBallHitValidBall = (ballNumber == 8);
}
else
{
// 正常情况,必须先碰到自己花色的球
bool isMyBall = (currentPlayer.AssignedType == BallType.Solid && ballNumber >= 1 && ballNumber <= 7)
|| (currentPlayer.AssignedType == BallType.Stripe && ballNumber >= 9 && ballNumber <= 15);
_cueBallHitValidBall = isMyBall;
}
if (!_cueBallHitValidBall)
{
GD.Print($"犯规!母球先碰到了不合法的球 #{ballNumber}");
}
}
/// <summary>
/// 判定回合结果(所有球停下后调用)
/// </summary>
public TurnResult JudgeTurn()
{
var currentPlayer = GetCurrentPlayer();
var otherPlayer = GetOtherPlayer();
// 检查犯规
_turnFoul = false;
string foulReason = "";
if (_cueBallPocketed)
{
_turnFoul = true;
foulReason = "母球进袋";
}
else if (!_cueBallHitValidBall && CurrentState != GameState.Breaking)
{
_turnFoul = true;
foulReason = "未碰到合法的目标球";
}
// 检查是否打进了8号球
if (_turnPocketedBalls.Contains(8))
{
if (currentPlayer.CanShootEight && !_turnFoul)
{
// 合法打进8号球 → 赢了!
GD.Print($"玩家{currentPlayer.PlayerId} 获胜!合法打进8号球!");
CurrentState = GameState.GameOver;
EmitSignal(SignalName.StateChanged, GameState.GameOver);
EmitSignal(SignalName.GameOver, currentPlayer.PlayerId, "合法打进8号球");
return TurnResult.Win;
}
else
{
// 非法打进8号球 → 输了!
GD.Print($"玩家{currentPlayer.PlayerId} 失败!非法打进8号球!");
CurrentState = GameState.GameOver;
EmitSignal(SignalName.StateChanged, GameState.GameOver);
EmitSignal(SignalName.GameOver, otherPlayer.PlayerId, "对手非法打进8号球");
return TurnResult.Lose;
}
}
// 处理犯规
if (_turnFoul)
{
GD.Print($"犯规!原因: {foulReason}");
SwitchPlayer();
CurrentState = GameState.PlacingCueBall;
EmitSignal(SignalName.StateChanged, GameState.PlacingCueBall);
EmitSignal(SignalName.TurnResult, TurnResult.Foul, $"犯规: {foulReason},对手获得自由球");
_ResetTurnData();
return TurnResult.Foul;
}
// 处理进球
bool pocketedOwnBall = false;
foreach (int ballNum in _turnPocketedBalls)
{
if (!IsTypeAssigned)
{
// 第一次有效进球,分配花色
AssignBallTypes(ballNum);
IsTypeAssigned = true;
}
// 记录进球到对应玩家
var ballType = GetBallType(ballNum);
if (ballType == BallType.Solid || ballType == BallType.Stripe)
{
if (currentPlayer.AssignedType == ballType)
{
currentPlayer.PocketedBalls.Add(ballNum);
pocketedOwnBall = true;
GD.Print($"玩家{currentPlayer.PlayerId} 打进了自己的球 #{ballNum}");
// 检查是否打完了所有球
if (currentPlayer.RemainingBalls == 0)
{
currentPlayer.CanShootEight = true;
GD.Print($"玩家{currentPlayer.PlayerId} 打完了所有球,可以打8号球了!");
}
}
else
{
otherPlayer.PocketedBalls.Add(ballNum);
GD.Print($"玩家{currentPlayer.PlayerId} 打进了对方的球 #{ballNum}");
}
}
}
// 从开球状态切换到正常游戏
if (CurrentState == GameState.Breaking)
{
CurrentState = GameState.Playing;
EmitSignal(SignalName.StateChanged, GameState.Playing);
}
// 判定回合结果
if (pocketedOwnBall)
{
GD.Print($"玩家{currentPlayer.PlayerId} 继续回合");
EmitSignal(SignalName.TurnResult, TurnResult.Continue, "打进了自己的球,继续!");
_ResetTurnData();
return TurnResult.Continue;
}
else
{
GD.Print($"换人");
SwitchPlayer();
EmitSignal(SignalName.TurnResult, TurnResult.SwitchPlayer, $"换玩家{CurrentPlayerId}");
_ResetTurnData();
return TurnResult.SwitchPlayer;
}
}
/// <summary>
/// 分配花色
/// </summary>
private void AssignBallTypes(int firstPocketedBall)
{
var type = GetBallType(firstPocketedBall);
if (type == BallType.Solid)
{
GetCurrentPlayer().AssignedType = BallType.Solid;
GetOtherPlayer().AssignedType = BallType.Stripe;
}
else if (type == BallType.Stripe)
{
GetCurrentPlayer().AssignedType = BallType.Stripe;
GetOtherPlayer().AssignedType = BallType.Solid;
}
else
{
// 如果第一颗进的是8号球(不太可能但需要处理)
GetCurrentPlayer().AssignedType = BallType.Solid;
GetOtherPlayer().AssignedType = BallType.Stripe;
}
GD.Print($"花色分配: 玩家1={Player1.AssignedType}, 玩家2={Player2.AssignedType}");
}
/// <summary>
/// 获取球的花色类型
/// </summary>
private BallType GetBallType(int ballNumber)
{
return ballNumber switch
{
0 => BallType.Unassigned,
>= 1 and <= 7 => BallType.Solid,
8 => BallType.EightBall,
>= 9 and <= 15 => BallType.Stripe,
_ => BallType.Unassigned
};
}
/// <summary>
/// 切换玩家
/// </summary>
private void SwitchPlayer()
{
CurrentPlayerId = (CurrentPlayerId == 1) ? 2 : 1;
GD.Print($"切换到玩家{CurrentPlayerId}");
EmitSignal(SignalName.TurnChanged, CurrentPlayerId);
}
/// <summary>
/// 获取当前玩家
/// </summary>
public PlayerData GetCurrentPlayer()
{
return CurrentPlayerId == 1 ? Player1 : Player2;
}
/// <summary>
/// 获取另一位玩家
/// </summary>
public PlayerData GetOtherPlayer()
{
return CurrentPlayerId == 1 ? Player2 : Player1;
}
/// <summary>
/// 重置回合数据
/// </summary>
private void _ResetTurnData()
{
_turnPocketedBalls.Clear();
_turnFoul = false;
_cueBallHitValidBall = false;
_cueBallPocketed = false;
}
/// <summary>
/// 放置母球完成(自由球放置后调用)
/// </summary>
public void OnCueBallPlaced()
{
if (CurrentState == GameState.PlacingCueBall)
{
CurrentState = GameState.Playing;
EmitSignal(SignalName.StateChanged, GameState.Playing);
GD.Print("母球放置完成,继续游戏");
}
}
}GDScript
## 规则管理器 - 桌球游戏的核心规则引擎
extends Node
## 游戏状态枚举
enum GameState { WAITING_TO_START, BREAKING, PLAYING, PLACING_CUE_BALL, GAME_OVER }
## 花色类型枚举
enum BallType { UNASSIGNED, SOLID, STRIPE, EIGHT_BALL }
## 回合结果枚举
enum TurnResult { CONTINUE, SWITCH_PLAYER, FOUL, WIN, LOSE }
## 当前游戏状态
var current_state: int = GameState.WAITING_TO_START
## 当前玩家编号
var current_player_id: int = 1
## 玩家数据字典
var players: Dictionary = {
1: { "name": "玩家1", "type": BallType.UNASSIGNED, "pocketed": [], "can_shoot_eight": false },
2: { "name": "玩家2", "type": BallType.UNASSIGNED, "pocketed": [], "can_shoot_eight": false }
}
## 花色是否已分配
var is_type_assigned: bool = false
## 回合结果事件
signal turn_result(result: int, message: String)
## 游戏结束事件
signal game_over(winner_id: int, message: String)
## 回合切换事件
signal turn_changed(new_player_id: int)
## 状态变化事件
signal state_changed(new_state: int)
## 本轮进袋的球列表
var _turn_pocketed: Array[int] = []
## 本轮是否犯规
var _turn_foul: bool = false
## 本轮母球是否先碰到合法球
var _cue_hit_valid: bool = false
## 本轮母球是否进袋
var _cue_pocketed: bool = false
func _ready() -> void:
print("规则管理器初始化完成")
## 开始新游戏
func start_game() -> void:
players = {
1: { "name": "玩家1", "type": BallType.UNASSIGNED, "pocketed": [], "can_shoot_eight": false },
2: { "name": "玩家2", "type": BallType.UNASSIGNED, "pocketed": [], "can_shoot_eight": false }
}
current_player_id = 1
is_type_assigned = false
current_state = GameState.BREAKING
print("新游戏开始!玩家1开球")
state_changed.emit(GameState.BREAKING)
turn_changed.emit(1)
## 记录球进袋
func record_pocketed_ball(ball_number: int) -> void:
if ball_number == 0:
_cue_pocketed = true
print("犯规!母球进袋!")
return
_turn_pocketed.append(ball_number)
print("球 #%d 进袋(本轮记录)" % ball_number)
## 记录母球碰到的第一颗球
func record_first_contact(ball_number: int) -> void:
if _cue_hit_valid:
return
var current = _get_current_player()
if current.type == BallType.UNASSIGNED:
_cue_hit_valid = true
elif current.can_shoot_eight:
_cue_hit_valid = (ball_number == 8)
else:
var is_my_ball = (current.type == BallType.SOLID and ball_number >= 1 and ball_number <= 7) or \
(current.type == BallType.STRIPE and ball_number >= 9 and ball_number <= 15)
_cue_hit_valid = is_my_ball
if not _cue_hit_valid:
print("犯规!母球先碰到了不合法的球 #%d" % ball_number)
## 判定回合结果
func judge_turn() -> int:
var current = _get_current_player()
var other = _get_other_player()
# 检查犯规
_turn_foul = false
var foul_reason = ""
if _cue_pocketed:
_turn_foul = true
foul_reason = "母球进袋"
elif not _cue_hit_valid and current_state != GameState.BREAKING:
_turn_foul = true
foul_reason = "未碰到合法的目标球"
# 检查8号球
if 8 in _turn_pocketed:
if current.can_shoot_eight and not _turn_foul:
print("玩家%d 获胜!合法打进8号球!" % current_player_id)
current_state = GameState.GAME_OVER
state_changed.emit(GameState.GAME_OVER)
game_over.emit(current_player_id, "合法打进8号球")
_reset_turn_data()
return TurnResult.WIN
else:
print("玩家%d 失败!非法打进8号球!" % current_player_id)
current_state = GameState.GAME_OVER
state_changed.emit(GameState.GAME_OVER)
game_over.emit(other.player_id if other else 2, "对手非法打进8号球")
_reset_turn_data()
return TurnResult.LOSE
# 处理犯规
if _turn_foul:
print("犯规!原因: %s" % foul_reason)
_switch_player()
current_state = GameState.PLACING_CUE_BALL
state_changed.emit(GameState.PLACING_CUE_BALL)
turn_result.emit(TurnResult.FOUL, "犯规: %s,对手获得自由球" % foul_reason)
_reset_turn_data()
return TurnResult.FOUL
# 处理进球
var pocketed_own = false
for ball_num in _turn_pocketed:
if not is_type_assigned:
_assign_ball_types(ball_num)
is_type_assigned = true
var btype = _get_ball_type(ball_num)
if btype == BallType.SOLID or btype == BallType.STRIPE:
if current.type == btype:
current.pocketed.append(ball_num)
pocketed_own = true
if current.pocketed.size() == 7:
current.can_shoot_eight = true
else:
other.pocketed.append(ball_num)
if current_state == GameState.BREAKING:
current_state = GameState.PLAYING
state_changed.emit(GameState.PLAYING)
if pocketed_own:
print("玩家%d 继续回合" % current_player_id)
turn_result.emit(TurnResult.CONTINUE, "打进了自己的球,继续!")
_reset_turn_data()
return TurnResult.CONTINUE
else:
_switch_player()
turn_result.emit(TurnResult.SWITCH_PLAYER, "换玩家%d" % current_player_id)
_reset_turn_data()
return TurnResult.SWITCH_PLAYER
## 分配花色
func _assign_ball_types(first_ball: int) -> void:
var btype = _get_ball_type(first_ball)
if btype == BallType.SOLID:
_get_current_player().type = BallType.SOLID
_get_other_player().type = BallType.STRIPE
elif btype == BallType.STRIPE:
_get_current_player().type = BallType.STRIPE
_get_other_player().type = BallType.SOLID
else:
_get_current_player().type = BallType.SOLID
_get_other_player().type = BallType.STRIPE
print("花色分配完成")
## 获取球类型
func _get_ball_type(ball_number: int) -> int:
if ball_number >= 1 and ball_number <= 7:
return BallType.SOLID
elif ball_number == 8:
return BallType.EIGHT_BALL
elif ball_number >= 9 and ball_number <= 15:
return BallType.STRIPE
return BallType.UNASSIGNED
## 切换玩家
func _switch_player() -> void:
current_player_id = 2 if current_player_id == 1 else 1
print("切换到玩家%d" % current_player_id)
turn_changed.emit(current_player_id)
## 获取当前玩家数据
func _get_current_player() -> Dictionary:
return players[current_player_id]
## 获取另一位玩家数据
func _get_other_player() -> Dictionary:
var other_id = 2 if current_player_id == 1 else 1
return players[other_id]
## 重置回合数据
func _reset_turn_data() -> void:
_turn_pocketed.clear()
_turn_foul = false
_cue_hit_valid = false
_cue_pocketed = false
## 放置母球完成
func on_cue_ball_placed() -> void:
if current_state == GameState.PLACING_CUE_BALL:
current_state = GameState.PLAYING
state_changed.emit(GameState.PLAYING)
print("母球放置完成,继续游戏")6.6 自由球放置
犯规后,对手获得自由球,可以把母球放在球桌上的任意位置。
放置规则
| 规则 | 说明 |
|---|---|
| 放置范围 | 球桌内部任意位置 |
| 不能重叠 | 母球不能和其他球重叠 |
| 不能出界 | 母球必须在球桌范围内 |
| 确认放置 | 点击桌面确认位置 |
自由球是很大的优势
获得自由球的玩家可以把母球放在最有利的位置,比如正对目标球和球袋的直线上。所以一定要避免犯规!
6.7 犯规判定汇总
| 犯规类型 | 检测方式 | 处罚 |
|---|---|---|
| 母球进袋 | 球袋Area3D检测到母球 | 对方自由球 |
| 未碰到球 | 碰撞记录为空 | 对方自由球 |
| 先碰错球 | 第一次碰撞对象不对 | 对方自由球 |
| 球飞出桌外 | 球位置超出球桌边界 | 对方自由球 |
6.8 胜负判定
| 条件 | 结果 | 说明 |
|---|---|---|
| 打完7球后合法打进8号球 | 获胜 | 正常获胜 |
| 未打完7球就打进8号球 | 判负 | 提前打进8号球 |
| 犯规时打进8号球 | 判负 | 犯规+8号球 |
| 8号球飞出球桌 | 判负 | 8号球出界 |
6.9 小结
在本章中,我们实现了完整的游戏规则系统:
- 球种类定义:纯色球、花色球、8号球的分类
- 开球排列:三角形排列的计算和规则
- 玩家数据:存储每位玩家的花色、进球列表等
- 规则管理器:判定回合结果、犯规、胜负
- 自由球:犯规后的母球放置逻辑
- 犯规检测:母球进袋、未碰球、先碰错球
规则系统让桌球从一个"物理模拟器"变成了一个"完整的游戏"。有了规则,玩家才能知道该谁打、打了之后会发生什么、什么时候赢。
