13. 无障碍与本地化
2026/4/14大约 10 分钟
无障碍与本地化
想象一下:一个色盲玩家看不清你游戏中的红绿提示,一个听障玩家错过了一段只有语音没有字幕的关键剧情,一个法国玩家打开你的游戏发现全是中文——这些都会让人放弃你的游戏。无障碍和本地化就是让更多人能玩到并享受你的游戏。
本章你将学到
- 无障碍设计原则(色盲支持、字幕系统、按键自定义)
- 本地化(Localization)系统设计
- 翻译文件格式(CSV/JSON/PO)
- 动态语言切换
- 文本自动换行与 UI 适配
- 字体管理(多语言字体)
- RTL(从右到左)语言支持
无障碍设计原则
无障碍设计不是"锦上添花",而是让游戏面向所有玩家的基础要求。以下是三个最重要的无障碍特性:
1. 色盲支持
全球约 8% 的男性和 0.5% 的女性有某种形式的色觉障碍。最常见的红绿色盲意味着你游戏中的"红色 = 危险、绿色 = 安全"对他们是无效的。
解决方案:不仅用颜色区分,还要用形状、图标、文字来辅助。
C#
using Godot;
public partial class ColorblindSupport : Node
{
// 色盲友好的颜色方案
private static readonly Color SafeColor = new(0.0f, 0.6f, 1.0f); // 蓝色替代绿色
private static readonly Color DangerColor = new(1.0f, 0.5f, 0.0f); // 橙色替代红色
private static readonly Color WarningColor = new(1.0f, 1.0f, 0.0f); // 黄色保持不变
[Export] public bool ColorblindMode { get; set; } = false;
public Color GetStatusColor(float healthPercent)
{
if (ColorblindMode)
{
// 色盲模式:使用蓝/橙对比
if (healthPercent > 0.6f) return SafeColor;
if (healthPercent > 0.3f) return WarningColor;
return DangerColor;
}
else
{
// 标准模式:绿/黄/红
if (healthPercent > 0.6f) return Colors.Green;
if (healthPercent > 0.3f) return Colors.Yellow;
return Colors.Red;
}
}
public void UpdateHealthBar(ProgressBar bar, TextureRect icon, float hp, float maxHp)
{
float percent = hp / maxHp;
bar.Value = percent;
// 颜色
var styleBox = (StyleBoxFlat)bar.GetThemeStylebox("fill");
styleBox.BgColor = GetStatusColor(percent);
// 色盲模式额外加图标提示
if (ColorblindMode)
{
// 显示文字百分比而不是仅依赖颜色条
var label = bar.GetNodeOrNull<Label>("PercentLabel");
if (label != null)
{
label.Text = $"{(int)(percent * 100)}%";
label.Visible = true;
}
}
}
}GDScript
extends Node
# 色盲友好的颜色方案
const SAFE_COLOR: Color = Color(0.0, 0.6, 1.0) # 蓝色替代绿色
const DANGER_COLOR: Color = Color(1.0, 0.5, 0.0) # 橙色替代红色
const WARNING_COLOR: Color = Color(1.0, 1.0, 0.0) # 黄色保持不变
@export var colorblind_mode: bool = false
func get_status_color(health_percent: float) -> Color:
if colorblind_mode:
# 色盲模式:使用蓝/橙对比
if health_percent > 0.6:
return SAFE_COLOR
elif health_percent > 0.3:
return WARNING_COLOR
else:
return DANGER_COLOR
else:
# 标准模式:绿/黄/红
if health_percent > 0.6:
return Color.GREEN
elif health_percent > 0.3:
return Color.YELLOW
else:
return Color.RED
func update_health_bar(bar: ProgressBar, hp: float, max_hp: float):
var percent = hp / max_hp
bar.value = percent * 100
# 颜色
var style_box = bar.get_theme_stylebox("fill") as StyleBoxFlat
if style_box:
style_box.bg_color = get_status_color(percent)
# 色盲模式额外加文字提示
if colorblind_mode:
var label = bar.get_node_or_null("PercentLabel") as Label
if label:
label.text = "%d%%" % int(percent * 100)
label.visible = true2. 字幕系统
所有语音内容都应该提供字幕,包括:
- 对话文本
- 语音方向指示(左/右/前方)
- 说话者名字
- 重要音效描述(如"[爆炸声]")
C#
using Godot;
public partial class SubtitleSystem : CanvasLayer
{
private RichTextLabel _subtitleLabel;
private Label _speakerLabel;
private Tween _currentTween;
public override void _Ready()
{
_subtitleLabel = GetNode<RichTextLabel>("SubtitlePanel/SubtitleLabel");
_speakerLabel = GetNode<Label>("SubtitlePanel/SpeakerLabel");
_subtitleLabel.Visible = false;
_speakerLabel.Visible = false;
}
/// <summary>
/// 显示字幕
/// </summary>
/// <param name="speaker">说话者名字</param>
/// <param name="text">字幕文本</param>
/// <param name="duration">显示时长(秒)</param>
public void ShowSubtitle(string speaker, string text, float duration = 3.0f)
{
// 取消正在播放的字幕动画
_currentTween?.Kill();
_speakerLabel.Text = speaker;
_speakerLabel.Visible = true;
_subtitleLabel.Text = text;
_subtitleLabel.Visible = true;
_subtitleLabel.Modulate = Colors.White;
// 设置自动隐藏
_currentTween = CreateTween();
_currentTween.TweenInterval(duration);
_currentTween.TweenCallback(Callable.From(() =>
{
_subtitleLabel.Visible = false;
_speakerLabel.Visible = false;
}));
}
/// <summary>
/// 显示音效描述字幕(给听障玩家)
/// </summary>
public void ShowSoundDescription(string description)
{
ShowSubtitle("[音效]", $"[{description}]", 2.0f);
}
}GDScript
extends CanvasLayer
@onready var subtitle_label: RichTextLabel = $SubtitlePanel/SubtitleLabel
@onready var speaker_label: Label = $SubtitlePanel/SpeakerLabel
var current_tween: Tween
func _ready():
subtitle_label.visible = false
speaker_label.visible = false
## 显示字幕
## speaker: 说话者名字
## text: 字幕文本
## duration: 显示时长(秒)
func show_subtitle(speaker: String, text: String, duration: float = 3.0):
# 取消正在播放的字幕动画
if current_tween:
current_tween.kill()
speaker_label.text = speaker
speaker_label.visible = true
subtitle_label.text = text
subtitle_label.visible = true
subtitle_label.modulate = Color.WHITE
# 设置自动隐藏
current_tween = create_tween()
current_tween.tween_interval(duration)
current_tween.tween_callback(func():
subtitle_label.visible = false
speaker_label.visible = false
)
## 显示音效描述字幕(给听障玩家)
func show_sound_description(description: String):
show_subtitle("[音效]", "[%s]" % description, 2.0)3. 按键自定义
不同玩家的习惯和物理条件不同,允许自定义按键是基本的无障碍要求。
C#
using Godot;
using System.Collections.Generic;
public partial class InputRemapper : Control
{
[Export] public string ActionName { get; set; } = "move_forward";
[Export] public string DisplayName { get; set; } = "向前移动";
private Button _bindButton;
private bool _isListening = false;
public override void _Ready()
{
_bindButton = GetNode<Button>("BindButton");
_bindButton.Text = GetCurrentBinding();
_bindButton.Pressed += OnBindButtonPressed;
}
public override void _Input(InputEvent @event)
{
if (!_isListening) return;
if (@event is InputEventKey keyEvent && keyEvent.Pressed)
{
// 重新映射按键
// 先移除旧的绑定
InputMap.EraseAction(ActionName);
// 添加新的绑定
InputMap.AddAction(ActionName);
InputMap.ActionAddEvent(ActionName, keyEvent);
_bindButton.Text = keyEvent.AsText();
_isListening = false;
_bindButton.Modulate = Colors.White;
GD.Print($"按键 [{keyEvent.AsText()}] 已绑定到 {DisplayName}");
// 保存到配置文件
SaveBindings();
GetViewport().SetInputAsHandled();
}
}
private void OnBindButtonPressed()
{
_isListening = true;
_bindButton.Text = "按下新按键...";
_bindButton.Modulate = Colors.Yellow;
}
private string GetCurrentBinding()
{
var actions = InputMap.ActionGetEvents(ActionName);
if (actions.Count > 0)
return actions[0].AsText();
return "未绑定";
}
private void SaveBindings()
{
var config = new ConfigFile();
var actions = InputMap.ActionGetEvents(ActionName);
if (actions.Count > 0 && actions[0] is InputEventKey key)
{
config.SetValue("bindings", ActionName, (int)key.Keycode);
}
config.Save("user://input_bindings.cfg");
}
}GDScript
extends Control
@export var action_name: String = "move_forward"
@export var display_name: String = "向前移动"
@onready var bind_button: Button = $BindButton
var is_listening: bool = false
func _ready():
bind_button.text = _get_current_binding()
bind_button.pressed.connect(_on_bind_button_pressed)
func _input(event: InputEvent):
if not is_listening:
return
if event is InputEventKey and event.pressed:
# 重新映射按键
# 先移除旧的绑定
InputMap.erase_action(action_name)
# 添加新的绑定
InputMap.add_action(action_name)
InputMap.action_add_event(action_name, event)
bind_button.text = event.as_text()
is_listening = false
bind_button.modulate = Color.WHITE
print("按键 [%s] 已绑定到 %s" % [event.as_text(), display_name])
# 保存到配置文件
_save_bindings()
get_viewport().set_input_as_handled()
func _on_bind_button_pressed():
is_listening = true
bind_button.text = "按下新按键..."
bind_button.modulate = Color.YELLOW
func _get_current_binding() -> String:
var actions = InputMap.action_get_events(action_name)
if actions.size() > 0:
return actions[0].as_text()
return "未绑定"
func _save_bindings():
var config = ConfigFile.new()
var actions = InputMap.action_get_events(action_name)
if actions.size() > 0 and actions[0] is InputEventKey:
config.set_value("bindings", action_name, actions[0].keycode)
config.save("user://input_bindings.cfg")本地化系统设计
本地化是把游戏中的文字翻译成不同语言的过程。Godot 内置了基本的本地化支持,我们可以在此基础上构建更完善的系统。
翻译文件格式
Godot 支持多种翻译文件格式,最常用的是 CSV 和 PO。
CSV 格式(简单直观,适合小项目):
key,zh_CN,en_US,ja_JP
START_GAME,开始游戏,Start Game,ゲーム開始
SETTINGS,设置,Settings,設定
QUIT,退出,Quit,終了
HEALTH,生命值,Health,体力
ATTACK,攻击,Attack,攻撃
INVENTORY,背包,Inventory,持ち物PO 格式(gettext 标准,适合大型项目和专业翻译团队):
# zh_CN.po
msgid "START_GAME"
msgstr "开始游戏"
msgid "SETTINGS"
msgstr "设置"
msgid "HEALTH"
msgstr "生命值"动态语言切换
C#
using Godot;
using System.Collections.Generic;
public partial class LocalizationManager : Node
{
private const string TRANSLATIONS_DIR = "res://locales/";
private string _currentLocale = "zh_CN";
// 支持的语言列表
private static readonly Dictionary<string, string> SupportedLocales = new()
{
{ "zh_CN", "简体中文" },
{ "zh_Hant", "繁體中文" },
{ "en_US", "English" },
{ "ja_JP", "日本語" },
{ "ko_KR", "한국어" },
{ "fr_FR", "Français" },
{ "de_DE", "Deutsch" },
{ "es_ES", "Español" },
{ "ru_RU", "Русский" },
{ "ar_SA", "العربية" } // RTL 语言
};
public override void _Ready()
{
// 加载所有翻译文件
LoadAllTranslations();
// 从设置中恢复语言选择
string savedLocale = LoadSavedLocale();
SetLocale(savedLocale);
}
/// <summary>
/// 加载所有翻译文件
/// </summary>
private void LoadAllTranslations()
{
var dir = DirAccess.Open(TRANSLATIONS_DIR);
if (dir == null) return;
dir.ListDirBegin();
string fileName = dir.GetNext();
while (fileName != "")
{
if (fileName.EndsWith(".csv") || fileName.EndsWith(".translation"))
{
var translation = GD.Load<Translation>(TRANSLATIONS_DIR + fileName);
if (translation != null)
{
TranslationServer.AddTranslation(translation);
GD.Print($"已加载翻译: {fileName}");
}
}
fileName = dir.GetNext();
}
}
/// <summary>
/// 切换游戏语言
/// </summary>
public void SetLocale(string locale)
{
_currentLocale = locale;
TranslationServer.SetLocale(locale);
// 保存语言设置
SaveLocale(locale);
// 通知所有 UI 更新
GetTree().CallGroup("localized_ui", "update_locale");
GD.Print($"语言已切换为: {SupportedLocales.GetValueOrDefault(locale, locale)}");
}
/// <summary>
/// 获取翻译文本
/// </summary>
public string GetText(string key)
{
return TranslationServer.Translate(key);
}
/// <summary>
/// 获取支持的语言列表
/// </summary>
public Dictionary<string, string> GetSupportedLocales() => SupportedLocales;
private string LoadSavedLocale()
{
var config = new ConfigFile();
if (config.Load("user://settings.cfg") == Error.Ok)
{
return (string)config.GetValue("general", "locale", "zh_CN");
}
return "zh_CN";
}
private void SaveLocale(string locale)
{
var config = new ConfigFile();
config.Load("user://settings.cfg");
config.SetValue("general", "locale", locale);
config.Save("user://settings.cfg");
}
}GDScript
extends Node
const TRANSLATIONS_DIR: String = "res://locales/"
var current_locale: String = "zh_CN"
# 支持的语言列表
const SUPPORTED_LOCALES: Dictionary = {
"zh_CN": "简体中文",
"zh_Hant": "繁體中文",
"en_US": "English",
"ja_JP": "日本語",
"ko_KR": "한국어",
"fr_FR": "Français",
"de_DE": "Deutsch",
"es_ES": "Español",
"ru_RU": "Русский",
"ar_SA": "العربية" # RTL 语言
}
func _ready():
# 加载所有翻译文件
_load_all_translations()
# 从设置中恢复语言选择
var saved_locale = _load_saved_locale()
set_locale(saved_locale)
## 加载所有翻译文件
func _load_all_translations():
var dir = DirAccess.open(TRANSLATIONS_DIR)
if dir == null:
return
dir.list_dir_begin()
var file_name = dir.get_next()
while file_name != "":
if file_name.ends_with(".csv") or file_name.ends_with(".translation"):
var translation = load(TRANSLATIONS_DIR + file_name)
if translation:
TranslationServer.add_translation(translation)
print("已加载翻译: ", file_name)
file_name = dir.get_next()
## 切换游戏语言
func set_locale(locale: String):
current_locale = locale
TranslationServer.set_locale(locale)
# 保存语言设置
_save_locale(locale)
# 通知所有 UI 更新
get_tree().call_group("localized_ui", "update_locale")
print("语言已切换为: ", SUPPORTED_LOCALES.get(locale, locale))
## 获取翻译文本
func get_text(key: String) -> String:
return TranslationServer.translate(key)
## 获取支持的语言列表
func get_supported_locales() -> Dictionary:
return SUPPORTED_LOCALES
func _load_saved_locale() -> String:
var config = ConfigFile.new()
if config.load("user://settings.cfg") == OK:
return config.get_value("general", "locale", "zh_CN")
return "zh_CN"
func _save_locale(locale: String):
var config = ConfigFile.new()
config.load("user://settings.cfg")
config.set_value("general", "locale", locale)
config.save("user://settings.cfg")文本自动换行与 UI 适配
不同语言的文本长度差异很大。比如 "Settings" 在英文是 8 个字符,在德文 "Einstellungen" 是 14 个字符。UI 必须能自适应不同长度的文本。
C#
using Godot;
/// <summary>
/// 自动适配文本大小的 UI 组件
/// </summary>
public partial class LocalizedLabel : Label
{
[Export] public string TranslationKey { get; set; } = "";
[Export] public bool AutoFitSize { get; set; } = true;
[Export] public int MinFontSize { get; set; } = 12;
[Export] public int MaxFontSize { get; set; } = 24;
public override void _Ready()
{
UpdateText();
AddToGroup("localized_ui");
}
public void UpdateLocale()
{
UpdateText();
}
private void UpdateText()
{
if (string.IsNullOrEmpty(TranslationKey)) return;
Text = Tr(TranslationKey);
if (!AutoFitSize) return;
// 等待下一帧,让 Label 计算好文本尺寸
Callable.From(() => AdjustFontSize()).CallDeferred();
}
private void AdjustFontSize()
{
// 自动缩小字体以适应容器
if (GetParent() is Control container)
{
float availableWidth = container.Size.X - 20; // 留边距
for (int size = MaxFontSize; size >= MinFontSize; size--)
{
AddThemeFontSizeOverride("font_size", size);
var minSize = GetCombinedMinimumSize();
if (minSize.X <= availableWidth)
break;
}
}
}
}GDScript
extends Label
@export var translation_key: String = ""
@export var auto_fit_size: bool = true
@export var min_font_size: int = 12
@export var max_font_size: int = 24
func _ready():
update_text()
add_to_group("localized_ui")
func update_locale():
update_text()
func update_text():
if translation_key == "":
return
text = tr(translation_key)
if not auto_fit_size:
return
# 等待下一帧,让 Label 计算好文本尺寸
adjust_font_size.call_deferred()
func adjust_font_size():
# 自动缩小字体以适应容器
var container = get_parent() as Control
if container == null:
return
var available_width = container.size.x - 20 # 留边距
for size in range(max_font_size, min_font_size - 1, -1):
add_theme_font_size_override("font_size", size)
var min_size = get_combined_minimum_size()
if min_size.x <= available_width:
break字体管理
不同语言需要不同的字体:中文需要支持几千个常用汉字,日文需要支持假名和汉字,阿拉伯文需要支持连字。
字体回退链(Font Fallback)
Godot 支持"字体回退链"——如果主字体找不到某个字符,会自动在回退字体中查找。利用这个机制,你可以设置:
- 英文字体(覆盖拉丁字母)
- 中文字体(覆盖汉字)
- 日文字体(覆盖假名和日文汉字)
- 符号字体(覆盖特殊字符)
C#
using Godot;
public partial class FontManager : Node
{
private SystemFont _mainFont;
public override void _Ready()
{
SetupFonts();
}
private void SetupFonts()
{
_mainFont = new SystemFont();
_mainFont.FontNames = new string[] { "Noto Sans CJK SC", "Noto Sans", "Arial" };
_mainFont.FontWeight = 400;
// 设置回退字体链
var chineseFont = GD.Load<FontFile>("res://fonts/NotoSansCJKsc-Regular.ttf");
var japaneseFont = GD.Load<FontFile>("res://fonts/NotoSansCJKjp-Regular.ttf");
if (chineseFont != null)
_mainFont.Fallbacks.Add(chineseFont);
if (japaneseFont != null)
_mainFont.Fallbacks.Add(japaneseFont);
// 应用为默认主题字体
var theme = ThemeDefault();
theme.DefaultFont = _mainFont;
GD.Print("字体系统初始化完成");
}
private Theme ThemeDefault()
{
var defaultTheme = ThemeDB.GetDefaultTheme();
return defaultTheme;
}
}GDScript
extends Node
func _ready():
_setup_fonts()
func _setup_fonts():
var main_font = SystemFont.new()
main_font.font_names = ["Noto Sans CJK SC", "Noto Sans", "Arial"]
main_font.font_weight = 400
# 设置回退字体链
var chinese_font = load("res://fonts/NotoSansCJKsc-Regular.ttf") as FontFile
var japanese_font = load("res://fonts/NotoSansCJKjp-Regular.ttf") as FontFile
if chinese_font:
main_font.fallbacks.add(chinese_font)
if japanese_font:
main_font.fallbacks.add(japanese_font)
# 应用为默认主题字体
var default_theme = ThemeDB.get_default_theme()
default_theme.default_font = main_font
print("字体系统初始化完成")RTL(从右到左)语言支持
阿拉伯语、希伯来语等语言是从右到左书写的。Godot 的 Label 和 RichTextLabel 节点内置了 BiDi(双向文本)支持,可以自动处理 RTL 文本。
关键设置:
Label.layout_direction:设为LAYOUT_DIRECTION_RTL强制从右到左Label.text_direction:设为TEXT_DIRECTION_RTL或TEXT_DIRECTION_AUTO- 整个 UI 布局可能需要镜像(按钮顺序、对齐方式等)
本章小结
| 无障碍特性 | 受益人群 | 实现难度 |
|---|---|---|
| 色盲支持 | 色觉障碍玩家 | 低 |
| 字幕系统 | 听障玩家 | 中 |
| 按键自定义 | 所有玩家 | 中 |
| 本地化 | 非母语玩家 | 中 |
| 文本自适应 | 所有语言的 UI | 低 |
| 多字体管理 | 多语言游戏 | 中 |
| RTL 支持 | 阿拉伯/希伯来玩家 | 中 |
相关章节
- 基础篇 - 场景搭建:UI 组件基础
- 移动端适配:移动端的 UI 适配
- 存档系统:保存玩家的语言和无障碍偏好
