3. 地图与路径
塔防——地图与路径
想象你在一张白纸上画了一条蜿蜒的小路,路的起点在地图左边,终点在右边。敌人们会像排队过独木桥一样,一个接一个地沿着这条路走过来。你的任务就是在路的两边放塔,在敌人走过的时候消灭它们。
地图和路径是塔防游戏的"战场"。本章你将学会如何用 TileMap 画地图,以及如何用 NavigationRegion2D 让敌人知道该往哪走。
用 TileMap 绘制地图
TileMap(瓦片地图)是 Godot 中绘制 2D 地图的核心工具。你可以把它想象成一堵用瓷砖铺成的墙——每一块"瓷砖"就是一个小方格,你可以在方格里放草地、石头、水面等不同图案。
创建 TileMap
- 在游戏主场景中添加
TileMap节点 - 在属性面板中设置 Tile Set(瓦片集)
- 用 TileMap 编辑器在画布上绘制地图
网格设置
塔防游戏需要网格来决定塔放在哪里。在 TileMap 的属性中设置:
| 设置项 | 值 | 说明 |
|---|---|---|
| Rendering → Tile Origin | Top Left | 瓦片左上角对齐网格 |
| Physics → Collision Enabled | 勾选 | 启用碰撞 |
| Navigation → Navigation Enabled | 勾选 | 启用导航 |
图层设计
建议至少使用两个图层:
| 图层 | 名称 | 内容 |
|---|---|---|
| 0 | Ground | 草地、泥土等地面 |
| 1 | Path | 道路、装饰物 |
多图层的好处
地面和路径分开画,修改路径时不会误改地面。就像画画时先用铅笔画底稿,再上色——各层互不干扰。
用 NavigationRegion2D 定义敌人路径
NavigationRegion2D 是 Godot 的"导航区域"节点。你可以把它想象成一张给 GPS 用的地图——你在上面画出哪些地方可以走、哪些地方不能走,然后敌人就可以自动找到路。
创建导航区域
- 在 Map 节点下添加
NavigationRegion2D - 在属性面板中设置 Navigation Layer 为第 1 层
- 使用 NavigationPolygon 编辑器绘制可行走区域
绘制路径区域
在 NavigationPolygon 编辑器中:
- 点击 绘制多边形 工具
- 在路径上点击多个点,围出敌人可以行走的区域
- 点击第一个点闭合多边形
路径宽度要够
敌人有碰撞体积,路径区域必须比敌人宽,否则敌人会被卡住。建议路径宽度至少是敌人宽度的 2 倍。
敌人沿路径移动
敌人怎么知道该往哪走?答案是 NavigationAgent2D。这个节点就像一个内置的 GPS 导航,你只需要告诉它"目的地在哪",它会自动帮你算出最短路线。
NavigationAgent2D 基础代码
using Godot;
/// <summary>
/// 敌人基类——沿导航路径移动。
/// NavigationAgent2D 会自动计算从当前位置到目标点的路线,
/// 我们只需要让它沿着路线走就行了。
/// </summary>
public partial class EnemyBase : CharacterBody2D
{
[Export] public float Speed { get; set; } = 100.0f; // 移动速度
[Export] public int MaxHealth { get; set; } = 100; // 最大生命值
private NavigationAgent2D _navigationAgent;
private int _health;
private Vector2 _direction = Vector2.Zero;
/// <summary>当前生命值</summary>
public int Health
{
get => _health;
set
{
_health = Mathf.Max(0, value);
// 生命值归零时敌人死亡
if (_health <= 0)
Die();
}
}
public override void _Ready()
{
_navigationAgent = GetNode<NavigationAgent2D>("NavigationAgent2D");
_health = MaxHealth;
// 设置目标位置为路径终点
_navigationAgent.TargetPosition = GetNode<Node2D>(
"/root/Game/Map/PathEnd").GlobalPosition;
// 等待导航计算完成后开始移动
CallDeferred(MethodName.OnNavigationReady);
}
/// <summary>导航就绪后的初始化</summary>
private void OnNavigationReady()
{
_navigationAgent.VelocityComputed += OnVelocityComputed;
}
public override void _PhysicsProcess(double delta)
{
if (_navigationAgent.IsNavigationFinished())
{
// 到达终点,扣玩家生命值
GameManager.Instance.Lives -= 1;
QueueFree();
return;
}
// 获取下一个路径点的位置
Vector2 targetPosition = _navigationAgent.GetNextPathPosition();
_direction = GlobalPosition.DirectionTo(targetPosition);
// 设置期望速度
_navigationAgent.Velocity = _direction * Speed;
}
/// <summary>导航代理计算完速度后调用</summary>
private void OnVelocityComputed(Vector2 safeVelocity)
{
Velocity = safeVelocity;
MoveAndSlide();
}
/// <summary>敌人死亡</summary>
private void Die()
{
// 播放死亡动画
// 给玩家金币奖励
GameManager.Instance.Gold += 10;
QueueFree();
}
/// <summary>受到伤害</summary>
public void TakeDamage(int damage)
{
Health -= damage;
}
}extends CharacterBody2D
## 敌人基类——沿导航路径移动。
## NavigationAgent2D 会自动计算从当前位置到目标点的路线,
## 我们只需要让它沿着路线走就行了。
@export var speed: float = 100.0 # 移动速度
@export var max_health: int = 100 # 最大生命值
@onready var navigation_agent: NavigationAgent2D = $NavigationAgent2D
var health: int
var direction: Vector2 = Vector2.ZERO
## 当前生命值
var _health: int:
get:
return health
set(value):
health = max(0, value)
if health <= 0:
die()
func _ready():
health = max_health
# 设置目标位置为路径终点
navigation_agent.target_position = get_node(
"/root/Game/Map/PathEnd").global_position
# 等待导航计算完成后开始移动
call_deferred("_on_navigation_ready")
## 导航就绪后的初始化
func _on_navigation_ready():
navigation_agent.velocity_computed.connect(_on_velocity_computed)
func _physics_process(_delta):
if navigation_agent.is_navigation_finished():
# 到达终点,扣玩家生命值
GameManager.instance.lives -= 1
queue_free()
return
# 获取下一个路径点的位置
var target_position = navigation_agent.get_next_path_position()
direction = global_position.direction_to(target_position)
# 设置期望速度
navigation_agent.velocity = direction * speed
## 导航代理计算完速度后调用
func _on_velocity_computed(safe_velocity: Vector2):
velocity = safe_velocity
move_and_slide()
## 敌人死亡
func die():
# 播放死亡动画
# 给玩家金币奖励
GameManager.instance.gold += 10
queue_free()
## 受到伤害
func take_damage(damage: int):
_health = damage网格吸附系统
塔只能放在网格上,不能随意乱放。这个"自动对齐到网格"的功能叫网格吸附。
什么是网格吸附
就像你在方格纸上画画,笔尖会自动"跳"到最近的格线交叉点上。网格吸附让塔整齐地排列在地图上,不会出现两个塔重叠或歪歪扭扭的情况。
网格管理器代码
using Godot;
/// <summary>
/// 网格管理器——负责把任意坐标"吸附"到最近的网格点上,
/// 并判断某个网格位置是否已经被占了。
/// </summary>
public partial class GridManager : Node2D
{
[Export] public int CellSize { get; set; } = 64; // 网格大小(像素)
// 记录哪些网格位置已经被占用
// Key 是 "x,y" 格式的字符串,Value 是放在该位置的塔
private readonly Dictionary<string, Node2D> _occupiedCells = new();
/// <summary>
/// 把一个世界坐标吸附到最近的网格中心点。
/// 比如网格大小是 64,坐标 (100, 50) 会被吸附到 (128, 64)。
/// </summary>
public Vector2 SnapToGrid(Vector2 worldPosition)
{
int snappedX = (int)Mathf.Round(worldPosition.X / CellSize) * CellSize;
int snappedY = (int)Mathf.Round(worldPosition.Y / CellSize) * CellSize;
return new Vector2(snappedX, snappedY);
}
/// <summary>检查某个网格位置是否可放置</summary>
public bool IsCellAvailable(Vector2 gridPosition)
{
string key = $"{(int)gridPosition.X},{(int)gridPosition.Y}";
return !_occupiedCells.ContainsKey(key);
}
/// <summary>标记一个网格位置为已占用</summary>
public void OccupyCell(Vector2 gridPosition, Node2D tower)
{
string key = $"{(int)gridPosition.X},{(int)gridPosition.Y}";
_occupiedCells[key] = tower;
}
/// <summary>释放一个网格位置</summary>
public void FreeCell(Vector2 gridPosition)
{
string key = $"{(int)gridPosition.X},{(int)gridPosition.Y}";
_occupiedCells.Remove(key);
}
}extends Node2D
## 网格管理器——负责把任意坐标"吸附"到最近的网格点上,
## 并判断某个网格位置是否已经被占了。
@export var cell_size: int = 64 # 网格大小(像素)
# 记录哪些网格位置已经被占用
# Key 是 "x,y" 格式的字符串,Value 是放在该位置的塔
var _occupied_cells: Dictionary = {}
## 把一个世界坐标吸附到最近的网格中心点。
## 比如网格大小是 64,坐标 (100, 50) 会被吸附到 (128, 64)。
func snap_to_grid(world_position: Vector2) -> Vector2:
var snapped_x = round(world_position.x / cell_size) * cell_size
var snapped_y = round(world_position.y / cell_size) * cell_size
return Vector2(snapped_x, snapped_y)
## 检查某个网格位置是否可放置
func is_cell_available(grid_position: Vector2) -> bool:
var key = "%d,%d" % [int(grid_position.x), int(grid_position.y)]
return not _occupied_cells.has(key)
## 标记一个网格位置为已占用
func occupy_cell(grid_position: Vector2, tower: Node2D) -> void:
var key = "%d,%d" % [int(grid_position.x), int(grid_position.y)]
_occupied_cells[key] = tower
## 释放一个网格位置
func free_cell(grid_position: Vector2) -> void:
var key = "%d,%d" % [int(grid_position.x), int(grid_position.y)]
_occupied_cells.erase(key)在地图上显示可放置区域
为了让玩家知道哪些位置可以放塔,可以在鼠标悬停时显示一个半透明的方框:
using Godot;
/// <summary>
/// 放置预览——鼠标移动时显示一个半透明的方框,
/// 告诉玩家"塔会放在这里"。绿色=可以放,红色=不能放。
/// </summary>
public partial class PlacementPreview : Node2D
{
private GridManager _gridManager;
private Sprite2D _previewSprite;
private Color _validColor = new Color(0, 1, 0, 0.3f); // 绿色半透明
private Color _invalidColor = new Color(1, 0, 0, 0.3f); // 红色半透明
public override void _Ready()
{
_gridManager = GetParent<GridManager>();
_previewSprite = GetNode<Sprite2D>("PreviewSprite");
}
public override void _Process(double _delta)
{
// 获取鼠标在游戏世界的位置
Vector2 mousePos = GetGlobalMousePosition();
Vector2 snappedPos = _gridManager.SnapToGrid(mousePos);
GlobalPosition = snappedPos;
// 根据是否可放置改变颜色
bool available = _gridManager.IsCellAvailable(snappedPos);
_previewSprite.Modulate = available ? _validColor : _invalidColor;
Visible = true;
}
}extends Node2D
## 放置预览——鼠标移动时显示一个半透明的方框,
## 告诉玩家"塔会放在这里"。绿色=可以放,红色=不能放。
@onready var grid_manager: GridManager = get_parent()
@onready var preview_sprite: Sprite2D = $PreviewSprite
var valid_color: Color = Color(0, 1, 0, 0.3) # 绿色半透明
var invalid_color: Color = Color(1, 0, 0, 0.3) # 红色半透明
func _process(_delta):
# 获取鼠标在游戏世界的位置
var mouse_pos = get_global_mouse_position()
var snapped_pos = grid_manager.snap_to_grid(mouse_pos)
global_position = snapped_pos
# 根据是否可放置改变颜色
var available = grid_manager.is_cell_available(snapped_pos)
preview_sprite.modulate = valid_color if available else invalid_color
visible = true路径终点标记
在地图上放一个标记节点,告诉敌人"走到这里就到终点了":
Map (Node2D)
├── TileMap
├── NavigationRegion2D
├── PathStart (Marker2D) ← 敌人出生点
└── PathEnd (Marker2D) ← 敌人终点(城堡位置)Marker2D 节点在游戏中不可见,只用来标记一个位置。敌人从 PathStart 出发,目标走到 PathEnd。
下一章
地图和路径搞定了,接下来实现防御塔系统。
