From f125ad848ee92e5f13829cec64658fb2e5935351 Mon Sep 17 00:00:00 2001 From: Hleb Rubanau Date: Tue, 6 Jan 2026 19:55:04 +0100 Subject: [PATCH 1/4] feat(examples/trapsensor): grid-based puzzle game Player must reveal all cells while avoiding hidden traps --- src/examples/trapsensor/main.lua | 660 ++++++++++++++++++++++++++ src/examples/trapsensor/rectangle.lua | 79 +++ 2 files changed, 739 insertions(+) create mode 100644 src/examples/trapsensor/main.lua create mode 100644 src/examples/trapsensor/rectangle.lua diff --git a/src/examples/trapsensor/main.lua b/src/examples/trapsensor/main.lua new file mode 100644 index 00000000..e58c9784 --- /dev/null +++ b/src/examples/trapsensor/main.lua @@ -0,0 +1,660 @@ +-- Title: Trap Sensor +gfx = love.graphics +Rectangle = require("rectangle") + +--- constants + +TRAPS_PERCENT=15 +FLOODFILL=false +DEFAULT_MODE = { 9, 9, 12 } + +CELL_SIZE=32 +CELL_FONTSIZE = 28 + +STATUS_PANEL_SIZE = 0.1 +STATUS_PANEL_FONTSIZE = 24 +STATUS_PANEL_PADDING = STATUS_PANEL_FONTSIZE / 2 +STATUS_PANEL_ALIGN = 'center' + +colors = { + main_panel_bg = Color[Color.blue], + field_border = Color[Color.white], + status_panel_bg = Color[Color.bright], + status_panel_border = Color[Color.green], + status_panel_text = Color[Color.yellow], + hints_text = Color[Color.green], + cell_bg_not_revealed = { 0.5, 0.5, 0.5 }, + cell_bg_revealed = Color[Color.green], + cell_bg_flagged = Color[Color.yellow], + cell_bg_blown = Color[Color.red], + cell_fg_trap = Color[Color.black], + cell_fg_flagged = Color[Color.red], + cell_fg_default = Color[Color.blue], + cell_fg_revealed_1 = Color[Color.white], + cell_fg_revealed_2 = Color[Color.black], + cell_fg_revealed_3 = Color[Color.magenta], + cell_fg_revealed_4 = Color[Color.red] +} + +fonts = { + status = gfx.newFont(STATUS_PANEL_FONTSIZE), + cell = gfx.newFont(CELL_FONTSIZE) +} + +-- runtime variables, to be initialized + +mode_idx = 1 +modes = { } +rectangles = { } +config = { } +state = { } +grid = { } +counters = { } +traps = { } + +function logdebug(...) + if love.DEBUG then + local msg = string.format(...) + print(msg) + end +end + +-- layouts(rectangles) -- immutable areas on screen + +function initLayoutStatusPanel() + local th = font.getHeight(fonts.status) + local vpad = th*0.5 + local panel_height = vpad + th + vpad + th + vpad + + local sp = rectangles.screen:lower(panel_height) + + local status_box = sp:new(0, vpad, sp.w, th) + local hints_box = sp:new(0, (vpad+th+vpad), sp.w, th ) + + rectangles.status_panel = sp + rectangles.status_box = status_box + rectangles.hints_box = hints_box +end + +function initLayoutMainPanel() + local screen = rectangles.screen + local sp = rectangles.status_panel + rectangles.main_panel = screen:upper( screen.h - sp.h) +end + +function initLayoutUI() + local screen_w, screen_h = gfx.getDimensions() + rectangles.screen = Rectangle:new(0, 0, screen_w, screen_h) + + initLayoutStatusPanel() + initLayoutMainPanel() +end + +-- modes ( cols,rows,traps ) -- derived from panel layout + +function initModes() + local main_panel = rectangles.main_panel + local max_cols = math.floor( main_panel.width / CELL_SIZE ) + local max_rows = math.floor( main_panel.height / CELL_SIZE ) + local max_cells = max_cols * max_rows + local max_traps = math.ceil( max_cells * TRAPS_PERCENT / 100 ) + local mid_cols = math.floor( max_cols / 10 )*10 + local mid_rows = math.floor( max_rows / 10 )*10 + local mid_cells = mid_cols * mid_rows + local mid_traps = math.ceil( mid_cells * TRAPS_PERCENT / 100 ) + modes[1] = DEFAULT_MODE + modes[2] = { mid_cols, mid_rows, mid_traps } + modes[3] = { max_cols, max_rows, max_traps } +end + +-- gamefield layout is dynamic, it depends on configured mode + +function setLayoutGameField() + local width = config.cell_size * config.cols + local height = config.cell_size * config.rows + + local mp = rectangles.main_panel + rectangles.field = mp:central( width, height ) +end + +--- visualisation + +--- visualisation: main panels + +function drawGameField() + gfx.setColor(colors.field_border) + local f = rectangles.field + + for i = 0, config.cols do + local border_pos_x = f.x + i*CELL_SIZE + gfx.line( border_pos_x, f.top, border_pos_x, f.bottom ) + end + + for j = 0 , config.rows do + local border_pos_y = f.y + j*CELL_SIZE + gfx.line( f.left, border_pos_y, f.right, border_pos_y ) + end +end + +function drawMainPanel() + gfx.setColor(colors.main_panel_bg) + gfx.rectangle("fill", rectangles.main_panel:x_y_w_h() ) +end + +function drawStatusPanel(status_line, hints_line) + gfx.setColor(colors.status_panel_bg) + gfx.rectangle("fill", rectangles.status_panel:x_y_w_h() ) + gfx.setColor(colors.status_panel_border) + gfx.rectangle("line", rectangles.status_panel:x_y_w_h() ) + gfx.setFont(fonts.status) + + local sb = rectangles.status_box + gfx.setColor(colors.status_panel_text) + gfx.printf( status_line, sb.x, sb.y, sb.w, STATUS_PANEL_ALIGN) + local hb = rectangles.hints_box + gfx.setColor(colors.hints_text) + gfx.printf( hints_line, hb.x, hb.y, hb.w, STATUS_PANEL_ALIGN) +end + +--- visualisation: status panel and its helpers + +function statusStatsLine() + local c = counters + local f = string.format + local substrings = { + f("Flagged: %s/%s", c.flagged, c.traps), + f("Opened: %s/%s", c.revealed, config.n_cells ), + f("Clicks: %s (%s s)", c.clicks, c.seconds) + } + return table.concat(substrings, " | ") +end + +function statusReadyLine() + local c = config.cols + local r = config.rows + local t = config.n_traps + local f = string.format + local msg = f("Ready! Field: %s x %s, traps: %s", c,r,t) + return msg +end + +function getStatusLine() + if (state.status == 'ready') then + return statusReadyLine() + end + local msg = statusStatsLine() + if (state.status=='finished') then + local prefix = string.upper(state.result) + msg = '['..prefix..'] '..msg + end + return msg +end + +function getHintsLine() + local msg = '' + if (state.status == 'finished') then + msg = "Double-click or press [r] for restart" + end + if (state.status == 'ready') then + msg = "Click to change mode, double-click to start" + end + if (state.status == 'started') then + msg = "Click to flag, double-click to open, [r] to restart" + end + return msg +end + +function drawStatus() + local status_line = getStatusLine() + local hints_line = getHintsLine() + drawStatusPanel( status_line, hints_line ) +end + +--- visualisation: cells and gamefield + +function getCellRectangle(i,j) + local field = rectangles.field + local c = config.cell_size + local cell_x_rel = (i-1)*c + local cell_y_rel = (j-1)*c + return field:new( cell_x_rel, cell_y_rel, c, c) +end + +function writeCellLabel(canvas, content, fgColor) + gfx.setColor( fgColor ) + gfx.setFont( fonts.cell ) + local fontHeight = fonts.cell:getHeight() + local text_y = canvas.y_mid - (fontHeight/ 2 ) + gfx.printf( content, canvas.x, text_y, canvas.w, 'center' ) +end + +function renderCell(canvas, bgcolor, fgcolor, content) + gfx.setColor( bgcolor ) + gfx.rectangle('fill', canvas:x_y_w_h() ) + gfx.setColor( colors.field_border ) + gfx.rectangle('line', canvas:x_y_w_h() ) + + if content then + if type(content) == 'function' then + content( canvas ) + else + writeCellLabel( canvas, content, fgcolor ) + end + end +end + +function getCellBackgroundColor(cell) + if cell.flagged then + return colors.cell_bg_flagged + elseif cell.blown then + return colors.cell_bg_blown + elseif cell.revealed then + return colors.cell_bg_revealed + else + return colors.cell_bg_not_revealed + end +end + +function getTrapsAroundColor(n_traps_nearby) + for v = 8,1,-1 do + if n_traps_nearby >= v then + local color_name = "cell_fg_revealed_"..v + if colors[color_name] then + return colors[color_name] + end + end + end + return colors.cell_fg_default +end + +function getCellForegroundColor(cell) + if cell.flagged then + return colors.cell_fg_flagged + elseif cell.trap then + return colors.cell_fg_trap + else + return getTrapsAroundColor(cell.n_traps_nearby) + end +end + +function getCellDisplayContent(cell) + local is_exposed_trap = cell.trap and cell.exposed + + if cell.blown then + return "X" + elseif is_exposed_trap then + return '*' + elseif cell.flagged then + return '?' + elseif cell.revealed then + if cell.n_traps_nearby > 0 then + return ''..cell.n_traps_nearby + end + end + + return false +end + +function drawCell(i,j) + local cell = grid[i][j] + local canvas = getCellRectangle(i,j) + + local bgColor = getCellBackgroundColor(cell) + local fgColor = getCellForegroundColor(cell) + local content = getCellDisplayContent(cell) + + renderCell( canvas, bgColor, fgColor, content ) +end + +function redrawField() + for i = 1, config.cols do + for j = 1, config.rows do + drawCell(i,j) + end + end +end + +--- visualisation: redraw hook (only field and status) + +function redraw(i,j) + redrawField() + drawStatus() +end + +--- data: grid and cells + +function newCell() + local cell = { + revealed = false, + flagged = false, + trap = nil, + exposed = false, + blown = false, + n_traps_nearby = 0, + } + return cell +end + +function getNeighbourPositions(i, j) + local result = { } + local i_min = math.max(i-1, 1) + local i_max = math.min(i+1, config.cols) + local j_min = math.max(j-1, 1) + local j_max = math.min(j+1, config.rows) + for n = i_min, i_max do + for m = j_min, j_max do + local is_original = (n==i) and (m==j) + if not is_original then + table.insert(result, {n,m}) + end + end + end + return result +end + +function getNonNeighbourPositions(i, j) + local result = { } + for n = 1, config.cols do + local i_near = math.abs( i - n ) <= 1 + for m = 1, config.rows do + local j_near = math.abs( j - m ) <= 1 + local proximity = i_near and j_near + if not proximity then + table.insert(result, {n,m}) + end + end + end + return result +end + +--- flows (updating game state via runtime variables) + +function flowInitConfig() + local mode = modes[ mode_idx ] + local cols, rows, traps = unpack(mode) + + config.cell_size = CELL_SIZE + config.floodfill = FLOODFILL + config.cols = cols + config.rows = rows + config.n_cells = cols * rows + config.n_traps = traps +end + +function flowInitGrid() + grid = { } + for i = 1, config.cols do + local col = {} + for j = 1, config.rows do + col[j] = newCell() + end + grid[i] = col + end +end + +function flowInitState() + state.status = 'ready' + state.result = nil + state.started = nil + + counters.revealed = 0 + counters.seconds = 0 + counters.clicks = 0 + counters.pending = 0 + counters.traps = 0 + + flowInitGrid() +end + +function flowPlaceTrap(i,j) + local cell = grid[i][j] + cell.trap = true + + table.insert( traps, cell ) -- for later reference + counters.traps = counters.traps+1 + + --logdebug("Trap #%s at: (%s, %s)", counters.traps, i, j) + + local neighbours = getNeighbourPositions(i,j) + for idx, position in ipairs(neighbours) do + local pos_i, pos_j = unpack(position) + local neighbour = grid[ pos_i ][ pos_j ] + neighbour.n_traps_nearby = neighbour.n_traps_nearby + 1 + end +end + +-- [i,j] is the firt click index, guaranteed to be safe zone +function flowTrapsPlacement(i,j) + math.randomseed(os.time()) + local positions = getNonNeighbourPositions( i, j ) + local n = #positions + local m = math.min( config.n_traps, n ) + for ipos, pos in ipairs(positions) do + local p = (m / n) + local selected = math.random() < p + if selected then + flowPlaceTrap( unpack(pos) ) + m = m - 1 + end + n = n - 1 + end +end + +function flowStart(i,j) + --logdebug("GAME STARTS...") + flowTrapsPlacement(i,j) + + state.status = 'started' + state.started = os.time() + counters.clicks = 0 + counters.seconds = 0 + counters.revealed = 0 + counters.flagged = 0 + counters.blown = 0 + counters.pending = config.n_cells - counters.traps +end + +function flowUpdateTimer() + if state.started then + counters.seconds = os.time() - state.started + end +end + +function flowTrackClick() + counters.clicks = counters.clicks + 1 +end + +function flowRevealCell(i,j) + logdebug("Revealing cell at (%s,%s)",i,j) + local cell = grid[i][j] + if cell.revealed or cell.trap then + logdebug("-> Backoff: revealed=%s, trap=%s", cell.revealed, cell.trap) + return + end + cell.revealed = true + counters.revealed = counters.revealed + 1 + counters.pending = counters.pending - 1 + if cell.n_traps_nearby == 0 and config.floodfill then + local neighbours = getNeighbourPositions(i,j) + logdebug("FLOODFILL START at (%s,%s): %s neighbours", i, j, #neighbours) + for _, pos in ipairs(neighbours) do + flowRevealCell( pos[1], pos[2] ) + --redraw() + end + end +end + +-- blow or reveal +function flowCheckCell(i,j) + local cell = grid[i][j] + if cell.trap then + cell.blown = true + counters.blown = counters.blown + 1 + else + -- lots of logic, factored into separate function + flowRevealCell(i,j) + end +end + +function flowEvaluateGameStatus(i,j) + if counters.pending == 0 then + state.status = 'finished' + state.result = 'win' + end + + if counters.blown > 0 then + state.status = 'finished' + state.result = 'lost' + for n, cell in ipairs(traps) do + cell.exposed = true + end + end +end + +function flowReveal(i,j) + flowTrackClick() + flowCheckCell(i,j) + flowEvaluateGameStatus(i,j) +end + +--- actions (trigger flows in response to user activity) + +function actionInit() + flowInitConfig() + flowInitState() + setLayoutGameField() +end + +function actionFlag(i,j) + local cell = grid[i][j] + + if not(cell.revealed) then + cell.flagged = not(cell.flagged) + + local adjust = cell.flagged and 1 or -1 + counters.flagged = counters.flagged + adjust + + flowUpdateTimer() + end +end + +function actionReveal(i,j) + local game_not_started = (state.status == 'ready') + if game_not_started then + flowStart(i,j) + end + + local cell = grid[i][j] + local can_be_revealed = not( cell.revealed or cell.flagged ) + if can_be_revealed then + flowReveal(i,j) + end + flowUpdateTimer() +end + +function actionNextMode() + if mode_idx == #modes then + mode_idx = 1 + else + mode_idx = mode_idx + 1 + end + actionInit() +end + +--- events dispatching helpers + +function isActionAllowed(action_name) + local game_status = state.status + if game_status == 'started' then + return true + end + -- first reveal starts the game + if game_status == 'ready' then + if action_name == 'reveal' then + return true + end + end + return false +end + +function isPointInGameField(x,y) + local field = rectangles.field + local x_valid = ( x >= field.x ) and ( x <= field.x + field.w) + local y_valid = ( x >= field.y ) and ( y <= field.y + field.h) + return x_valid and y_valid +end + +function detectCellPosition(x,y) + local field = rectangles.field + local x_rel = x - field.x + local y_rel = y - field.y + local c = config.cell_size + local i = math.ceil( x_rel / c ) + local j = math.ceil( y_rel / c ) + -- corner cases, left boundary still is cell + if x_rel == 0 then + i = 1 + end + if y_rel == 0 then + j = 1 + end + return i,j +end + +--- game actions dispatcher + +actions = { + flag = actionFlag, + reveal = actionReveal +} + +function dispatchAction(action_name, x, y) + local action_allowed = isActionAllowed(action_name) + local click_within_field = isPointInGameField(x, y) + + if action_allowed then + flowUpdateTimer() + if click_within_field then + local i, j = detectCellPosition(x,y) + logdebug("Action: %s -> [%s,%s]", action_name, i, j) + local action = actions[action_name] + action( i, j ) + end + end +end + +--- events binding + +function love.singleclick(x,y) + if state.status == 'started' then + dispatchAction('flag', x, y ) + else + if state.status == 'ready' then + actionNextMode() + end + end +end + +function love.doubleclick(x,y) + if state.status=='finished' then + actionInit() + else + dispatchAction('reveal', x, y ) + end +end + +function love.keyreleased(k) + if k == "r" then + actionInit() + end +end + +function love.draw() + redraw() +end + +--- game initialization + +initLayoutUI() +initModes() +actionInit() diff --git a/src/examples/trapsensor/rectangle.lua b/src/examples/trapsensor/rectangle.lua new file mode 100644 index 00000000..f4e390bc --- /dev/null +++ b/src/examples/trapsensor/rectangle.lua @@ -0,0 +1,79 @@ +-- Simple Rectangle class for representing rectangle coordinates +Rectangle = {} +Rectangle.__index = Rectangle + +-- Constructor: create a new Rectangle instance +function Rectangle:new(x, y, width, height) + -- If called on an instance, add parent's coordinates + if self ~= Rectangle then + x = self.x + x + y = self.y + y + end + -- Create new instance + local instance = setmetatable({}, Rectangle) + instance:init( x, y, width, height ) + return instance +end + +function Rectangle:init(x, y, width, height) + -- Store attributes + self.x = x + self.y = y + self.w = width + self.h = height + self.x_end = self.x + self.w + self.y_end = self.y + self.h + self.x_mid = self.x + self.w / 2 + self.y_mid = self.y + self.h / 2 + self:setup_aliases() +end + +function Rectangle:setup_aliases() + self.width = self.w + self.height = self.h + self.top = self.y + self.bottom = self.y_end + self.left = self.x + self.right = self.x_end +end + +function Rectangle:upper(new_height) + if new_height<1 then + new_height = self.height * new_height + end + return Rectangle:new( self.x, self.y, self.w, new_height ) +end + +function Rectangle:lower(new_height) + if new_height < 1 then + new_height = self.height * new_height + end + local new_y = self.y + (self.height - new_height) + return Rectangle:new(self.x, new_y, self.w, new_height) +end + +function Rectangle:central(w, h) + + local cx = self.x_mid - w/2 + local cy = self.y_mid - h/2 + return Rectangle:new( cx, cy, w, h ) + +end + +function Rectangle:x_y_w_h() + return self.x, self.y, self.w, self.h +end + +function Rectangle:inspect() + local s = self + local f = string.format + local shape = f("%s x %s", self.w, self.h) + local topleft = f("(%s,%s)", self.x, self.y) + local bottomright = f("(%s,%s)", self.x_end, self.y_end) + local center = f("(%s,%s)", self.x_mid, self.y_mid) + local coords = topleft..".."..bottomright + local result = shape.." ["..coords.."] -> "..center + return result +end + +return Rectangle From cb05ab3c67ce0e61df589625247f0fc2a58b6630 Mon Sep 17 00:00:00 2001 From: Hleb Rubanau Date: Tue, 6 Jan 2026 08:18:04 +0100 Subject: [PATCH 2/4] doc(examples/trapsensor): add README.md --- src/examples/trapsensor/README.md | 89 +++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/examples/trapsensor/README.md diff --git a/src/examples/trapsensor/README.md b/src/examples/trapsensor/README.md new file mode 100644 index 00000000..51cffa43 --- /dev/null +++ b/src/examples/trapsensor/README.md @@ -0,0 +1,89 @@ +# Trap Sensor + +Trap Sensor is a grid-based puzzle game where players must reveal all safe cells while avoiding hidden traps. The game features configurable difficulty levels, a clean UI with status tracking, and classic Minesweeper mechanics. + +## Project Architecture + +### Key Design Decisions + +- **Safe First Click**: Traps are placed *after* first reveal, excluding that cell and neighbors +- **Optional Flood Fill support**: Recursive reveal disabled/enabled via hardcoded setting +- **Stateless Rendering**: All visual state derived from data; no separate render state + +### Core Concepts + +#### 1. Layout System (`rectangles`) + +Given that game has no *moving* parts, UI is built as hierarchy of rectangles + +The `Rectangle` class (`rectangle.lua`) provides geometric abstraction for UI layout: + +- **Coordinate Management**: Stores position (x, y) and dimensions (w, h) with computed properties for edges and center points +- **Relative Positioning**: Child rectangles inherit parent coordinates for hierarchical layouts +- **Layout Helpers**: Methods for positioning child rectangle (`upper`, `lower`, `central`) +- **Aliases**: Multiple ways to reference the same attributes (width/w, height/h, top/bottom/left/right) + +AFter initialization each rectangle exposes absolute coordinates. + +The layout hierarchy: +``` +screen +│── main_panel (top) + └── field (centered grid) +└── status_panel (bottom) + ├── status_box (game stats) + └── hints_box (user guidance) +``` + +#### 2. Configuration & Modes + +Three difficulty presets adjust grid size and trap count: +- **Default**: 9×9 grid with 12 traps +- **Medium**: Dynamic sizing to 10-cell increments +- **Maximum**: Fills available screen space + +Configuration is calculated from: +- Panel dimensions (derived from screen size) +- Cell size constants (32px) +- Selected mode + +#### 3. Game State Management + +**State Variables:** +- `state`: Game phase (ready/started/finished) and outcome (win/lost) +- `grid`: 2D array of cell objects with properties (revealed, flagged, trap, blown, n_traps_nearby) +- `counters`: Statistics tracking (clicks, seconds, revealed cells, flags, pending cells) +- `traps`: Reference array of all trap cells, used for final exposure + +**State Transitions:** +- `ready` → `started`: First cell reveal (triggers trap placement) +- `started` → `finished`: All safe cells revealed (win) or trap hit (lost) + +#### 4. Separation of concerns + +* `flow` functions only alter model state +* `action` functions trigger flows and reinitialize gamefield as needed (gamefield is the only UI panel with mutable geometry) +* `redraw` function provides stateless rendering -- full layout is redrawn from the model state + +#### 5. Key Algorithms +- **Trap Placement**: Random selection from non-neighboring positions: + - Ensures first click is always safe + - Dynamically tweaks placement probability to guarantee placement of all traps +- **Neighbor Counting**: Each trap placement increments the `n_traps_nearby` counter for surrounding cells +- **Flood Fill**: Optional recursive reveal of connected zero-trap cells (currently disabled via `FLOODFILL=false`) + +## User Actions + +Actions are dispatched through a validation layer: + +``` +User Input → Event Handler (love.singleclick/doubleclick/keyreleased) + → Dispatcher (checks status, validates position, converts physical coordinates to cell index) + → Action Function (actionFlag/actionReveal/actionNextMode) + → Flow Functions +``` + +**Interaction Model:** +- Single-click: Flag cell (during game) or cycle modes (before game) +- Double-click: Reveal cell or restart (when finished) +- 'R' key: Restart at any time From 04b893951db78607b9cbb2d4eeca665de0741432 Mon Sep 17 00:00:00 2001 From: Hleb Rubanau Date: Mon, 12 Jan 2026 01:04:30 +0100 Subject: [PATCH 3/4] fix(example/trapsensor): typo --- src/examples/trapsensor/main.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/trapsensor/main.lua b/src/examples/trapsensor/main.lua index e58c9784..c4ed7ef8 100644 --- a/src/examples/trapsensor/main.lua +++ b/src/examples/trapsensor/main.lua @@ -580,7 +580,7 @@ end function isPointInGameField(x,y) local field = rectangles.field local x_valid = ( x >= field.x ) and ( x <= field.x + field.w) - local y_valid = ( x >= field.y ) and ( y <= field.y + field.h) + local y_valid = ( y >= field.y ) and ( y <= field.y + field.h) return x_valid and y_valid end From 15a8a02fe6415fbc230605b662133ee28a05945d Mon Sep 17 00:00:00 2001 From: Hleb Rubanau Date: Mon, 12 Jan 2026 01:05:07 +0100 Subject: [PATCH 4/4] feat(example/trapsensor_simple): radically simplified version of TrapSensor --- src/examples/trapsensor_simple/README.md | 8 + src/examples/trapsensor_simple/main.lua | 432 +++++++++++++++++++++++ 2 files changed, 440 insertions(+) create mode 100644 src/examples/trapsensor_simple/README.md create mode 100644 src/examples/trapsensor_simple/main.lua diff --git a/src/examples/trapsensor_simple/README.md b/src/examples/trapsensor_simple/README.md new file mode 100644 index 00000000..68002837 --- /dev/null +++ b/src/examples/trapsensor_simple/README.md @@ -0,0 +1,8 @@ +# Trapsensor (simple) + +Radically simplified version of trapsensor +* No dynamic configuration and abstract layouts +* Hardcoded grid size (9x9 with 12 mines) +* No flood-fill functionality +* All unnecessary abstractions eliminated +* Safe-click rule is the only convenience feature diff --git a/src/examples/trapsensor_simple/main.lua b/src/examples/trapsensor_simple/main.lua new file mode 100644 index 00000000..cec1333d --- /dev/null +++ b/src/examples/trapsensor_simple/main.lua @@ -0,0 +1,432 @@ +cols = 9 +rows = 9 +n_traps = 12 +cell_size = 32 +cell_font = 28 +status_font = 24 + +gfx=love.graphics + +colors = { + background = Color[Color.black], + status = Color[Color.green], + hint = Color[Color.yellow], + cell_border = Color[Color.white], + cell_bg_not_revealed = { 0.5, 0.5, 0.5 }, + cell_bg_revealed = Color[Color.green], + cell_bg_flagged = Color[Color.yellow], + cell_bg_blown = Color[Color.red], + cell_fg_trap = Color[Color.black], + cell_fg_flagged = Color[Color.red], + cell_fg_default = Color[Color.blue], + cell_fg_revealed_1 = Color[Color.white], + cell_fg_revealed_2 = Color[Color.black], + cell_fg_revealed_3 = Color[Color.magenta], + cell_fg_revealed_4 = Color[Color.red] +} + +screen_w, screen_h = gfx.getDimensions() +fonts = { + status = gfx.newFont(status_font), + cell = gfx.newFont(cell_font) +} + +cell_fh = font.getHeight(fonts.cell) +status_fh = font.getHeight(fonts.status) + +padding = status_fh +hint_start = screen_h - padding - status_fh +status_start = hint_start - padding - status_fh + +field_size = cols*cell_size +field_x = (screen_w - field_size) / 2 +field_y = (status_start - padding - field_size)/2 + +cells = cols*rows + +--- runtime variables +state = { } +grid = { } +counters = { } +traps = { } + +function newCell() + local cell = { + revealed = false, + flagged = false, + trap = nil, + exposed = false, + blown = false, + n_traps_nearby = 0, + } + return cell +end + +function flowInitGrid() + grid = { } + for i = 1, cols do + local col = {} + for j = 1, rows do + col[j] = newCell() + end + grid[i] = col + end +end + +function flowInitState() + state.status = 'ready' + state.result = nil + state.started = nil + + counters.revealed = 0 + counters.seconds = 0 + counters.clicks = 0 + counters.pending = 0 + counters.traps = 0 + + flowInitGrid() +end + +function getNeighbourPositions(i, j) + local result = { } + local i_min = math.max(i-1, 1) + local i_max = math.min(i+1, cols) + local j_min = math.max(j-1, 1) + local j_max = math.min(j+1, rows) + for n = i_min, i_max do + for m = j_min, j_max do + local is_original = (n==i) and (m==j) + if not is_original then + table.insert(result, {n,m}) + end + end + end + return result +end + +function getNonNeighbourPositions(i, j) + local result = { } + for n = 1, cols do + local i_near = math.abs( i - n ) <= 1 + for m = 1, rows do + local j_near = math.abs( j - m ) <= 1 + local proximity = i_near and j_near + if not proximity then + table.insert(result, {n,m}) + end + end + end + return result +end + +function flowPlaceTrap(i,j) + local cell = grid[i][j] + cell.trap = true + + table.insert( traps, cell ) -- for later reference + counters.traps = counters.traps+1 + + local neighbours = getNeighbourPositions(i,j) + for idx, position in ipairs(neighbours) do + local pos_i, pos_j = unpack(position) + local neighbour = grid[ pos_i ][ pos_j ] + neighbour.n_traps_nearby = neighbour.n_traps_nearby + 1 + end +end + +-- [i,j] is the firt click index, guaranteed to be safe zone +function flowTrapsPlacement(i,j) + math.randomseed(os.time()) + local positions = getNonNeighbourPositions( i, j ) + local n = #positions + local m = math.min( n_traps, n ) + for ipos, pos in ipairs(positions) do + local p = (m / n) + local selected = math.random() < p + if selected then + flowPlaceTrap( unpack(pos) ) + m = m - 1 + end + n = n - 1 + end +end + +function flowStart(i,j) + flowTrapsPlacement(i,j) + + state.status = 'started' + state.started = os.time() + counters.clicks = 0 + counters.seconds = 0 + counters.revealed = 0 + counters.flagged = 0 + counters.blown = 0 + counters.pending = cells - n_traps +end + + +function flowUpdateTimer() + if state.started then + counters.seconds = os.time() - state.started + end +end + +-- blow or reveal +function flowCheckCell(i,j) + local cell = grid[i][j] + if cell.trap then + cell.blown = true + counters.blown = counters.blown + 1 + else + cell.revealed = true + counters.revealed = counters.revealed + 1 + counters.pending = counters.pending - 1 + end +end + +function flowEvaluateGameStatus(i,j) + if counters.pending == 0 then + state.status = 'finished' + state.result = 'win' + end + + if counters.blown > 0 then + state.status = 'finished' + state.result = 'lost' + for n, cell in ipairs(traps) do + cell.exposed = true + end + end +end + +function flowReveal(i,j) + flowCheckCell(i,j) + flowEvaluateGameStatus(i,j) +end + +function actionInit() + flowInitState() +end + +function actionFlag(i,j) + local cell = grid[i][j] + + if not(cell.revealed) then + cell.flagged = not(cell.flagged) + + local adjust = cell.flagged and 1 or -1 + counters.flagged = counters.flagged + adjust + + flowUpdateTimer() + end +end + +function actionReveal(i,j) + local game_not_started = (state.status == 'ready') + if game_not_started then + flowStart(i,j) + end + + local cell = grid[i][j] + local can_be_revealed = not( cell.revealed or cell.flagged ) + if can_be_revealed then + flowReveal(i,j) + end + flowUpdateTimer() +end + +function isPointInGameField(x,y) + local x_min = field_x + local x_max = field_x + field_size + local y_min = field_y + local y_max = field_y + field_size + local x_valid = ( x >= x_min ) and ( x <= x_max ) + local y_valid = ( y >= y_min ) and ( y <= y_max ) + return x_valid and y_valid +end + +function detectCellPosition(x,y) + local x_rel = x - field_x + local y_rel = y - field_y + local c = cell_size + local i = math.ceil( x_rel / c ) + local j = math.ceil( y_rel / c ) + -- corner cases, left boundary still is cell + if x_rel == 0 then + i = 1 + end + if y_rel == 0 then + j = 1 + end + return i,j +end + +function love.singleclick(x,y) + if state.status=='started' then + flowUpdateTimer() + if isPointInGameField(x,y) then + local i, j = detectCellPosition(x,y) + actionFlag(i,j) + end + end +end + +function love.doubleclick(x,y) + if state.status=='finished' then + actionInit() + else + flowUpdateTimer() + if isPointInGameField(x,y) then + local i, j = detectCellPosition(x,y) + actionReveal(i,j) + end + end +end + +function getStatusLine() + local msg = nil + if not(state.status == 'ready') then + local r = counters.revealed + local p = counters.pending + local f = counters.flagged + local t = counters.traps + local s = counters.seconds + local fmt = string.format + local template = "Flags: %s/%s | Open: %s/%s | Sec: %s" + msg = fmt(template, f, t, r, p, s) + end + return msg +end + +function getHintsLine() + if state.status == 'ready' then + return 'Double-click to start' + end + if state.status == 'started' then + return 'Click to flag, double-click to open' + end + + local result = string.upper(state.result) + return result.."! (double-click to restart)" +end + +function redrawStatus() + local status = getStatusLine() + local hint = getHintsLine() + gfx.setFont(fonts.status) + if status then + gfx.setColor(colors.status) + gfx.printf( status, 0, status_start, screen_w, 'center') + end + if hint then + gfx.setColor(colors.hint) + gfx.printf( hint, 0, hint_start, screen_w, 'center') + end +end + +-- drawing cells +function getCellRectangle(i,j) + local cell_x_rel = (i-1)*cell_size + local cell_y_rel = (j-1)*cell_size + local cell_x = field_x + cell_x_rel + local cell_y = field_y + cell_y_rel + return { cell_x, cell_y } +end + +function renderCell(coords, bgcolor, fgcolor, txt) + local cell_x, cell_y = unpack(coords) + gfx.setColor( bgcolor ) + gfx.rectangle('fill', cell_x, cell_y, cell_size, cell_size) + gfx.setColor( colors.cell_border ) + gfx.rectangle('line', cell_x, cell_y, cell_size, cell_size) + if txt then + gfx.setColor( fgcolor ) + local text_y = cell_y + cell_size*0.5 - cell_fh*0.5 + gfx.printf( txt, cell_x, text_y, cell_size, 'center' ) + end +end + +function getCellBackgroundColor(cell) + if cell.flagged then + return colors.cell_bg_flagged + elseif cell.blown then + return colors.cell_bg_blown + elseif cell.revealed then + return colors.cell_bg_revealed + else + return colors.cell_bg_not_revealed + end +end + +function getTrapsAroundColor(n_traps_nearby) + for v = 8,1,-1 do + if n_traps_nearby >= v then + local color_name = "cell_fg_revealed_"..v + if colors[color_name] then + return colors[color_name] + end + end + end + return colors.cell_fg_default +end + +function getCellForegroundColor(cell) + if cell.flagged then + return colors.cell_fg_flagged + end + if cell.trap then + return colors.cell_fg_trap + end + return getTrapsAroundColor(cell.n_traps_nearby) +end + +function getCellDisplayContent(cell) + local is_exposed_trap = cell.trap and cell.exposed + + if cell.blown then + return "X" + elseif is_exposed_trap then + return '*' + elseif cell.flagged then + return '?' + elseif cell.revealed then + if cell.n_traps_nearby > 0 then + return ''..cell.n_traps_nearby + end + end + + return false +end + +function drawCell(i,j) + local cell = grid[i][j] + local coords = getCellRectangle(i,j) + + local bgColor = getCellBackgroundColor(cell) + local fgColor = getCellForegroundColor(cell) + local content = getCellDisplayContent(cell) + + renderCell( coords, bgColor, fgColor, content ) +end + +function redrawField() + gfx.setFont( fonts.cell ) + for i = 1, cols do + for j = 1, rows do + drawCell(i,j) + end + end +end + +function redraw() + gfx.setColor(colors.background) + gfx.rectangle('fill', 0, 0, screen_w, screen_h) + redrawField() + redrawStatus() +end + +function love.draw() + redraw() +end + +actionInit()