第二十章:TileMap与地图编辑

第二十章:TileMap与地图编辑

"TileMap是构建游戏世界的画布,让关卡设计变得高效且有趣。"

TileMap是2D游戏中创建关卡、地形的核心工具。本章将全面讲解Godot 4的TileMap系统,包括TileSet创建、图层管理、自动图块、程序化生成等内容。


20.1 TileMap概述

20.1.1 Godot 4 TileMap新特性

Godot 4 TileMap改进:
├── 多图层支持(Layer)
├── 地形系统(Terrain)
├── 物理层(Physics Layer)
├── 导航层(Navigation Layer)
├── 场景图块(Scene Tiles)
├── 动画图块改进
└── 编辑器工具优化

20.1.2 基本概念

TileMap系统组成:
├── TileMap节点 - 管理图块的放置和渲染
├── TileSet资源 - 定义可用的图块
│   ├── 图块源(TileSetSource)
│   │   ├── TileSetAtlasSource - 图集图块
│   │   └── TileSetScenesCollectionSource - 场景图块
│   └── 图块数据(Tile Data)
├── 图层(Layer)- 多层结构
└── 地形(Terrain)- 自动图块规则

20.2 创建TileSet

20.2.1 图集图块

# 在编辑器中创建:
# 1. 选择TileMap节点
# 2. 在检查器中创建新的TileSet
# 3. 在底部面板的TileSet编辑器中添加图块源

# 代码创建TileSet
func create_tileset() -> TileSet:
    var tileset = TileSet.new()
    
    # 设置图块大小
    tileset.tile_size = Vector2i(16, 16)
    
    # 添加图集源
    var source = TileSetAtlasSource.new()
    source.texture = preload("res://tiles/tileset.png")
    source.texture_region_size = Vector2i(16, 16)
    
    # 创建图块(自动从图集创建)
    source.create_tile(Vector2i(0, 0))  # 第一个图块
    source.create_tile(Vector2i(1, 0))  # 第二个图块
    
    # 添加到TileSet
    tileset.add_source(source, 0)  # source_id = 0
    
    return tileset

20.2.2 场景图块

# 场景图块允许使用完整的场景作为图块
func create_scene_tiles() -> TileSet:
    var tileset = TileSet.new()
    tileset.tile_size = Vector2i(32, 32)
    
    # 创建场景集合源
    var scenes_source = TileSetScenesCollectionSource.new()
    
    # 添加场景
    var torch_scene = preload("res://objects/torch.tscn")
    scenes_source.create_scene_tile(torch_scene, 0)  # id = 0
    
    var chest_scene = preload("res://objects/chest.tscn")
    scenes_source.create_scene_tile(chest_scene, 1)  # id = 1
    
    tileset.add_source(scenes_source, 1)  # source_id = 1
    
    return tileset

20.2.3 图块属性

# 设置图块的自定义数据
func setup_tile_data(tileset: TileSet, source: TileSetAtlasSource):
    # 添加自定义数据层
    tileset.add_custom_data_layer()
    tileset.set_custom_data_layer_name(0, "walkable")
    tileset.set_custom_data_layer_type(0, TYPE_BOOL)
    
    tileset.add_custom_data_layer()
    tileset.set_custom_data_layer_name(1, "damage")
    tileset.set_custom_data_layer_type(1, TYPE_INT)
    
    # 获取图块数据并设置
    var tile_data = source.get_tile_data(Vector2i(0, 0), 0)
    tile_data.set_custom_data("walkable", true)
    tile_data.set_custom_data("damage", 0)
    
    # 设置碰撞
    # 首先在TileSet中添加物理层
    tileset.add_physics_layer()
    tileset.set_physics_layer_collision_layer(0, 1)
    tileset.set_physics_layer_collision_mask(0, 1)

20.3 TileMap图层

20.3.1 多图层管理

extends TileMap

func _ready():
    # 图层操作
    add_layer(1)  # 添加图层
    set_layer_name(0, "Ground")
    set_layer_name(1, "Decoration")
    
    # 图层属性
    set_layer_enabled(1, true)
    set_layer_modulate(1, Color(1, 1, 1, 0.8))
    set_layer_y_sort_enabled(1, true)
    set_layer_y_sort_origin(1, 8)
    set_layer_z_index(1, 1)

# 在不同图层绘制
func draw_tiles():
    # 在地面层绘制
    set_cell(0, Vector2i(0, 0), 0, Vector2i(0, 0))
    
    # 在装饰层绘制
    set_cell(1, Vector2i(0, 0), 0, Vector2i(1, 0))

20.3.2 图层用途示例

推荐的图层结构:
Layer 0: Background  - 背景(远景)
Layer 1: Ground      - 地面(主要碰撞)
Layer 2: Decoration  - 装饰(草、花)
Layer 3: Objects     - 物件(可交互)
Layer 4: Foreground  - 前景(遮挡玩家)

20.4 地形系统

20.4.1 设置地形

# 地形系统用于自动图块(Auto-tiling)
func setup_terrain(tileset: TileSet):
    # 添加地形集
    tileset.add_terrain_set(0)
    tileset.set_terrain_set_mode(0, TileSet.TERRAIN_MODE_MATCH_CORNERS_AND_SIDES)
    # TERRAIN_MODE_MATCH_CORNERS_AND_SIDES - 47图块
    # TERRAIN_MODE_MATCH_CORNERS - 16图块
    # TERRAIN_MODE_MATCH_SIDES - 16图块
    
    # 添加地形
    tileset.add_terrain(0, 0)  # terrain_set_id, terrain_id
    tileset.set_terrain_name(0, 0, "Grass")
    tileset.set_terrain_color(0, 0, Color.GREEN)

# 设置图块的地形
func setup_tile_terrain(source: TileSetAtlasSource, atlas_coords: Vector2i):
    var tile_data = source.get_tile_data(atlas_coords, 0)
    
    # 设置地形所属
    tile_data.terrain_set = 0
    tile_data.terrain = 0
    
    # 设置各个角和边的地形
    # 使用地形掩码
    tile_data.set_terrain_peering_bit(TileSet.CELL_NEIGHBOR_TOP_LEFT_CORNER, 0)
    tile_data.set_terrain_peering_bit(TileSet.CELL_NEIGHBOR_TOP_SIDE, 0)
    # ... 其他方向

20.4.2 使用地形绘制

extends TileMap

func draw_terrain_line(from: Vector2i, to: Vector2i, terrain_set: int, terrain: int):
    var cells = []
    
    # 收集路径上的单元格
    var diff = to - from
    var steps = max(abs(diff.x), abs(diff.y))
    
    for i in range(steps + 1):
        var t = float(i) / steps if steps > 0 else 0
        var cell = Vector2i(
            from.x + int(diff.x * t),
            from.y + int(diff.y * t)
        )
        cells.append(cell)
    
    # 使用地形绘制
    set_cells_terrain_connect(0, cells, terrain_set, terrain)

func draw_terrain_rect(rect: Rect2i, terrain_set: int, terrain: int):
    var cells = []
    
    for x in range(rect.position.x, rect.position.x + rect.size.x):
        for y in range(rect.position.y, rect.position.y + rect.size.y):
            cells.append(Vector2i(x, y))
    
    set_cells_terrain_connect(0, cells, terrain_set, terrain)

20.5 碰撞与导航

20.5.1 物理碰撞

# 设置TileSet的物理层
func setup_physics(tileset: TileSet):
    # 添加物理层
    tileset.add_physics_layer()
    tileset.set_physics_layer_collision_layer(0, 1)  # 在第1层
    tileset.set_physics_layer_collision_mask(0, 0)   # 不检测任何层

# 为图块设置碰撞形状
func setup_tile_collision(source: TileSetAtlasSource, atlas_coords: Vector2i):
    var tile_data = source.get_tile_data(atlas_coords, 0)
    
    # 创建碰撞多边形
    var polygon = PackedVector2Array([
        Vector2(-8, -8),
        Vector2(8, -8),
        Vector2(8, 8),
        Vector2(-8, 8)
    ])
    
    tile_data.set_collision_polygons_count(0, 1)  # physics_layer, count
    tile_data.set_collision_polygon_points(0, 0, polygon)  # physics_layer, polygon_index

# 查询图块碰撞
func get_collision_at(world_pos: Vector2) -> bool:
    var tile_pos = local_to_map(world_pos)
    var tile_data = get_cell_tile_data(0, tile_pos)
    
    if tile_data:
        return tile_data.get_collision_polygons_count(0) > 0
    return false

20.5.2 导航网格

# 设置导航层
func setup_navigation(tileset: TileSet):
    tileset.add_navigation_layer()
    tileset.set_navigation_layer_layers(0, 1)

# 为图块设置导航多边形
func setup_tile_navigation(source: TileSetAtlasSource, atlas_coords: Vector2i):
    var tile_data = source.get_tile_data(atlas_coords, 0)
    
    var nav_polygon = NavigationPolygon.new()
    nav_polygon.add_outline(PackedVector2Array([
        Vector2(-8, -8),
        Vector2(8, -8),
        Vector2(8, 8),
        Vector2(-8, 8)
    ]))
    nav_polygon.make_polygons_from_outlines()
    
    tile_data.set_navigation_polygon(0, nav_polygon)

# 烘焙导航网格
func bake_navigation():
    # TileMap会自动生成导航网格
    # 需要在场景中添加NavigationRegion2D
    pass

20.6 代码操作TileMap

20.6.1 放置和移除图块

extends TileMap

func place_tile(layer: int, pos: Vector2i, source_id: int, atlas_coords: Vector2i):
    set_cell(layer, pos, source_id, atlas_coords)

func remove_tile(layer: int, pos: Vector2i):
    erase_cell(layer, pos)

func clear_layer(layer: int):
    clear_layer(layer)

func clear_all():
    clear()

# 批量操作
func fill_rect(layer: int, rect: Rect2i, source_id: int, atlas_coords: Vector2i):
    for x in range(rect.position.x, rect.position.x + rect.size.x):
        for y in range(rect.position.y, rect.position.y + rect.size.y):
            set_cell(layer, Vector2i(x, y), source_id, atlas_coords)

20.6.2 查询图块

extends TileMap

func get_tile_info(layer: int, pos: Vector2i) -> Dictionary:
    var info = {}
    
    info["source_id"] = get_cell_source_id(layer, pos)
    info["atlas_coords"] = get_cell_atlas_coords(layer, pos)
    info["alternative_tile"] = get_cell_alternative_tile(layer, pos)
    
    var tile_data = get_cell_tile_data(layer, pos)
    if tile_data:
        # 获取自定义数据
        info["walkable"] = tile_data.get_custom_data("walkable")
        info["damage"] = tile_data.get_custom_data("damage")
    
    return info

func get_used_cells_in_layer(layer: int) -> Array[Vector2i]:
    return get_used_cells(layer)

func get_used_rect_in_layer() -> Rect2i:
    return get_used_rect()

20.6.3 坐标转换

extends TileMap

func world_to_tile(world_pos: Vector2) -> Vector2i:
    return local_to_map(to_local(world_pos))

func tile_to_world(tile_pos: Vector2i) -> Vector2:
    return to_global(map_to_local(tile_pos))

func get_tile_center(tile_pos: Vector2i) -> Vector2:
    return to_global(map_to_local(tile_pos))

# 获取鼠标所在的图块
func get_mouse_tile() -> Vector2i:
    var mouse_pos = get_global_mouse_position()
    return local_to_map(to_local(mouse_pos))

20.7 程序化生成

20.7.1 简单地形生成

extends TileMap

@export var width: int = 50
@export var height: int = 30
@export var ground_level: int = 20

func generate_simple_terrain():
    clear()
    
    # 生成地面
    for x in range(width):
        # 使用噪声调整地面高度
        var noise_height = int(sin(x * 0.3) * 3)
        var ground_y = ground_level + noise_height
        
        # 填充地面以下
        for y in range(ground_y, height):
            if y == ground_y:
                # 草地表面
                set_cell(0, Vector2i(x, y), 0, Vector2i(0, 0))
            else:
                # 泥土
                set_cell(0, Vector2i(x, y), 0, Vector2i(1, 0))

20.7.2 使用噪声生成

extends TileMap

var noise: FastNoiseLite

func _ready():
    noise = FastNoiseLite.new()
    noise.seed = randi()
    noise.noise_type = FastNoiseLite.TYPE_PERLIN
    noise.frequency = 0.05

func generate_cave():
    clear()
    
    var width = 100
    var height = 60
    var threshold = 0.0
    
    for x in range(width):
        for y in range(height):
            var noise_value = noise.get_noise_2d(x, y)
            
            if noise_value > threshold:
                # 墙壁
                set_cell(0, Vector2i(x, y), 0, Vector2i(0, 0))
            # else: 空地
    
    # 可选:应用细胞自动机平滑
    smooth_cave(3)

func smooth_cave(iterations: int):
    for i in range(iterations):
        var changes = []
        
        for cell in get_used_cells(0):
            var neighbors = count_neighbors(cell)
            if neighbors < 4:
                changes.append({"pos": cell, "remove": true})
        
        # 检查空单元格
        var rect = get_used_rect()
        for x in range(rect.position.x, rect.end.x):
            for y in range(rect.position.y, rect.end.y):
                var pos = Vector2i(x, y)
                if get_cell_source_id(0, pos) == -1:
                    if count_neighbors(pos) > 4:
                        changes.append({"pos": pos, "remove": false})
        
        # 应用更改
        for change in changes:
            if change.remove:
                erase_cell(0, change.pos)
            else:
                set_cell(0, change.pos, 0, Vector2i(0, 0))

func count_neighbors(pos: Vector2i) -> int:
    var count = 0
    for dx in range(-1, 2):
        for dy in range(-1, 2):
            if dx == 0 and dy == 0:
                continue
            var neighbor = pos + Vector2i(dx, dy)
            if get_cell_source_id(0, neighbor) != -1:
                count += 1
    return count

20.7.3 房间生成器

# room_generator.gd
class_name RoomGenerator
extends RefCounted

var tilemap: TileMap
var rooms: Array[Rect2i] = []

func generate(map: TileMap, room_count: int, map_size: Vector2i):
    tilemap = map
    rooms.clear()
    
    # 填充墙壁
    _fill_with_walls(map_size)
    
    # 生成房间
    for i in range(room_count):
        _try_place_room(map_size)
    
    # 连接房间
    _connect_rooms()

func _fill_with_walls(size: Vector2i):
    for x in range(size.x):
        for y in range(size.y):
            tilemap.set_cell(0, Vector2i(x, y), 0, Vector2i(0, 0))

func _try_place_room(map_size: Vector2i, attempts: int = 30):
    for i in range(attempts):
        var width = randi_range(5, 12)
        var height = randi_range(5, 10)
        var x = randi_range(1, map_size.x - width - 1)
        var y = randi_range(1, map_size.y - height - 1)
        
        var room = Rect2i(x, y, width, height)
        
        if _can_place_room(room):
            _carve_room(room)
            rooms.append(room)
            return

func _can_place_room(room: Rect2i) -> bool:
    var expanded = room.grow(2)
    for existing in rooms:
        if expanded.intersects(existing):
            return false
    return true

func _carve_room(room: Rect2i):
    for x in range(room.position.x, room.end.x):
        for y in range(room.position.y, room.end.y):
            tilemap.erase_cell(0, Vector2i(x, y))

func _connect_rooms():
    for i in range(rooms.size() - 1):
        var room_a = rooms[i]
        var room_b = rooms[i + 1]
        
        var center_a = room_a.get_center()
        var center_b = room_b.get_center()
        
        # L形走廊
        if randi() % 2 == 0:
            _carve_horizontal_tunnel(center_a.x, center_b.x, center_a.y)
            _carve_vertical_tunnel(center_a.y, center_b.y, center_b.x)
        else:
            _carve_vertical_tunnel(center_a.y, center_b.y, center_a.x)
            _carve_horizontal_tunnel(center_a.x, center_b.x, center_b.y)

func _carve_horizontal_tunnel(x1: int, x2: int, y: int):
    for x in range(min(x1, x2), max(x1, x2) + 1):
        tilemap.erase_cell(0, Vector2i(x, y))

func _carve_vertical_tunnel(y1: int, y2: int, x: int):
    for y in range(min(y1, y2), max(y1, y2) + 1):
        tilemap.erase_cell(0, Vector2i(x, y))

20.8 实际案例

20.8.1 关卡编辑器

# level_editor.gd
extends Node2D

@onready var tilemap: TileMap = $TileMap
@onready var cursor: Sprite2D = $Cursor

var current_layer: int = 0
var current_source: int = 0
var current_tile: Vector2i = Vector2i.ZERO
var is_erasing: bool = false

func _process(delta: float):
    # 更新光标位置
    var mouse_pos = get_global_mouse_position()
    var tile_pos = tilemap.local_to_map(tilemap.to_local(mouse_pos))
    cursor.global_position = tilemap.to_global(tilemap.map_to_local(tile_pos))

func _unhandled_input(event: InputEvent):
    if event is InputEventMouseButton:
        if event.button_index == MOUSE_BUTTON_LEFT:
            if event.pressed:
                _paint_tile()
        elif event.button_index == MOUSE_BUTTON_RIGHT:
            if event.pressed:
                _erase_tile()
    
    elif event is InputEventMouseMotion:
        if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
            _paint_tile()
        elif Input.is_mouse_button_pressed(MOUSE_BUTTON_RIGHT):
            _erase_tile()

func _paint_tile():
    var tile_pos = _get_mouse_tile_pos()
    tilemap.set_cell(current_layer, tile_pos, current_source, current_tile)

func _erase_tile():
    var tile_pos = _get_mouse_tile_pos()
    tilemap.erase_cell(current_layer, tile_pos)

func _get_mouse_tile_pos() -> Vector2i:
    var mouse_pos = get_global_mouse_position()
    return tilemap.local_to_map(tilemap.to_local(mouse_pos))

func save_level(path: String):
    var data = {
        "layers": []
    }
    
    for layer in range(tilemap.get_layers_count()):
        var layer_data = []
        for cell in tilemap.get_used_cells(layer):
            layer_data.append({
                "pos": [cell.x, cell.y],
                "source": tilemap.get_cell_source_id(layer, cell),
                "atlas": [
                    tilemap.get_cell_atlas_coords(layer, cell).x,
                    tilemap.get_cell_atlas_coords(layer, cell).y
                ]
            })
        data.layers.append(layer_data)
    
    var file = FileAccess.open(path, FileAccess.WRITE)
    file.store_string(JSON.stringify(data))
    file.close()

func load_level(path: String):
    var file = FileAccess.open(path, FileAccess.READ)
    var data = JSON.parse_string(file.get_as_text())
    file.close()
    
    tilemap.clear()
    
    for layer_idx in range(data.layers.size()):
        for cell in data.layers[layer_idx]:
            var pos = Vector2i(cell.pos[0], cell.pos[1])
            var atlas = Vector2i(cell.atlas[0], cell.atlas[1])
            tilemap.set_cell(layer_idx, pos, cell.source, atlas)

本章小结

本章全面学习了Godot的TileMap系统:

  1. TileMap概述:新特性、基本概念
  2. 创建TileSet:图集图块、场景图块、属性设置
  3. TileMap图层:多图层管理、图层用途
  4. 地形系统:自动图块设置、地形绘制
  5. 碰撞与导航:物理碰撞、导航网格
  6. 代码操作:放置/移除图块、查询、坐标转换
  7. 程序化生成:简单地形、噪声生成、房间生成器
  8. 实际案例:关卡编辑器

下一章将学习2D相机系统。


上一章:2D动画系统

下一章:2D相机系统

← 返回目录