5. 地图滚动与场景探索
2026/4/13大约 10 分钟
地图滚动与场景探索
赤色要塞的核心体验之一就是"一路向前冲"。地图不断滚动,玩家不断推进,新区域不断出现。这一章我们来实现地图滚动、区域加载和3D地形。
地图滚动方式
原版赤色要塞的地图滚动
在原版赤色要塞中,地图是单向滚动的——玩家只能往前走,不能后退。就像考试一样,只能往前翻页,不能往回看。这给玩家制造了一种紧迫感:没有退路,只能一直往前冲。
我们的实现方案
| 方案 | 说明 | 复杂度 | 推荐程度 |
|---|---|---|---|
| 摄像机跟随 | 摄像机跟着玩家移动,无限制 | 低 | 原型阶段 |
| 单向限制 | 摄像机只能向前,不能后退 | 中 | 推荐实现 |
| 区域切换 | 地图分成多个区域,逐个加载 | 高 | 最终版本 |
从简单开始
先实现"摄像机跟随"跑通流程,再加入"单向限制",最后做"区域切换"。每一步都建立在上一步的基础上。
摄像机跟随系统
在2.5D游戏中,摄像机跟随玩家的逻辑很简单:摄像机的XZ坐标跟随玩家,Y坐标保持不变。
C
using Godot;
/// <summary>
/// 摄像机跟随控制器 - 跟随玩家,支持单向滚动限制
/// </summary>
public partial class CameraFollow : Node3D
{
[Export] public NodePath TargetPath { get; set; } // 跟随目标路径
[Export] public float FollowSpeed { get; set; } = 5.0f; // 跟随速度
[Export] public bool LockBackward { get; set; } = true; // 是否锁定后退
[Export] public Vector2 MapBounds { get; set; } = new(-100, 100); // 地图边界(Z轴)
private Node3D _target;
private float _maxForwardZ; // 玩家到达过的最远Z坐标
public override void _Ready()
{
if (TargetPath != null)
_target = GetNode<Node3D>(TargetPath);
_maxForwardZ = _target?.GlobalPosition.Z ?? 0f;
}
public override void _PhysicsProcess(double delta)
{
if (_target == null) return;
// 更新最远前进记录
if (_target.GlobalPosition.Z > _maxForwardZ)
_maxForwardZ = _target.GlobalPosition.Z;
// 计算目标位置
float targetZ = _target.GlobalPosition.Z;
// 如果启用了后退锁定,摄像机Z不能小于最远记录
if (LockBackward && targetZ < _maxForwardZ)
{
targetZ = _maxForwardZ;
}
// 平滑跟随
Vector3 targetPos = new(
_target.GlobalPosition.X,
GlobalPosition.Y, // Y轴保持不变
targetZ
);
GlobalPosition = GlobalPosition.Lerp(targetPos, (float)(FollowSpeed * delta));
}
/// <summary>
/// 设置跟随目标
/// </summary>
public void SetTarget(Node3D target)
{
_target = target;
_maxForwardZ = target.GlobalPosition.Z;
}
}GDScript
# 摄像机跟随控制器 - 跟随玩家,支持单向滚动限制
extends Node3D
@export var target_path: NodePath # 跟随目标路径
@export var follow_speed: float = 5.0 # 跟随速度
@export var lock_backward: bool = true # 是否锁定后退
@export var map_bounds: Vector2 = Vector2(-100, 100) # 地图边界(Z轴)
var _target: Node3D
var _max_forward_z: float = 0.0 # 玩家到达过的最远Z坐标
func _ready():
if target_path:
_target = get_node(target_path)
_max_forward_z = _target.global_position.z if _target else 0.0
func _physics_process(delta):
if _target == null:
return
# 更新最远前进记录
if _target.global_position.z > _max_forward_z:
_max_forward_z = _target.global_position.z
# 计算目标位置
var target_z = _target.global_position.z
# 如果启用了后退锁定,摄像机Z不能小于最远记录
if lock_backward and target_z < _max_forward_z:
target_z = _max_forward_z
# 平滑跟随
var target_pos = Vector3(
_target.global_position.x,
global_position.y, # Y轴保持不变
target_z
)
global_position = global_position.lerp(target_pos, follow_speed * delta)
## 设置跟随目标
func set_target(target: Node3D):
_target = target
_max_forward_z = target.global_position.z地图分块系统
大型地图不能一次性全部加载——那会消耗大量内存。解决方案是把地图切成多个区块(Chunk),只加载玩家附近的区块。
区块原理
想象地图是一条很长的纸带,我们把它裁成一段一段的。玩家走到哪一段,就加载那一段和它相邻的几段。走远的段落就卸载掉。
区块1 区块2 区块3 区块4 区块5
[====] [====] [====] [====] [====]
↑
玩家在这里
← 加载这些 →
✓ ✓ ✓
✗ ✗ ← 这些卸载区块管理器
C
using Godot;
using System.Collections.Generic;
/// <summary>
/// 地图区块管理器 - 负责加载和卸载地图区块
/// </summary>
public partial class ChunkManager : Node3D
{
[Export] public float ChunkSize { get; set; } = 30.0f; // 每个区块的大小
[Export] public int LoadRange { get; set; } = 1; // 前后各加载几个区块
[Export] public PackedScene[] ChunkScenes { get; set; } // 区块场景数组
private readonly Dictionary<int, Node3D> _loadedChunks = new();
private int _currentChunkIndex = 0;
public override void _PhysicsProcess(double delta)
{
// 找到玩家并计算当前所在区块
var player = GetTree().GetFirstNodeInGroup("player") as Node3D;
if (player == null) return;
int newChunkIndex = (int)(player.GlobalPosition.Z / ChunkSize);
if (newChunkIndex != _currentChunkIndex)
{
_currentChunkIndex = newChunkIndex;
UpdateChunks();
}
}
/// <summary>
/// 根据玩家位置更新加载的区块
/// </summary>
private void UpdateChunks()
{
// 计算需要加载的区块范围
var chunksToLoad = new HashSet<int>();
for (int i = _currentChunkIndex - LoadRange; i <= _currentChunkIndex + LoadRange; i++)
{
if (i >= 0 && i < ChunkScenes.Length)
chunksToLoad.Add(i);
}
// 卸载不在范围内的区块
var chunksToRemove = new List<int>();
foreach (var pair in _loadedChunks)
{
if (!chunksToLoad.Contains(pair.Key))
chunksToRemove.Add(pair.Key);
}
foreach (int index in chunksToRemove)
{
_loadedChunks[index].QueueFree();
_loadedChunks.Remove(index);
}
// 加载新的区块
foreach (int index in chunksToLoad)
{
if (!_loadedChunks.ContainsKey(index))
{
var chunk = ChunkScenes[index].Instantiate<Node3D>();
chunk.Position = new Vector3(0, 0, index * ChunkSize);
AddChild(chunk);
_loadedChunks[index] = chunk;
}
}
}
}GDScript
# 地图区块管理器 - 负责加载和卸载地图区块
extends Node3D
@export var chunk_size: float = 30.0 # 每个区块的大小
@export var load_range: int = 1 # 前后各加载几个区块
@export var chunk_scenes: Array[PackedScene] = [] # 区块场景数组
var _loaded_chunks: Dictionary = {}
var _current_chunk_index: int = 0
func _physics_process(delta):
# 找到玩家并计算当前所在区块
var players = get_tree().get_nodes_in_group("player")
if players.size() == 0:
return
var player = players[0] as Node3D
if player == null:
return
var new_chunk_index = int(player.global_position.z / chunk_size)
if new_chunk_index != _current_chunk_index:
_current_chunk_index = new_chunk_index
_update_chunks()
## 根据玩家位置更新加载的区块
func _update_chunks():
# 计算需要加载的区块范围
var chunks_to_load: Dictionary = {}
for i in range(_current_chunk_index - load_range, _current_chunk_index + load_range + 1):
if i >= 0 and i < chunk_scenes.size():
chunks_to_load[i] = true
# 卸载不在范围内的区块
var chunks_to_remove = []
for index in _loaded_chunks:
if not chunks_to_load.has(index):
chunks_to_remove.append(index)
for index in chunks_to_remove:
_loaded_chunks[index].queue_free()
_loaded_chunks.erase(index)
# 加载新的区块
for index in chunks_to_load:
if not _loaded_chunks.has(index):
var chunk = chunk_scenes[index].instantiate()
chunk.position = Vector3(0, 0, index * chunk_size)
add_child(chunk)
_loaded_chunks[index] = chunk3D 地形元素
地形类型
| 地形类型 | 可通行 | 视觉效果 | 游戏影响 |
|---|---|---|---|
| 平地 | 可以 | 绿色草地/灰色道路 | 正常移动 |
| 上坡 | 可以 | 地面高度上升 | 移动速度稍慢 |
| 桥梁 | 可以 | 木桥/石桥横跨水面 | 只能从桥上过河 |
| 河流 | 不可以 | 蓝色水面 | 阻挡前进,必须走桥 |
| 树林 | 可以 | 树木模型 | 遮挡视线,减速 |
| 围墙 | 不可以 | 砖墙/铁丝网 | 阻挡前进,可被手雷炸毁 |
| 沙地 | 可以 | 黄色沙地 | 移动速度稍慢 |
| 废墟 | 可以 | 破碎的建筑 | 有掩体效果 |
地面场景结构
创建 scenes/maps/GroundChunk.tscn:
GroundChunk (StaticBody3D) ← 地面碰撞体
├── MeshInstance3D ← 地面网格
├── CollisionShape3D ← 地面碰撞形状
├── Obstacles (Node3D) ← 障碍物容器
│ ├── Rock1 (StaticBody3D) ← 石头
│ ├── Tree1 (StaticBody3D) ← 树
│ └── Wall1 (Destructible) ← 可摧毁的墙
└── RiverArea (Area3D) ← 河流区域(检测玩家进入)
└── CollisionShape3D使用 HeightMapShape3D 制作起伏地形
如果你想让地形有高低起伏(2.5D的优势!),可以使用 HeightMapShape3D:
- 创建一个 StaticBody3D
- 添加 CollisionShape3D,形状选 HeightMapShape3D
- 在 HeightMapShape3D 中设置高度图
什么是高度图?
高度图就是一张灰度图片,每个像素的亮度代表那个位置的高度——白色=高,黑色=低。就像把一张纸按照深浅不同的墨迹折叠起来,有墨迹的地方就凸起来。
你可以用任何画图软件画一张黑白图,然后导入 Godot 作为高度图。
地形障碍物
可摧毁障碍物
像围墙、碉堡这样的障碍物,可以被手雷炸毁:
C
using Godot;
/// <summary>
/// 可摧毁的障碍物
/// </summary>
public partial class Destructible : StaticBody3D
{
[Export] public int MaxHealth { get; set; } = 3;
[Export] public PackedScene DestroyEffect { get; set; } // 销毁特效
private int _currentHealth;
public override void _Ready()
{
_currentHealth = MaxHealth;
}
/// <summary>
/// 受到伤害(被手雷/子弹调用)
/// </summary>
public void TakeDamage(int damage)
{
_currentHealth -= damage;
// 受击反馈:闪红
Modulate = new Color(2.0f, 0.5f, 0.5f);
CreateTween().TweenProperty(this, "modulate", Colors.White, 0.15f);
if (_currentHealth <= 0)
{
Destroy();
}
}
private void Destroy()
{
// 播放销毁特效
if (DestroyEffect != null)
{
var effect = DestroyEffect.Instantiate<Node3D>();
GetTree().CurrentScene.AddChild(effect);
effect.GlobalPosition = GlobalPosition;
}
// 关闭碰撞,让碎片动画播放完再移除
SetDeferred("collision_layer", 0);
SetDeferred("collision_mask", 0);
// 简单的缩小消失动画
var tween = CreateTween();
tween.TweenProperty(this, "scale", Vector3.Zero, 0.3f);
tween.TweenCallback(Callable.From(() => QueueFree()));
}
}GDScript
# 可摧毁的障碍物
extends StaticBody3D
@export var max_health: int = 3
@export var destroy_effect: PackedScene # 销毁特效
var _current_health: int
func _ready():
_current_health = max_health
## 受到伤害(被手雷/子弹调用)
func take_damage(damage: int):
_current_health -= damage
# 受击反馈:闪红
modulate = Color(2.0, 0.5, 0.5)
var tween = create_tween()
tween.tween_property(self, "modulate", Color.WHITE, 0.15)
if _current_health <= 0:
_destroy()
func _destroy():
# 播放销毁特效
if destroy_effect:
var effect = destroy_effect.instantiate()
get_tree().current_scene.add_child(effect)
effect.global_position = global_position
# 关闭碰撞
set_deferred("collision_layer", 0)
set_deferred("collision_mask", 0)
# 简单的缩小消失动画
var tween = create_tween()
tween.tween_property(self, "scale", Vector3.ZERO, 0.3)
tween.tween_callback(queue_free)小地图/雷达系统
赤色要塞的右上角有一个小地图,显示玩家位置和周围的敌人。这在2.5D中用 SubViewport 来实现。
小地图实现原理
主画面:
┌─────────────────────────────┐
│ │
│ 游戏画面 │
│ │
│ ┌────┐ │
│ │小地图│ │ ← SubViewport 在右上角渲染缩小的3D场景
│ └────┘ │
└─────────────────────────────┘小地图场景结构
MiniMap (Control) ← UI 容器
├── SubViewportContainer ← SubViewport 容器
│ └── SubViewport ← 独立的渲染视口
│ └── MiniMapCamera (Camera3D) ← 小地图专用摄像机(正上方俯视)
├── PlayerIcon (TextureRect) ← 玩家位置图标
└── Border (NinePatchRect) ← 小地图边框小地图摄像机跟随脚本
C
using Godot;
/// <summary>
/// 小地图摄像机 - 正上方俯视,跟随玩家
/// </summary>
public partial class MiniMapCamera : Camera3D
{
[Export] public float FollowSpeed { get; set; } = 8.0f;
[Export] public float CameraHeight { get; set; } = 30.0f;
[Export] public float ViewSize { get; set; } = 40.0f;
private Node3D _target;
public override void _Ready()
{
// 小地图用完全正上方的俯视角度
Projection = ProjectionType.Orthogonal;
Size = ViewSize;
RotationDegrees = new Vector3(-90, 0, 0); // 正上方
}
public override void _PhysicsProcess(double delta)
{
if (_target == null)
{
var players = GetTree().GetNodesInGroup("player");
if (players.Count > 0)
_target = players[0] as Node3D;
return;
}
// 跟随玩家位置
Vector3 targetPos = new(
_target.GlobalPosition.X,
CameraHeight,
_target.GlobalPosition.Z
);
GlobalPosition = GlobalPosition.Lerp(targetPos, (float)(FollowSpeed * delta));
}
}GDScript
# 小地图摄像机 - 正上方俯视,跟随玩家
extends Camera3D
@export var follow_speed: float = 8.0
@export var camera_height: float = 30.0
@export var view_size: float = 40.0
var _target: Node3D
func _ready():
# 小地图用完全正上方的俯视角度
projection = Camera3D.PROJECTION_ORTHOGONAL
size = view_size
rotation_degrees = Vector3(-90, 0, 0) # 正上方
func _physics_process(delta):
if _target == null:
var players = get_tree().get_nodes_in_group("player")
if players.size() > 0:
_target = players[0]
return
# 跟随玩家位置
var target_pos = Vector3(
_target.global_position.x,
camera_height,
_target.global_position.z
)
global_position = global_position.lerp(target_pos, follow_speed * delta)区域切换与触发器
当玩家进入新区域时,可以触发事件(生成敌人、播放音乐、显示提示等):
C
using Godot;
/// <summary>
/// 区域触发器 - 玩家进入时触发事件
/// </summary>
public partial class AreaTrigger : Area3D
{
[Export] public string TriggerId { get; set; } = ""; // 触发器ID
[Export] public bool TriggerOnce { get; set; } = true; // 是否只触发一次
[Export] public string Message { get; set; } = ""; // 触发时显示的消息
[Signal]
public delegate void TriggerEnteredEventHandler(string triggerId);
private bool _hasTriggered = false;
public override void _Ready()
{
BodyEntered += OnBodyEntered;
}
private void OnBodyEntered(Node3D body)
{
// 只对玩家触发
if (!body.IsInGroup("player")) return;
if (_hasTriggered && TriggerOnce) return;
_hasTriggered = true;
EmitSignal(SignalName.TriggerEntered, TriggerId);
if (!string.IsNullOrEmpty(Message))
{
GD.Print($"[AreaTrigger] {Message}");
// TODO: 显示 HUD 提示消息
}
}
}GDScript
# 区域触发器 - 玩家进入时触发事件
extends Area3D
@export var trigger_id: String = "" # 触发器ID
@export var trigger_once: bool = true # 是否只触发一次
@export var message: String = "" # 触发时显示的消息
signal trigger_entered(trigger_id: String)
var _has_triggered: bool = false
func _ready():
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node3D):
# 只对玩家触发
if not body.is_in_group("player"):
return
if _has_triggered and trigger_once:
return
_has_triggered = true
trigger_enterd.emit(trigger_id)
if message != "":
print("[AreaTrigger] ", message)
# TODO: 显示 HUD 提示消息地图设计要点
| 要点 | 说明 | 示例 |
|---|---|---|
| 通道感 | 地图应该引导玩家向前走 | 两侧有围墙或河流,中间留通路 |
| 视觉地标 | 每个区域有醒目的建筑或地标 | 巨大的碉堡、桥梁、水塔 |
| 难度递增 | 越往后敌人越多、越强 | 第一区3个步兵,第五区2辆坦克+炮台 |
| 隐藏奖励 | 鼓励玩家探索边角 | 树后面藏着人质或武器升级 |
| 节奏变化 | 紧张战斗和轻松探索交替 | 一段密集敌人区 → 一段空旷道路 |
本章检查清单
下一章
地图搭好了,接下来给地图填充各种敌人。
