4
0

Compare commits

..

10 Commits

Author SHA1 Message Date
b579692d42 feat: Add controller B button for back action 2025-12-05 01:32:45 +01:00
e9e0ef9da2 fix: Remove "Clone" from main menu 2025-12-05 01:30:37 +01:00
e9c53a5717 fix: Remove "Clone" from boot splash screen 2025-12-05 01:29:48 +01:00
05564c3136 feat: Rename Bomberman to BombExpert 2025-12-05 01:29:08 +01:00
Zsolt Tasnadi
04405066c4 refact 2025-12-04 17:52:35 +01:00
Zsolt Tasnadi
94a412d168 refact 2025-12-04 17:46:24 +01:00
Zsolt Tasnadi
70f18d4f3b settings menu 2025-12-04 17:39:27 +01:00
Zsolt Tasnadi
bc1944b163 refact 2025-12-04 17:32:20 +01:00
Zsolt Tasnadi
f47bd6b2e0 refact 2025-12-04 17:27:07 +01:00
Zsolt Tasnadi
b56eb8cdb4 README update 2025-12-04 17:00:21 +01:00
3 changed files with 618 additions and 251 deletions

143
README.md
View File

@@ -1,93 +1,88 @@
# Bomberman # BombExpert
A classic BombExpert for [TIC-80](https://tic80.com/) fantasy console.
## Features
## Getting started - 1 Player mode (vs AI)
- 2 Player local multiplayer
- Grid-based movement with smooth animation
- Destructible walls
- Power-ups:
- **B** (yellow): +1 bomb capacity
- **P** (orange): +1 blast range
- Smart AI opponent that seeks power-ups and avoids explosions
- Score tracking across rounds
To make it easy for you to get started with GitLab, here's a list of recommended next steps. ## Controls
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! ### Player 1 (Blue)
| Action | Key |
|--------|-----|
| Move | Arrow Keys |
| Place Bomb | Space |
## Add your files ### Player 2 (Red)
| Action | Key |
|--------|-----|
| Move | W, A, S, D |
| Place Bomb | G |
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files ### Menu Navigation
- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: | Action | Key |
|--------|-----|
| Navigate | Up / Down |
| Select | Space |
| Back / Exit | Backspace |
``` ## How to Play
cd existing_repo
git remote add origin https://tea.zenheads.hu/zsolt.tasnadi/bomberman.git 1. Run the game in TIC-80
git branch -M main 2. Select "1 Player Game" or "2 Player Game" from the menu
git push -uf origin main 3. Navigate through the maze and place bombs to destroy breakable walls
4. Collect power-ups to increase your bomb capacity and blast range
5. Eliminate your opponent by catching them in an explosion
6. First player to win the round scores a point
## Running the Game
### In TIC-80
```bash
load bombexpert.lua
run
``` ```
## Integrate with your tools ### In Browser
Use the HTML export in the `bombexpert/` folder with the included server:
```bash
python serve.py
```
Then open http://localhost:3333 in your browser.
- [ ] [Set up project integrations](https://tea.zenheads.hu/zsolt.tasnadi/bomberman/-/settings/integrations) ## Requirements
## Collaborate with your team - [TIC-80](https://tic80.com/) fantasy console (free version works)
- Or any modern web browser (for HTML export)
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) ## Project Structure
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html)
## Test and Deploy ```
bomberman/
├── bombexpert.lua # Main game source code
├── serve.py # Simple HTTP server for browser testing
└── README.md # This file
```
Use the built-in continuous integration in GitLab. ## Credits
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) - **Author**: Zsolt Tasnadi
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) - **Powered by**: Claude
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) - **Sponsored by**: Zen Heads
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License ## License
For open source projects, say how it is licensed.
## Project status MIT License
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
---
Happy X-MAS!

View File

@@ -1,6 +1,6 @@
-- title: Bomberman Clone -- title: BombExpert
-- author: Zsolt Tasnadi -- author: Zsolt Tasnadi
-- desc: Simple Bomberman clone for TIC-80 -- desc: Simple BombExpert for TIC-80
-- site: http://teletype.hu -- site: http://teletype.hu
-- license: MIT License -- license: MIT License
-- version: 0.2 -- version: 0.2
@@ -25,18 +25,6 @@ local EMPTY = 0
local SOLID_WALL = 1 local SOLID_WALL = 1
local BREAKABLE_WALL = 2 local BREAKABLE_WALL = 2
-- Timing constants
local BOMB_TIMER = 90
local EXPLOSION_TIMER = 30
local SPREAD_DELAY = 6 -- ticks per cell spread
local SPLASH_DURATION = 90 -- 1.5 seconds at 60fps
local WIN_SCREEN_DURATION = 60
local AI_MOVE_DELAY = 20
local AI_BOMB_COOLDOWN = 90
-- Movement
local MOVE_SPEED = 2
-- Directions (up, down, left, right) -- Directions (up, down, left, right)
local DIRECTIONS = { local DIRECTIONS = {
{0, -1}, {0, -1},
@@ -44,6 +32,7 @@ local DIRECTIONS = {
{-1, 0}, {-1, 0},
{1, 0} {1, 0}
} }
local SPREAD_DIRS = {-1, 1} -- negative and positive spread directions
-- Sprite indices (SPRITES section loads at 256+) -- Sprite indices (SPRITES section loads at 256+)
local PLAYER_BLUE = 256 local PLAYER_BLUE = 256
@@ -71,9 +60,76 @@ local GAME_STATE_MENU = 1
local GAME_STATE_PLAYING = 2 local GAME_STATE_PLAYING = 2
local GAME_STATE_HELP = 3 local GAME_STATE_HELP = 3
local GAME_STATE_CREDITS = 4 local GAME_STATE_CREDITS = 4
local GAME_STATE_SETTINGS = 5
-- Powerup spawn chance --------------------------------------------------------------------------------
local POWERUP_SPAWN_CHANCE = 0.3 -- 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 -- Modules
@@ -88,6 +144,7 @@ local Splash = {}
local Menu = {} local Menu = {}
local Help = {} local Help = {}
local Credits = {} local Credits = {}
local Settings = {}
local WinScreen = {} local WinScreen = {}
local GameBoard = {} local GameBoard = {}
local Bomb = {} local Bomb = {}
@@ -101,8 +158,10 @@ local Game = {}
local State = { local State = {
game_state = GAME_STATE_SPLASH, game_state = GAME_STATE_SPLASH,
splash_timer = SPLASH_DURATION, splash_timer = 0, -- Will be set from Config on first frame
initialized = false, -- Config loaded flag
menu_selection = 1, menu_selection = 1,
settings_selection = 1,
two_player_mode = false, two_player_mode = false,
players = {}, players = {},
powerups = {}, powerups = {},
@@ -132,14 +191,18 @@ local POWERUP_TYPES = {
weight = 50, weight = 50,
color = COLOR_YELLOW, color = COLOR_YELLOW,
label = "B", label = "B",
apply = function(player) player.maxBombs = player.maxBombs + 1 end apply = function(player)
player.maxBombs = player.maxBombs + 1
end
}, },
{ {
type = "power", type = "power",
weight = 50, weight = 50,
color = COLOR_ORANGE, color = COLOR_ORANGE,
label = "P", label = "P",
apply = function(player) player.bombPower = player.bombPower + 1 end apply = function(player)
player.bombPower = player.bombPower + 1
end
}, },
} }
@@ -176,7 +239,7 @@ function Powerup.init()
State.powerups = {} State.powerups = {}
for row = 1, MAP_HEIGHT do for row = 1, MAP_HEIGHT do
for col = 1, MAP_WIDTH do for col = 1, MAP_WIDTH do
if State.map[row][col] == BREAKABLE_WALL and math.random() < POWERUP_SPAWN_CHANCE then if State.map[row][col] == BREAKABLE_WALL and math.random() < Config.map.powerup_spawn_chance then
table.insert(State.powerups, { table.insert(State.powerups, {
gridX = col, gridX = col,
gridY = row, gridY = row,
@@ -209,7 +272,7 @@ function Powerup.check_pickup()
local config = Powerup.get_config(pw.type) local config = Powerup.get_config(pw.type)
config.apply(player) config.apply(player)
table.remove(State.powerups, i) table.remove(State.powerups, i)
sfx(1, nil, 8) Sound.play("pickup")
end end
end end
end end
@@ -224,7 +287,7 @@ function Input.action_pressed()
end end
function Input.back_pressed() function Input.back_pressed()
return keyp(51) -- Backspace key return keyp(51) or btnp(5) -- Backspace key or B button
end end
function Input.up() function Input.up()
@@ -251,6 +314,14 @@ function Input.down_pressed()
return btnp(1) return btnp(1)
end end
function Input.left_pressed()
return btnp(2)
end
function Input.right_pressed()
return btnp(3)
end
-- Player 2 inputs (WASD + G for bomb) -- Player 2 inputs (WASD + G for bomb)
function Input.p2_up() function Input.p2_up()
return key(23) or btn(8) -- W key or gamepad 2 up return key(23) or btn(8) -- W key or gamepad 2 up
@@ -320,28 +391,95 @@ function Map.is_spawn_area(row, col)
return false return false
end end
function Map.generate() --------------------------------------------------------------------------------
-- 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 row = 1, MAP_HEIGHT do
for col = 1, MAP_WIDTH do for col = 1, MAP_WIDTH do
-- Border walls -- Border walls (always)
if row == 1 or row == MAP_HEIGHT or col == 1 or col == MAP_WIDTH then if row == 1 or row == MAP_HEIGHT or col == 1 or col == MAP_WIDTH then
State.map[row][col] = SOLID_WALL State.map[row][col] = SOLID_WALL
-- Spawn areas MUST be empty -- Spawn areas MUST be empty (always)
elseif Map.is_spawn_area(row, col) then elseif Map.is_spawn_area(row, col) then
State.map[row][col] = EMPTY State.map[row][col] = EMPTY
-- Grid pattern solid walls (odd row AND odd col, but not border) -- Use selected generator for the rest
elseif row % 2 == 1 and col % 2 == 1 and row > 1 and col > 1 then
State.map[row][col] = SOLID_WALL
-- Random: breakable wall or empty
else else
if math.random() < 0.7 then State.map[row][col] = generator(row, col)
State.map[row][col] = BREAKABLE_WALL
else
State.map[row][col] = EMPTY
end end
end 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 end
return names
end end
function Map.draw_shadows() function Map.draw_shadows()
@@ -418,10 +556,17 @@ end
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
function Splash.update() 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) cls(COLOR_BLACK)
UI.print_shadow("Bomberman", 85, 50, COLOR_BLUE, false, 2) UI.print_shadow("BombExpert", 85, 50, COLOR_BLUE, false, 2)
UI.print_shadow("Clone", 100, 70, COLOR_BLUE, false, 2)
State.splash_timer = State.splash_timer - 1 State.splash_timer = State.splash_timer - 1
if State.splash_timer <= 0 then if State.splash_timer <= 0 then
@@ -433,52 +578,74 @@ end
-- Menu module -- 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() function Menu.update()
cls(COLOR_BLACK) cls(COLOR_BLACK)
UI.print_shadow("Bomberman", 85, 20, COLOR_BLUE, false, 2) UI.print_shadow("BombExpert", 85, 20, COLOR_BLUE, false, 2)
UI.print_shadow("Clone", 100, 40, COLOR_BLUE, false, 2)
local unselected = COLOR_GRAY_LIGHT
local p1_color = (State.menu_selection == 1) and COLOR_CYAN or unselected
local p2_color = (State.menu_selection == 2) and COLOR_CYAN or unselected
local help_color = (State.menu_selection == 3) and COLOR_CYAN or unselected
local credits_color = (State.menu_selection == 4) and COLOR_CYAN or unselected
local exit_color = (State.menu_selection == 5) and COLOR_CYAN or unselected
local cursor_y = 60 + (State.menu_selection - 1) * 14 local cursor_y = 60 + (State.menu_selection - 1) * 14
UI.print_shadow(">", 60, cursor_y, COLOR_CYAN) UI.print_shadow(">", 60, cursor_y, COLOR_CYAN)
UI.print_shadow("1 Player Game", 70, 60, p1_color) for i, item in ipairs(MENU_ITEMS) do
UI.print_shadow("2 Player Game", 70, 74, p2_color) UI.print_shadow(item.label, 70, 60 + (i - 1) * 14, get_menu_color(i))
UI.print_shadow("Help", 70, 88, help_color) end
UI.print_shadow("Credits", 70, 102, credits_color)
UI.print_shadow("Exit", 70, 116, exit_color)
if Input.back_pressed() then if Input.back_pressed() then
exit() exit()
elseif Input.up_pressed() then elseif Input.up_pressed() then
State.menu_selection = State.menu_selection - 1 State.menu_selection = State.menu_selection - 1
if State.menu_selection < 1 then State.menu_selection = 5 end if State.menu_selection < 1 then State.menu_selection = #MENU_ITEMS end
elseif Input.down_pressed() then elseif Input.down_pressed() then
State.menu_selection = State.menu_selection + 1 State.menu_selection = State.menu_selection + 1
if State.menu_selection > 5 then State.menu_selection = 1 end if State.menu_selection > #MENU_ITEMS then State.menu_selection = 1 end
elseif Input.action_pressed() then elseif Input.action_pressed() then
if State.menu_selection == 1 then MENU_ITEMS[State.menu_selection].action()
State.two_player_mode = false
State.game_state = GAME_STATE_PLAYING
Game.init()
elseif State.menu_selection == 2 then
State.two_player_mode = true
State.game_state = GAME_STATE_PLAYING
Game.init()
elseif State.menu_selection == 3 then
State.game_state = GAME_STATE_HELP
elseif State.menu_selection == 4 then
State.game_state = GAME_STATE_CREDITS
else
exit()
end
end end
end end
@@ -552,6 +719,224 @@ function Credits.update()
end end
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 -- WinScreen module
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
@@ -606,7 +991,7 @@ function Bomb.draw_explosions()
if expl.spread <= 0 then if expl.spread <= 0 then
rect(drawX, drawY, TILE_SIZE, TILE_SIZE, COLOR_RED) rect(drawX, drawY, TILE_SIZE, TILE_SIZE, COLOR_RED)
else else
local progress = 1 - (expl.spread / (expl.dist * SPREAD_DELAY)) local progress = 1 - (expl.spread / (expl.dist * Config.bomb.spread_delay))
if progress > 0 then if progress > 0 then
local size = math.floor(TILE_SIZE * progress) local size = math.floor(TILE_SIZE * progress)
local off = math.floor((TILE_SIZE - size) / 2) local off = math.floor((TILE_SIZE - size) / 2)
@@ -631,20 +1016,59 @@ function Bomb.place(player)
table.insert(State.bombs, { table.insert(State.bombs, {
x = bombX, x = bombX,
y = bombY, y = bombY,
timer = BOMB_TIMER, timer = Config.bomb.timer,
owner = player, owner = player,
power = player.bombPower power = player.bombPower
}) })
player.activeBombs = player.activeBombs + 1 player.activeBombs = player.activeBombs + 1
end 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) function Bomb.explode(bombX, bombY, power)
power = power or 1 power = power or 1
sfx(0, nil, 30) Sound.play("explosion")
table.insert(State.explosions, { table.insert(State.explosions, {
x = bombX, x = bombX,
y = bombY, y = bombY,
timer = EXPLOSION_TIMER, timer = Config.bomb.explosion_duration,
dist = 0, dist = 0,
spread = 0 spread = 0
}) })
@@ -652,63 +1076,8 @@ function Bomb.explode(bombX, bombY, power)
local gridX = math.floor(bombX / TILE_SIZE) + 1 local gridX = math.floor(bombX / TILE_SIZE) + 1
local gridY = math.floor(bombY / TILE_SIZE) + 1 local gridY = math.floor(bombY / TILE_SIZE) + 1
-- horizontal explosion spread_explosion(bombX, bombY, gridX, gridY, power, true) -- horizontal
for _, dir in ipairs({-1, 1}) do spread_explosion(bombX, bombY, gridX, gridY, power, false) -- vertical
for dist = 1, power do
local explX = bombX + dir * dist * TILE_SIZE
local eGridX = gridX + dir * dist
if eGridX < 1 or eGridX > MAP_WIDTH then break end
local tile = State.map[gridY][eGridX]
if tile == SOLID_WALL then break end
if tile == BREAKABLE_WALL then
State.map[gridY][eGridX] = EMPTY
table.insert(State.explosions, {
x = explX,
y = bombY,
timer = EXPLOSION_TIMER,
dist = dist,
spread = dist * SPREAD_DELAY
})
break
end
table.insert(State.explosions, {
x = explX,
y = bombY,
timer = EXPLOSION_TIMER,
dist = dist,
spread = dist * SPREAD_DELAY
})
end
end
-- vertical explosion
for _, dir in ipairs({-1, 1}) do
for dist = 1, power do
local explY = bombY + dir * dist * TILE_SIZE
local eGridY = gridY + dir * dist
if eGridY < 1 or eGridY > MAP_HEIGHT then break end
local tile = State.map[eGridY][gridX]
if tile == SOLID_WALL then break end
if tile == BREAKABLE_WALL then
State.map[eGridY][gridX] = EMPTY
table.insert(State.explosions, {
x = bombX,
y = explY,
timer = EXPLOSION_TIMER,
dist = dist,
spread = dist * SPREAD_DELAY
})
break
end
table.insert(State.explosions, {
x = bombX,
y = explY,
timer = EXPLOSION_TIMER,
dist = dist,
spread = dist * SPREAD_DELAY
})
end
end
end end
function Bomb.update_all() function Bomb.update_all()
@@ -748,6 +1117,18 @@ end
-- AI module -- 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) function AI.is_dangerous(gridX, gridY)
-- Check active explosions -- Check active explosions
for _, expl in ipairs(State.explosions) do for _, expl in ipairs(State.explosions) do
@@ -758,44 +1139,31 @@ function AI.is_dangerous(gridX, gridY)
end end
end end
-- Check bombs about to explode (timer < 30) - need to escape! -- Check bombs about to explode - need to escape!
for _, bomb in ipairs(State.bombs) do for _, bomb in ipairs(State.bombs) do
local bombGridX = math.floor(bomb.x / TILE_SIZE) + 1 local bombGridX = math.floor(bomb.x / TILE_SIZE) + 1
local bombGridY = math.floor(bomb.y / TILE_SIZE) + 1 local bombGridY = math.floor(bomb.y / TILE_SIZE) + 1
local power = bomb.power or 1 local power = bomb.power or 1
-- Only urgent if bomb is about to explode -- Only urgent if bomb is about to explode
if bomb.timer < 30 then if bomb.timer < Config.ai.danger_threshold then
if gridX == bombGridX and gridY == bombGridY then if gridX == bombGridX and gridY == bombGridY then
return true return true
end end
-- Check blast radius only for soon-to-explode bombs -- Check horizontal blast radius
if gridY == bombGridY and math.abs(gridX - bombGridX) <= power then if gridY == bombGridY and math.abs(gridX - bombGridX) <= power then
local blocked = false if not is_blast_line_blocked(gridX, bombGridX, gridY, true) then
local minX = math.min(gridX, bombGridX) return true
local maxX = math.max(gridX, bombGridX)
for x = minX + 1, maxX - 1 do
if State.map[gridY][x] == SOLID_WALL then
blocked = true
break
end end
end end
if not blocked then return true end
end
-- Check vertical blast radius
if gridX == bombGridX and math.abs(gridY - bombGridY) <= power then if gridX == bombGridX and math.abs(gridY - bombGridY) <= power then
local blocked = false if not is_blast_line_blocked(gridY, bombGridY, gridX, false) then
local minY = math.min(gridY, bombGridY) return true
local maxY = math.max(gridY, bombGridY)
for y = minY + 1, maxY - 1 do
if State.map[y][gridX] == SOLID_WALL then
blocked = true
break
end end
end end
if not blocked then return true end
end
else else
-- For bombs with more time, just avoid the bomb cell itself -- For bombs with more time, just avoid the bomb cell itself
if gridX == bombGridX and gridY == bombGridY then if gridX == bombGridX and gridY == bombGridY then
@@ -898,7 +1266,10 @@ function AI.move_and_bomb(player, target)
local pwDist = math.abs(powerup.gridX - player.gridX) + math.abs(powerup.gridY - player.gridY) 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) local targetDist = math.abs(target.gridX - player.gridX) + math.abs(target.gridY - player.gridY)
if pwDist < targetDist or pwDist <= 5 then if pwDist < targetDist or pwDist <= 5 then
actualTarget = {gridX = powerup.gridX, gridY = powerup.gridY} actualTarget = {
gridX = powerup.gridX,
gridY = powerup.gridY
}
end end
end end
@@ -917,7 +1288,7 @@ function AI.move_and_bomb(player, target)
player.lastGridX = player.gridX player.lastGridX = player.gridX
player.lastGridY = player.gridY player.lastGridY = player.gridY
Bomb.place(player) Bomb.place(player)
player.bombCooldown = AI_BOMB_COOLDOWN player.bombCooldown = Config.ai.bomb_cooldown
AI.escape_from_bomb(player) AI.escape_from_bomb(player)
return return
end end
@@ -1009,7 +1380,7 @@ function AI.update(player, target)
end end
player.moveTimer = player.moveTimer + 1 player.moveTimer = player.moveTimer + 1
if player.moveTimer < AI_MOVE_DELAY then return end if player.moveTimer < Config.ai.move_delay then return end
player.moveTimer = 0 player.moveTimer = 0
AI.move_and_bomb(player, target) AI.move_and_bomb(player, target)
@@ -1033,9 +1404,9 @@ function Player.create(gridX, gridY, color, is_ai)
pixelX = (gridX - 1) * TILE_SIZE, pixelX = (gridX - 1) * TILE_SIZE,
pixelY = (gridY - 1) * TILE_SIZE, pixelY = (gridY - 1) * TILE_SIZE,
moving = false, moving = false,
maxBombs = 1, maxBombs = Config.player.start_bombs,
activeBombs = 0, activeBombs = 0,
bombPower = 1, bombPower = Config.player.start_power,
color = color, color = color,
is_ai = is_ai, is_ai = is_ai,
moveTimer = 0, moveTimer = 0,
@@ -1050,16 +1421,16 @@ function Player.update_movement(player)
local targetY = (player.gridY - 1) * TILE_SIZE local targetY = (player.gridY - 1) * TILE_SIZE
if player.pixelX < targetX then if player.pixelX < targetX then
player.pixelX = math.min(player.pixelX + MOVE_SPEED, targetX) player.pixelX = math.min(player.pixelX + Config.player.move_speed, targetX)
player.moving = true player.moving = true
elseif player.pixelX > targetX then elseif player.pixelX > targetX then
player.pixelX = math.max(player.pixelX - MOVE_SPEED, targetX) player.pixelX = math.max(player.pixelX - Config.player.move_speed, targetX)
player.moving = true player.moving = true
elseif player.pixelY < targetY then elseif player.pixelY < targetY then
player.pixelY = math.min(player.pixelY + MOVE_SPEED, targetY) player.pixelY = math.min(player.pixelY + Config.player.move_speed, targetY)
player.moving = true player.moving = true
elseif player.pixelY > targetY then elseif player.pixelY > targetY then
player.pixelY = math.max(player.pixelY - MOVE_SPEED, targetY) player.pixelY = math.max(player.pixelY - Config.player.move_speed, targetY)
player.moving = true player.moving = true
else else
player.moving = false player.moving = false
@@ -1119,9 +1490,9 @@ function Player.reset(player)
player.pixelX = (player.spawnX - 1) * TILE_SIZE player.pixelX = (player.spawnX - 1) * TILE_SIZE
player.pixelY = (player.spawnY - 1) * TILE_SIZE player.pixelY = (player.spawnY - 1) * TILE_SIZE
player.moving = false player.moving = false
player.maxBombs = 1 player.maxBombs = Config.player.start_bombs
player.activeBombs = 0 player.activeBombs = 0
player.bombPower = 1 player.bombPower = Config.player.start_power
player.bombCooldown = 0 player.bombCooldown = 0
end end
@@ -1158,7 +1529,7 @@ end
function Game.set_winner(player_num) function Game.set_winner(player_num)
State.winner = player_num State.winner = player_num
State.win_timer = WIN_SCREEN_DURATION State.win_timer = Config.timing.win_screen_duration
State.score[player_num] = State.score[player_num] + 1 State.score[player_num] = State.score[player_num] + 1
end end
@@ -1205,22 +1576,7 @@ end
-- Main game loop -- Main game loop
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
function TIC() local function update_playing()
if State.game_state == GAME_STATE_SPLASH then
Splash.update()
return
elseif State.game_state == GAME_STATE_MENU then
Menu.update()
return
elseif State.game_state == GAME_STATE_HELP then
Help.update()
return
elseif State.game_state == GAME_STATE_CREDITS then
Credits.update()
return
end
-- GAME_STATE_PLAYING
cls(COLOR_GREEN) cls(COLOR_GREEN)
-- ESC to return to menu -- ESC to return to menu
@@ -1243,6 +1599,22 @@ function TIC()
GameBoard.draw() GameBoard.draw()
end 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> -- <TILES>
-- 001:eccccccccc888888caaaaaaaca888888cacccccccacc0ccccacc0ccccacc0ccc -- 001:eccccccccc888888caaaaaaaca888888cacccccccacc0ccccacc0ccccacc0ccc
-- 002:ccccceee8888cceeaaaa0cee888a0ceeccca0ccc0cca0c0c0cca0c0c0cca0c0c -- 002:ccccceee8888cceeaaaa0cee888a0ceeccca0ccc0cca0c0c0cca0c0c0cca0c0c

View File

@@ -7,7 +7,7 @@ import os
import webbrowser import webbrowser
PORT = 3333 PORT = 3333
DIRECTORY = "bomberman" DIRECTORY = "bombexpert"
os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), DIRECTORY)) os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), DIRECTORY))