← Volver al listado de tecnologías

Tilemaps y Diseño de Niveles Avanzado

Por: Artiko
defoldtilemapslevel-designparallaxprocedural

Tilemaps y Diseño de Niveles Avanzado

Los tilemaps son una herramienta fundamental para crear mundos de juego eficientes y visualmente atractivos. En esta lección aprenderás técnicas avanzadas para el diseño de niveles profesionales.

🗺️ Conceptos Fundamentales de Tilemaps

Componente Tilemap

Un tilemap en Defold es un componente que permite renderizar grandes áreas usando tiles (azulejos) de manera eficiente:

-- tilemap_controller.script
local TILE_SIZE = 32
local MAP_WIDTH = 100
local MAP_HEIGHT = 50

function init(self)
    self.tilemap_url = msg.url("#tilemap")
    self.tile_source = resource.get_text("/assets/tilesets/main_tileset.tilesource")

    -- Configurar el tilemap
    tilemap.set_tile(self.tilemap_url, "background", 0, 0, 1)
end

Configuración de Tile Sources

Crea un tile source (.tilesource) optimizado:

// main_tileset.tilesource
{
    "tile_width": 32,
    "tile_height": 32,
    "tile_margin": 1,
    "tile_spacing": 2,
    "collision": "/assets/collision/tiles.convexshape",
    "material": "/builtins/materials/tile_map.material",
    "texture": "/assets/sprites/tileset_atlas.png"
}

🏗️ Sistema de Múltiples Capas

Estructura de Capas

Organiza tu nivel en múltiples capas para mayor flexibilidad:

-- level_manager.script
local LAYERS = {
    BACKGROUND = "background",
    MIDGROUND = "midground",
    FOREGROUND = "foreground",
    COLLISION = "collision",
    DECORATION = "decoration"
}

local function setup_layers(self)
    for layer_name, layer_id in pairs(LAYERS) do
        tilemap.set_visible(msg.url("#tilemap"), layer_id, true)
    end
end

local function set_tile_multiple_layers(self, x, y, tiles_by_layer)
    for layer, tile_id in pairs(tiles_by_layer) do
        if tile_id > 0 then
            tilemap.set_tile(self.tilemap_url, layer, x, y, tile_id)
        end
    end
end

Generador de Terrain Procedural

-- terrain_generator.script
local noise = require "main.noise"

local TERRAIN_RULES = {
    GRASS = { min_height = 0.3, max_height = 0.7, tile_id = 1 },
    STONE = { min_height = 0.7, max_height = 1.0, tile_id = 2 },
    WATER = { min_height = 0.0, max_height = 0.3, tile_id = 3 }
}

local function generate_terrain(self, width, height, seed)
    math.randomseed(seed or os.time())

    for x = 0, width - 1 do
        for y = 0, height - 1 do
            local noise_value = noise.perlin(x * 0.1, y * 0.1, 0)
            local height_value = (noise_value + 1) * 0.5 -- Normalizar 0-1

            for terrain_type, rule in pairs(TERRAIN_RULES) do
                if height_value >= rule.min_height and height_value < rule.max_height then
                    tilemap.set_tile(self.tilemap_url, "background", x, y, rule.tile_id)
                    break
                end
            end
        end
    end
end

function init(self)
    self.tilemap_url = msg.url("#tilemap")
    generate_terrain(self, 200, 100, 12345)
end

🌄 Sistema de Parallax Scrolling

Parallax Manager

-- parallax_manager.script
local PARALLAX_LAYERS = {
    { name = "sky", speed = 0.1, tilemap = "#sky_tilemap" },
    { name = "mountains", speed = 0.3, tilemap = "#mountains_tilemap" },
    { name = "trees", speed = 0.6, tilemap = "#trees_tilemap" },
    { name = "ground", speed = 1.0, tilemap = "#ground_tilemap" }
}

function init(self)
    self.camera_pos = vmath.vector3(0)
    self.last_camera_pos = vmath.vector3(0)
end

function update(self, dt)
    -- Obtener posición de la cámara
    local camera_pos = go.get_position("main:/camera")
    local delta = camera_pos - self.last_camera_pos

    -- Aplicar parallax a cada capa
    for _, layer in ipairs(PARALLAX_LAYERS) do
        local current_pos = go.get_position(layer.tilemap)
        local new_pos = current_pos - delta * layer.speed
        go.set_position(new_pos, layer.tilemap)
    end

    self.last_camera_pos = camera_pos
end

Parallax Infinito

-- infinite_parallax.script
local function wrap_tilemap(tilemap_url, world_width)
    local pos = go.get_position(tilemap_url)

    if pos.x < -world_width then
        pos.x = pos.x + world_width * 2
        go.set_position(pos, tilemap_url)
    elseif pos.x > world_width then
        pos.x = pos.x - world_width * 2
        go.set_position(pos, tilemap_url)
    end
end

function update(self, dt)
    -- Aplicar wrapping a capas de fondo
    wrap_tilemap("#background_1", 2048)
    wrap_tilemap("#background_2", 2048)
end

🎯 Colisiones Avanzadas con Tilemaps

Collision Shapes Personalizadas

-- tilemap_collision.script
local function setup_custom_collision(self)
    -- Definir shapes personalizadas para diferentes tiles
    local collision_shapes = {
        [1] = { type = "box", size = vmath.vector3(32, 32, 0) },
        [2] = { type = "polygon", points = {
            vmath.vector3(0, 0, 0),
            vmath.vector3(32, 0, 0),
            vmath.vector3(16, 32, 0)
        }},
        [3] = { type = "circle", radius = 16 }
    }

    return collision_shapes
end

local function update_collision_layer(self)
    local w, h = tilemap.get_bounds(self.tilemap_url)

    for x = 0, w-1 do
        for y = 0, h-1 do
            local tile_id = tilemap.get_tile(self.tilemap_url, "collision", x, y)

            if tile_id > 0 then
                -- Crear collision object dinámicamente
                local pos = vmath.vector3(x * 32 + 16, y * 32 + 16, 0)
                physics.set_shape(self.collision_url, tile_id, pos)
            end
        end
    end
end

Detección de Tiles Inteligente

-- tile_detection.script
local function get_tile_at_world_position(tilemap_url, world_pos, layer)
    local tile_x = math.floor(world_pos.x / 32)
    local tile_y = math.floor(world_pos.y / 32)

    return tilemap.get_tile(tilemap_url, layer, tile_x, tile_y)
end

local function get_surrounding_tiles(tilemap_url, world_pos, layer, radius)
    local center_x = math.floor(world_pos.x / 32)
    local center_y = math.floor(world_pos.y / 32)
    local tiles = {}

    for x = center_x - radius, center_x + radius do
        for y = center_y - radius, center_y + radius do
            local tile_id = tilemap.get_tile(tilemap_url, layer, x, y)
            if tile_id > 0 then
                table.insert(tiles, {
                    x = x, y = y,
                    tile_id = tile_id,
                    world_pos = vmath.vector3(x * 32 + 16, y * 32 + 16, 0)
                })
            end
        end
    end

    return tiles
end

🔧 Herramientas de Level Design

Editor de Niveles en Runtime

-- level_editor.script
local EDIT_MODES = {
    PAINT = 1,
    ERASE = 2,
    EYEDROPPER = 3
}

function init(self)
    self.edit_mode = EDIT_MODES.PAINT
    self.selected_tile = 1
    self.current_layer = "foreground"
    self.tilemap_url = msg.url("#tilemap")
end

function on_input(self, action_id, action)
    if action_id == hash("touch") and action.pressed then
        local world_pos = camera.screen_to_world("main:/camera", vmath.vector3(action.x, action.y, 0))
        local tile_x = math.floor(world_pos.x / 32)
        local tile_y = math.floor(world_pos.y / 32)

        if self.edit_mode == EDIT_MODES.PAINT then
            tilemap.set_tile(self.tilemap_url, self.current_layer, tile_x, tile_y, self.selected_tile)
        elseif self.edit_mode == EDIT_MODES.ERASE then
            tilemap.set_tile(self.tilemap_url, self.current_layer, tile_x, tile_y, 0)
        elseif self.edit_mode == EDIT_MODES.EYEDROPPER then
            self.selected_tile = tilemap.get_tile(self.tilemap_url, self.current_layer, tile_x, tile_y)
        end
    end
end

Sistema de Chunks para Mundos Grandes

-- chunk_manager.script
local CHUNK_SIZE = 32
local LOAD_DISTANCE = 3

local function get_chunk_key(chunk_x, chunk_y)
    return tostring(chunk_x) .. "," .. tostring(chunk_y)
end

local function load_chunk(self, chunk_x, chunk_y)
    local chunk_key = get_chunk_key(chunk_x, chunk_y)

    if not self.loaded_chunks[chunk_key] then
        -- Cargar datos del chunk desde archivo o generar proceduralmente
        local chunk_data = load_chunk_data(chunk_x, chunk_y)

        for x = 0, CHUNK_SIZE - 1 do
            for y = 0, CHUNK_SIZE - 1 do
                local world_x = chunk_x * CHUNK_SIZE + x
                local world_y = chunk_y * CHUNK_SIZE + y
                local tile_id = chunk_data[x][y]

                tilemap.set_tile(self.tilemap_url, "background", world_x, world_y, tile_id)
            end
        end

        self.loaded_chunks[chunk_key] = true
    end
end

local function unload_chunk(self, chunk_x, chunk_y)
    local chunk_key = get_chunk_key(chunk_x, chunk_y)

    if self.loaded_chunks[chunk_key] then
        -- Limpiar tiles del chunk
        for x = 0, CHUNK_SIZE - 1 do
            for y = 0, CHUNK_SIZE - 1 do
                local world_x = chunk_x * CHUNK_SIZE + x
                local world_y = chunk_y * CHUNK_SIZE + y
                tilemap.set_tile(self.tilemap_url, "background", world_x, world_y, 0)
            end
        end

        self.loaded_chunks[chunk_key] = nil
    end
end

function update(self, dt)
    local camera_pos = go.get_position("main:/camera")
    local camera_chunk_x = math.floor(camera_pos.x / (CHUNK_SIZE * 32))
    local camera_chunk_y = math.floor(camera_pos.y / (CHUNK_SIZE * 32))

    -- Cargar chunks cercanos
    for x = camera_chunk_x - LOAD_DISTANCE, camera_chunk_x + LOAD_DISTANCE do
        for y = camera_chunk_y - LOAD_DISTANCE, camera_chunk_y + LOAD_DISTANCE do
            load_chunk(self, x, y)
        end
    end

    -- Descargar chunks lejanos
    for chunk_key, _ in pairs(self.loaded_chunks) do
        local chunk_x, chunk_y = chunk_key:match("([^,]+),([^,]+)")
        chunk_x, chunk_y = tonumber(chunk_x), tonumber(chunk_y)

        local distance = math.max(
            math.abs(chunk_x - camera_chunk_x),
            math.abs(chunk_y - camera_chunk_y)
        )

        if distance > LOAD_DISTANCE then
            unload_chunk(self, chunk_x, chunk_y)
        end
    end
end

📊 Optimización de Rendimiento

Tile Pooling

-- tile_pool.script
local function create_tile_pool(self, tile_count)
    self.tile_pool = {}

    for i = 1, tile_count do
        local tile_go = factory.create("#tile_factory")
        go.set_position(vmath.vector3(-1000, -1000, 0), tile_go)
        table.insert(self.tile_pool, tile_go)
    end
end

local function get_tile_from_pool(self)
    if #self.tile_pool > 0 then
        return table.remove(self.tile_pool)
    else
        return factory.create("#tile_factory")
    end
end

local function return_tile_to_pool(self, tile_go)
    go.set_position(vmath.vector3(-1000, -1000, 0), tile_go)
    table.insert(self.tile_pool, tile_go)
end

LOD (Level of Detail) para Tilemaps

-- tilemap_lod.script
local LOD_DISTANCES = {
    HIGH = 500,
    MEDIUM = 1000,
    LOW = 2000
}

local function update_lod(self, camera_pos)
    local w, h = tilemap.get_bounds(self.tilemap_url)

    for x = 0, w-1, 4 do -- Sampling cada 4 tiles para performance
        for y = 0, h-1, 4 do
            local tile_world_pos = vmath.vector3(x * 32, y * 32, 0)
            local distance = vmath.length(camera_pos - tile_world_pos)

            local visible = true
            local detail_level = "HIGH"

            if distance > LOD_DISTANCES.LOW then
                visible = false
            elseif distance > LOD_DISTANCES.MEDIUM then
                detail_level = "LOW"
            elseif distance > LOD_DISTANCES.HIGH then
                detail_level = "MEDIUM"
            end

            -- Aplicar LOD
            self:apply_lod_to_tile_area(x, y, detail_level, visible)
        end
    end
end

🎮 Proyecto Práctico: Metroidvania Level System

Vamos a crear un sistema completo de niveles para un juego estilo Metroidvania:

1. Room Manager

-- room_manager.script
local ROOM_SIZE = 20 * 32 -- 20 tiles = 640 pixels

function init(self)
    self.current_room = { x = 0, y = 0 }
    self.rooms = {}
    self.tilemap_url = msg.url("#tilemap")

    -- Cargar datos de rooms
    self:load_room_data()
end

local function load_room_data(self)
    -- Cargar desde JSON o definir aquí
    self.rooms = {
        ["0,0"] = {
            background = "room_start.json",
            collision = "room_start_collision.json",
            entities = { "player_spawn", "save_point" }
        },
        ["1,0"] = {
            background = "room_corridor.json",
            collision = "room_corridor_collision.json",
            entities = { "enemy_goomba", "collectible_health" }
        }
    }
end

local function transition_to_room(self, room_x, room_y)
    local room_key = tostring(room_x) .. "," .. tostring(room_y)
    local room_data = self.rooms[room_key]

    if room_data then
        -- Limpiar room actual
        self:clear_current_room()

        -- Cargar nueva room
        self:load_room_tiles(room_data.background, "background")
        self:load_room_tiles(room_data.collision, "collision")
        self:spawn_room_entities(room_data.entities)

        self.current_room = { x = room_x, y = room_y }

        -- Mover cámara
        local camera_pos = vmath.vector3(room_x * ROOM_SIZE, room_y * ROOM_SIZE, 0)
        go.animate("main:/camera", "position", go.PLAYBACK_ONCE_FORWARD, camera_pos, go.EASING_OUTSINE, 1.0)
    end
end

2. Room Transitions

-- room_transition.script
function on_message(self, message_id, message, sender)
    if message_id == hash("trigger_response") then
        if message.other_group == hash("player") then
            local trigger_name = go.get("#")[2] -- Obtener nombre del trigger

            if trigger_name == "exit_right" then
                msg.post("main:/room_manager", "transition_room", { x = 1, y = 0 })
            elseif trigger_name == "exit_left" then
                msg.post("main:/room_manager", "transition_room", { x = -1, y = 0 })
            elseif trigger_name == "exit_up" then
                msg.post("main:/room_manager", "transition_room", { x = 0, y = 1 })
            elseif trigger_name == "exit_down" then
                msg.post("main:/room_manager", "transition_room", { x = 0, y = -1 })
            end
        end
    end
end

📚 Recursos y Referencias

API de Tilemap en Defold

Herramientas Recomendadas

🎯 Ejercicios Propuestos

  1. Sistema de Biomas: Crea un generador procedural que genere diferentes biomas (bosque, desierto, montaña) con reglas específicas.

  2. Dynamic Loading: Implementa un sistema que cargue niveles desde archivos JSON dinámicamente.

  3. Destructible Terrain: Crea tiles que se puedan destruir y regenerar en runtime.

  4. Weather System: Añade efectos climáticos que afecten la apariencia de los tiles.

  5. Mini-mapa: Crea un mini-mapa que muestre la estructura del nivel usando los datos del tilemap.

Los tilemaps son la base para crear mundos de juego ricos y detallados. Con estas técnicas avanzadas podrás crear niveles profesionales que cautiven a los jugadores y optimicen el rendimiento de tu juego.