-- title: BombExpert -- author: Zsolt Tasnadi -- desc: Simple BombExpert for TIC-80 -- site: http://teletype.hu -- license: MIT License -- version: 0.2 -- script: lua -- luacheck: globals TIC btn btnp cls rect spr print exit sfx keyp key -- luacheck: max line length 150 -------------------------------------------------------------------------------- -- Constants -------------------------------------------------------------------------------- -- Tile constants local TILE_SIZE = 8 local MAP_WIDTH = 27 local MAP_HEIGHT = 15 local BOARD_OFFSET_X = 12 -- (240-27*8)/2 = 12 local BOARD_OFFSET_Y = 14 -- top bar (10) + shadow (2) + gap (2) -- Tile types local EMPTY = 0 local SOLID_WALL = 1 local BREAKABLE_WALL = 2 -- Directions (up, down, left, right) local DIRECTIONS = { {0, -1}, {0, 1}, {-1, 0}, {1, 0} } local SPREAD_DIRS = {-1, 1} -- negative and positive spread directions -- Sprite indices (SPRITES section loads at 256+) local PLAYER_BLUE = 256 local PLAYER_RED = 257 local BOMB_SPRITE = 258 local BREAKABLE_WALL_SPRITE = 259 local SOLID_WALL_SPRITE = 260 -- Colors (Sweetie 16 palette) local COLOR_BLACK = 0 local COLOR_SHADOW = 1 local COLOR_RED = 2 local COLOR_ORANGE = 3 local COLOR_YELLOW = 4 local COLOR_GREEN = 6 local COLOR_BLUE = 9 local COLOR_BLUE_LIGHT = 10 local COLOR_CYAN = 11 local COLOR_LIGHT = 12 -- f4f4f4 - lightest color local COLOR_GRAY_LIGHT = 13 -- Game states local GAME_STATE_SPLASH = 0 local GAME_STATE_MENU = 1 local GAME_STATE_PLAYING = 2 local GAME_STATE_HELP = 3 local GAME_STATE_CREDITS = 4 local GAME_STATE_SETTINGS = 5 -------------------------------------------------------------------------------- -- Game Configuration (easy to tweak game parameters) -------------------------------------------------------------------------------- local Config = { -- Player settings player = { move_speed = 2, start_bombs = 1, start_power = 1, }, -- Bomb settings bomb = { timer = 90, explosion_duration = 30, spread_delay = 6, }, -- AI settings ai = { move_delay = 20, bomb_cooldown = 90, danger_threshold = 30, }, -- Map settings map = { breakable_wall_chance = 0.7, powerup_spawn_chance = 0.3, generator = "classic", }, -- Timing timing = { splash_duration = 90, win_screen_duration = 60, }, } -------------------------------------------------------------------------------- -- Sound System (centralized audio management) -------------------------------------------------------------------------------- local Sound = { effects = { explosion = { id = 0, note = nil, duration = 30 }, pickup = { id = 1, note = nil, duration = 8 }, -- Add new sounds here: -- menu_select = {id = 2, note = nil, duration = 10}, -- player_death = {id = 3, note = nil, duration = 20}, } } function Sound.play(effect_name) local effect = Sound.effects[effect_name] if effect then sfx(effect.id, effect.note, effect.duration) end end -------------------------------------------------------------------------------- -- Modules -------------------------------------------------------------------------------- local Input = {} local Map = {} local Powerup = {} local UI = {} local TopBar = {} local Splash = {} local Menu = {} local Help = {} local Credits = {} local Settings = {} local WinScreen = {} local GameBoard = {} local Bomb = {} local AI = {} local Player = {} local Game = {} -------------------------------------------------------------------------------- -- Game State -------------------------------------------------------------------------------- local State = { game_state = GAME_STATE_SPLASH, splash_timer = 0, -- Will be set from Config on first frame initialized = false, -- Config loaded flag menu_selection = 1, settings_selection = 1, two_player_mode = false, players = {}, powerups = {}, bombs = {}, explosions = {}, winner = nil, win_timer = 0, score = {0, 0}, map = {} } -- Initialize empty map for row = 1, MAP_HEIGHT do State.map[row] = {} for col = 1, MAP_WIDTH do State.map[row][col] = EMPTY end end -------------------------------------------------------------------------------- -- Powerup System (extensible) -------------------------------------------------------------------------------- local POWERUP_TYPES = { { type = "bomb", weight = 50, color = COLOR_YELLOW, label = "B", apply = function(player) player.maxBombs = player.maxBombs + 1 end }, { type = "power", weight = 50, color = COLOR_ORANGE, label = "P", apply = function(player) player.bombPower = player.bombPower + 1 end }, } -------------------------------------------------------------------------------- -- Powerup module -------------------------------------------------------------------------------- function Powerup.get_config(type_name) for _, p in ipairs(POWERUP_TYPES) do if p.type == type_name then return p end end return POWERUP_TYPES[1] end function Powerup.get_random_type() local total_weight = 0 for _, p in ipairs(POWERUP_TYPES) do total_weight = total_weight + p.weight end local roll = math.random() * total_weight local cumulative = 0 for _, p in ipairs(POWERUP_TYPES) do cumulative = cumulative + p.weight if roll <= cumulative then return p.type end end return POWERUP_TYPES[1].type end function Powerup.init() State.powerups = {} for row = 1, MAP_HEIGHT do for col = 1, MAP_WIDTH do if State.map[row][col] == BREAKABLE_WALL and math.random() < Config.map.powerup_spawn_chance then table.insert(State.powerups, { gridX = col, gridY = row, type = Powerup.get_random_type() }) end end end end function Powerup.draw_all() for _, pw in ipairs(State.powerups) do if State.map[pw.gridY][pw.gridX] == EMPTY then local drawX = (pw.gridX - 1) * TILE_SIZE + BOARD_OFFSET_X local drawY = (pw.gridY - 1) * TILE_SIZE + BOARD_OFFSET_Y local config = Powerup.get_config(pw.type) rect(drawX + 2, drawY + 2, 5, 5, COLOR_SHADOW) rect(drawX + 1, drawY + 1, 5, 5, config.color) print(config.label, drawX + 2, drawY + 1, COLOR_BLACK) end end end function Powerup.check_pickup() for _, player in ipairs(State.players) do for i = #State.powerups, 1, -1 do local pw = State.powerups[i] if State.map[pw.gridY][pw.gridX] == EMPTY and player.gridX == pw.gridX and player.gridY == pw.gridY then local config = Powerup.get_config(pw.type) config.apply(player) table.remove(State.powerups, i) Sound.play("pickup") end end end end -------------------------------------------------------------------------------- -- Input module -------------------------------------------------------------------------------- function Input.action_pressed() return btnp(4) or keyp(48) -- A button or Space end function Input.back_pressed() return keyp(51) or btnp(5) -- Backspace key or B button end function Input.up() return btn(0) end function Input.down() return btn(1) end function Input.left() return btn(2) end function Input.right() return btn(3) end function Input.up_pressed() return btnp(0) end function Input.down_pressed() return btnp(1) end function Input.left_pressed() return btnp(2) end function Input.right_pressed() return btnp(3) end -- Player 2 inputs (WASD + G for bomb) function Input.p2_up() return key(23) or btn(8) -- W key or gamepad 2 up end function Input.p2_down() return key(19) or btn(9) -- S key or gamepad 2 down end function Input.p2_left() return key(1) or btn(10) -- A key or gamepad 2 left end function Input.p2_right() return key(4) or btn(11) -- D key or gamepad 2 right end function Input.p2_action() return keyp(7) or btnp(12) -- G key or gamepad 2 A end -------------------------------------------------------------------------------- -- Map module -------------------------------------------------------------------------------- function Map.can_move_to(gridX, gridY, player) if gridX < 1 or gridY < 1 or gridX > MAP_WIDTH or gridY > MAP_HEIGHT then return false end if State.map[gridY][gridX] >= SOLID_WALL then return false end -- Check for bombs (but allow staying on bomb you just placed) for _, bomb in ipairs(State.bombs) do local bombGridX = math.floor(bomb.x / TILE_SIZE) + 1 local bombGridY = math.floor(bomb.y / TILE_SIZE) + 1 if gridX == bombGridX and gridY == bombGridY then -- Allow if player is currently on the bomb (just placed it) if player and player.gridX == bombGridX and player.gridY == bombGridY then return true end return false end end return true end function Map.is_spawn_area(row, col) local lastCol = MAP_WIDTH - 1 -- 26 (for MAP_WIDTH=27) local lastRow = MAP_HEIGHT - 1 -- 14 (for MAP_HEIGHT=15) -- Top-left spawn (2,2) and adjacent if (row == 2 and col == 2) or (row == 2 and col == 3) or (row == 3 and col == 2) then return true end -- Top-right spawn and adjacent if (row == 2 and col == lastCol) or (row == 2 and col == lastCol - 1) or (row == 3 and col == lastCol) then return true end -- Bottom-left spawn and adjacent if (row == lastRow and col == 2) or (row == lastRow and col == 3) or (row == lastRow - 1 and col == 2) then return true end -- Bottom-right spawn and adjacent if (row == lastRow and col == lastCol) or (row == lastRow and col == lastCol - 1) or (row == lastRow - 1 and col == lastCol) then return true end return false end -------------------------------------------------------------------------------- -- Map Generators (extensible map generation system) -------------------------------------------------------------------------------- local MapGenerators = {} -- Classic Bomberman grid pattern function MapGenerators.classic(row, col) if row % 2 == 1 and col % 2 == 1 and row > 1 and col > 1 then return SOLID_WALL elseif math.random() < Config.map.breakable_wall_chance then return BREAKABLE_WALL end return EMPTY end -- Open arena with fewer pillars function MapGenerators.arena(row, col) -- Only pillars at every 4th position if row % 4 == 1 and col % 4 == 1 and row > 1 and col > 1 then return SOLID_WALL elseif math.random() < Config.map.breakable_wall_chance * 0.5 then return BREAKABLE_WALL end return EMPTY end -- Dense maze with more walls function MapGenerators.maze(row, col) if row % 2 == 1 and col % 2 == 1 and row > 1 and col > 1 then return SOLID_WALL elseif math.random() < 0.85 then return BREAKABLE_WALL end return EMPTY end -- Corridors pattern function MapGenerators.corridors(row, col) -- Horizontal corridors at rows 4, 8, 12 if (row == 4 or row == 8 or row == 12) and col > 1 and col < MAP_WIDTH then if math.random() < 0.3 then return BREAKABLE_WALL end return EMPTY end -- Vertical corridors at cols 7, 14, 21 if (col == 7 or col == 14 or col == 21) and row > 1 and row < MAP_HEIGHT then if math.random() < 0.3 then return BREAKABLE_WALL end return EMPTY end -- Rest is classic pattern if row % 2 == 1 and col % 2 == 1 and row > 1 and col > 1 then return SOLID_WALL elseif math.random() < Config.map.breakable_wall_chance then return BREAKABLE_WALL end return EMPTY end function Map.generate(generator_name) generator_name = generator_name or Config.map.generator local generator = MapGenerators[generator_name] or MapGenerators.classic for row = 1, MAP_HEIGHT do for col = 1, MAP_WIDTH do -- Border walls (always) if row == 1 or row == MAP_HEIGHT or col == 1 or col == MAP_WIDTH then State.map[row][col] = SOLID_WALL -- Spawn areas MUST be empty (always) elseif Map.is_spawn_area(row, col) then State.map[row][col] = EMPTY -- Use selected generator for the rest else State.map[row][col] = generator(row, col) end end end end -- Helper to get available generators function Map.get_generators() local names = {} for name, _ in pairs(MapGenerators) do table.insert(names, name) end return names end function Map.draw_shadows() for row = 1, MAP_HEIGHT do for col = 1, MAP_WIDTH do local tile = State.map[row][col] if tile == SOLID_WALL or tile == BREAKABLE_WALL then local drawX = (col - 1) * TILE_SIZE + BOARD_OFFSET_X local drawY = (row - 1) * TILE_SIZE + BOARD_OFFSET_Y rect(drawX + 1, drawY + 1, TILE_SIZE, TILE_SIZE, COLOR_SHADOW) end end end end function Map.draw_tiles() for row = 1, MAP_HEIGHT do for col = 1, MAP_WIDTH do local tile = State.map[row][col] local drawX = (col - 1) * TILE_SIZE + BOARD_OFFSET_X local drawY = (row - 1) * TILE_SIZE + BOARD_OFFSET_Y if tile == SOLID_WALL then spr(SOLID_WALL_SPRITE, drawX, drawY, 0, 1) elseif tile == BREAKABLE_WALL then spr(BREAKABLE_WALL_SPRITE, drawX, drawY, 0, 1) end -- Empty spaces use background color (no floor sprite) end end end -------------------------------------------------------------------------------- -- TopBar module -------------------------------------------------------------------------------- function TopBar.draw() -- Background rect(0, 0, 240, 10, COLOR_SHADOW) -- Shadow rect(0, 10, 240, 2, COLOR_BLACK) local p1 = State.players[1] local p2 = State.players[2] -- Player 1 (left side) - blue if p1 then print("P1", 2, 2, COLOR_BLUE_LIGHT) print("W:"..State.score[1], 16, 2, COLOR_BLUE_LIGHT) print("B:"..p1.maxBombs, 40, 2, COLOR_YELLOW) print("P:"..p1.bombPower, 64, 2, COLOR_ORANGE) end -- Player 2 (right side) - red if p2 then print("P:"..p2.bombPower, 156, 2, COLOR_ORANGE) print("B:"..p2.maxBombs, 180, 2, COLOR_YELLOW) print("W:"..State.score[2], 204, 2, COLOR_RED) print("P2", 226, 2, COLOR_RED) end end -------------------------------------------------------------------------------- -- UI module (shared utilities) -------------------------------------------------------------------------------- function UI.print_shadow(text, x, y, color, fixed, scale) scale = scale or 1 print(text, x + 1, y + 1, COLOR_SHADOW, fixed, scale) print(text, x, y, color, fixed, scale) end -------------------------------------------------------------------------------- -- Splash module -------------------------------------------------------------------------------- function Splash.update() -- Initialize on first frame if not State.initialized then Settings.load() State.splash_timer = Config.timing.splash_duration State.initialized = true end cls(COLOR_BLACK) UI.print_shadow("BombExpert", 85, 50, COLOR_BLUE, false, 2) State.splash_timer = State.splash_timer - 1 if State.splash_timer <= 0 then State.game_state = GAME_STATE_MENU end end -------------------------------------------------------------------------------- -- Menu module -------------------------------------------------------------------------------- local MENU_ITEMS = { { label = "1 Player Game", action = function() State.two_player_mode = false State.game_state = GAME_STATE_PLAYING Game.init() end }, { label = "2 Player Game", action = function() State.two_player_mode = true State.game_state = GAME_STATE_PLAYING Game.init() end }, { label = "Settings", action = function() State.game_state = GAME_STATE_SETTINGS end }, { label = "Help", action = function() State.game_state = GAME_STATE_HELP end }, { label = "Credits", action = function() State.game_state = GAME_STATE_CREDITS end }, { label = "Exit", action = exit }, } local function get_menu_color(index) return (State.menu_selection == index) and COLOR_CYAN or COLOR_GRAY_LIGHT end function Menu.update() cls(COLOR_BLACK) UI.print_shadow("BombExpert", 85, 20, COLOR_BLUE, false, 2) local cursor_y = 60 + (State.menu_selection - 1) * 14 UI.print_shadow(">", 60, cursor_y, COLOR_CYAN) for i, item in ipairs(MENU_ITEMS) do UI.print_shadow(item.label, 70, 60 + (i - 1) * 14, get_menu_color(i)) end if Input.back_pressed() then exit() elseif Input.up_pressed() then State.menu_selection = State.menu_selection - 1 if State.menu_selection < 1 then State.menu_selection = #MENU_ITEMS end elseif Input.down_pressed() then State.menu_selection = State.menu_selection + 1 if State.menu_selection > #MENU_ITEMS then State.menu_selection = 1 end elseif Input.action_pressed() then MENU_ITEMS[State.menu_selection].action() end end -------------------------------------------------------------------------------- -- Help module -------------------------------------------------------------------------------- function Help.update() cls(COLOR_BLACK) UI.print_shadow("Help", 100, 8, COLOR_BLUE, false, 2) -- Controls section UI.print_shadow("Controls", 20, 30, COLOR_LIGHT) -- P1 controls rect(20, 42, 90, 30, COLOR_SHADOW) rect(19, 41, 90, 30, COLOR_BLUE_LIGHT) print("Player 1 (Blue)", 22, 44, COLOR_LIGHT) print("Move: Arrow Keys", 22, 54, COLOR_LIGHT) print("Bomb: SPACE", 22, 64, COLOR_LIGHT) -- P2 controls rect(130, 42, 90, 30, COLOR_SHADOW) rect(129, 41, 90, 30, COLOR_RED) print("Player 2 (Red)", 132, 44, COLOR_LIGHT) print("Move: W A S D", 132, 54, COLOR_LIGHT) print("Bomb: G", 132, 64, COLOR_LIGHT) -- Powerups section UI.print_shadow("Powerups", 20, 80, COLOR_LIGHT) -- Bomb powerup rect(22, 93, 5, 5, COLOR_SHADOW) rect(21, 92, 5, 5, COLOR_YELLOW) print("B", 22, 92, COLOR_BLACK) print("+1 Bomb capacity", 32, 92, COLOR_YELLOW) -- Power powerup rect(22, 105, 5, 5, COLOR_SHADOW) rect(21, 104, 5, 5, COLOR_ORANGE) print("P", 22, 104, COLOR_BLACK) print("+1 Blast range", 32, 104, COLOR_ORANGE) -- Back instruction UI.print_shadow("SPACE / Backspace: return", 50, 122, COLOR_CYAN) if Input.action_pressed() or Input.back_pressed() then State.game_state = GAME_STATE_MENU end end -------------------------------------------------------------------------------- -- Credits module -------------------------------------------------------------------------------- function Credits.update() cls(COLOR_BLACK) UI.print_shadow("Credits", 90, 20, COLOR_BLUE, false, 2) UI.print_shadow("Author: Zsolt Tasnadi", 60, 50, COLOR_LIGHT) UI.print_shadow("Powered by Claude", 68, 66, COLOR_LIGHT) UI.print_shadow("Sponsored by Zen Heads", 52, 82, COLOR_LIGHT) UI.print_shadow("Happy X-MAS!", 80, 98, COLOR_RED) UI.print_shadow("SPACE / Backspace: return", 50, 122, COLOR_CYAN) if Input.action_pressed() or Input.back_pressed() then State.game_state = GAME_STATE_MENU end end -------------------------------------------------------------------------------- -- Settings module (persistent settings menu) -------------------------------------------------------------------------------- -- luacheck: globals pmem -- Settings definition: each setting maps to a pmem slot -- pmem stores integers, so we use multipliers for decimals local SETTINGS_ITEMS = { { label = "Start Bombs", path = {"player", "start_bombs"}, min = 1, max = 5, step = 1, pmem_slot = 0 }, { label = "Start Power", path = {"player", "start_power"}, min = 1, max = 5, step = 1, pmem_slot = 1 }, { label = "Move Speed", path = {"player", "move_speed"}, min = 1, max = 4, step = 1, pmem_slot = 2 }, { label = "Bomb Timer", path = {"bomb", "timer"}, min = 60, max = 180, step = 15, pmem_slot = 3 }, { label = "AI Speed", path = {"ai", "move_delay"}, min = 10, max = 40, step = 5, pmem_slot = 4 }, { label = "Wall Density", path = {"map", "breakable_wall_chance"}, min = 30, max = 90, step = 10, pmem_slot = 5, multiplier = 100 }, { label = "Powerup Chance", path = {"map", "powerup_spawn_chance"}, min = 10, max = 50, step = 5, pmem_slot = 6, multiplier = 100 }, { label = "Map Style", path = {"map", "generator"}, min = 1, max = 4, step = 1, pmem_slot = 7, is_enum = true, enum_values = {"classic", "arena", "maze", "corridors"} }, } -- Magic number to detect if pmem has been initialized local PMEM_INIT_SLOT = 255 local PMEM_INIT_VALUE = 12345 local function get_config_value(item) local value = Config for _, key in ipairs(item.path) do value = value[key] end if item.multiplier then return math.floor(value * item.multiplier + 0.5) end if item.is_enum then for i, v in ipairs(item.enum_values) do if v == value then return i end end return 1 end return value end local function set_config_value(item, value) local target = Config for i = 1, #item.path - 1 do target = target[item.path[i]] end local final_key = item.path[#item.path] if item.multiplier then target[final_key] = value / item.multiplier elseif item.is_enum then target[final_key] = item.enum_values[value] else target[final_key] = value end end local function get_display_value(item, value) if item.is_enum then return item.enum_values[value] or "?" end if item.multiplier then return value .. "%" end return tostring(value) end function Settings.load() -- Check if pmem has been initialized if pmem(PMEM_INIT_SLOT) ~= PMEM_INIT_VALUE then -- First run - save defaults Settings.save() pmem(PMEM_INIT_SLOT, PMEM_INIT_VALUE) return end -- Load values from pmem for _, item in ipairs(SETTINGS_ITEMS) do local stored = pmem(item.pmem_slot) if stored >= item.min and stored <= item.max then set_config_value(item, stored) end end end function Settings.save() for _, item in ipairs(SETTINGS_ITEMS) do local value = get_config_value(item) pmem(item.pmem_slot, value) end end function Settings.update() cls(COLOR_BLACK) UI.print_shadow("Settings", 85, 4, COLOR_BLUE, false, 2) local start_y = 22 local item_height = 11 for i, item in ipairs(SETTINGS_ITEMS) do local y = start_y + (i - 1) * item_height local is_selected = (State.settings_selection == i) local color = is_selected and COLOR_CYAN or COLOR_GRAY_LIGHT -- Cursor if is_selected then print("<", 16, y, COLOR_CYAN) print(">", 221, y, COLOR_CYAN) end -- Label print(item.label, 26, y, color) -- Value local value = get_config_value(item) local display = get_display_value(item, value) print(display, 161, y, COLOR_YELLOW) end -- Back option local back_y = start_y + #SETTINGS_ITEMS * item_height + 4 local back_selected = (State.settings_selection == #SETTINGS_ITEMS + 1) if back_selected then print(">", 71, back_y, COLOR_CYAN) end print("Save & Back", 81, back_y, back_selected and COLOR_CYAN or COLOR_GRAY_LIGHT) -- Instructions at bottom print("UP/DOWN:select LEFT/RIGHT:change", 28, 128, COLOR_GRAY_LIGHT) -- Input handling local max_selection = #SETTINGS_ITEMS + 1 if Input.up_pressed() then State.settings_selection = State.settings_selection - 1 if State.settings_selection < 1 then State.settings_selection = max_selection end elseif Input.down_pressed() then State.settings_selection = State.settings_selection + 1 if State.settings_selection > max_selection then State.settings_selection = 1 end elseif Input.left_pressed() and State.settings_selection <= #SETTINGS_ITEMS then local item = SETTINGS_ITEMS[State.settings_selection] local value = get_config_value(item) value = value - item.step if value < item.min then value = item.max end set_config_value(item, value) elseif Input.right_pressed() and State.settings_selection <= #SETTINGS_ITEMS then local item = SETTINGS_ITEMS[State.settings_selection] local value = get_config_value(item) value = value + item.step if value > item.max then value = item.min end set_config_value(item, value) elseif Input.action_pressed() or Input.back_pressed() then Settings.save() State.settings_selection = 1 State.game_state = GAME_STATE_MENU end end -------------------------------------------------------------------------------- -- WinScreen module -------------------------------------------------------------------------------- function WinScreen.draw() cls(COLOR_BLACK) rect(20, 30, 200, 80, COLOR_BLUE) rect(22, 32, 196, 76, COLOR_BLACK) UI.print_shadow("PLAYER "..State.winner.." WON!", 70, 55, COLOR_BLUE, false, 2) if State.win_timer <= 0 or math.floor(State.win_timer / 15) % 2 == 0 then UI.print_shadow("Press SPACE (A) to restart", 55, 80, COLOR_BLUE) end end -------------------------------------------------------------------------------- -- GameBoard module -------------------------------------------------------------------------------- function GameBoard.draw() Map.draw_shadows() Map.draw_tiles() Bomb.draw_explosions() Powerup.draw_all() Bomb.draw_all() -- draw players for idx, player in ipairs(State.players) do Player.draw(player.pixelX + BOARD_OFFSET_X, player.pixelY + BOARD_OFFSET_Y, idx == 1) end TopBar.draw() end -------------------------------------------------------------------------------- -- Bomb module (includes explosions) -------------------------------------------------------------------------------- function Bomb.draw(x, y) spr(BOMB_SPRITE, x, y, 0, 1) end function Bomb.draw_all() for _, bomb in ipairs(State.bombs) do Bomb.draw(bomb.x + BOARD_OFFSET_X, bomb.y + BOARD_OFFSET_Y) end end function Bomb.draw_explosions() for _, expl in ipairs(State.explosions) do local drawX = expl.x + BOARD_OFFSET_X local drawY = expl.y + BOARD_OFFSET_Y if expl.spread <= 0 then rect(drawX, drawY, TILE_SIZE, TILE_SIZE, COLOR_RED) else local progress = 1 - (expl.spread / (expl.dist * Config.bomb.spread_delay)) if progress > 0 then local size = math.floor(TILE_SIZE * progress) local off = math.floor((TILE_SIZE - size) / 2) rect(drawX + off, drawY + off, size, size, COLOR_RED) end end end end function Bomb.place(player) if player.activeBombs >= player.maxBombs then return end local bombX = (player.gridX - 1) * TILE_SIZE local bombY = (player.gridY - 1) * TILE_SIZE for _, b in ipairs(State.bombs) do if b.x == bombX and b.y == bombY then return end end table.insert(State.bombs, { x = bombX, y = bombY, timer = Config.bomb.timer, owner = player, power = player.bombPower }) player.activeBombs = player.activeBombs + 1 end local function spread_explosion(bombX, bombY, gridX, gridY, power, is_horizontal) for _, dir in ipairs(SPREAD_DIRS) do for dist = 1, power do local explX, explY, eGridX, eGridY if is_horizontal then explX = bombX + dir * dist * TILE_SIZE explY = bombY eGridX = gridX + dir * dist eGridY = gridY if eGridX < 1 or eGridX > MAP_WIDTH then break end else explX = bombX explY = bombY + dir * dist * TILE_SIZE eGridX = gridX eGridY = gridY + dir * dist if eGridY < 1 or eGridY > MAP_HEIGHT then break end end local tile = State.map[eGridY][eGridX] if tile == SOLID_WALL then break end local is_breakable = tile == BREAKABLE_WALL if is_breakable then State.map[eGridY][eGridX] = EMPTY end table.insert(State.explosions, { x = explX, y = explY, timer = Config.bomb.explosion_duration, dist = dist, spread = dist * Config.bomb.spread_delay }) if is_breakable then break end end end end function Bomb.explode(bombX, bombY, power) power = power or 1 Sound.play("explosion") table.insert(State.explosions, { x = bombX, y = bombY, timer = Config.bomb.explosion_duration, dist = 0, spread = 0 }) local gridX = math.floor(bombX / TILE_SIZE) + 1 local gridY = math.floor(bombY / TILE_SIZE) + 1 spread_explosion(bombX, bombY, gridX, gridY, power, true) -- horizontal spread_explosion(bombX, bombY, gridX, gridY, power, false) -- vertical end function Bomb.update_all() -- update bombs for i = #State.bombs, 1, -1 do local bomb = State.bombs[i] bomb.timer = bomb.timer - 1 if bomb.timer <= 0 then Bomb.explode(bomb.x, bomb.y, bomb.power) if bomb.owner then bomb.owner.activeBombs = bomb.owner.activeBombs - 1 end table.remove(State.bombs, i) end end -- update explosions for i = #State.explosions, 1, -1 do local expl = State.explosions[i] if expl.spread > 0 then expl.spread = expl.spread - 1 else expl.timer = expl.timer - 1 if expl.timer <= 0 then table.remove(State.explosions, i) end end end end function Bomb.clear_all() State.bombs = {} State.explosions = {} end -------------------------------------------------------------------------------- -- AI module -------------------------------------------------------------------------------- local function is_blast_line_blocked(pos1, pos2, fixedCoord, is_horizontal) local minPos = math.min(pos1, pos2) local maxPos = math.max(pos1, pos2) for i = minPos + 1, maxPos - 1 do local tile = is_horizontal and State.map[fixedCoord][i] or State.map[i][fixedCoord] if tile == SOLID_WALL then return true end end return false end function AI.is_dangerous(gridX, gridY) -- Check active explosions for _, expl in ipairs(State.explosions) do local explGridX = math.floor(expl.x / TILE_SIZE) + 1 local explGridY = math.floor(expl.y / TILE_SIZE) + 1 if gridX == explGridX and gridY == explGridY then return true end end -- Check bombs about to explode - need to escape! for _, bomb in ipairs(State.bombs) do local bombGridX = math.floor(bomb.x / TILE_SIZE) + 1 local bombGridY = math.floor(bomb.y / TILE_SIZE) + 1 local power = bomb.power or 1 -- Only urgent if bomb is about to explode if bomb.timer < Config.ai.danger_threshold then if gridX == bombGridX and gridY == bombGridY then return true end -- Check horizontal blast radius if gridY == bombGridY and math.abs(gridX - bombGridX) <= power then if not is_blast_line_blocked(gridX, bombGridX, gridY, true) then return true end end -- Check vertical blast radius if gridX == bombGridX and math.abs(gridY - bombGridY) <= power then if not is_blast_line_blocked(gridY, bombGridY, gridX, false) then return true end end else -- For bombs with more time, just avoid the bomb cell itself if gridX == bombGridX and gridY == bombGridY then return true end end end return false end function AI.has_adjacent_breakable_wall(gridX, gridY) for _, dir in ipairs(DIRECTIONS) do local checkX = gridX + dir[1] local checkY = gridY + dir[2] if checkX >= 1 and checkX <= MAP_WIDTH and checkY >= 1 and checkY <= MAP_HEIGHT then if State.map[checkY][checkX] == BREAKABLE_WALL then return true end end end return false end function AI.find_nearest_powerup(gridX, gridY) local nearest = nil local nearestDist = 9999 for _, pw in ipairs(State.powerups) do if State.map[pw.gridY][pw.gridX] == EMPTY then local dist = math.abs(pw.gridX - gridX) + math.abs(pw.gridY - gridY) if dist < nearestDist then nearestDist = dist nearest = pw end end end return nearest end function AI.is_in_blast_line(cellX, cellY, bombX, bombY, power) -- Check if cell is in same row or column as bomb and within power range if cellY == bombY and math.abs(cellX - bombX) <= power then return true end if cellX == bombX and math.abs(cellY - bombY) <= power then return true end return false end function AI.find_safe_cell(gridX, gridY, player) -- Find a cell to escape to that's OUTSIDE the bomb's blast line local power = player.bombPower -- First try: find a path that gets us completely out of blast line for _, dir in ipairs(DIRECTIONS) do local newX = gridX + dir[1] local newY = gridY + dir[2] if Map.can_move_to(newX, newY, player) and not AI.is_dangerous(newX, newY) then -- Check if this first step gets us out of blast line if not AI.is_in_blast_line(newX, newY, gridX, gridY, power) then return {newX, newY} end -- If not, check if we can turn corner to get out for _, dir2 in ipairs(DIRECTIONS) do local safeX = newX + dir2[1] local safeY = newY + dir2[2] if Map.can_move_to(safeX, safeY, player) and not AI.is_dangerous(safeX, safeY) then if not AI.is_in_blast_line(safeX, safeY, gridX, gridY, power) then return {newX, newY} end end end end end return nil end function AI.has_escape_route(gridX, gridY, player) return AI.find_safe_cell(gridX, gridY, player) ~= nil end function AI.escape_from_bomb(player) local safe = AI.find_safe_cell(player.gridX, player.gridY, player) if safe then player.gridX = safe[1] player.gridY = safe[2] end end function AI.move_and_bomb(player, target) if not target then return end -- Check for nearby powerup first local powerup = AI.find_nearest_powerup(player.gridX, player.gridY) local actualTarget = target -- If powerup is closer than target, go for powerup if powerup then local pwDist = math.abs(powerup.gridX - player.gridX) + math.abs(powerup.gridY - player.gridY) local targetDist = math.abs(target.gridX - player.gridX) + math.abs(target.gridY - player.gridY) if pwDist < targetDist or pwDist <= 5 then actualTarget = { gridX = powerup.gridX, gridY = powerup.gridY } end end local dx = actualTarget.gridX - player.gridX local dy = actualTarget.gridY - player.gridY local dist = math.abs(dx) + math.abs(dy) local should_bomb = false if dist <= 2 and actualTarget == target then should_bomb = true end if AI.has_adjacent_breakable_wall(player.gridX, player.gridY) then should_bomb = true end if should_bomb and player.activeBombs < player.maxBombs and player.bombCooldown <= 0 then if AI.has_escape_route(player.gridX, player.gridY, player) then player.lastGridX = player.gridX player.lastGridY = player.gridY Bomb.place(player) player.bombCooldown = Config.ai.bomb_cooldown AI.escape_from_bomb(player) return end end -- Build direction list (preferred directions first, then all others) local dirs = {} if dx > 0 then table.insert(dirs, {1, 0}) elseif dx < 0 then table.insert(dirs, {-1, 0}) end if dy > 0 then table.insert(dirs, {0, 1}) elseif dy < 0 then table.insert(dirs, {0, -1}) end for _, d in ipairs(DIRECTIONS) do table.insert(dirs, d) end -- Try to move, avoiding going back to last position unless necessary local fallback = nil for _, dir in ipairs(dirs) do local newGridX = player.gridX + dir[1] local newGridY = player.gridY + dir[2] if Map.can_move_to(newGridX, newGridY, player) and not AI.is_dangerous(newGridX, newGridY) then -- Avoid going back unless it's the only option if newGridX == player.lastGridX and newGridY == player.lastGridY then if not fallback then fallback = {newGridX, newGridY} end else player.lastGridX = player.gridX player.lastGridY = player.gridY player.gridX = newGridX player.gridY = newGridY return end end end -- Use fallback if no other option if fallback then player.lastGridX = player.gridX player.lastGridY = player.gridY player.gridX = fallback[1] player.gridY = fallback[2] end end function AI.update(player, target) -- Even while moving, check if destination becomes dangerous if player.moving then if AI.is_dangerous(player.gridX, player.gridY) then -- Destination is dangerous! Try to stop or reverse local currentGridX = math.floor(player.pixelX / TILE_SIZE) + 1 local currentGridY = math.floor(player.pixelY / TILE_SIZE) + 1 if not AI.is_dangerous(currentGridX, currentGridY) then -- Stay at current position player.gridX = currentGridX player.gridY = currentGridY end end return end local in_danger = AI.is_dangerous(player.gridX, player.gridY) if in_danger then local best_dir = nil local best_safe = false for _, dir in ipairs(DIRECTIONS) do local newX = player.gridX + dir[1] local newY = player.gridY + dir[2] if Map.can_move_to(newX, newY, player) then local safe = not AI.is_dangerous(newX, newY) if safe and not best_safe then best_dir = dir best_safe = true elseif not best_dir then best_dir = dir end end end if best_dir then player.gridX = player.gridX + best_dir[1] player.gridY = player.gridY + best_dir[2] end player.moveTimer = 0 return end player.moveTimer = player.moveTimer + 1 if player.moveTimer < Config.ai.move_delay then return end player.moveTimer = 0 AI.move_and_bomb(player, target) end -------------------------------------------------------------------------------- -- Player module -------------------------------------------------------------------------------- function Player.draw(x, y, is_player1) local sprite_id = is_player1 and PLAYER_BLUE or PLAYER_RED spr(sprite_id, x, y, 0, 1) end function Player.create(gridX, gridY, color, is_ai) return { gridX = gridX, gridY = gridY, lastGridX = gridX, lastGridY = gridY, pixelX = (gridX - 1) * TILE_SIZE, pixelY = (gridY - 1) * TILE_SIZE, moving = false, maxBombs = Config.player.start_bombs, activeBombs = 0, bombPower = Config.player.start_power, color = color, is_ai = is_ai, moveTimer = 0, bombCooldown = 0, spawnX = gridX, spawnY = gridY } end function Player.update_movement(player) local targetX = (player.gridX - 1) * TILE_SIZE local targetY = (player.gridY - 1) * TILE_SIZE if player.pixelX < targetX then player.pixelX = math.min(player.pixelX + Config.player.move_speed, targetX) player.moving = true elseif player.pixelX > targetX then player.pixelX = math.max(player.pixelX - Config.player.move_speed, targetX) player.moving = true elseif player.pixelY < targetY then player.pixelY = math.min(player.pixelY + Config.player.move_speed, targetY) player.moving = true elseif player.pixelY > targetY then player.pixelY = math.max(player.pixelY - Config.player.move_speed, targetY) player.moving = true else player.moving = false end if player.bombCooldown > 0 then player.bombCooldown = player.bombCooldown - 1 end end function Player.handle_input(player, input) if player.moving then return end local newGridX = player.gridX local newGridY = player.gridY if input.up() then newGridY = player.gridY - 1 elseif input.down() then newGridY = player.gridY + 1 elseif input.left() then newGridX = player.gridX - 1 elseif input.right() then newGridX = player.gridX + 1 end if Map.can_move_to(newGridX, newGridY, player) then player.gridX = newGridX player.gridY = newGridY end if input.action() then Bomb.place(player) end end -- Input configurations for each player local P1_INPUT = { up = Input.up, down = Input.down, left = Input.left, right = Input.right, action = Input.action_pressed } local P2_INPUT = { up = Input.p2_up, down = Input.p2_down, left = Input.p2_left, right = Input.p2_right, action = Input.p2_action } function Player.reset(player) player.gridX = player.spawnX player.gridY = player.spawnY player.pixelX = (player.spawnX - 1) * TILE_SIZE player.pixelY = (player.spawnY - 1) * TILE_SIZE player.moving = false player.maxBombs = Config.player.start_bombs player.activeBombs = 0 player.bombPower = Config.player.start_power player.bombCooldown = 0 end -------------------------------------------------------------------------------- -- Game module -------------------------------------------------------------------------------- function Game.init() State.winner = nil State.win_timer = 0 Bomb.clear_all() Map.generate() State.players = {} table.insert(State.players, Player.create(2, 2, COLOR_BLUE, false)) local p2_is_ai = not State.two_player_mode table.insert(State.players, Player.create(MAP_WIDTH - 1, MAP_HEIGHT - 1, COLOR_RED, p2_is_ai)) Powerup.init() end function Game.restart() State.winner = nil State.win_timer = 0 Bomb.clear_all() Map.generate() for _, p in ipairs(State.players) do Player.reset(p) end Powerup.init() end function Game.set_winner(player_num) State.winner = player_num State.win_timer = Config.timing.win_screen_duration State.score[player_num] = State.score[player_num] + 1 end function Game.check_death_by_explosion() for idx, player in ipairs(State.players) do for _, expl in ipairs(State.explosions) do if expl.spread <= 0 then local explGridX = math.floor(expl.x / TILE_SIZE) + 1 local explGridY = math.floor(expl.y / TILE_SIZE) + 1 if player.gridX == explGridX and player.gridY == explGridY then local winner_idx = (idx == 1) and 2 or 1 Game.set_winner(winner_idx) return true end end end end return false end function Game.update() -- Get human player as target for AI local human_player = State.players[1] -- update all players for idx, player in ipairs(State.players) do Player.update_movement(player) if player.is_ai then AI.update(player, human_player) else local input = (idx == 1) and P1_INPUT or P2_INPUT Player.handle_input(player, input) end end Bomb.update_all() Powerup.check_pickup() if Game.check_death_by_explosion() then return true end return false end -------------------------------------------------------------------------------- -- Main game loop -------------------------------------------------------------------------------- local function update_playing() cls(COLOR_GREEN) -- ESC to return to menu if Input.back_pressed() then State.game_state = GAME_STATE_MENU return end if State.winner then State.win_timer = State.win_timer - 1 WinScreen.draw() if Input.action_pressed() and State.win_timer <= 0 then Game.restart() end return end if Game.update() then return end GameBoard.draw() end local STATE_HANDLERS = { [GAME_STATE_SPLASH] = Splash.update, [GAME_STATE_MENU] = Menu.update, [GAME_STATE_HELP] = Help.update, [GAME_STATE_CREDITS] = Credits.update, [GAME_STATE_SETTINGS] = Settings.update, [GAME_STATE_PLAYING] = update_playing, } function TIC() local handler = STATE_HANDLERS[State.game_state] if handler then handler() end end -- -- 001:eccccccccc888888caaaaaaaca888888cacccccccacc0ccccacc0ccccacc0ccc -- 002:ccccceee8888cceeaaaa0cee888a0ceeccca0ccc0cca0c0c0cca0c0c0cca0c0c -- 003:eccccccccc888888caaaaaaaca888888cacccccccacccccccacc0ccccacc0ccc -- 004:ccccceee8888cceeaaaa0cee888a0ceeccca0cccccca0c0c0cca0c0c0cca0c0c -- 017:cacccccccaaaaaaacaaacaaacaaaaccccaaaaaaac8888888cc000cccecccccec -- 018:ccca00ccaaaa0ccecaaa0ceeaaaa0ceeaaaa0cee8888ccee000cceeecccceeee -- 019:cacccccccaaaaaaacaaacaaacaaaaccccaaaaaaac8888888cc000cccecccccec -- 020:ccca00ccaaaa0ccecaaa0ceeaaaa0ceeaaaa0cee8888ccee000cceeecccceeee -- -- -- 000:00000000ffffffff00000000ffffffff -- 001:0123456789abcdeffedcba9876543210 -- 002:0123456789abcdef0123456789abcdef -- -- -- 000:f0e0d0c0b0a090807060504030201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020500000 -- 001:050005000500050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000305000000000 -- -- -- 000:100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 -- -- -- 000:00cccc000c1cc1c00ccccccc00cccc000c0cc0c00c0cc0c00000000000000000 -- 001:00222200021221200222222200222200020220200202202000000000000000000 -- 002:00043000001111000111111001111110011111100011110000011000000000000 -- 003:ddd1ddddddd1dddd1111111ddddd1dddddddd1dd1111111ddd1ddddddd1ddddd -- 004:8888888888888888888888888888888888888888888888888888888888888888 -- -- -- 000:1a1c2c5d275db13e53ef7d57ffcd75a7f07038b76425717929366f3b5dc941a6f673eff7f4f4f494b0c2566c86333c57 --