All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
1644 lines
44 KiB
Lua
1644 lines
44 KiB
Lua
--------------------------------------------------------------------------------
|
|
-- 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
|
|
|
|
-- <TILES>
|
|
-- 001:eccccccccc888888caaaaaaaca888888cacccccccacc0ccccacc0ccccacc0ccc
|
|
-- 002:ccccceee8888cceeaaaa0cee888a0ceeccca0ccc0cca0c0c0cca0c0c0cca0c0c
|
|
-- 003:eccccccccc888888caaaaaaaca888888cacccccccacccccccacc0ccccacc0ccc
|
|
-- 004:ccccceee8888cceeaaaa0cee888a0ceeccca0cccccca0c0c0cca0c0c0cca0c0c
|
|
-- 017:cacccccccaaaaaaacaaacaaacaaaaccccaaaaaaac8888888cc000cccecccccec
|
|
-- 018:ccca00ccaaaa0ccecaaa0ceeaaaa0ceeaaaa0cee8888ccee000cceeecccceeee
|
|
-- 019:cacccccccaaaaaaacaaacaaacaaaaccccaaaaaaac8888888cc000cccecccccec
|
|
-- 020:ccca00ccaaaa0ccecaaa0ceeaaaa0ceeaaaa0cee8888ccee000cceeecccceeee
|
|
-- </TILES>
|
|
|
|
-- <WAVES>
|
|
-- 000:00000000ffffffff00000000ffffffff
|
|
-- 001:0123456789abcdeffedcba9876543210
|
|
-- 002:0123456789abcdef0123456789abcdef
|
|
-- </WAVES>
|
|
|
|
-- <SFX>
|
|
-- 000:f0e0d0c0b0a090807060504030201000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020500000
|
|
-- 001:050005000500050000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000305000000000
|
|
-- </SFX>
|
|
|
|
-- <TRACKS>
|
|
-- 000:100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
|
|
-- </TRACKS>
|
|
|
|
-- <SPRITES>
|
|
-- 000:00cccc000c1cc1c00ccccccc00cccc000c0cc0c00c0cc0c00000000000000000
|
|
-- 001:00222200021221200222222200222200020220200202202000000000000000000
|
|
-- 002:00043000001111000111111001111110011111100011110000011000000000000
|
|
-- 003:ddd1ddddddd1dddd1111111ddddd1dddddddd1dd1111111ddd1ddddddd1ddddd
|
|
-- 004:8888888888888888888888888888888888888888888888888888888888888888
|
|
-- </SPRITES>
|
|
|
|
-- <PALETTE>
|
|
-- 000:1a1c2c5d275db13e53ef7d57ffcd75a7f07038b76425717929366f3b5dc941a6f673eff7f4f4f494b0c2566c86333c57
|
|
-- </PALETTE>
|