9. UI 美化
2026/4/13大约 7 分钟
9. UI 美化:让棋盘更好看、更好用
9.1 为什么要美化 UI?
你的象棋游戏功能已经齐全了——能下棋、有 AI、能联网。但现在的界面可能还很"素",就像毛坯房,能用但不够好看。
UI 美化的目标:
| 目标 | 说明 | 比喻 |
|---|---|---|
| 信息清晰 | 谁的回合、计时器、棋谱一目了然 | 指路牌 |
| 操作便捷 | 悔棋、认输、设置一键可达 | 遥控器 |
| 视觉精美 | 棋盘质感、动画过渡、色彩搭配 | 装修房子 |
| 战魂风格 | 3D 光影特效、粒子动画、技能释放 | 电影特效 |
9.2 棋谱记录面板
棋谱就是"这局棋每一步的记录",就像棋手们在赛后复盘时用的笔记本。
9.2.1 棋谱的数据结构
每一步记录包含:第几步、谁走的、从哪到哪、吃了什么。
{
"move_number": 1,
"player": "red",
"piece": "cannon",
"from": [4, 7],
"to": [4, 4],
"captured": "pawn",
"notation": "炮二平五"
}9.2.2 棋谱 UI 实现
C#
using Godot;
using System.Collections.Generic;
/// <summary>
/// 棋谱面板 —— 显示当前这局棋的所有走法记录
/// </summary>
public partial class MoveHistoryPanel : Control
{
[Export] private RichTextLabel _historyLabel;
[Export] private ScrollContainer _scrollContainer;
// 所有走法记录
private readonly List<MoveRecord> _records = new();
/// <summary>
/// 添加一条走法记录
/// </summary>
public void AddMove(MoveRecord record)
{
_records.Add(record);
UpdateDisplay();
}
/// <summary>
/// 更新棋谱显示
/// </summary>
private void UpdateDisplay()
{
_historyLabel.Clear();
string text = "[table=3][cell]步数[/cell][cell]红方[/cell][cell]黑方[/cell]\n";
for (int i = 0; i < _records.Count; i += 2)
{
int moveNum = (i / 2) + 1;
string redMove = _records[i].Notation;
string blackMove = (i + 1 < _records.Count)
? _records[i + 1].Notation
: "...";
text += $"[cell]{moveNum}.[/cell][cell]{redMove}[/cell][cell]{blackMove}[/cell]\n";
}
text += "[/table]";
_historyLabel.AppendBbcode(text);
// 自动滚动到最新一步
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
_scrollContainer.ScrollVertical = int.MaxValue;
}
/// <summary>
/// 清空棋谱
/// </summary>
public void Clear()
{
_records.Clear();
_historyLabel.Clear();
}
}
/// <summary>
/// 走法记录数据
/// </summary>
public record MoveRecord
{
public int MoveNumber { get; init; }
public string Player { get; init; }
public string Piece { get; init; }
public Vector2I From { get; init; }
public Vector2I To { get; init; }
public string Captured { get; init; }
public string Notation { get; init; }
}GDScript
extends Control
@export var history_label: RichTextLabel
@export var scroll_container: ScrollContainer
## 所有走法记录
var _records: Array[Dictionary] = []
## 添加一条走法记录
func add_move(record: Dictionary) -> void:
_records.append(record)
_update_display()
## 更新棋谱显示
func _update_display() -> void:
history_label.clear()
var text := "[table=3][cell]步数[/cell][cell]红方[/cell][cell]黑方[/cell]\n"
var i := 0
while i < _records.size():
var move_num := (i / 2) + 1
var red_move: String = _records[i].notation
var black_move: String
if i + 1 < _records.size():
black_move = _records[i + 1].notation
else:
black_move = "..."
text += "[cell]%d.[/cell][cell]%s[/cell][cell]%s[/cell]\n"
text = text % [move_num, red_move, black_move]
i += 2
text += "[/table]"
history_label.append_bbcode(text)
# 自动滚动到最新一步
await get_tree().process_frame
scroll_container.scroll_vertical = 999999
## 清空棋谱
func clear() -> void:
_records.clear()
history_label.clear()9.3 计时器
正式比赛中每方都有时间限制。计时器让游戏更有紧迫感。
9.3.1 计时器功能
| 功能 | 说明 |
|---|---|
| 倒计时 | 每方各 10/15/30 分钟可选 |
| 回合计时 | 当前走棋方的时钟在走 |
| 超时判负 | 时间用完自动判负 |
| 低时间警告 | 剩余 1 分钟时变红色闪烁 |
C#
using Godot;
/// <summary>
/// 计时器管理器 —— 管理双方倒计时
/// </summary>
public partial class ChessTimer : Control
{
[Export] private Label _redTimerLabel;
[Export] private Label _blackTimerLabel;
[Export] private Color _normalColor = new Color(1, 1, 1);
[Export] private Color _warningColor = new Color(1, 0.3f, 0.3f);
// 每方总时间(秒)
private int _totalTimeSeconds = 600; // 10 分钟
// 红方剩余时间
private int _redTimeSeconds;
// 黑方剩余时间
private int _blackTimeSeconds;
// 当前是否在计时
private bool _isRunning;
// 低时间警告阈值(秒)
private const int WarningThreshold = 60;
public override void _Ready()
{
ResetTimer();
}
public override void _Process(double delta)
{
if (!_isRunning) return;
// 当前走棋方的时间减少
if (GameManager.Instance.CurrentTurn == PieceColor.Red)
{
_redTimeSeconds -= (int)delta;
_redTimerLabel.Text = FormatTime(_redTimeSeconds);
if (_redTimeSeconds <= WarningThreshold)
_redTimerLabel.Modulate = _warningColor;
if (_redTimeSeconds <= 0)
{
_isRunning = false;
EmitSignal(SignalName.TimeUp, "red");
}
}
else
{
_blackTimeSeconds -= (int)delta;
_blackTimerLabel.Text = FormatTime(_blackTimeSeconds);
if (_blackTimeSeconds <= WarningThreshold)
_blackTimerLabel.Modulate = _warningColor;
if (_blackTimeSeconds <= 0)
{
_isRunning = false;
EmitSignal(SignalName.TimeUp, "black");
}
}
}
/// <summary>
/// 格式化时间为 MM:SS
/// </summary>
private string FormatTime(int seconds)
{
int minutes = Mathf.Max(0, seconds) / 60;
int secs = Mathf.Max(0, seconds) % 60;
return $"{minutes:D2}:{secs:D2}";
}
/// <summary>
/// 重置计时器
/// </summary>
public void ResetTimer()
{
_redTimeSeconds = _totalTimeSeconds;
_blackTimeSeconds = _totalTimeSeconds;
_isRunning = false;
_redTimerLabel.Text = FormatTime(_redTimeSeconds);
_blackTimerLabel.Text = FormatTime(_blackTimeSeconds);
_redTimerLabel.Modulate = _normalColor;
_blackTimerLabel.Modulate = _normalColor;
}
/// <summary>
/// 开始计时
/// </summary>
public void StartTimer() => _isRunning = true;
/// <summary>
/// 暂停计时
/// </summary>
public void PauseTimer() => _isRunning = false;
[Signal] public delegate void TimeUpEventHandler(string color);
}GDScript
extends Control
@export var red_timer_label: Label
@export var black_timer_label: Label
@export var normal_color := Color.WHITE
@export var warning_color := Color(1, 0.3, 0.3)
## 每方总时间(秒)
var _total_time_seconds: int = 600 # 10 分钟
var _red_time_seconds: int
var _black_time_seconds: int
var _is_running: bool = false
const WARNING_THRESHOLD := 60 # 1 分钟
signal time_up(color: String)
func _ready() -> void:
reset_timer()
func _process(delta: float) -> void:
if not _is_running:
return
var current_turn = GameManager.instance.current_turn
if current_turn == PieceColor.RED:
_red_time_seconds -= int(delta)
red_timer_label.text = _format_time(_red_time_seconds)
if _red_time_seconds <= WARNING_THRESHOLD:
red_timer_label.modulate = warning_color
if _red_time_seconds <= 0:
_is_running = false
time_up.emit("red")
else:
_black_time_seconds -= int(delta)
black_timer_label.text = _format_time(_black_time_seconds)
if _black_time_seconds <= WARNING_THRESHOLD:
black_timer_label.modulate = warning_color
if _black_time_seconds <= 0:
_is_running = false
time_up.emit("black")
func _format_time(seconds: int) -> String:
var s := max(0, seconds)
var minutes := s / 60
var secs := s % 60
return "%02d:%02d" % [minutes, secs]
func reset_timer() -> void:
_red_time_seconds = _total_time_seconds
_black_time_seconds = _total_time_seconds
_is_running = false
red_timer_label.text = _format_time(_red_time_seconds)
black_timer_label.text = _format_time(_black_time_seconds)
red_timer_label.modulate = normal_color
black_timer_label.modulate = normal_color
func start_timer() -> void:
_is_running = true
func pause_timer() -> void:
_is_running = false9.4 悔棋功能
悔棋就是"撤销上一步",就像写字写错了用橡皮擦掉。
9.4.1 悔棋实现原理
每走一步之前,把棋盘状态"存档"。悔棋时,读取上一次的"存档"恢复。
走棋前:保存棋盘状态到历史栈
↓
走棋后:棋盘状态变了
↓
悔棋:从历史栈弹出上一步的状态,恢复棋盘C#
using Godot;
using System.Collections.Generic;
/// <summary>
/// 悔棋管理器 —— 用"历史栈"实现撤销
/// </summary>
public partial class UndoManager : Node
{
// 历史栈:每一步的棋盘快照
private readonly Stack<BoardSnapshot> _history = new();
// 最大记录步数
private const int MaxHistory = 200;
/// <summary>
/// 走棋前保存棋盘快照
/// </summary>
public void SaveSnapshot(Piece3D.PieceColor[,] board, PieceColor currentTurn)
{
// 深拷贝棋盘
var snapshot = new BoardSnapshot
{
Board = CloneBoard(board),
CurrentTurn = currentTurn
};
_history.Push(snapshot);
// 限制历史栈大小
if (_history.Count > MaxHistory)
{
// 简单处理:创建新栈丢弃最早的记录
var newStack = new Stack<BoardSnapshot>();
var arr = _history.ToArray();
for (int i = 1; i < arr.Length; i++)
newStack.Push(arr[i]);
_history.Clear();
foreach (var item in newStack) _history.Push(item);
}
}
/// <summary>
/// 悔棋:恢复上一步的棋盘状态
/// </summary>
public BoardSnapshot Undo()
{
if (_history.Count == 0)
{
GD.Print("没有可以撤销的走法");
return null;
}
return _history.Pop();
}
/// <summary>
/// 深拷贝棋盘
/// </summary>
private Piece3D.PieceColor[,] CloneBoard(Piece3D.PieceColor[,] original)
{
var clone = new Piece3D.PieceColor[9, 10];
for (int col = 0; col < 9; col++)
{
for (int row = 0; row < 10; row++)
{
clone[col, row] = original[col, row];
}
}
return clone;
}
}
/// <summary>
/// 棋盘快照
/// </summary>
public record BoardSnapshot
{
public Piece3D.PieceColor[,] Board { get; init; }
public PieceColor CurrentTurn { get; init; }
}GDScript
extends Node
## 历史栈
var _history: Array[Dictionary] = []
## 最大记录步数
const MAX_HISTORY := 200
## 走棋前保存棋盘快照
func save_snapshot(board: Array, current_turn: int) -> void:
var snapshot := {
"board": _clone_board(board),
"current_turn": current_turn
}
_history.append(snapshot)
# 限制历史栈大小
if _history.size() > MAX_HISTORY:
_history.pop_front()
## 悔棋:恢复上一步的棋盘状态
func undo() -> Dictionary:
if _history.is_empty():
print("没有可以撤销的走法")
return {}
return _history.pop_back()
## 深拷贝棋盘
func _clone_board(board: Array) -> Array:
var clone := []
for col in range(9):
var row_arr := []
for row in range(10):
row_arr.append(board[col][row])
clone.append(row_arr)
return clone悔棋规则
- 对战模式下,悔棋需要对手同意(或限制每局只能悔棋 3 次)
- AI 对战模式可以自由悔棋
- 网络对战模式,悔棋需要双方确认
9.5 3D 棋盘装饰
9.5.1 木质边框效果
用 3D 模型给棋盘加上木质边框,让棋盘看起来更像真实的象棋桌:
| 效果 | 实现方式 |
|---|---|
| 木质纹理 | 使用 StandardMaterial3D 的 AlbedoTexture |
| 棋盘边框 | CSGBox3D 做四个边框 |
| 棋盘底座 | CSGBox3D 做底座 |
| 台灯效果 | SpotLight3D 从上方照射 |
| 环境反射 | WorldEnvironment 设置环境光 |
9.5.2 3D 棋盘场景搭建
ChessTable3D (Node3D)
├── BoardSurface (CSGBox3D) # 棋盘面(绿色/黄色)
├── BorderTop (CSGBox3D) # 上边框
├── BorderBottom (CSGBox3D) # 下边框
├── BorderLeft (CSGBox3D) # 左边框
├── BorderRight (CSGBox3D) # 右边框
├── Base (CSGBox3D) # 底座
├── TableLight (SpotLight3D) # 台灯
└── AmbientLight (DirectionalLight3D) # 环境光9.6 设置面板
| 设置项 | 选项 | 说明 |
|---|---|---|
| AI 难度 | 新手/简单/中等/困难/大师 | 影响 AI 搜索深度 |
| 每方时间 | 5分钟/10分钟/15分钟/30分钟/无限制 | 计时器设置 |
| 棋盘主题 | 经典木纹/现代简约/战魂暗黑 | 棋盘外观风格 |
| 音效开关 | 开/关 | 是否播放音效 |
| 动画开关 | 开/关 | 是否显示走棋动画 |
| 语言 | 简中/繁中/English | 界面语言 |
9.7 小结
| UI 组件 | 功能 |
|---|---|
| 棋谱面板 | 记录每一步走法,方便复盘 |
| 计时器 | 增加对局紧迫感,超时判负 |
| 悔棋功能 | 撤销上一步,支持多次撤销 |
| 3D 装饰 | 木质边框、光影效果,提升视觉质感 |
| 设置面板 | AI 难度、时间、主题等自定义选项 |
