10. 打磨与发布
2026/4/14大约 10 分钟
10. 坦克大战——打磨与发布
简介
打磨(Polish)就像装修房子——房子盖好了,但还需要刷墙、摆家具、打扫卫生,才能入住。游戏也一样——功能都写好了,但还需要调整手感、修复bug、优化性能,才能发布给玩家。
发布(Release)就是把游戏"打包"成可以在不同平台上运行的程序——比如 Windows 的 .exe 文件、安卓的 .apk 文件等。
双人对战模式
经典坦克大战支持两个玩家同时在一个键盘上操作。每个玩家用不同的按键控制自己的坦克。
操作按键设置
| 操作 | 玩家1 | 玩家2 |
|---|---|---|
| 上 | W | 上箭头 |
| 下 | S | 下箭头 |
| 左 | A | 左箭头 |
| 右 | D | 右箭头 |
| 射击 | Space | Enter |
输入映射配置
在 Godot 的 项目设置 → 输入映射 中添加以下映射:
| 动作名称 | 按键 |
|---|---|
| p1_move_up | W |
| p1_move_down | S |
| p1_move_left | A |
| p1_move_right | D |
| p1_shoot | Space |
| p2_move_up | 上箭头 |
| p2_move_down | 下箭头 |
| p2_move_left | 左箭头 |
| p2_move_right | 右箭头 |
| p2_shoot | Enter |
双人模式管理器
// GameMode.cs - 游戏模式管理
using Godot;
public enum GameMode
{
SinglePlayer, // 单人模式
TwoPlayer // 双人对战
}
public partial class GameModeManager : Node
{
// 当前游戏模式
public GameMode CurrentMode { get; private set; } = GameMode.SinglePlayer;
// 玩家2的引用
private PlayerTank _player2;
public void SetGameMode(GameMode mode)
{
CurrentMode = mode;
GD.Print($"游戏模式切换为: {mode}");
}
// 在双人模式下生成第二个玩家
public void SpawnPlayer2(Node2D container)
{
if (CurrentMode != GameMode.TwoPlayer) return;
if (_player2 != null) return;
var playerScene = GD.Load<PackedScene>("res://scenes/tanks/player.tscn");
_player2 = playerScene.Instantiate<PlayerTank>();
_player2.Position = new Vector2(336, 576); // 第二个玩家的出生位置
_player2.Name = "Player2";
// 玩家2使用不同的输入映射
_player2.SetInputPrefix("p2_");
container.AddChild(_player2);
}
// 切换回单人模式时移除玩家2
public void RemovePlayer2()
{
if (_player2 != null)
{
_player2.QueueFree();
_player2 = null;
}
}
}# game_mode_manager.gd - 游戏模式管理
extends Node
# 游戏模式枚举
enum Mode {
SINGLE_PLAYER, # 单人模式
TWO_PLAYER # 双人对战
}
# 当前游戏模式
var current_mode: int = Mode.SINGLE_PLAYER
# 玩家2的引用
var player2: Node = null
## 设置游戏模式
func set_game_mode(mode: int) -> void:
current_mode = mode
print("游戏模式切换为: %s" % ["单人", "双人"][mode])
## 在双人模式下生成第二个玩家
func spawn_player2(container: Node2D) -> void:
if current_mode != Mode.TWO_PLAYER:
return
if player2 != null:
return
var player_scene = load("res://scenes/tanks/player.tscn")
player2 = player_scene.instantiate()
player2.position = Vector2(336, 576) # 第二个玩家的出生位置
player2.name = "Player2"
# 玩家2使用不同的输入映射
player2.set_input_prefix("p2_")
container.add_child(player2)
## 切换回单人模式时移除玩家2
func remove_player2() -> void:
if player2 != null:
player2.queue_free()
player2 = null玩家坦克的输入前缀
修改 PlayerTank,支持不同的输入前缀:
// 在 PlayerTank.cs 中添加
private string _inputPrefix = "p1_"; // 默认玩家1
// 设置输入前缀
public void SetInputPrefix(string prefix)
{
_inputPrefix = prefix;
}
// 修改输入处理方法
private void HandleMovementInput()
{
if (Input.IsActionPressed(_inputPrefix + "move_up"))
{
SetDirection(GameManager.Direction.Up);
}
else if (Input.IsActionPressed(_inputPrefix + "move_down"))
{
SetDirection(GameManager.Direction.Down);
}
else if (Input.IsActionPressed(_inputPrefix + "move_left"))
{
SetDirection(GameManager.Direction.Left);
}
else if (Input.IsActionPressed(_inputPrefix + "move_right"))
{
SetDirection(GameManager.Direction.Right);
}
}
private void HandleShootInput()
{
if (Input.IsActionPressed(_inputPrefix + "shoot") && _shootTimer <= 0)
{
Shoot();
_shootTimer = _shootCooldown;
}
}# 在 player_tank.gd 中添加
var input_prefix: String = "p1_" # 默认玩家1
## 设置输入前缀
func set_input_prefix(prefix: String) -> void:
input_prefix = prefix
## 修改输入处理方法
func handle_movement_input() -> void:
if Input.is_action_pressed(input_prefix + "move_up"):
set_direction(GameManager.Direction.UP)
elif Input.is_action_pressed(input_prefix + "move_down"):
set_direction(GameManager.Direction.DOWN)
elif Input.is_action_pressed(input_prefix + "move_left"):
set_direction(GameManager.Direction.LEFT)
elif Input.is_action_pressed(input_prefix + "move_right"):
set_direction(GameManager.Direction.RIGHT)
func handle_shoot_input() -> void:
if Input.is_action_pressed(input_prefix + "shoot") and shoot_timer <= 0:
shoot()
shoot_timer = shoot_cooldown自定义关卡分享
玩家创建的自定义关卡保存在 user://custom_levels/ 目录中。要分享关卡,玩家需要把 JSON 文件复制出来。
导出关卡
// MapShare.cs - 地图分享工具
using Godot;
public partial class MapShare : Node
{
// 把地图数据导出为可分享的字符串
public static string ExportMapToString(int[,] mapData, string mapName)
{
var exportData = new Godot.Collections.Dictionary
{
{ "name", mapName },
{ "version", "1.0" },
{ "game", "tank_battle" },
{ "cols", mapData.GetLength(1) },
{ "rows", mapData.GetLength(0) },
{ "map", Convert2DArray(mapData) }
};
return Json.Stringify(exportData);
}
// 从分享字符串导入地图
public static int[,] ImportMapFromString(string jsonString)
{
var json = new Json();
if (json.Parse(jsonString) != Error.Ok)
{
GD.PrintErr("地图数据解析失败");
return null;
}
var data = json.Data.AsGodotDictionary();
// 验证是坦克大战的地图
if (!data.ContainsKey("game") || (string)data["game"] != "tank_battle")
{
GD.PrintErr("这不是坦克大战的地图数据");
return null;
}
var mapArray = (Godot.Collections.Array)data["map"];
int rows = mapArray.Count;
int cols = ((Godot.Collections.Array)mapArray[0]).Count;
var result = new int[rows, cols];
for (int r = 0; r < rows; r++)
{
var rowData = (Godot.Collections.Array)mapArray[r];
for (int c = 0; c < cols; c++)
{
result[r, c] = (int)rowData[c];
}
}
return result;
}
// 把地图复制到系统剪贴板
public static void CopyMapToClipboard(string jsonString)
{
DisplayServer.ClipboardSet(jsonString);
GD.Print("地图已复制到剪贴板!");
}
// 从系统剪贴板读取地图
public static string ReadMapFromClipboard()
{
return DisplayServer.ClipboardGet();
}
// 转换二维数组
private static Godot.Collections.Array Convert2DArray(int[,] array)
{
var result = new Godot.Collections.Array();
for (int r = 0; r < array.GetLength(0); r++)
{
var row = new Godot.Collections.Array();
for (int c = 0; c < array.GetLength(1); c++)
{
row.Add(array[r, c]);
}
result.Add(row);
}
return result;
}
}# map_share.gd - 地图分享工具
extends Node
## 把地图数据导出为可分享的字符串
static func export_map_to_string(map_data: Array, map_name: String) -> String:
var export_data = {
"name": map_name,
"version": "1.0",
"game": "tank_battle",
"cols": map_data[0].size(),
"rows": map_data.size(),
"map": map_data
}
return JSON.stringify(export_data)
## 从分享字符串导入地图
static func import_map_from_string(json_string: String) -> Variant:
var json = JSON.new()
if json.parse(json_string) != OK:
push_error("地图数据解析失败")
return null
var data = json.data
# 验证是坦克大战的地图
if not data.has("game") or data.game != "tank_battle":
push_error("这不是坦克大战的地图数据")
return null
return data.map
## 把地图复制到系统剪贴板
static func copy_map_to_clipboard(json_string: String) -> void:
DisplayServer.clipboard_set(json_string)
print("地图已复制到剪贴板!")
## 从系统剪贴板读取地图
static func read_map_from_clipboard() -> String:
return DisplayServer.clipboard_get()性能优化
在发布之前,需要对游戏做一些性能优化,确保在各种设备上都能流畅运行。
优化清单
| 优化项 | 说明 | 预期效果 |
|---|---|---|
| 对象池 | 子弹和特效使用对象池复用 | 减少 GC 压力 |
| 视锥裁剪 | 飞出屏幕的子弹自动销毁 | 减少不必要的计算 |
| 纹理压缩 | 使用压缩纹理格式 | 减少显存占用 |
| 音效池 | 限制同时播放的音效数量 | 避免音频卡顿 |
| 固定帧率 | 设置合理的帧率上限 | 避免高刷屏幕浪费性能 |
// PerformanceSettings.cs - 性能设置
using Godot;
public partial class PerformanceSettings : Node
{
public override void _Ready()
{
ApplySettings();
}
private void ApplySettings()
{
// 设置目标帧率(60FPS 对大多数2D游戏足够)
Engine.MaxFps = 60;
// 启用视锥裁剪(只渲染摄像机可见的区域)
var viewport = GetViewport();
// 2D游戏默认就启用了视锥裁剪
// 低端设备优化
if (OS.GetProcessorCount() <= 2)
{
// 减少粒子数量
ProjectSettings.SetSetting("rendering/2d/particles/canvas_item_max_count", 64);
// 降低阴影质量
ProjectSettings.SetSetting("rendering/shadows/quality", 0);
}
GD.Print("性能优化设置已应用");
}
}# performance_settings.gd - 性能设置
extends Node
func _ready() -> void:
apply_settings()
func apply_settings() -> void:
# 设置目标帧率(60FPS 对大多数2D游戏足够)
Engine.max_fps = 60
# 低端设备优化
if OS.get_processor_count() <= 2:
# 减少粒子数量
ProjectSettings.set_setting("rendering/2d/particles/canvas_item_max_count", 64)
# 降低阴影质量
ProjectSettings.set_setting("rendering/shadows/quality", 0)
print("性能优化设置已应用")游戏导出
导出平台列表
| 平台 | 目标文件格式 | 最低要求 | 说明 |
|---|---|---|---|
| Windows Desktop | .exe | Windows 7+ | 最常见的PC平台 |
| macOS | .app | macOS 10.12+ | 苹果电脑 |
| Linux | .x86_64 | 任意现代发行版 | Linux桌面 |
| Android | .apk | Android 6.0+ | 手机和平板 |
| Web (HTML5) | .html | 现代浏览器 | 网页游戏 |
Windows 导出步骤
安装导出模板
- 打开 Godot 编辑器
- 点击 编辑器 → 管理导出模板
- 下载并安装对应版本的模板
配置导出预设
- 点击 项目 → 导出
- 点击 添加,选择 Windows Desktop
- 配置以下选项:
| 设置项 | 推荐值 | 说明 |
|---|---|---|
| 应用程序名称 | TankBattle | 程序名 |
| 图标 | 自定义 .ico | 程序图标 |
| 启动画面 | 自定义图片 | 加载时的画面 |
| 64位 | 勾选 | 现代系统都是64位 |
| DXGI模式 | 默认 | DirectX 图形接口 |
| VRAM压缩 | 勾选 | 压缩纹理 |
- 执行导出
- 选择导出路径(比如桌面)
- 点击 导出项目
- 等待编译完成
- 得到
TankBattle.exe文件
Android 导出步骤
准备 Android SDK
- 在 Godot 中,点击 编辑器 → 编辑器设置 → 导出 → Android
- 设置 Android SDK 路径
配置导出预设
- 添加 Android 预设
- 填写以下信息:
| 设置项 | 说明 |
|---|---|
| 包名 | com.evocosmos.tankbattle(唯一标识) |
| 版本号 | 1.0 |
| 版本代码 | 1 |
| 最小 SDK | 23(Android 6.0) |
| 目标 SDK | 33(Android 13) |
- 移动端适配
- 添加虚拟摇杆和射击按钮
- 调整UI尺寸适配手机屏幕
// MobileControls.cs - 移动端虚拟控制器
using Godot;
public partial class MobileControls : CanvasLayer
{
// 虚拟摇杆
private TouchScreenButton _joystickBase;
private TouchScreenButton _joystickKnob;
private Vector2 _joystickCenter;
private float _joystickRadius = 50.0f;
// 射击按钮
private TouchScreenButton _fireButton;
public Vector2 JoystickDirection { get; private set; } = Vector2.Zero;
public bool IsFiring { get; private set; } = false;
public override void _Ready()
{
// 只在移动设备上显示
if (!OS.HasFeature("mobile"))
{
Visible = false;
return;
}
Visible = true;
SetupControls();
}
private void SetupControls()
{
// 设置虚拟摇杆
_joystickBase = GetNode<TouchScreenButton>("Joystick/Base");
_joystickKnob = GetNode<TouchScreenButton>("Joystick/Knob");
_joystickCenter = _joystickBase.Position;
// 设置射击按钮
_fireButton = GetNode<TouchScreenButton>("FireButton");
_fireButton.Pressed += () => IsFiring = true;
_fireButton.Released += () => IsFiring = false;
}
public override void _Process(double delta)
{
if (!Visible) return;
// 处理虚拟摇杆输入
if (Input.IsActionPressed("joystick_touch"))
{
var touchPos = GetViewport().Get_mouse_position();
var diff = touchPos - _joystickCenter;
float distance = Mathf.Min(diff.Length(), _joystickRadius);
var direction = diff.Normalized();
JoystickDirection = direction * (distance / _joystickRadius);
// 更新摇杆旋钮位置
_joystickKnob.Position = _joystickCenter + direction * distance;
}
else
{
JoystickDirection = Vector2.Zero;
_joystickKnob.Position = _joystickCenter;
}
}
}# mobile_controls.gd - 移动端虚拟控制器
extends CanvasLayer
# 虚拟摇杆
var joystick_base: TouchScreenButton
var joystick_knob: TouchScreenButton
var joystick_center: Vector2
var joystick_radius: float = 50.0
# 射击按钮
var fire_button: TouchScreenButton
var joystick_direction: Vector2 = Vector2.ZERO
var is_firing: bool = false
func _ready() -> void:
# 只在移动设备上显示
if not OS.has_feature("mobile"):
visible = false
return
visible = true
setup_controls()
func setup_controls() -> void:
# 设置虚拟摇杆
joystick_base = get_node("Joystick/Base") as TouchScreenButton
joystick_knob = get_node("Joystick/Knob") as TouchScreenButton
joystick_center = joystick_base.position
# 设置射击按钮
fire_button = get_node("FireButton") as TouchScreenButton
fire_button.pressed.connect(func(): is_firing = true)
fire_button.released.connect(func(): is_firing = false)
func _process(delta: float) -> void:
if not visible:
return
# 处理虚拟摇杆输入
if Input.is_action_pressed("joystick_touch"):
var touch_pos = get_viewport().get_mouse_position()
var diff = touch_pos - joystick_center
var distance = minf(diff.length(), joystick_radius)
var direction = diff.normalized()
joystick_direction = direction * (distance / joystick_radius)
# 更新摇杆旋钮位置
joystick_knob.position = joystick_center + direction * distance
else:
joystick_direction = Vector2.ZERO
joystick_knob.position = joystick_center发布前检查清单
在正式发布游戏之前,逐项检查以下内容:
功能检查
| 检查项 | 状态 | 说明 |
|---|---|---|
| 单人模式完整通关 | 待检查 | 从第1关玩到最后一关 |
| 双人模式正常工作 | 待检查 | 两个玩家能同时操作 |
| 地图编辑器正常工作 | 待检查 | 能创建、保存、加载地图 |
| 自定义关卡可玩 | 待检查 | 自定义关卡能正常游戏 |
| 暂停/恢复功能 | 待检查 | ESC键暂停,能继续或退出 |
| 音效正常播放 | 待检查 | 所有音效都能正常播放 |
| 分数正确计算 | 待检查 | 消灭不同敌人得分正确 |
性能检查
| 检查项 | 目标值 | 说明 |
|---|---|---|
| 帧率 | 稳定60FPS | 在低端设备上也不卡顿 |
| 内存占用 | < 200MB | 不应占用过多内存 |
| 加载时间 | < 3秒 | 关卡切换要快 |
| 安装包大小 | < 50MB | 压缩资源文件 |
兼容性检查
| 检查项 | 说明 |
|---|---|
| Windows 10/11 | 在主流 Windows 系统上测试 |
| 不同分辨率 | 从 720p 到 4K 都能正常显示 |
| 键盘冲突 | 确保两个玩家的按键不会互相干扰 |
总结
恭喜你完成了坦克大战的全部开发!让我们回顾一下这个项目的完整架构:
坦克大战
├── 核心系统
│ ├── GameManager(游戏总管理器)
│ ├── ScoreManager(分数管理)
│ └── GameModeManager(模式管理)
│
├── 坦克系统
│ ├── TankBase(坦克基类)
│ ├── PlayerTank(玩家坦克)
│ ├── EnemyTank(敌人坦克)
│ └── EnemyAI(敌人AI)
│
├── 战斗系统
│ ├── Bullet(子弹)
│ ├── BulletPool(子弹对象池)
│ └── TerrainCollision(地形碰撞)
│
├── 地图系统
│ ├── GameMap(游戏地图)
│ ├── MapEditor(地图编辑器)
│ ├── MapFileManager(地图文件管理)
│ └── MapValidator(地图验证)
│
├── 道具系统
│ ├── ItemBase(道具基类)
│ ├── StarItem(星星道具)
│ ├── BombItem(炸弹道具)
│ └── ShovelItem(铲子道具)
│
├── UI系统
│ ├── HUD(抬头显示)
│ ├── MainMenu(主菜单)
│ ├── StageSelectUI(关卡选择)
│ └── PauseMenu(暂停菜单)
│
├── 音效系统
│ ├── AudioManager(音效管理器)
│ └── ScreenShake(屏幕震动)
│
└── 特效系统
├── Explosion(爆炸特效)
├── SpawnEffect(出生特效)
└── MobileControls(移动端控制)通过这个项目,你学会了:
- 游戏循环设计:状态机驱动的游戏流程
- 网格系统:坐标转换、对齐、碰撞
- AI系统:状态机、概率决策
- 对象池模式:性能优化
- 编辑器工具:让玩家创造内容
- 多平台适配:PC 和移动端
- 游戏导出:打包发布
