1. 核心玩法设计
1. 坦克大战——核心玩法
本章你将掌握
可以学到的 Godot 技能
| 技能 | 说明 |
|---|---|
| TileMap 地图编辑器 | 用 TileMap 节点绘制关卡地图,每个格子存储地形类型(砖墙/钢墙/草地/水面) |
| 双人输入映射 | 用 InputMap 配置两套操作键,实现本地双人同屏模式 |
| 子弹弹射逻辑 | 子弹碰到钢墙时反弹,碰到砖墙时破坏地形格子 |
| 敌人 AI 巡逻 | 敌人自动寻路、随机转向,优先追踪并攻击玩家基地 |
| 地形破坏系统 | 子弹击中砖墙时,精确删除 TileMap 中被打中的格子 |
| 关卡数据加载 | 从 JSON 文件读取关卡地图布局和敌人波次配置 |
| 生命与复活系统 | 玩家被击中后扣除生命值,剩余命数归零时触发游戏结束 |
| 分屏显示 | 双人模式下用两个 SubViewport 实现各自视角的分屏画面 |
关键 Node 节点
TankBattle(Node2D)
├── GameMap(TileMap) ← 关卡地图(砖墙/钢墙/草地/水面/冰面)
├── Base(Area2D) ← 玩家基地(鹰标志)
│ └── CollisionShape2D ← 基地碰撞区
├── Players(Node2D) ← 玩家节点容器
│ ├── Player1(CharacterBody2D) ← 1P 坦克
│ │ ├── Sprite2D ← 坦克贴图
│ │ ├── CollisionShape2D ← 碰撞体
│ │ └── ShootTimer(Timer) ← 射击间隔计时器
│ └── Player2(CharacterBody2D) ← 2P 坦克(双人模式)
├── EnemySpawner(Node2D) ← 敌人生成器
│ └── SpawnTimer(Timer) ← 生成间隔计时器
├── BulletPool(Node2D) ← 子弹对象池
├── ExplosionPool(Node2D) ← 爆炸特效对象池
└── HUD(CanvasLayer) ← 游戏界面
├── LivesContainer(HBoxContainer)← 玩家生命数显示
├── EnemyCounter(Label) ← 剩余敌人数
└── StageLabel(Label) ← 当前关卡编号游戏核心节点
点击节点可跳转到对应文档查看详细说明。
游戏系统结构图
┌─────────────────────────────────────────────────────────────┐
│ 坦克大战 系统架构 │
├──────────────┬──────────────┬──────────────┬────────────────┤
│ 地图系统 │ 战斗系统 │ 敌人系统 │ 道具系统 │
│ │ │ │ │
│ TileMap编辑 │ 子弹对象池 │ 敌人对象池 │ 随机掉落 │
│ 地形碰撞 │ 弹射逻辑 │ AI巡逻 │ 武器升级 │
│ 砖墙破坏 │ 伤害判定 │ 追踪基地 │ 护盾道具 │
│ 钢墙反弹 │ 爆炸特效 │ 波次生成 │ 炸弹清屏 │
└──────────────┴──────────────┴──────────────┴────────────────┘
↓ ↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ 关卡系统 │
│ 关卡选择 │ 地图加载 │ 敌人配置 │ 通关判定 │
└─────────────────────────────────────────────────────────────┘1.1 简介
坦克大战(Tank Battle)是一款经典的俯视角射击游戏。想象你站在一座高塔上往下看,你的坦克在一个方形战场里,四周有各种墙壁和障碍物。你的任务很简单:驾驶坦克,消灭所有来犯的敌人,保护身后的基地不被摧毁。
这就像是在一个沙盘上玩打仗游戏——你控制一辆小坦克,敌人从地图上方不断出现,你们在迷宫一样的战场里互相射击,看谁能坚持到最后。
1.2 为什么要做这个项目?
坦克大战虽然看起来简单,但它几乎涵盖了2D游戏开发的所有核心知识点:
| 知识点 | 在游戏中的体现 |
|---|---|
| 网格移动 | 坦克在网格上对齐移动 |
| 碰撞检测 | 子弹打墙壁、坦克撞坦克 |
| AI系统 | 敌人自动巡逻和追踪 |
| 状态管理 | 游戏开始、进行、结束 |
| 关卡系统 | 多个关卡、自定义地图 |
| 道具系统 | 星星升级、炸弹清屏 |
| UI界面 | HUD、菜单、地图编辑器 |
做完这个项目,你就掌握了制作大多数2D游戏的"基本功"。
1.3 游戏核心循环
每个游戏都有一个核心循环(Game Loop),就像钟表一样不停地转。坦克大战的核心循环是这样的:
游戏开始
↓
敌人从上方出生 → 玩家控制坦克移动和射击 → 子弹飞行和碰撞检测
↓ ↓
判断结果 ←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←←
↓
所有敌人消灭? → 是 → 进入下一关
↓ 否
基地被摧毁或玩家死亡? → 是 → 游戏结束
↓ 否
回到"敌人从上方出生"继续循环用大白话说就是:敌人来了 → 你打敌人 → 打完了再来一波 → 全部打完过关 → 没打过就游戏结束。
1.4 四种敌人类型
经典的坦克大战里有四种不同类型的敌人,就像打怪升级游戏里的不同小怪:
| 敌人类型 | 外观颜色 | 特点 | 击杀得分 |
|---|---|---|---|
| 基础坦克 | 银色/灰色 | 移动慢、子弹慢、血量低,最菜的那种 | 100分 |
| 快速坦克 | 浅蓝色 | 移动快、子弹慢、血量低,跑得飞快 | 200分 |
| 强力坦克 | 绿色 | 移动慢、子弹快、需要打两枪才死 | 300分 |
| 装甲坦克 | 红色 | 移动慢、子弹快、需要打四枪才死,最硬的boss | 400分 |
敌人出生规则
每一关的敌人总数是固定的(通常是20辆),但屏幕上同时存在的敌人数量有限制(通常是4辆)。这就像公交车站——总共20个人要坐车,但公交车一次只能载4个人,走了一辆再来一辆。
// 敌人生成管理器的基本逻辑
public partial class EnemySpawner : Node
{
// 这一关总共要生成多少敌人
[Export] public int TotalEnemies { get; set; } = 20;
// 屏幕上最多同时存在几个敌人
[Export] public int MaxOnScreen { get; set; } = 4;
// 已经生成了多少敌人
private int _spawnedCount = 0;
// 当前屏幕上有多少敌人
private int _currentOnScreen = 0;
// 敌人出生点的位置列表
[Export] public Vector2[] SpawnPoints { get; set; } = new Vector2[3];
public override void _Process(double delta)
{
// 如果还有敌人没生成,而且屏幕上的敌人没满
if (_spawnedCount < TotalEnemies && _currentOnScreen < MaxOnScreen)
{
// 生成一个新敌人
SpawnEnemy();
}
}
private void SpawnEnemy()
{
// 随机选一个出生点
int pointIndex = (int)(GD.Randi() % SpawnPoints.Length);
// 创建敌人坦克
var enemy = SceneManager.Instance.CreateEnemy();
enemy.Position = SpawnPoints[pointIndex];
// 把敌人加到场景里
AddChild(enemy);
// 计数器更新
_spawnedCount++;
_currentOnScreen++;
}
}# 敌人生成管理器的基本逻辑
extends Node
## 这一关总共要生成多少敌人
@export var total_enemies: int = 20
## 屏幕上最多同时存在几个敌人
@export var max_on_screen: int = 4
## 已经生成了多少敌人
var spawned_count: int = 0
## 当前屏幕上有多少敌人
var current_on_screen: int = 0
## 敌人出生点的位置列表
@export var spawn_points: Array[Vector2] = [Vector2.ZERO, Vector2.ZERO, Vector2.ZERO]
func _process(delta: float) -> void:
# 如果还有敌人没生成,而且屏幕上的敌人没满
if spawned_count < total_enemies and current_on_screen < max_on_screen:
# 生成一个新敌人
spawn_enemy()
func spawn_enemy() -> void:
# 随机选一个出生点
var point_index: int = randi() % spawn_points.size()
# 创建敌人坦克
var enemy = SceneManager.create_enemy()
enemy.position = spawn_points[point_index]
# 把敌人加到场景里
add_child(enemy)
# 计数器更新
spawned_count += 1
current_on_screen += 11.5 地形系统
坦克大战的战场不是空荡荡的,上面有各种地形。你可以把地形理解成"棋盘上的格子"——每个格子可以是空的、是墙、是草地、是水。
地形类型一览
| 地形类型 | 作用 | 是否可破坏 | 是否可穿过 |
|---|---|---|---|
| 空地 | 什么都没有,可以自由通过 | - | 是 |
| 砖墙 | 普通墙壁,子弹可以打碎 | 是(一枪碎一块) | 否 |
| 钢墙 | 金属墙壁,普通子弹打不碎 | 否(高级子弹才能碎) | 否 |
| 草地 | 坦克可以穿过,但会遮挡视线 | 否 | 是 |
| 水面 | 坦克不能通过,子弹可以飞过去 | 否 | 否 |
| 冰面 | 坦克会打滑,不容易停下来 | 否 | 是 |
打个比方:砖墙就像纸箱子,一撞就破;钢墙就像铁门,撞不坏;草地就像灌木丛,你能钻过去但看不见外面;水面就像小河,你过不去但子弹能飞过去;冰面就像冬天结冰的路面,踩上去会打滑。
地形数据结构
地图用一个二维数组(可以理解成一张表格)来存储。每个数字代表一种地形:
// 地形类型枚举
public enum TileType
{
Empty = 0, // 空地
Brick = 1, // 砖墙
Steel = 2, // 钢墙
Grass = 3, // 草地
Water = 4, // 水面
Ice = 5, // 冰面
Base = 9 // 基地(鹰标志)
}
// 地图数据类
public partial class GameMap : Node2D
{
// 地图网格大小(13列 x 13行)
private const int MAP_COLS = 13;
private const int MAP_ROWS = 13;
// 每个格子的大小(像素)
private const int TILE_SIZE = 48;
// 地图数据:数字表示地形类型
private int[,] _mapData = new int[MAP_ROWS, MAP_COLS];
// 获取某个位置的地形类型
public TileType GetTile(int row, int col)
{
if (row < 0 || row >= MAP_ROWS || col < 0 || col >= MAP_COLS)
return TileType.Steel; // 超出边界当作钢墙
return (TileType)_mapData[row, col];
}
// 设置某个位置的地形类型
public void SetTile(int row, int col, TileType type)
{
if (row >= 0 && row < MAP_ROWS && col >= 0 && col < MAP_COLS)
{
_mapData[row, col] = (int)type;
RenderTile(row, col); // 重新渲染这个格子
}
}
// 把世界坐标转换成网格坐标
public Vector2I WorldToGrid(Vector2 worldPos)
{
return new Vector2I(
(int)(worldPos.X / TILE_SIZE),
(int)(worldPos.Y / TILE_SIZE)
);
}
// 把网格坐标转换成世界坐标
public Vector2 GridToWorld(int row, int col)
{
return new Vector2(col * TILE_SIZE, row * TILE_SIZE);
}
}# 地形类型枚举
enum TileType {
EMPTY = 0, # 空地
BRICK = 1, # 砖墙
STEEL = 2, # 钢墙
GRASS = 3, # 草地
WATER = 4, # 水面
ICE = 5, # 冰面
BASE = 9 # 基地(鹰标志)
}
# 地图数据类
extends Node2D
# 地图网格大小(13列 x 13行)
const MAP_COLS: int = 13
const MAP_ROWS: int = 13
# 每个格子的大小(像素)
const TILE_SIZE: int = 48
# 地图数据:数字表示地形类型
var map_data: Array = []
func _ready() -> void:
# 初始化空的地图数据
map_data = []
for row in MAP_ROWS:
var row_data: Array = []
for col in MAP_COLS:
row_data.append(TileType.EMPTY)
map_data.append(row_data)
## 获取某个位置的地形类型
func get_tile(row: int, col: int) -> int:
if row < 0 or row >= MAP_ROWS or col < 0 or col >= MAP_COLS:
return TileType.STEEL # 超出边界当作钢墙
return map_data[row][col]
## 设置某个位置的地形类型
func set_tile(row: int, col: int, type: int) -> void:
if row >= 0 and row < MAP_ROWS and col >= 0 and col < MAP_COLS:
map_data[row][col] = type
render_tile(row, col) # 重新渲染这个格子
## 把世界坐标转换成网格坐标
func world_to_grid(world_pos: Vector2) -> Vector2i:
return Vector2i(int(world_pos.x / TILE_SIZE), int(world_pos.y / TILE_SIZE))
## 把网格坐标转换成世界坐标
func grid_to_world(row: int, col: int) -> Vector2:
return Vector2(col * TILE_SIZE, row * TILE_SIZE)1.6 基地保护机制
基地是地图最下方中间的鹰标志。这是整个游戏最重要的东西——基地被摧毁,游戏直接结束,不管你还有多少条命、打了多少分。
基地的工作方式
- 基地一开始是被砖墙包围的,像一个被围墙保护的小房子
- 如果敌人的子弹打到了基地,基地就坏了,游戏结束
- 玩家可以通过获取铲子道具来临时把砖墙变成钢墙(不可破坏),保护基地
// 基地节点
public partial class Base : Area2D
{
// 基地是否还活着
public bool IsAlive { get; private set; } = true;
// 基地被摧毁时发出信号
[Signal] public delegate void BaseDestroyedEventHandler();
// 基地被击中
private void OnBodyEntered(Node2D body)
{
// 只有子弹能摧毁基地
if (body is Bullet bullet && !bullet.IsPlayerBullet)
{
DestroyBase();
}
}
// 摧毁基地
private void DestroyBase()
{
if (!IsAlive) return;
IsAlive = false;
// 播放爆炸效果
var explosion = SceneManager.Instance.CreateExplosion();
explosion.Position = Position;
GetParent().AddChild(explosion);
// 切换到被摧毁的精灵图
var sprite = GetNode<Sprite2D>("Sprite");
sprite.Frame = 1; // 第0帧是完好的鹰,第1帧是被炸的鹰
// 发出信号,通知GameManager游戏结束
EmitSignal(SignalName.BaseDestroyed);
}
}# 基地节点
extends Area2D
# 基地是否还活着
var is_alive: bool = true
# 基地被摧毁时发出信号
signal base_destroyed
# 基地被击中
func _on_body_entered(body: Node2D) -> void:
# 只有敌人的子弹能摧毁基地
if body is Bullet and not body.is_player_bullet:
destroy_base()
# 摧毁基地
func destroy_base() -> void:
if not is_alive:
return
is_alive = false
# 播放爆炸效果
var explosion = SceneManager.create_explosion()
explosion.position = position
get_parent().add_child(explosion)
# 切换到被摧毁的精灵图
var sprite = get_node("Sprite") as Sprite2D
sprite.frame = 1 # 第0帧是完好的鹰,第1帧是被炸的鹰
# 发出信号,通知GameManager游戏结束
base_destroyed.emit()1.7 玩家生命与得分
生命系统
- 玩家一开始有 3条命
- 被敌人子弹打中,失去一条命,在出生点重新出现
- 3条命用完,游戏结束
得分系统
消灭不同类型的敌人获得不同分数。你可以把分数想象成"考试成绩"——消灭越难的敌人,得分越高:
| 敌人类型 | 分值 |
|---|---|
| 基础坦克 | 100分 |
| 快速坦克 | 200分 |
| 强力坦克 | 300分 |
| 装甲坦克 | 400分 |
// 得分管理器
public partial class ScoreManager : Node
{
// 当前分数
public int CurrentScore { get; private set; } = 0;
// 最高分
public int HighScore { get; private set; } = 0;
// 每种敌人的分值表
private static readonly Dictionary<EnemyType, int> ScoreTable = new()
{
{ EnemyType.Basic, 100 },
{ EnemyType.Fast, 200 },
{ EnemyType.Power, 300 },
{ EnemyType.Armor, 400 }
};
// 敌人被消灭时加分
public void AddScore(EnemyType enemyType)
{
if (ScoreTable.TryGetValue(enemyType, out int score))
{
CurrentScore += score;
// 更新最高分
if (CurrentScore > HighScore)
{
HighScore = CurrentScore;
SaveHighScore(); // 保存到本地文件
}
}
}
// 保存最高分到文件
private void SaveHighScore()
{
var saveData = new Godot.Collections.Dictionary
{
{ "high_score", HighScore }
};
using var file = FileAccess.Open("user://tank_battle_save.json", FileAccess.ModeFlags.Write);
file.StoreString(Json.Stringify(saveData));
}
// 从文件读取最高分
public void LoadHighScore()
{
if (!FileAccess.FileExists("user://tank_battle_save.json"))
return;
using var file = FileAccess.Open("user://tank_battle_save.json", FileAccess.ModeFlags.Read);
var json = new Json();
json.Parse(file.GetAsText());
var data = json.Data.AsGodotDictionary();
HighScore = (int)data["high_score"];
}
}# 得分管理器
extends Node
# 当前分数
var current_score: int = 0
# 最高分
var high_score: int = 0
# 每种敌人的分值表
var score_table: Dictionary = {
EnemyType.BASIC: 100,
EnemyType.FAST: 200,
EnemyType.POWER: 300,
EnemyType.ARMOR: 400
}
## 敌人被消灭时加分
func add_score(enemy_type: int) -> void:
if enemy_type in score_table:
current_score += score_table[enemy_type]
# 更新最高分
if current_score > high_score:
high_score = current_score
save_high_score() # 保存到本地文件
## 保存最高分到文件
func save_high_score() -> void:
var save_data = {"high_score": high_score}
var file = FileAccess.open("user://tank_battle_save.json", FileAccess.WRITE)
file.store_string(JSON.stringify(save_data))
## 从文件读取最高分
func load_high_score() -> void:
if not FileAccess.file_exists("user://tank_battle_save.json"):
return
var file = FileAccess.open("user://tank_battle_save.json", FileAccess.READ)
var json = JSON.new()
json.parse(file.get_as_text())
var data = json.data
high_score = data.high_score1.8 游戏状态管理
整个游戏有几种不同的"状态",就像交通灯有红、黄、绿三种状态一样:
| 游戏状态 | 说明 |
|---|---|
| 主菜单 | 游戏刚打开,显示标题和"开始游戏"按钮 |
| 关卡选择 | 选择要玩的关卡 |
| 游戏进行中 | 正在打坦克 |
| 关卡过渡 | 这一关打完了,显示"Stage Clear" |
| 游戏结束 | 基地被摧毁或命用完了 |
// 游戏状态枚举
public enum GameState
{
MainMenu,
StageSelect,
Playing,
StageClear,
GameOver
}
// 游戏管理器(总控制器)
public partial class GameManager : Node
{
// 当前游戏状态
public GameState CurrentState { get; private set; } = GameState.MainMenu;
// 当前关卡
public int CurrentStage { get; private set; } = 1;
// 玩家剩余生命数
public int PlayerLives { get; private set; } = 3;
// 状态改变时发出信号
[Signal] public delegate void StateChangedEventHandler(GameState newState);
// 切换游戏状态
public void ChangeState(GameState newState)
{
var oldState = CurrentState;
CurrentState = newState;
GD.Print($"游戏状态切换: {oldState} → {newState}");
// 根据新状态做不同的处理
switch (newState)
{
case GameState.MainMenu:
ShowMainMenu();
break;
case GameState.Playing:
StartStage(CurrentStage);
break;
case GameState.StageClear:
OnStageClear();
break;
case GameState.GameOver:
OnGameOver();
break;
}
EmitSignal(SignalName.StateChanged, (int)newState);
}
// 玩家死亡
public void OnPlayerDied()
{
PlayerLives--;
if (PlayerLives <= 0)
{
ChangeState(GameState.GameOver);
}
else
{
// 还有命,重生
RespawnPlayer();
}
}
// 下一关
public void NextStage()
{
CurrentStage++;
ChangeState(GameState.Playing);
}
}# 游戏状态枚举
enum GameState {
MAIN_MENU,
STAGE_SELECT,
PLAYING,
STAGE_CLEAR,
GAME_OVER
}
# 游戏管理器(总控制器)
extends Node
# 当前游戏状态
var current_state: int = GameState.MAIN_MENU
# 当前关卡
var current_stage: int = 1
# 玩家剩余生命数
var player_lives: int = 3
# 状态改变时发出信号
signal state_changed(new_state: int)
## 切换游戏状态
func change_state(new_state: int) -> void:
var old_state = current_state
current_state = new_state
print("游戏状态切换: %s → %s" % [old_state, new_state])
# 根据新状态做不同的处理
match new_state:
GameState.MAIN_MENU:
show_main_menu()
GameState.PLAYING:
start_stage(current_stage)
GameState.STAGE_CLEAR:
on_stage_clear()
GameState.GAME_OVER:
on_game_over()
state_changed.emit(new_state)
## 玩家死亡
func on_player_died() -> void:
player_lives -= 1
if player_lives <= 0:
change_state(GameState.GAME_OVER)
else:
# 还有命,重生
respawn_player()
## 下一关
func next_stage() -> void:
current_stage += 1
change_state(GameState.PLAYING)下一章预告
这一章我们了解了坦克大战的整体设计。下一章我们将正式开始搭建项目——创建项目结构、编写游戏管理器、搭建场景骨架。就像盖房子要先打地基一样,项目搭建是后续所有功能的基础。
