← Volver al listado de tecnologías

Patrones de Diseño para Juegos

Por: Artiko
defoldpatrones-diseñoarquitecturaclean-codegame-dev

Patrones de Diseño para Juegos

Los patrones de diseño son soluciones probadas a problemas comunes en el desarrollo de software. En el desarrollo de juegos, ciertos patrones son especialmente útiles para manejar la complejidad inherente de los sistemas interactivos. Esta lección te enseñará los patrones más importantes aplicados a Defold.

🎯 State Machine Pattern

Finite State Machine Básica

-- state_machine.lua
local StateMachine = {}
StateMachine.__index = StateMachine

function StateMachine.new()
    local self = setmetatable({}, StateMachine)
    self.states = {}
    self.current_state = nil
    self.previous_state = nil
    self.state_data = {}
    return self
end

function StateMachine:add_state(name, state_obj)
    self.states[name] = state_obj
    state_obj.name = name
    state_obj.machine = self
end

function StateMachine:change_state(new_state_name, ...)
    local new_state = self.states[new_state_name]
    if not new_state then
        error("State '" .. new_state_name .. "' does not exist")
    end

    -- Exit current state
    if self.current_state and self.current_state.on_exit then
        self.current_state:on_exit()
    end

    -- Change state
    self.previous_state = self.current_state
    self.current_state = new_state

    -- Enter new state
    if self.current_state.on_enter then
        self.current_state:on_enter(...)
    end

    print("State changed to:", new_state_name)
end

function StateMachine:update(dt)
    if self.current_state and self.current_state.update then
        self.current_state:update(dt)
    end
end

function StateMachine:on_message(message_id, message, sender)
    if self.current_state and self.current_state.on_message then
        self.current_state:on_message(message_id, message, sender)
    end
end

function StateMachine:get_current_state_name()
    return self.current_state and self.current_state.name or nil
end

return StateMachine

Game State Manager

-- game_state_manager.script
local StateMachine = require "main.state_machine"

-- Estados del juego
local MenuState = {}
local GameplayState = {}
local PauseState = {}
local GameOverState = {}

function init(self)
    self.state_machine = StateMachine.new()

    -- Configurar estados
    self.state_machine:add_state("menu", MenuState)
    self.state_machine:add_state("gameplay", GameplayState)
    self.state_machine:add_state("pause", PauseState)
    self.state_machine:add_state("game_over", GameOverState)

    -- Estado inicial
    self.state_machine:change_state("menu")

    -- Data compartida entre estados
    self.game_data = {
        score = 0,
        lives = 3,
        level = 1,
        settings = {}
    }
end

-- === MENU STATE ===
function MenuState:on_enter()
    msg.post("menu_gui", "show")
    msg.post("background_music", "play", {track = "menu_theme"})
end

function MenuState:on_exit()
    msg.post("menu_gui", "hide")
end

function MenuState:on_message(message_id, message, sender)
    if message_id == hash("start_game") then
        self.machine:change_state("gameplay")
    elseif message_id == hash("show_settings") then
        msg.post("settings_gui", "show")
    end
end

-- === GAMEPLAY STATE ===
function GameplayState:on_enter()
    msg.post("game_gui", "show")
    msg.post("player", "enable")
    msg.post("enemy_spawner", "start_spawning")
    msg.post("background_music", "play", {track = "gameplay_theme"})

    -- Inicializar datos de gameplay
    self.machine.state_data.gameplay = {
        start_time = socket.gettime(),
        enemies_killed = 0,
        powerups_collected = 0
    }
end

function GameplayState:on_exit()
    msg.post("player", "disable")
    msg.post("enemy_spawner", "stop_spawning")
end

function GameplayState:update(dt)
    -- Lógica específica del gameplay
    local data = self.machine.state_data.gameplay
    if data then
        data.play_time = socket.gettime() - data.start_time
    end
end

function GameplayState:on_message(message_id, message, sender)
    if message_id == hash("pause_game") then
        self.machine:change_state("pause")
    elseif message_id == hash("player_died") then
        local game_data = self.machine.state_data
        game_data.lives = game_data.lives - 1

        if game_data.lives <= 0 then
            self.machine:change_state("game_over")
        else
            -- Respawn player
            msg.post("player", "respawn")
        end
    elseif message_id == hash("level_completed") then
        self.machine.state_data.level = self.machine.state_data.level + 1
        -- Transition to next level or victory screen
    end
end

-- === PAUSE STATE ===
function PauseState:on_enter()
    msg.post("pause_gui", "show")
    msg.post(".", "set_time_step", {factor = 0, mode = 1}) -- Pausar tiempo
    msg.post("background_music", "set_gain", {gain = 0.3}) -- Reducir volumen
end

function PauseState:on_exit()
    msg.post("pause_gui", "hide")
    msg.post(".", "set_time_step", {factor = 1, mode = 1}) -- Reanudar tiempo
    msg.post("background_music", "set_gain", {gain = 1.0}) -- Restaurar volumen
end

function PauseState:on_message(message_id, message, sender)
    if message_id == hash("resume_game") then
        self.machine:change_state("gameplay")
    elseif message_id == hash("quit_to_menu") then
        self.machine:change_state("menu")
    end
end

-- === GAME OVER STATE ===
function GameOverState:on_enter()
    msg.post("game_over_gui", "show")
    msg.post("background_music", "play", {track = "game_over_theme"})

    -- Calcular estadísticas finales
    local gameplay_data = self.machine.state_data.gameplay or {}
    local final_stats = {
        final_score = self.machine.state_data.score,
        play_time = gameplay_data.play_time or 0,
        enemies_killed = gameplay_data.enemies_killed or 0,
        level_reached = self.machine.state_data.level
    }

    msg.post("game_over_gui", "show_stats", final_stats)

    -- Guardar high score
    self:save_high_score(final_stats.final_score)
end

function GameOverState:save_high_score(score)
    local high_score = sys.load("high_score") or 0
    if score > high_score then
        sys.save("high_score", score)
        msg.post("game_over_gui", "show_new_record")
    end
end

function GameOverState:on_message(message_id, message, sender)
    if message_id == hash("restart_game") then
        -- Reset game data
        self.machine.state_data = {
            score = 0,
            lives = 3,
            level = 1
        }
        self.machine:change_state("gameplay")
    elseif message_id == hash("return_to_menu") then
        self.machine:change_state("menu")
    end
end

function update(self, dt)
    self.state_machine:update(dt)
end

function on_message(self, message_id, message, sender)
    self.state_machine:on_message(message_id, message, sender)
end

👁️ Observer Pattern

Event System

-- event_system.lua
local EventSystem = {}
EventSystem.__index = EventSystem

function EventSystem.new()
    local self = setmetatable({}, EventSystem)
    self.observers = {}
    self.event_queue = {}
    self.processing_events = false
    return self
end

function EventSystem:subscribe(event_type, observer, callback)
    if not self.observers[event_type] then
        self.observers[event_type] = {}
    end

    local subscription = {
        observer = observer,
        callback = callback,
        id = tostring(observer) .. "_" .. tostring(callback)
    }

    table.insert(self.observers[event_type], subscription)
    return subscription.id
end

function EventSystem:unsubscribe(event_type, subscription_id)
    local observers_list = self.observers[event_type]
    if not observers_list then return false end

    for i, subscription in ipairs(observers_list) do
        if subscription.id == subscription_id then
            table.remove(observers_list, i)
            return true
        end
    end

    return false
end

function EventSystem:unsubscribe_all(observer)
    for event_type, observers_list in pairs(self.observers) do
        for i = #observers_list, 1, -1 do
            if observers_list[i].observer == observer then
                table.remove(observers_list, i)
            end
        end
    end
end

function EventSystem:emit(event_type, event_data)
    local event = {
        type = event_type,
        data = event_data or {},
        timestamp = socket.gettime()
    }

    -- Si estamos procesando eventos, añadir a la cola para evitar recursión
    if self.processing_events then
        table.insert(self.event_queue, event)
        return
    end

    self:process_event(event)
end

function EventSystem:process_event(event)
    local observers_list = self.observers[event.type]
    if not observers_list then return end

    self.processing_events = true

    -- Crear copia de la lista para evitar problemas si se modifican durante la iteración
    local observers_copy = {}
    for i, observer in ipairs(observers_list) do
        observers_copy[i] = observer
    end

    for _, subscription in ipairs(observers_copy) do
        local success, error_msg = pcall(subscription.callback, subscription.observer, event)
        if not success then
            print("Error in event callback:", error_msg)
        end
    end

    self.processing_events = false

    -- Procesar eventos que se añadieron durante el procesamiento
    if #self.event_queue > 0 then
        local queued_event = table.remove(self.event_queue, 1)
        self:process_event(queued_event)
    end
end

function EventSystem:clear_all()
    self.observers = {}
    self.event_queue = {}
end

return EventSystem

Game Events Implementation

-- game_events.script
local EventSystem = require "main.event_system"

-- Singleton del sistema de eventos
local events = EventSystem.new()

function init(self)
    -- Hacer el sistema de eventos global
    _G.game_events = events

    -- Ejemplos de eventos del juego
    self:setup_game_events()
end

local function setup_game_events(self)
    -- Evento: Player Score Changed
    events:subscribe("player_score_changed", self, function(observer, event)
        local new_score = event.data.score
        local old_score = event.data.old_score
        local difference = new_score - old_score

        msg.post("hud", "update_score", {score = new_score})

        -- Efectos especiales para puntuaciones altas
        if difference >= 1000 then
            msg.post("effects_manager", "show_score_burst", {amount = difference})
        end
    end)

    -- Evento: Enemy Defeated
    events:subscribe("enemy_defeated", self, function(observer, event)
        local enemy_type = event.data.enemy_type
        local position = event.data.position
        local score_value = event.data.score_value

        -- Crear efectos visuales
        msg.post("effects_manager", "create_explosion", {
            position = position,
            type = enemy_type
        })

        -- Reproducir sonido
        msg.post("audio_manager", "play_sound", {
            sound = "enemy_death_" .. enemy_type
        })

        -- Actualizar estadísticas
        msg.post("stats_manager", "increment_stat", {
            stat = "enemies_killed",
            value = 1
        })

        -- Otorgar puntos
        if score_value > 0 then
            events:emit("player_score_changed", {
                score = game.score + score_value,
                old_score = game.score
            })
            game.score = game.score + score_value
        end
    end)

    -- Evento: Power-up Collected
    events:subscribe("powerup_collected", self, function(observer, event)
        local powerup_type = event.data.type
        local player = event.data.player

        if powerup_type == "health" then
            msg.post(player, "heal", {amount = 25})
            events:emit("player_healed", {amount = 25})

        elseif powerup_type == "speed_boost" then
            msg.post(player, "apply_effect", {
                effect = "speed_boost",
                duration = 10,
                multiplier = 1.5
            })

        elseif powerup_type == "double_damage" then
            msg.post(player, "apply_effect", {
                effect = "double_damage",
                duration = 15
            })

        elseif powerup_type == "shield" then
            msg.post(player, "activate_shield", {duration = 20})
        end

        -- Efecto visual universal
        msg.post("effects_manager", "create_powerup_effect", {
            type = powerup_type,
            position = go.get_position(player)
        })
    end)

    -- Evento: Level Completed
    events:subscribe("level_completed", self, function(observer, event)
        local level = event.data.level
        local completion_time = event.data.time
        local bonus_score = event.data.bonus

        -- Calcular bonus por tiempo
        local time_bonus = math.max(0, 10000 - completion_time * 10)

        events:emit("player_score_changed", {
            score = game.score + bonus_score + time_bonus,
            old_score = game.score
        })

        -- Mostrar pantalla de completado
        msg.post("level_complete_gui", "show", {
            level = level,
            time = completion_time,
            bonus = bonus_score + time_bonus
        })

        -- Guardar progreso
        msg.post("save_manager", "save_progress", {
            level = level + 1,
            score = game.score
        })
    end)
end

-- Funciones helper para emitir eventos comunes
function emit_player_damaged(damage, source)
    events:emit("player_damaged", {
        damage = damage,
        source = source,
        player_health = game.player_health - damage
    })
end

function emit_item_pickup(item_type, position)
    events:emit("item_picked_up", {
        type = item_type,
        position = position,
        timestamp = socket.gettime()
    })
end

function emit_achievement_unlocked(achievement_id)
    events:emit("achievement_unlocked", {
        id = achievement_id,
        timestamp = socket.gettime()
    })
end

🧩 Component System Pattern

Entity Component System

-- ecs.lua
local ECS = {}

-- Entity Manager
local EntityManager = {}
EntityManager.__index = EntityManager

function EntityManager.new()
    local self = setmetatable({}, EntityManager)
    self.next_id = 1
    self.entities = {}
    return self
end

function EntityManager:create_entity()
    local entity_id = self.next_id
    self.next_id = self.next_id + 1

    self.entities[entity_id] = {
        id = entity_id,
        components = {},
        active = true
    }

    return entity_id
end

function EntityManager:destroy_entity(entity_id)
    self.entities[entity_id] = nil
end

function EntityManager:is_active(entity_id)
    local entity = self.entities[entity_id]
    return entity and entity.active
end

-- Component Manager
local ComponentManager = {}
ComponentManager.__index = ComponentManager

function ComponentManager.new()
    local self = setmetatable({}, ComponentManager)
    self.components = {} -- [component_type][entity_id] = component_data
    self.component_types = {}
    return self
end

function ComponentManager:register_component_type(component_type, component_class)
    self.component_types[component_type] = component_class
    if not self.components[component_type] then
        self.components[component_type] = {}
    end
end

function ComponentManager:add_component(entity_id, component_type, component_data)
    if not self.components[component_type] then
        self.components[component_type] = {}
    end

    local component_class = self.component_types[component_type]
    local component = component_class and component_class.new(component_data) or component_data

    self.components[component_type][entity_id] = component
    return component
end

function ComponentManager:get_component(entity_id, component_type)
    return self.components[component_type] and self.components[component_type][entity_id]
end

function ComponentManager:remove_component(entity_id, component_type)
    if self.components[component_type] then
        self.components[component_type][entity_id] = nil
    end
end

function ComponentManager:get_entities_with_component(component_type)
    local entities = {}
    if self.components[component_type] then
        for entity_id, _ in pairs(self.components[component_type]) do
            table.insert(entities, entity_id)
        end
    end
    return entities
end

-- System Manager
local SystemManager = {}
SystemManager.__index = SystemManager

function SystemManager.new(entity_manager, component_manager)
    local self = setmetatable({}, SystemManager)
    self.systems = {}
    self.entity_manager = entity_manager
    self.component_manager = component_manager
    return self
end

function SystemManager:add_system(system)
    system.entity_manager = self.entity_manager
    system.component_manager = self.component_manager
    table.insert(self.systems, system)

    if system.init then
        system:init()
    end
end

function SystemManager:update(dt)
    for _, system in ipairs(self.systems) do
        if system.update then
            system:update(dt)
        end
    end
end

-- ECS World
function ECS.create_world()
    local entity_manager = EntityManager.new()
    local component_manager = ComponentManager.new()
    local system_manager = SystemManager.new(entity_manager, component_manager)

    return {
        entity_manager = entity_manager,
        component_manager = component_manager,
        system_manager = system_manager
    }
end

return ECS

Game Components

-- game_components.lua

-- Transform Component
local TransformComponent = {}
TransformComponent.__index = TransformComponent

function TransformComponent.new(data)
    data = data or {}
    local self = setmetatable({}, TransformComponent)
    self.position = data.position or vmath.vector3()
    self.rotation = data.rotation or vmath.quat()
    self.scale = data.scale or vmath.vector3(1, 1, 1)
    return self
end

-- Health Component
local HealthComponent = {}
HealthComponent.__index = HealthComponent

function HealthComponent.new(data)
    data = data or {}
    local self = setmetatable({}, HealthComponent)
    self.current_health = data.current_health or data.max_health or 100
    self.max_health = data.max_health or 100
    self.regeneration_rate = data.regeneration_rate or 0
    self.invulnerable = false
    self.invulnerability_time = 0
    return self
end

function HealthComponent:take_damage(amount)
    if self.invulnerable then return false end

    self.current_health = math.max(0, self.current_health - amount)
    self.invulnerable = true
    self.invulnerability_time = 0.5 -- 500ms de invulnerabilidad

    return true
end

function HealthComponent:heal(amount)
    self.current_health = math.min(self.max_health, self.current_health + amount)
end

function HealthComponent:is_alive()
    return self.current_health > 0
end

-- Velocity Component
local VelocityComponent = {}
VelocityComponent.__index = VelocityComponent

function VelocityComponent.new(data)
    data = data or {}
    local self = setmetatable({}, VelocityComponent)
    self.velocity = data.velocity or vmath.vector3()
    self.max_speed = data.max_speed or 200
    self.friction = data.friction or 0.9
    return self
end

-- Weapon Component
local WeaponComponent = {}
WeaponComponent.__index = WeaponComponent

function WeaponComponent.new(data)
    data = data or {}
    local self = setmetatable({}, WeaponComponent)
    self.damage = data.damage or 10
    self.fire_rate = data.fire_rate or 1.0 -- disparos por segundo
    self.last_shot_time = 0
    self.ammo = data.ammo or -1 -- -1 = munición infinita
    self.max_ammo = data.max_ammo or 30
    self.projectile_speed = data.projectile_speed or 500
    return self
end

function WeaponComponent:can_shoot()
    local current_time = socket.gettime()
    local time_since_last_shot = current_time - self.last_shot_time
    local shot_cooldown = 1.0 / self.fire_rate

    return time_since_last_shot >= shot_cooldown and
           (self.ammo > 0 or self.ammo == -1)
end

function WeaponComponent:shoot()
    if not self:can_shoot() then return false end

    self.last_shot_time = socket.gettime()
    if self.ammo > 0 then
        self.ammo = self.ammo - 1
    end

    return true
end

-- AI Component
local AIComponent = {}
AIComponent.__index = AIComponent

function AIComponent.new(data)
    data = data or {}
    local self = setmetatable({}, AIComponent)
    self.state = data.initial_state or "idle"
    self.target = nil
    self.detection_range = data.detection_range or 100
    self.attack_range = data.attack_range or 50
    self.patrol_points = data.patrol_points or {}
    self.current_patrol_index = 1
    self.state_timer = 0
    return self
end

return {
    TransformComponent = TransformComponent,
    HealthComponent = HealthComponent,
    VelocityComponent = VelocityComponent,
    WeaponComponent = WeaponComponent,
    AIComponent = AIComponent
}

🔄 Object Pool Pattern

Generic Object Pool

-- object_pool.lua
local ObjectPool = {}
ObjectPool.__index = ObjectPool

function ObjectPool.new(factory_function, reset_function, initial_size)
    local self = setmetatable({}, ObjectPool)
    self.factory_function = factory_function
    self.reset_function = reset_function
    self.available_objects = {}
    self.used_objects = {}
    self.total_created = 0

    -- Pre-crear objetos iniciales
    initial_size = initial_size or 10
    for i = 1, initial_size do
        local obj = self.factory_function()
        table.insert(self.available_objects, obj)
        self.total_created = self.total_created + 1
    end

    return self
end

function ObjectPool:acquire()
    local obj = nil

    if #self.available_objects > 0 then
        obj = table.remove(self.available_objects)
    else
        -- Pool vacío, crear nuevo objeto
        obj = self.factory_function()
        self.total_created = self.total_created + 1
    end

    self.used_objects[obj] = true
    return obj
end

function ObjectPool:release(obj)
    if not self.used_objects[obj] then
        return false -- Objeto no pertenece a este pool
    end

    -- Reset del objeto
    if self.reset_function then
        self.reset_function(obj)
    end

    -- Devolver al pool
    self.used_objects[obj] = nil
    table.insert(self.available_objects, obj)

    return true
end

function ObjectPool:get_stats()
    return {
        total_created = self.total_created,
        available = #self.available_objects,
        used = self:count_used_objects(),
        efficiency = self:calculate_efficiency()
    }
end

function ObjectPool:count_used_objects()
    local count = 0
    for _ in pairs(self.used_objects) do
        count = count + 1
    end
    return count
end

function ObjectPool:calculate_efficiency()
    local used_count = self:count_used_objects()
    return used_count / self.total_created
end

return ObjectPool

Game Object Pools

-- game_object_pools.script
local ObjectPool = require "main.object_pool"

function init(self)
    self.pools = {}

    -- Pool de balas
    self.pools.bullets = ObjectPool.new(
        function() -- Factory function
            return factory.create("#bullet_factory")
        end,
        function(bullet) -- Reset function
            go.set_position(vmath.vector3(-1000, -1000, 0), bullet)
            go.set_rotation(vmath.quat(), bullet)
            msg.post(bullet, "reset")
        end,
        50 -- Initial size
    )

    -- Pool de enemigos
    self.pools.enemies = ObjectPool.new(
        function()
            return factory.create("#enemy_factory")
        end,
        function(enemy)
            go.set_position(vmath.vector3(-1000, -1000, 0), enemy)
            msg.post(enemy, "reset")
        end,
        20
    )

    -- Pool de efectos de partículas
    self.pools.particles = ObjectPool.new(
        function()
            return factory.create("#particle_factory")
        end,
        function(particle)
            go.set_position(vmath.vector3(-1000, -1000, 0), particle)
            particlefx.stop(particle)
        end,
        30
    )

    -- Pool de power-ups
    self.pools.powerups = ObjectPool.new(
        function()
            return factory.create("#powerup_factory")
        end,
        function(powerup)
            go.set_position(vmath.vector3(-1000, -1000, 0), powerup)
            msg.post(powerup, "reset")
        end,
        15
    )
end

-- Funciones helper para usar los pools
function spawn_bullet(position, direction, speed)
    local bullet = self.pools.bullets:acquire()
    if bullet then
        go.set_position(position, bullet)
        msg.post(bullet, "initialize", {
            direction = direction,
            speed = speed
        })
    end
    return bullet
end

function spawn_enemy(position, enemy_type)
    local enemy = self.pools.enemies:acquire()
    if enemy then
        go.set_position(position, enemy)
        msg.post(enemy, "initialize", {
            type = enemy_type
        })
    end
    return enemy
end

function spawn_explosion(position, explosion_type)
    local particle = self.pools.particles:acquire()
    if particle then
        go.set_position(position, particle)
        msg.post(particle, "play_explosion", {
            type = explosion_type
        })

        -- Auto-return to pool después de la animación
        timer.delay(2.0, false, function()
            self.pools.particles:release(particle)
        end)
    end
    return particle
end

function destroy_bullet(bullet)
    if self.pools.bullets:release(bullet) then
        -- Crear efecto de impacto antes de devolver al pool
        local pos = go.get_position(bullet)
        spawn_explosion(pos, "bullet_impact")
    end
end

function destroy_enemy(enemy)
    if self.pools.enemies:release(enemy) then
        local pos = go.get_position(enemy)
        spawn_explosion(pos, "enemy_death")

        -- Posibilidad de drop de power-up
        if math.random() < 0.3 then -- 30% chance
            spawn_powerup(pos, "random")
        end
    end
end

function spawn_powerup(position, powerup_type)
    local powerup = self.pools.powerups:acquire()
    if powerup then
        go.set_position(position, powerup)
        msg.post(powerup, "initialize", {
            type = powerup_type == "random" and get_random_powerup_type() or powerup_type
        })
    end
    return powerup
end

function get_random_powerup_type()
    local types = {"health", "speed_boost", "double_damage", "shield"}
    return types[math.random(#types)]
end

-- Debug y estadísticas
function on_message(self, message_id, message, sender)
    if message_id == hash("show_pool_stats") then
        for pool_name, pool in pairs(self.pools) do
            local stats = pool:get_stats()
            print(string.format("%s Pool - Created: %d, Available: %d, Used: %d, Efficiency: %.2f",
                               pool_name, stats.total_created, stats.available,
                               stats.used, stats.efficiency))
        end
    end
end

⚡ Command Pattern

Command System

-- command_system.lua
local CommandSystem = {}
CommandSystem.__index = CommandSystem

function CommandSystem.new()
    local self = setmetatable({}, CommandSystem)
    self.command_history = {}
    self.undo_stack = {}
    self.redo_stack = {}
    self.max_history = 100
    return self
end

-- Comando base
local Command = {}
Command.__index = Command

function Command.new()
    return setmetatable({}, Command)
end

function Command:execute()
    error("execute() must be implemented by subclass")
end

function Command:undo()
    error("undo() must be implemented by subclass")
end

function Command:can_undo()
    return true
end

-- Ejecutar comando
function CommandSystem:execute_command(command)
    -- Ejecutar comando
    local success, result = pcall(command.execute, command)

    if success then
        -- Añadir al historial
        table.insert(self.command_history, {
            command = command,
            timestamp = socket.gettime(),
            result = result
        })

        -- Añadir a pila de undo si es posible
        if command:can_undo() then
            table.insert(self.undo_stack, command)
            self.redo_stack = {} -- Limpiar redo stack
        end

        -- Mantener historial limitado
        if #self.command_history > self.max_history then
            table.remove(self.command_history, 1)
        end

        return true, result
    else
        print("Command execution failed:", result)
        return false, result
    end
end

function CommandSystem:undo()
    if #self.undo_stack == 0 then
        return false, "Nothing to undo"
    end

    local command = table.remove(self.undo_stack)
    local success, result = pcall(command.undo, command)

    if success then
        table.insert(self.redo_stack, command)
        return true, result
    else
        -- Devolver comando a la pila si falló el undo
        table.insert(self.undo_stack, command)
        return false, result
    end
end

function CommandSystem:redo()
    if #self.redo_stack == 0 then
        return false, "Nothing to redo"
    end

    local command = table.remove(self.redo_stack)
    return self:execute_command(command)
end

function CommandSystem:clear_history()
    self.command_history = {}
    self.undo_stack = {}
    self.redo_stack = {}
end

CommandSystem.Command = Command
return CommandSystem

Game Commands Implementation

-- game_commands.script
local CommandSystem = require "main.command_system"

function init(self)
    self.command_system = CommandSystem.new()

    -- Hacer el sistema de comandos global
    _G.game_commands = self.command_system

    -- Registrar input para undo/redo
    msg.post(".", "acquire_input_focus")
end

-- === MOVE COMMAND ===
local MoveCommand = setmetatable({}, {__index = CommandSystem.Command})
MoveCommand.__index = MoveCommand

function MoveCommand.new(entity, from_position, to_position)
    local self = setmetatable(CommandSystem.Command.new(), MoveCommand)
    self.entity = entity
    self.from_position = from_position
    self.to_position = to_position
    return self
end

function MoveCommand:execute()
    go.set_position(self.to_position, self.entity)
    return "Moved to " .. tostring(self.to_position)
end

function MoveCommand:undo()
    go.set_position(self.from_position, self.entity)
    return "Moved back to " .. tostring(self.from_position)
end

-- === DAMAGE COMMAND ===
local DamageCommand = setmetatable({}, {__index = CommandSystem.Command})
DamageCommand.__index = DamageCommand

function DamageCommand.new(target, damage_amount)
    local self = setmetatable(CommandSystem.Command.new(), DamageCommand)
    self.target = target
    self.damage_amount = damage_amount
    self.previous_health = nil
    return self
end

function DamageCommand:execute()
    -- Guardar salud anterior
    self.previous_health = go.get(self.target, "health")

    -- Aplicar daño
    local new_health = math.max(0, self.previous_health - self.damage_amount)
    go.set(self.target, "health", new_health)

    msg.post(self.target, "damaged", {
        damage = self.damage_amount,
        new_health = new_health
    })

    return string.format("Dealt %d damage", self.damage_amount)
end

function DamageCommand:undo()
    go.set(self.target, "health", self.previous_health)
    msg.post(self.target, "healed", {
        amount = self.damage_amount,
        new_health = self.previous_health
    })

    return string.format("Restored %d health", self.damage_amount)
end

-- === SPAWN COMMAND ===
local SpawnCommand = setmetatable({}, {__index = CommandSystem.Command})
SpawnCommand.__index = SpawnCommand

function SpawnCommand.new(factory_url, position, properties)
    local self = setmetatable(CommandSystem.Command.new(), SpawnCommand)
    self.factory_url = factory_url
    self.position = position
    self.properties = properties or {}
    self.spawned_object = nil
    return self
end

function SpawnCommand:execute()
    self.spawned_object = factory.create(self.factory_url, self.position, nil, self.properties)

    return "Spawned object: " .. tostring(self.spawned_object)
end

function SpawnCommand:undo()
    if self.spawned_object then
        go.delete(self.spawned_object)
        self.spawned_object = nil
    end

    return "Destroyed spawned object"
end

-- === COMPOSITE COMMAND ===
local CompositeCommand = setmetatable({}, {__index = CommandSystem.Command})
CompositeCommand.__index = CompositeCommand

function CompositeCommand.new(commands)
    local self = setmetatable(CommandSystem.Command.new(), CompositeCommand)
    self.commands = commands or {}
    return self
end

function CompositeCommand:add_command(command)
    table.insert(self.commands, command)
end

function CompositeCommand:execute()
    local results = {}
    for i, command in ipairs(self.commands) do
        local success, result = pcall(command.execute, command)
        if not success then
            -- Undo comandos ya ejecutados
            for j = i - 1, 1, -1 do
                pcall(self.commands[j].undo, self.commands[j])
            end
            error("Composite command failed at step " .. i .. ": " .. result)
        end
        table.insert(results, result)
    end
    return results
end

function CompositeCommand:undo()
    local results = {}
    -- Undo en orden inverso
    for i = #self.commands, 1, -1 do
        local success, result = pcall(self.commands[i].undo, self.commands[i])
        if success then
            table.insert(results, result)
        end
    end
    return results
end

-- Funciones helper para crear comandos
function create_move_command(entity, to_position)
    local from_position = go.get_position(entity)
    return MoveCommand.new(entity, from_position, to_position)
end

function create_damage_command(target, damage)
    return DamageCommand.new(target, damage)
end

function create_spawn_command(factory_url, position, properties)
    return SpawnCommand.new(factory_url, position, properties)
end

function create_attack_sequence(attacker, target, damage)
    local composite = CompositeCommand.new()

    -- Mover atacante hacia objetivo
    local attacker_pos = go.get_position(attacker)
    local target_pos = go.get_position(target)
    local attack_pos = target_pos + vmath.normalize(attacker_pos - target_pos) * 50

    composite:add_command(create_move_command(attacker, attack_pos))
    composite:add_command(create_damage_command(target, damage))
    composite:add_command(create_move_command(attacker, attacker_pos))

    return composite
end

-- Input handling
function on_input(self, action_id, action)
    if action_id == hash("undo") and action.pressed then
        local success, result = self.command_system:undo()
        if success then
            print("Undo:", result)
        else
            print("Cannot undo:", result)
        end

    elseif action_id == hash("redo") and action.pressed then
        local success, result = self.command_system:redo()
        if success then
            print("Redo:", result)
        else
            print("Cannot redo:", result)
        end
    end
end

📚 Recursos y Referencias

Patrones de Diseño Clásicos

Arquitecturas de Juegos

🎯 Ejercicios Propuestos

  1. Dialogue System: Crea un sistema de diálogos usando State Machine y Command Pattern.

  2. Inventory System: Implementa un inventario completo usando Component System.

  3. Quest System: Diseña un sistema de misiones con Observer Pattern para eventos.

  4. Save/Load System: Usa Command Pattern para crear un sistema de guardado que soporte undo.

  5. Skill System: Crea un sistema de habilidades con cooldowns usando múltiples patrones.

Los patrones de diseño son herramientas fundamentales para crear código mantenible y escalable. Dominar estos patrones te permitirá abordar problemas complejos de manera elegante y crear arquitecturas robustas que puedan evolucionar con tu juego.