8. 打磨与发布
2026/4/13大约 16 分钟
打磨与发布
8.1 UI美化
游戏的核心功能做完后,UI(用户界面)是提升游戏体验的关键一步。好的UI能让玩家一眼就知道当前的游戏状态,不需要猜测。
生活化比喻
UI就像商店的招牌和指示牌——如果一家商店没有招牌、没有价格标签、没有收银台标识,顾客进去后会一头雾水。游戏也是一样,如果没有清晰的UI,玩家就不知道"该谁打了""还剩几颗球""谁赢了"。
需要的UI元素
| UI元素 | 位置 | 说明 |
|---|---|---|
| 计分板 | 屏幕上方 | 显示两位玩家的进球和剩余球 |
| 回合指示器 | 屏幕上方中间 | 显示当前轮到谁打 |
| 力度条 | 屏幕右下角 | 显示击球力度 |
| 球列表 | 屏幕两侧 | 显示已进和未进的球 |
| 消息提示 | 屏幕中央 | 显示"犯规""换人""获胜"等 |
| 操作提示 | 屏幕下方 | 显示当前可以做什么 |
计分板设计
计分板应该清晰展示两位玩家的信息:
┌──────────────────────────────────────┐
│ [玩家1 ●●●○○○○] ← 回合 → [○○○○○●● 玩家2] │
│ 剩余: 4颗 剩余: 3颗 │
└──────────────────────────────────────┘消息提示系统
消息提示是在屏幕中央短暂显示的文字,告诉玩家发生了什么。
| 消息类型 | 显示时间 | 颜色 |
|---|---|---|
| 回合切换 | 2秒 | 白色 |
| 犯规 | 3秒 | 红色 |
| 进球 | 1.5秒 | 黄色 |
| 胜利 | 持续 | 金色 |
| 操作提示 | 3秒 | 灰色 |
C
using Godot;
/// <summary>
/// 消息提示系统 - 在屏幕中央显示游戏消息
/// </summary>
public partial class MessageSystem : Control
{
/// <summary>消息标签</summary>
[Export] public Label MessageLabel { get; set; }
/// <summary>消息面板(背景)</summary>
[Export] public PanelContainer MessagePanel { get; set; }
/// <summary>默认显示时间</summary>
[Export] public float DefaultDuration { get; set; } = 2.0f;
/// <summary>动画播放器</summary>
private AnimationPlayer _animPlayer;
/// <summary>消息队列</summary>
private System.Collections.Generic.Queue<(string text, float duration, Color color)> _messageQueue = new();
/// <summary>是否正在显示消息</summary>
private bool _isShowing = false;
public override void _Ready()
{
// 创建动画播放器
_animPlayer = new AnimationPlayer();
_animPlayer.Name = "MessageAnimPlayer";
AddChild(_animPlayer);
// 创建淡入淡出动画
_CreateAnimations();
// 初始隐藏
Visible = false;
}
/// <summary>
/// 显示消息
/// </summary>
public void ShowMessage(string text, float duration = -1, string colorName = "white")
{
var color = colorName switch
{
"red" => Colors.Red,
"yellow" => Colors.Yellow,
"gold" => Colors.Gold,
"green" => Colors.Green,
"gray" => Colors.Gray,
_ => Colors.White
};
ShowMessage(text, duration < 0 ? DefaultDuration : duration, color);
}
/// <summary>
/// 显示消息(带颜色)
/// </summary>
public void ShowMessage(string text, float duration, Color color)
{
_messageQueue.Enqueue((text, duration, color));
if (!_isShowing)
{
_ShowNextMessage();
}
}
/// <summary>
/// 显示队列中的下一条消息
/// </summary>
private void _ShowNextMessage()
{
if (_messageQueue.Count == 0)
{
_isShowing = false;
Visible = false;
return;
}
_isShowing = true;
var (text, duration, color) = _messageQueue.Dequeue();
// 设置消息内容
if (MessageLabel != null)
{
MessageLabel.Text = text;
MessageLabel.AddThemeColorOverride("font_color", color);
}
// 播放淡入动画
Visible = true;
_animPlayer.Play("fade_in");
// 延迟后淡出
var timer = GetTree().CreateTimer(duration);
timer.Timeout += () =>
{
_animPlayer.Play("fade_out");
var waitTimer = GetTree().CreateTimer(0.3);
waitTimer.Timeout += _ShowNextMessage;
};
}
/// <summary>
/// 创建动画
/// </summary>
private void _CreateAnimations()
{
// 淡入动画
var fadeIn = new Animation();
fadeIn.Name = "fade_in";
fadeIn.Length = 0.3;
fadeIn.AddTrack(Animation.TrackType.Value);
fadeIn.TrackSetPath(0, NodePath(".")); // Modulate
fadeIn.TrackInsertKey(0, 0.0, new Color(1, 1, 1, 0));
fadeIn.TrackInsertKey(0, 0.3, new Color(1, 1, 1, 1));
_animPlayer.AddAnimation("fade_in", fadeIn);
// 淡出动画
var fadeOut = new Animation();
fadeOut.Name = "fade_out";
fadeOut.Length = 0.3;
fadeOut.AddTrack(Animation.TrackType.Value);
fadeOut.TrackSetPath(0, NodePath("."));
fadeOut.TrackInsertKey(0, 0.0, new Color(1, 1, 1, 1));
fadeOut.TrackInsertKey(0, 0.3, new Color(1, 1, 1, 0));
_animPlayer.AddAnimation("fade_out", fadeOut);
}
/// <summary>
/// 显示持续消息(直到手动关闭)
/// </summary>
public void ShowPersistentMessage(string text, Color color)
{
// 清空队列
_messageQueue.Clear();
if (MessageLabel != null)
{
MessageLabel.Text = text;
MessageLabel.AddThemeColorOverride("font_color", color);
}
Visible = true;
Modulate = new Color(1, 1, 1, 1);
}
/// <summary>
/// 隐藏消息
/// </summary>
public void HideMessage()
{
_messageQueue.Clear();
_isShowing = false;
Visible = false;
}
}GDScript
## 消息提示系统 - 在屏幕中央显示游戏消息
extends Control
## 消息标签
@export var message_label: Label
## 消息面板
@export var message_panel: PanelContainer
## 默认显示时间
@export var default_duration: float = 2.0
## 动画播放器
var _anim_player: AnimationPlayer
## 消息队列
var _message_queue: Array = []
## 是否正在显示消息
var _is_showing: bool = false
func _ready() -> void:
# 创建动画播放器
_anim_player = AnimationPlayer.new()
_anim_player.name = "MessageAnimPlayer"
add_child(_anim_player)
_create_animations()
visible = false
## 显示消息(字符串颜色名)
func show_message(text: String, duration: float = -1.0, color_name: String = "white") -> void:
var color = match color_name:
"red": Color.RED
"yellow": Color.YELLOW
"gold": Color.GOLD
"green": Color.GREEN
"gray": Color.GRAY
_: Color.WHITE
show_message_color(text, duration if duration >= 0 else default_duration, color)
## 显示消息(带颜色)
func show_message_color(text: String, duration: float, color: Color) -> void:
_message_queue.append({"text": text, "duration": duration, "color": color})
if not _is_showing:
_show_next_message()
## 显示下一条消息
func _show_next_message() -> void:
if _message_queue.is_empty():
_is_showing = false
visible = false
return
_is_showing = true
var msg = _message_queue.pop_front()
if message_label:
message_label.text = msg.text
message_label.add_theme_color_override("font_color", msg.color)
visible = true
var timer = get_tree().create_timer(msg.duration)
timer.timeout.connect(func():
visible = false
var wait = get_tree().create_timer(0.1)
wait.timeout.connect(_show_next_message)
)
## 显示持续消息
func show_persistent_message(text: String, color: Color) -> void:
_message_queue.clear()
if message_label:
message_label.text = text
message_label.add_theme_color_override("font_color", color)
visible = true
modulate = Color(1, 1, 1, 1)
## 隐藏消息
func hide_message() -> void:
_message_queue.clear()
_is_showing = false
visible = false
## 创建动画
func _create_animations() -> void:
var fade_in = Animation.new()
fade_in.name = "fade_in"
fade_in.length = 0.3
_anim_player.add_animation("fade_in", fade_in)
var fade_out = Animation.new()
fade_out.name = "fade_out"
fade_out.length = 0.3
_anim_player.add_animation("fade_out", fade_out)8.2 音效系统
音效是游戏"感觉好不好"的重要组成部分。好的音效能让击球、碰撞、进球等动作变得更有打击感。
需要的音效
| 音效 | 触发时机 | 特点 |
|---|---|---|
| 击球声 | 球杆击打母球 | 清脆、有力 |
| 球碰球声 | 两颗球碰撞 | 清脆、短促 |
| 球碰边框声 | 球撞到边框 | 低沉、闷响 |
| 进袋声 | 球滚入球袋 | "咚"的一声 |
| 犯规提示音 | 犯规时 | 低沉警告声 |
| 胜利音效 | 获胜时 | 欢快的音乐 |
| 失败音效 | 失败时 | 低落的音效 |
音效资源获取
你可以在以下网站找到免费的音效资源:
- Freesound.org - 大量免费音效
- Kenney.nl - 游戏开发免费资源
- Mixkit.co - 免费音效和音乐
搜索关键词:pool cue hit、billiard collision、ball pocket 等。
音效管理器
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 音效管理器 - 统一管理游戏中的所有音效
/// </summary>
public partial class AudioManager : Node
{
/// <summary>音效字典:音效名称 → AudioStream</summary>
[Export] public Dictionary<string, AudioStream> SoundEffects { get; set; } = new();
/// <summary>背景音乐</summary>
[Export] public AudioStream BackgroundMusic { get; set; }
/// <summary>主音量(0-1)</summary>
[Export] public float MasterVolume { get; set; } = 0.8f;
/// <summary>音效音量(0-1)</summary>
[Export] public float SfxVolume { get; set; } = 1.0f;
/// <summary>背景音乐音量(0-1)</summary>
[Export] public float MusicVolume { get; set; } = 0.3f;
/// <summary>音效播放器池</summary>
private List<AudioStreamPlayer> _sfxPlayers = new();
private const int MaxSfxPlayers = 8;
/// <summary>背景音乐播放器</summary>
private AudioStreamPlayer _musicPlayer;
public override void _Ready()
{
// 创建音效播放器池
for (int i = 0; i < MaxSfxPlayers; i++)
{
var player = new AudioStreamPlayer();
player.Name = $"SfxPlayer_{i}";
AddChild(player);
_sfxPlayers.Add(player);
}
// 创建背景音乐播放器
_musicPlayer = new AudioStreamPlayer();
_musicPlayer.Name = "MusicPlayer";
AddChild(_musicPlayer);
// 播放背景音乐
if (BackgroundMusic != null)
{
_musicPlayer.Stream = BackgroundMusic;
_musicPlayer.VolumeDb = Mathf.LinearToDb(MusicVolume * MasterVolume);
_musicPlayer.Play();
}
GD.Print($"音效管理器初始化完成,加载了 {SoundEffects.Count} 个音效");
}
/// <summary>
/// 播放音效
/// </summary>
/// <param name="soundName">音效名称</param>
/// <param name="volumeScale">音量缩放(0-1)</param>
/// <param name="pitchScale">音调缩放</param>
public void PlaySfx(string soundName, float volumeScale = 1.0f, float pitchScale = 1.0f)
{
if (!SoundEffects.ContainsKey(soundName))
{
GD.PrintWarn($"音效 '{soundName}' 未找到!");
return;
}
// 找一个空闲的播放器
AudioStreamPlayer player = null;
foreach (var p in _sfxPlayers)
{
if (!p.Playing)
{
player = p;
break;
}
}
// 如果没有空闲的,用第一个(打断之前的音效)
if (player == null)
{
player = _sfxPlayers[0];
}
player.Stream = SoundEffects[soundName];
player.VolumeDb = Mathf.LinearToDb(SfxVolume * MasterVolume * volumeScale);
player.PitchScale = pitchScale;
player.Play();
}
/// <summary>
/// 播放击球音效(根据力度调整音量和音调)
/// </summary>
public void PlayCueHit(float power)
{
float volumeScale = 0.3f + power * 0.7f;
float pitchScale = 0.8f + power * 0.4f;
PlaySfx("cue_hit", volumeScale, pitchScale);
}
/// <summary>
/// 播放球碰球音效(根据碰撞力度调整)
/// </summary>
public void PlayBallCollision(float impactForce)
{
float volumeScale = Mathf.Clamp(impactForce / 5.0f, 0.1f, 1.0f);
float pitchScale = 0.9f + GD.Randf() * 0.2f; // 随机微调音调
PlaySfx("ball_collision", volumeScale, pitchScale);
}
/// <summary>
/// 播放进袋音效
/// </summary>
public void PlayPocketSound()
{
PlaySfx("ball_pocket", 1.0f, 1.0f);
}
/// <summary>
/// 播放犯规音效
/// </summary>
public void PlayFoulSound()
{
PlaySfx("foul", 0.8f, 0.8f);
}
/// <summary>
/// 设置主音量
/// </summary>
public void SetMasterVolume(float volume)
{
MasterVolume = Mathf.Clamp(volume, 0.0f, 1.0f);
if (_musicPlayer.Playing)
{
_musicPlayer.VolumeDb = Mathf.LinearToDb(MusicVolume * MasterVolume);
}
}
/// <summary>
/// 暂停/恢复背景音乐
/// </summary>
public void ToggleMusic()
{
if (_musicPlayer.Playing)
{
_musicPlayer.StreamPaused = true;
}
else
{
_musicPlayer.StreamPaused = false;
}
}
}GDScript
## 音效管理器 - 统一管理游戏中的所有音效
extends Node
## 音效字典
@export var sound_effects: Dictionary = {}
## 背景音乐
@export var background_music: AudioStream
## 主音量
@export var master_volume: float = 0.8
## 音效音量
@export var sfx_volume: float = 1.0
## 背景音乐音量
@export var music_volume: float = 0.3
## 音效播放器池
var _sfx_players: Array[AudioStreamPlayer] = []
const MAX_SFX_PLAYERS: int = 8
## 背景音乐播放器
var _music_player: AudioStreamPlayer
func _ready() -> void:
# 创建音效播放器池
for i in range(MAX_SFX_PLAYERS):
var player = AudioStreamPlayer.new()
player.name = "SfxPlayer_%d" % i
add_child(player)
_sfx_players.append(player)
# 创建背景音乐播放器
_music_player = AudioStreamPlayer.new()
_music_player.name = "MusicPlayer"
add_child(_music_player)
# 播放背景音乐
if background_music:
_music_player.stream = background_music
_music_player.volume_db = linear_to_db(music_volume * master_volume)
_music_player.play()
print("音效管理器初始化完成,加载了 %d 个音效" % sound_effects.size())
## 播放音效
func play_sfx(sound_name: String, volume_scale: float = 1.0, pitch_scale: float = 1.0) -> void:
if not sound_name in sound_effects:
push_warning("音效 '%s' 未找到!" % sound_name)
return
# 找空闲播放器
var player: AudioStreamPlayer = null
for p in _sfx_players:
if not p.playing:
player = p
break
if not player:
player = _sfx_players[0]
player.stream = sound_effects[sound_name]
player.volume_db = linear_to_db(sfx_volume * master_volume * volume_scale)
player.pitch_scale = pitch_scale
player.play()
## 播放击球音效
func play_cue_hit(power: float) -> void:
var vol = 0.3 + power * 0.7
var pitch = 0.8 + power * 0.4
play_sfx("cue_hit", vol, pitch)
## 播放球碰球音效
func play_ball_collision(impact_force: float) -> void:
var vol = clampf(impact_force / 5.0, 0.1, 1.0)
var pitch = 0.9 + randf() * 0.2
play_sfx("ball_collision", vol, pitch)
## 播放进袋音效
func play_pocket_sound() -> void:
play_sfx("ball_pocket", 1.0, 1.0)
## 播放犯规音效
func play_foul_sound() -> void:
play_sfx("foul", 0.8, 0.8)
## 设置主音量
func set_master_volume(volume: float) -> void:
master_volume = clampf(volume, 0.0, 1.0)
if _music_player and _music_player.playing:
_music_player.volume_db = linear_to_db(music_volume * master_volume)
## 暂停/恢复背景音乐
func toggle_music() -> void:
if _music_player:
_music_player.stream_paused = not _music_player.stream_paused8.3 AI对手
一个完整的桌球游戏应该有单人模式,这就需要一个AI对手。AI不需要特别强,但要"看起来合理"。
AI的决策过程
AI回合开始
↓
扫描所有可能的目标球和球袋组合
↓
计算每种组合的难度(距离、角度)
↓
选择最容易的组合
↓
计算击球方向和力度
↓
执行击球(加入少量随机误差)AI难度设计
AI的难度可以通过以下方式调节:
- 方向误差:AI瞄准时加入随机角度偏差(简单AI误差大,困难AI误差小)
- 力度误差:力度控制不精确(简单AI力度偏差大)
- 思考时间:简单AI"思考"更长时间(模拟人类犹豫)
- 决策质量:简单AI可能不会选择最佳击球方案
AI对手代码
C
using Godot;
using System;
using System.Collections.Generic;
/// <summary>
/// AI对手 - 简单的桌球AI
/// </summary>
public partial class AIOpponent : Node
{
/// <summary>AI难度(0=简单,1=中等,2=困难)</summary>
[Export] public int Difficulty { get; set; } = 1;
/// <summary>思考时间范围(秒)</summary>
[Export] public Vector2 ThinkTimeRange { get; set; } = new Vector2(1.0f, 2.5f);
/// <summary>AI完成击球事件</summary>
[Signal] public delegate void AIShotReadyEventHandler(Vector3 direction, float power);
/// <summary>球管理器引用</summary>
private Node _ballManager;
/// <summary>规则管理器引用</summary>
private RuleManager _ruleManager;
/// <summary>方向误差(弧度)</summary>
private float DirectionError => Difficulty switch
{
0 => 0.15f, // 简单:约8.5度误差
1 => 0.07f, // 中等:约4度误差
2 => 0.02f, // 困难:约1度误差
_ => 0.07f
};
/// <summary>力度误差</summary>
private float PowerError => Difficulty switch
{
0 => 0.2f,
1 => 0.1f,
2 => 0.03f,
_ => 0.1f
};
public override void _Ready()
{
GD.Print($"AI对手初始化完成,难度: {Difficulty}");
}
/// <summary>
/// AI开始思考并击球
/// </summary>
public void StartTurn(Node ballManager, RuleManager ruleManager)
{
_ballManager = ballManager;
_ruleManager = ruleManager;
// 先"思考"一段时间
float thinkTime = (float)GD.RandRange(ThinkTimeRange.X, ThinkTimeRange.Y);
var timer = GetTree().CreateTimer(thinkTime);
timer.Timeout += ExecuteShot;
}
/// <summary>
/// 执行AI击球
/// </summary>
private void ExecuteShot()
{
if (_ruleManager == null) return;
var currentPlayer = _ruleManager.GetCurrentPlayer();
var cueBall = FindCueBall();
if (cueBall == null)
{
GD.PrintErr("AI找不到母球!");
return;
}
// 计算最佳击球方案
var shotPlan = CalculateBestShot(cueBall, currentPlayer);
if (shotPlan == null)
{
// 没有好的击球方案,随机打一下
GD.Print("AI没有找到好的击球方案,随机击球");
var randomDir = new Vector3(
(float)GD.RandRange(-1, 1),
0,
(float)GD.RandRange(-1, 1)
).Normalized();
EmitSignal(SignalName.AIShotReady, randomDir, 0.3f);
return;
}
// 加入误差
var direction = shotPlan.Value.Direction;
float power = shotPlan.Value.Power;
direction += new Vector3(
(float)GD.RandRange(-DirectionError, DirectionError),
0,
(float)GD.RandRange(-DirectionError, DirectionError)
).Normalized();
power += (float)GD.RandRange(-PowerError, PowerError);
power = Mathf.Clamp(power, 0.1f, 1.0f);
GD.Print($"AI击球: 方向={direction}, 力度={power:F2}");
EmitSignal(SignalName.AIShotReady, direction, power);
}
/// <summary>
/// 计算最佳击球方案
/// </summary>
private (Vector3 Direction, float Power)? CalculateBestShot(
RigidBody3D cueBall, PlayerData currentPlayer)
{
float bestScore = float.MaxValue;
(Vector3 Direction, float Power)? bestShot = null;
// 获取目标球列表
var targetBalls = GetTargetBalls(currentPlayer);
foreach (var targetBall in targetBalls)
{
var targetPos = targetBall.Position;
// 对每个球袋计算击球方案
var pockets = GetPocketPositions();
foreach (var pocketPos in pockets)
{
// 计算目标球应该被击打的方向(从目标球到球袋)
var ballToPocket = (pocketPos - targetPos);
ballToPocket.Y = 0;
ballToPocket = ballToPocket.Normalized();
// 母球应该击打目标球的"接触点"
// 接触点在目标球背向球袋的一侧
var contactPoint = targetPos - ballToPocket * 0.056f; // 两个球的半径之和
// 计算母球到接触点的方向
var cueToContact = (contactPoint - cueBall.Position);
cueToContact.Y = 0;
var distance = cueToContact.Length();
cueToContact = cueToContact.Normalized();
// 评估这个方案的难度
float score = EvaluateShot(cueBall.Position, targetPos, pocketPos, distance);
if (score < bestScore)
{
bestScore = score;
// 力度根据距离调整
float power = Mathf.Clamp(distance / 3.0f, 0.2f, 0.9f);
bestShot = (cueToContact, power);
}
}
}
return bestShot;
}
/// <summary>
/// 评估击球方案的难度(越小越好)
/// </summary>
private float EvaluateShot(Vector3 cuePos, Vector3 targetPos,
Vector3 pocketPos, float distance)
{
// 球到球袋的距离
float targetToPocket = (targetPos - pocketPos).Length();
// 母球到目标球的距离
float cueToTarget = (cuePos - targetPos).Length();
// 角度偏差(目标球-球袋 连线 vs 母球-目标球 连线)
var dir1 = (pocketPos - targetPos).Normalized();
var dir2 = (cuePos - targetPos).Normalized();
dir1.Y = 0;
dir2.Y = 0;
float angleDiff = Mathf.Acos(Mathf.Clamp(dir1.Dot(dir2), -1, 1));
// 综合评分
return cueToTarget * 0.4f + targetToPocket * 0.3f + angleDiff * 2.0f;
}
/// <summary>
/// 获取当前玩家需要打的目标球
/// </summary>
private List<RigidBody3D> GetTargetBalls(PlayerData player)
{
var balls = new List<RigidBody3D>();
var allBalls = _ballManager.GetChildren();
foreach (var ball in allBalls)
{
if (ball is RigidBody3D rb && rb.IsInGroup("balls"))
{
var script = rb.FindChild("BallPhysics") as BallPhysics;
if (script != null && !script.IsPocketed)
{
// 如果已分色,只选择自己的球
if (player.AssignedType != BallType.Unassigned)
{
if (player.CanShootEight)
{
if (script.BallNumber == 8) balls.Add(rb);
}
else
{
bool isMyBall = (player.AssignedType == BallType.Solid
&& script.BallNumber >= 1 && script.BallNumber <= 7)
|| (player.AssignedType == BallType.Stripe
&& script.BallNumber >= 9 && script.BallNumber <= 15);
if (isMyBall) balls.Add(rb);
}
}
else
{
if (script.BallNumber > 0 && script.BallNumber != 8)
balls.Add(rb);
}
}
}
}
return balls;
}
/// <summary>
/// 获取球袋位置
/// </summary>
private List<Vector3> GetPocketPositions()
{
return new List<Vector3>
{
new Vector3(-1.27f, 0.8f, -0.635f),
new Vector3(1.27f, 0.8f, -0.635f),
new Vector3(-1.27f, 0.8f, 0.635f),
new Vector3(1.27f, 0.8f, 0.635f),
new Vector3(0, 0.8f, -0.635f),
new Vector3(0, 0.8f, 0.635f),
};
}
/// <summary>
/// 找到母球
/// </summary>
private RigidBody3D FindCueBall()
{
var allBalls = _ballManager.GetChildren();
foreach (var ball in allBalls)
{
if (ball is RigidBody3D rb && rb.IsInGroup("cue_ball"))
{
return rb;
}
}
return null;
}
}GDScript
## AI对手 - 简单的桌球AI
extends Node
## AI难度(0=简单,1=中等,2=困难)
@export var difficulty: int = 1
## 思考时间范围(秒)
@export var think_time_range: Vector2 = Vector2(1.0, 2.5)
## AI完成击球事件
signal ai_shot_ready(direction: Vector3, power: float)
## 球管理器引用
var _ball_manager: Node
## 规则管理器引用
var _rule_manager: Node
## 方向误差(弧度)
var _direction_error: float:
get:
return [0.15, 0.07, 0.02][mini(difficulty, 2)]
## 力度误差
var _power_error: float:
get:
return [0.2, 0.1, 0.03][mini(difficulty, 2)]
func _ready() -> void:
print("AI对手初始化完成,难度: %d" % difficulty)
## AI开始思考并击球
func start_turn(ball_manager: Node, rule_manager: Node) -> void:
_ball_manager = ball_manager
_rule_manager = rule_manager
var think_time = randf_range(think_time_range.x, think_time_range.y)
var timer = get_tree().create_timer(think_time)
timer.timeout.connect(_execute_shot)
## 执行AI击球
func _execute_shot() -> void:
if not _rule_manager:
return
var cue_ball = _find_cue_ball()
if not cue_ball:
push_error("AI找不到母球!")
return
# 计算最佳击球方案
var shot_plan = _calculate_best_shot(cue_ball)
if not shot_plan:
# 没有好方案,随机打
var random_dir = Vector3(randf_range(-1, 1), 0, randf_range(-1, 1)).normalized()
ai_shot_ready.emit(random_dir, 0.3)
return
var direction = shot_plan.direction
var power = shot_plan.power
# 加入误差
direction += Vector3(
randf_range(-_direction_error, _direction_error),
0,
randf_range(-_direction_error, _direction_error)
).normalized()
power = clampf(power + randf_range(-_power_error, _power_error), 0.1, 1.0)
print("AI击球: 力度=%.2f" % power)
ai_shot_ready.emit(direction, power)
## 计算最佳击球方案
func _calculate_best_shot(cue_ball: RigidBody3D) -> Dictionary:
var best_score = INF
var best_shot = null
var target_balls = _get_target_balls()
var pockets = _get_pocket_positions()
for target in target_balls:
var target_pos = target.global_position
for pocket_pos in pockets:
var ball_to_pocket = (pocket_pos - target_pos)
ball_to_pocket.y = 0
ball_to_pocket = ball_to_pocket.normalized()
var contact_point = target_pos - ball_to_pocket * 0.056
var cue_to_contact = (contact_point - cue_ball.global_position)
cue_to_contact.y = 0
var dist = cue_to_contact.length()
cue_to_contact = cue_to_contact.normalized()
var score = _evaluate_shot(
cue_ball.global_position, target_pos, pocket_pos, dist)
if score < best_score:
best_score = score
var p = clampf(dist / 3.0, 0.2, 0.9)
best_shot = {"direction": cue_to_contact, "power": p}
return best_shot
## 评估击球方案难度
func _evaluate_shot(cue_pos: Vector3, target_pos: Vector3, pocket_pos: Vector3, distance: float) -> float:
var target_to_pocket = (target_pos - pocket_pos).length()
var cue_to_target = (cue_pos - target_pos).length()
var dir1 = (pocket_pos - target_pos).normalized()
var dir2 = (cue_pos - target_pos).normalized()
dir1.y = 0
dir2.y = 0
var angle_diff = acos(clampf(dir1.dot(dir2), -1, 1))
return cue_to_target * 0.4 + target_to_pocket * 0.3 + angle_diff * 2.0
## 获取目标球
func _get_target_balls() -> Array[RigidBody3D]:
var balls: Array[RigidBody3D] = []
for ball in _ball_manager.get_children():
if ball is RigidBody3D and ball.is_in_group("balls"):
if not ball.is_in_group("cue_ball"):
balls.append(ball)
return balls
## 获取球袋位置
func _get_pocket_positions() -> Array[Vector3]:
return [
Vector3(-1.27, 0.8, -0.635),
Vector3(1.27, 0.8, -0.635),
Vector3(-1.27, 0.8, 0.635),
Vector3(1.27, 0.8, 0.635),
Vector3(0, 0.8, -0.635),
Vector3(0, 0.8, 0.635),
]
## 找到母球
func _find_cue_ball() -> RigidBody3D:
for ball in _ball_manager.get_children():
if ball is RigidBody3D and ball.is_in_group("cue_ball"):
return ball
return null8.4 多平台导出
Godot 4支持导出到多个平台。桌球游戏适合导出的平台包括:
| 平台 | 特点 | 注意事项 |
|---|---|---|
| Windows | 桌面平台,性能最好 | 默认目标平台 |
| macOS | 桌面平台 | 需要Apple开发者账号签名 |
| Linux | 桌面平台 | 注意X11/Wayland兼容性 |
| Web | 浏览器中运行 | 注意文件大小和WebGL限制 |
| Android | 移动平台 | 需要适配触摸操作 |
| iOS | 移动平台 | 需要Apple开发者账号 |
导出步骤
- 打开 项目 → 导出
- 点击 添加平台
- 选择目标平台
- 配置导出选项
- 点击 导出项目
Web平台限制
导出到Web时需要注意:
- 文件大小限制(建议压缩到50MB以下)
- WebGL 2.0兼容性
- 音频需要使用Ogg Vorbis格式
- 不能使用线程(某些C#功能不可用)
8.5 发布检查清单
在正式发布游戏之前,请逐项检查以下清单:
功能检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 瞄准系统正常 | ☐ | 鼠标移动能正确瞄准 |
| 力度控制正常 | ☐ | 力度条能正确显示和确认 |
| 球碰撞正确 | ☐ | 球与球碰撞、球与边框反弹 |
| 进袋检测准确 | ☐ | 球进入球袋区域被正确检测 |
| 规则判定正确 | ☐ | 回合切换、犯规、胜负判定 |
| AI对手能运行 | ☐ | AI能正常思考和击球 |
| 所有球都能进袋 | ☐ | 测试所有球袋都能正常工作 |
UI检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 计分板正确显示 | ☐ | 两位玩家的信息正确 |
| 回合指示器正常 | ☐ | 正确显示当前玩家 |
| 消息提示正常 | ☐ | 犯规、换人等消息正确显示 |
| 力度条清晰可读 | ☐ | 力度条颜色和数值正确 |
| 操作提示清晰 | ☐ | 玩家知道当前该做什么 |
音效检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 击球音效 | ☐ | 击球时有声音 |
| 碰撞音效 | ☐ | 球碰撞时有声音 |
| 进袋音效 | ☐ | 球进袋时有声音 |
| 背景音乐 | ☐ | 背景音乐正常播放 |
| 音量控制 | ☐ | 音量可调 |
性能检查
| 检查项 | 状态 | 目标 |
|---|---|---|
| 帧率稳定 | ☐ | 目标平台60fps |
| 内存使用 | ☐ | 不超过512MB |
| 加载时间 | ☐ | 不超过5秒 |
| 无内存泄漏 | ☐ | 长时间运行不崩溃 |
兼容性检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 不同分辨率 | ☐ | 适配不同屏幕尺寸 |
| 窗口模式 | ☐ | 窗口化/全屏切换正常 |
| 多平台导出 | ☐ | 目标平台能正常运行 |
测试建议
发布前至少完整玩10局游戏,确保没有明显的bug。最好找几个朋友帮忙测试——你可能会忽略一些问题,因为你知道游戏"应该"怎么运作,但新玩家可能会遇到各种意外情况。
8.6 小结与总结
在本章中,我们完成了游戏的打磨和发布准备:
- UI美化:计分板、消息提示系统
- 音效系统:统一的音效管理器,根据力度调整音效
- AI对手:基于评分系统的简单AI,支持三种难度
- 多平台导出:Windows、Web、Android等平台
- 发布检查清单:功能、UI、音效、性能、兼容性
整个桌球教程回顾
| 章节 | 内容 | 关键成果 |
|---|---|---|
| 1. 核心玩法设计 | 游戏设计、规则、MVP规划 | 明确开发目标 |
| 2. 项目搭建 | Godot项目、场景、摄像机、灯光 | 搭建开发环境 |
| 3. 物理引擎 | 球体物理、碰撞层、2.5D约束 | 球能真实运动 |
| 4. 运动与碰撞 | 碰撞检测、进袋、回合判定 | 完整的物理交互 |
| 5. 瞄准与力度 | 瞄准线、预测线、力度条 | 玩家能操作 |
| 6. 规则判定 | 8球规则、犯规、胜负 | 完整的游戏规则 |
| 7. 摄像机视角 | 2.5D呈现、光影、材质 | 漂亮的视觉效果 |
| 8. 打磨与发布 | UI、音效、AI、导出 | 可以发布的游戏 |
恭喜你完成了整个桌球游戏教程!你现在拥有了一个功能完整、视觉精美、可以发布的2.5D桌球游戏。继续打磨和迭代,让游戏变得更好!
