3. 瓦片地图世界
2026/4/14大约 10 分钟
3. 伏魔记——瓦片地图世界
3.1 什么是瓦片地图?
想象你用乐高积木拼一幅画。你不需要画一张大图,而是用一个个小块(瓦片)按照一定的规则拼出来。地面上铺的是草地瓦片,水面上铺的是水波瓦片,墙角放的是石头瓦片——一张完整的地图就这样拼出来了。
瓦片地图(TileMap) 就是游戏开发中最常用的地图制作方式。在Godot 4中,我们使用 TileMapLayer 节点来管理瓦片地图。
为什么用瓦片而不是画大图?
| 方式 | 优点 | 缺点 |
|---|---|---|
| 画一张大图 | 看起来更自由 | 文件巨大、无法做碰撞检测、难以修改 |
| 瓦片地图 | 文件小、支持碰撞、方便编辑 | 需要按照网格排列 |
3.2 多层瓦片地图
一个好看的地图通常需要多个层叠在一起。你可以把它想象成一层层的透明纸叠在一起:
| 层名 | 用途 | 生活比喻 |
|---|---|---|
| 地面层(Ground) | 铺设草地、泥地、地板等基础地面 | 地板 |
| 装饰层(Decoration) | 放置花朵、石头、桌椅等装饰物 | 地板上的家具 |
| 碰撞层(Collision) | 标记哪些地方角色不能走(墙壁、水等) | 隐形的墙 |
| 上层(Top) | 角色走到下方时,此层遮住角色(制造纵深感) | 房间的天花板 |
在Godot 4中,每个层都是一个独立的 TileMapLayer 节点:
WorldMap (Node2D)
├── GroundLayer (TileMapLayer) ← 地面层
├── DecorationLayer (TileMapLayer) ← 装饰层
├── CollisionLayer (TileMapLayer) ← 碰撞层
└── TopLayer (TileMapLayer) ← 上层(遮挡层)创建瓦片地图的步骤
- 准备瓦片素材:准备一组32x32像素的小图片(草地、水面、墙壁等)
- 创建TileSet:在Godot中创建一个TileSet资源,把所有瓦片素材导入进去
- 绘制地图:选中TileMapLayer节点,在编辑器中用画笔工具把瓦片画到地图上
- 设置碰撞:在TileSet中给墙壁、水面等瓦片添加碰撞形状
- 添加导航:给可行走的瓦片添加导航区域(可选)
3.3 玩家角色移动
玩家在瓦片地图上移动,本质上是控制一个 CharacterBody2D 节点在一个2D平面上滑动。
玩家控制器
C
using Godot;
/// <summary>
/// 玩家控制器——处理角色在地图上的移动
/// </summary>
public partial class PlayerController : CharacterBody2D
{
[Export] public int Speed { get; set; } = 120;
// 动画相关
private AnimatedSprite2D _sprite;
private string _direction = "down"; // 当前朝向
private bool _isMoving = false; // 是否在移动
// 对话交互检测
private Area2D _interactArea;
public override void _Ready()
{
_sprite = GetNode<AnimatedSprite2D>("AnimatedSprite2D");
_interactArea = GetNode<Area2D>("InteractArea");
// 监听交互区域的信号
_interactArea.BodyEntered += OnBodyEnteredInteract;
_interactArea.BodyExited += OnBodyExitedInteract;
}
public override void _PhysicsProcess(double delta)
{
if (GameManager.Instance.GameState != RpgGameState.TownExplore)
return;
// 获取输入方向
Vector2 inputDirection = Vector2.Zero;
if (Input.IsActionPressed("move_up"))
inputDirection.Y -= 1;
if (Input.IsActionPressed("move_down"))
inputDirection.Y += 1;
if (Input.IsActionPressed("move_left"))
inputDirection.X -= 1;
if (Input.IsActionPressed("move_right"))
inputDirection.X += 1;
// 标准化方向向量(防止斜向移动更快)
if (inputDirection.Length() > 0)
{
inputDirection = inputDirection.Normalized();
_isMoving = true;
// 更新朝向
if (inputDirection.Y < 0) _direction = "up";
else if (inputDirection.Y > 0) _direction = "down";
else if (inputDirection.X < 0) _direction = "left";
else if (inputDirection.X > 0) _direction = "right";
}
else
{
_isMoving = false;
}
// 设置速度并移动
Velocity = inputDirection * Speed;
MoveAndSlide();
// 更新动画
UpdateAnimation();
// 检测交互按键
if (Input.IsActionJustPressed("confirm"))
{
TryInteract();
}
}
/// <summary>
/// 更新角色动画
/// </summary>
private void UpdateAnimation()
{
if (_isMoving)
{
_sprite.Play("walk_" + _direction);
}
else
{
_sprite.Play("idle_" + _direction);
}
}
/// <summary>
/// 尝试与面前的NPC交互
/// </summary>
private void TryInteract()
{
// 查找交互区域内的NPC
var bodies = _interactArea.GetOverlappingBodies();
foreach (var body in bodies)
{
if (body is NpcController npc)
{
npc.Interact();
return;
}
}
}
private void OnBodyEnteredInteract(Node2D body) { }
private void OnBodyExitedInteract(Node2D body) { }
}GDScript
extends CharacterBody2D
## 玩家控制器——处理角色在地图上的移动
@export var speed: int = 120
# 动画相关
@onready var _sprite: AnimatedSprite2D = $AnimatedSprite2D
@onready var _interact_area: Area2D = $InteractArea
var _direction: String = "down" ## 当前朝向
var _is_moving: bool = false ## 是否在移动
func _physics_process(delta: float) -> void:
if GameManager.game_state != RpgGameState.TOWN_EXPLORE:
return
# 获取输入方向
var input_direction := Vector2.ZERO
if Input.is_action_pressed("move_up"):
input_direction.y -= 1
if Input.is_action_pressed("move_down"):
input_direction.y += 1
if Input.is_action_pressed("move_left"):
input_direction.x -= 1
if Input.is_action_pressed("move_right"):
input_direction.x += 1
# 标准化方向向量(防止斜向移动更快)
if input_direction.length() > 0:
input_direction = input_direction.normalized()
_is_moving = true
# 更新朝向
if input_direction.y < 0:
_direction = "up"
elif input_direction.y > 0:
_direction = "down"
elif input_direction.x < 0:
_direction = "left"
elif input_direction.x > 0:
_direction = "right"
else:
_is_moving = false
# 设置速度并移动
velocity = input_direction * speed
move_and_slide()
# 更新动画
_update_animation()
# 检测交互按键
if Input.is_action_just_pressed("confirm"):
_try_interact()
## 更新角色动画
func _update_animation() -> void:
if _is_moving:
_sprite.play("walk_" + _direction)
else:
_sprite.play("idle_" + _direction)
## 尝试与面前的NPC交互
func _try_interact() -> void:
# 查找交互区域内的NPC
var bodies = _interact_area.get_overlapping_bodies()
for body in bodies:
if body is NpcController:
body.interact()
return玩家场景结构
Player (CharacterBody2D) → 附加脚本: player_controller.gd/.cs
├── CollisionShape2D ← 碰撞形状(小矩形,比精灵小一点)
├── AnimatedSprite2D ← 角色动画精灵
│ ├── idle_down (默认) ← 站立朝下
│ ├── idle_up ← 站立朝上
│ ├── idle_left ← 站立朝左
│ ├── idle_right ← 站立朝右
│ ├── walk_down ← 行走朝下
│ ├── walk_up ← 行走朝上
│ ├── walk_left ← 行走朝左
│ └── walk_right ← 行走朝右
└── InteractArea (Area2D) ← 交互检测区域
└── InteractShape (CollisionShape2D) ← 角色面前的一小块区域3.4 地图切换与传送点
RPG世界中有很多不同的区域——城镇、森林、洞穴、村庄等。当玩家走到地图边缘的特定位置时,就会"传送"到另一个地图。
传送点实现
传送点本质上就是一个看不见的区域,当玩家走进这个区域时,就触发地图切换。
C
using Godot;
/// <summary>
/// 传送点——当玩家走进此区域时切换到目标地图
/// </summary>
public partial class TeleportZone : Area2D
{
[Export] public string TargetMap { get; set; } // 目标地图路径
[Export] public string TargetSpawnId { get; set; } // 目标出生点ID
[Export] public Vector2 TargetPosition { get; set; } // 目标坐标
private bool _isTransitioning = false;
public override void _Ready()
{
BodyEntered += OnBodyEntered;
}
private void OnBodyEntered(Node2D body)
{
// 只对玩家角色生效
if (body is not PlayerController) return;
if (_isTransitioning) return;
if (GameManager.Instance.GameState != RpgGameState.TownExplore) return;
_isTransitioning = true;
GD.Print($"传送: 当前地图 → {TargetMap}");
// 通过SceneManager执行场景切换
var sceneManager = GetNode<SceneManager>("/root/Main/SceneManager");
sceneManager.ChangeScene(TargetMap, TargetPosition);
}
}GDScript
extends Area2D
## 传送点——当玩家走进此区域时切换到目标地图
@export var target_map: String ## 目标地图路径
@export var target_spawn_id: String ## 目标出生点ID
@export var target_position: Vector2 ## 目标坐标
var _is_transitioning: bool = false
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
# 只对玩家角色生效
if not body is PlayerController:
return
if _is_transitioning:
return
if GameManager.game_state != RpgGameState.TOWN_EXPLORE:
return
_is_transitioning = true
print("传送: 当前地图 → ", target_map)
# 通过SceneManager执行场景切换
var scene_manager = get_node("/root/Main/SceneManager")
scene_manager.change_scene(target_map, target_position)传送点场景结构
TeleportZone (Area2D) → 附加脚本: teleport_zone.gd/.cs
├── CollisionShape2D ← 传送触发区域(通常是一个窄矩形,放在地图出口)
└── VisibleIndicator ← 可选:用一个半透明方块标记位置(仅编辑时可见)城镇地图示例
城镇地图布局示意(每个格子代表一个瓦片):
┌──────────────────────────────────────────┐
│ 草 草 草 草 草 草 草 草 草 草 草 草 草 草 草 │
│ 草 [房子A] 房子A 房子A 草 草 [传送点→森林]│
│ 草 [门] 草 草 草 草 草 草 草 草 草 草 草 草 │
│ 草 草 草 草 草 草 草 [NPC1] 草 草 草 草 草 │
│ 草 草 草 草 草 [喷泉] 草 草 草 草 草 草 草 │
│ 草 草 草 [NPC2] 草 草 草 草 草 草 草 草 草 │
│ 草 [房子B] 房子B 草 草 草 草 草 草 草 草 草 │
│ 草 [门] 草 草 草 草 [商店NPC] 草 草 草 草 │
│ 草 草 草 草 草 草 草 草 草 草 草 草 草 草 │
│ 草 草 草 草 [玩家] 草 草 草 草 草 草 草 草 │
│ 草 草 草 草 草 草 草 草 草 草 草 草 草 草 │
└──────────────────────────────────────────┘3.5 遭遇战区域
在地下城(野外)地图中,玩家走动时会随机触发战斗。我们用一个步数计数器来实现这个机制。
C
using Godot;
/// <summary>
/// 遭遇战区域——玩家在此区域内行走时会随机触发战斗
/// </summary>
public partial class EncounterZone : Area2D
{
[Export] public int StepsPerEncounter { get; set; } = 15; // 每走多少步可能遇敌
[Export] public float EncounterRate { get; set; } = 0.3f; // 遭遇概率(30%)
[Export] public string[] EnemyGroups { get; set; } // 可能出现的敌人组合
private int _stepsSinceLastEncounter = 0;
public override void _Ready()
{
BodyEntered += OnBodyEntered;
BodyExited += OnBodyExited;
}
private void OnBodyEntered(Node2D body)
{
if (body is PlayerController player)
{
player.StepTaken += OnPlayerStep;
}
}
private void OnBodyExited(Node2D body)
{
if (body is PlayerController player)
{
player.StepTaken -= OnPlayerStep;
}
}
/// <summary>
/// 玩家每走一步就调用一次
/// </summary>
private void OnPlayerStep()
{
_stepsSinceLastEncounter++;
// 步数够了才开始判定
if (_stepsSinceLastEncounter < StepsPerEncounter)
return;
// 随机概率判定
if (GD.Randf() < EncounterRate)
{
_stepsSinceLastEncounter = 0;
TriggerBattle();
}
}
/// <summary>
/// 触发战斗
/// </summary>
private void TriggerBattle()
{
// 随机选一组敌人
int groupIndex = GD.Randi() % EnemyGroups.Length;
string enemyGroup = EnemyGroups[groupIndex];
GD.Print($"遭遇敌人: {enemyGroup}");
// 通过SceneManager进入战斗场景
var sceneManager = GetNode<SceneManager>("/root/Main/SceneManager");
sceneManager.EnterBattle(enemyGroup);
}
}GDScript
extends Area2D
## 遭遇战区域——玩家在此区域内行走时会随机触发战斗
@export var steps_per_encounter: int = 15 ## 每走多少步可能遇敌
@export var encounter_rate: float = 0.3 ## 遭遇概率(30%)
@export var enemy_groups: Array[String] = [] ## 可能出现的敌人组合
var _steps_since_last_encounter: int = 0
func _ready() -> void:
body_entered.connect(_on_body_entered)
body_exited.connect(_on_body_exited)
func _on_body_entered(body: Node2D) -> void:
if body is PlayerController:
body.step_taken.connect(_on_player_step)
func _on_body_exited(body: Node2D) -> void:
if body is PlayerController:
body.step_taken.disconnect(_on_player_step)
## 玩家每走一步就调用一次
func _on_player_step() -> void:
_steps_since_last_encounter += 1
# 步数够了才开始判定
if _steps_since_last_encounter < steps_per_encounter:
return
# 随机概率判定
if randf() < encounter_rate:
_steps_since_last_encounter = 0
_trigger_battle()
## 触发战斗
func _trigger_battle() -> void:
# 随机选一组敌人
var group_index: int = randi() % enemy_groups.size()
var enemy_group: String = enemy_groups[group_index]
print("遭遇敌人: ", enemy_group)
# 通过SceneManager进入战斗场景
var scene_manager = get_node("/root/Main/SceneManager")
scene_manager.enter_battle(enemy_group)在玩家控制器中添加步数信号
需要在PlayerController中添加一个步数计数和信号:
C
// 在 PlayerController 类中添加以下代码
[Signal] public delegate void StepTakenEventHandler();
private Vector2 _lastPosition;
private float _stepDistance = 16f; // 走多远算"一步"
public override void _PhysicsProcess(double delta)
{
// ... 原有的移动代码 ...
MoveAndSlide();
// 检查是否走够了一步的距离
float distanceMoved = GlobalPosition.DistanceTo(_lastPosition);
if (distanceMoved >= _stepDistance)
{
_lastPosition = GlobalPosition;
EmitSignal(SignalName.StepTaken);
}
}GDScript
# 在 PlayerController 中添加以下代码
signal step_taken
var _last_position: Vector2 = Vector2.ZERO
var _step_distance: float = 16.0 # 走多远算"一步"
func _physics_process(delta: float) -> void:
# ... 原有的移动代码 ...
move_and_slide()
# 检查是否走够了一步的距离
var distance_moved: float = global_position.distance_to(_last_position)
if distance_moved >= _step_distance:
_last_position = global_position
step_taken.emit()3.6 地图数据管理
为了方便管理所有地图的信息(名称、背景音乐、传送点等),我们创建一个地图数据类。
C
/// <summary>
/// 地图数据——记录一个地图的基本信息
/// </summary>
public class MapData
{
public string Id { get; set; } // 地图ID
public string DisplayName { get; set; } // 地图显示名称
public string ScenePath { get; set; } // 场景文件路径
public string BgmPath { get; set; } // 背景音乐路径
public bool IsDungeon { get; set; } // 是否是地下城(决定是否随机遇敌)
public int MinLevel { get; set; } // 推荐最低等级
}
/// <summary>
/// 地图数据库——所有地图的信息都注册在这里
/// </summary>
public static class MapDatabase
{
public static readonly MapData[] AllMaps = new[]
{
new MapData
{
Id = "town_main",
DisplayName = "清风镇",
ScenePath = "res://scenes/maps/town_main.tscn",
BgmPath = "res://assets/audio/music/town.ogg",
IsDungeon = false,
MinLevel = 1
},
new MapData
{
Id = "forest_path",
DisplayName = "幽暗森林",
ScenePath = "res://scenes/maps/dungeon_forest.tscn",
BgmPath = "res://assets/audio/music/forest.ogg",
IsDungeon = true,
MinLevel = 3
},
new MapData
{
Id = "dark_cave",
DisplayName = "暗影洞穴",
ScenePath = "res://scenes/maps/dungeon_cave.tscn",
BgmPath = "res://assets/audio/music/cave.ogg",
IsDungeon = true,
MinLevel = 8
}
};
/// <summary>
/// 根据ID查找地图
/// </summary>
public static MapData FindById(string id)
{
foreach (var map in AllMaps)
{
if (map.Id == id) return map;
}
return null;
}
}GDScript
## 地图数据——记录一个地图的基本信息
class_name MapData
var id: String ## 地图ID
var display_name: String ## 地图显示名称
var scene_path: String ## 场景文件路径
var bgm_path: String ## 背景音乐路径
var is_dungeon: bool ## 是否是地下城
var min_level: int ## 推荐最低等级
## 地图数据库——所有地图的信息都注册在这里
class_name MapDatabase
const ALL_MAPS: Array[MapData] = [
# 城镇地图
MapData.new("town_main", "清风镇",
"res://scenes/maps/town_main.tscn",
"res://assets/audio/music/town.ogg",
false, 1),
# 森林地下城
MapData.new("forest_path", "幽暗森林",
"res://scenes/maps/dungeon_forest.tscn",
"res://assets/audio/music/forest.ogg",
true, 3),
# 洞穴地下城
MapData.new("dark_cave", "暗影洞穴",
"res://scenes/maps/dungeon_cave.tscn",
"res://assets/audio/music/cave.ogg",
true, 8),
]
## 根据ID查找地图
static func find_by_id(map_id: String) -> MapData:
for map_data in ALL_MAPS:
if map_data.id == map_id:
return map_data
return null3.7 本章小结
| 知识点 | 说明 |
|---|---|
| 瓦片地图 | 用小方块拼出完整地图,节省内存、方便编辑 |
| 多层结构 | 地面层、装饰层、碰撞层、上层,各司其职 |
| 角色移动 | CharacterBody2D + 四方向动画 + MoveAndSlide |
| 传送点 | Area2D检测玩家进入,触发场景切换 |
| 遭遇战 | 步数计数器 + 随机概率判定 |
| 地图数据 | MapData类记录地图信息和背景音乐 |
下一章我们将实现NPC与对话系统,让游戏世界变得有"人味儿"。
