From 2dbf64a2e8c9bf828d69a4fc27f2af1bbfa780f2 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Sat, 1 Aug 2026 14:06:43 +0100 Subject: [PATCH] Release 1.0.0: rename to NoMines, add leaderboard, theme support and release metadata - Rename app/binary/app-id to nomines (io.github.bemagri.nomines), config dir moves to ~/.config/nomines with legacy path fallback - Add persistent best-time leaderboard (Glib::KeyFile) - Theme-aware board following system light/dark preference - Beveled 3D cells, confetti win animation, keyboard shortcuts, right-click chording, per-difficulty window sizing - Add AppStream metainfo, validate desktop file and metainfo via meson tests - Fix license metadata to GPL-3.0-or-later and real homepage --- .gitignore | 6 + AGENTS.md | 41 +++ README.md | 36 +- default.nix | 14 +- flake.nix | 12 +- meson.build | 33 +- resources/gresource.xml | 2 +- ...ktop => io.github.bemagri.nomines.desktop} | 6 +- .../io.github.bemagri.nomines.metainfo.xml | 52 +++ resources/{minesweeper.svg => nomines.svg} | 0 src/board_widget.cpp | 322 ++++++++++++++---- src/board_widget.hpp | 41 ++- src/leaderboard.cpp | 67 ++++ src/leaderboard.hpp | 24 ++ src/main.cpp | 2 +- src/minefield.cpp | 6 + src/minefield.hpp | 2 + src/window.cpp | 206 +++++++++-- src/window.hpp | 17 +- 19 files changed, 737 insertions(+), 152 deletions(-) create mode 100644 AGENTS.md rename resources/{org.gtkmm.minesweeper.desktop => io.github.bemagri.nomines.desktop} (79%) create mode 100644 resources/io.github.bemagri.nomines.metainfo.xml rename resources/{minesweeper.svg => nomines.svg} (100%) create mode 100644 src/leaderboard.cpp create mode 100644 src/leaderboard.hpp diff --git a/.gitignore b/.gitignore index 31de05d..a666135 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build directories build/ +build-*/ _build/ bin/ lib/ @@ -37,6 +38,7 @@ obj/ *.exe *.out *.app +nomines minesweeper # CMake and build system @@ -141,8 +143,12 @@ $RECYCLE.BIN/ *.gresource # Leaderboard and config files +.config/nomines/ .config/minesweeper/ #MacOS files .cache/ .DS_Store + +# direnv +.direnv/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..26f56bb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,41 @@ +# AGENTS.md + +GTK4/gtkmm4 Minesweeper clone ("NoMines") in C++20, built with Meson. ~800 lines total. + +## Build & run + +```bash +meson setup build # or reuse existing build/ +meson compile -C build +./build/nomines +``` + +- Dev deps come from the Nix flake via direnv (`.envrc` uses `use flake`); `nix develop` works standalone. +- Alternative: `nix build` / `nix run`. +- No unit tests; two optional release-validation tests (desktop file + AppStream metainfo via `desktop-file-validate`/`appstreamcli`) are auto-added when those tools exist. +- `build/compile_commands.json` exists for clangd (it's gitignored, regenerate with `meson setup --wipe build` if stale). +- WARNING: flake builds only see git-tracked files. New source files must be `git add`ed before `nix build` works. + +## Architecture + +- `src/minefield.*` — pure game logic, no GTK: `Minefield` (board state, flag counting, chording), `Cell` bitfield, `GameDifficulty` presets, `GameState` enum. +- `src/board_widget.*` — `Gtk::DrawingArea`; all rendering is hand-drawn Cairo primitives (draw_cell/draw_digit/draw_flag/draw_bomb), input via `Gtk::GestureClick` + `EventControllerMotion`, confetti particle animation on win (frame-rate independent via `Gdk::FrameClock`). +- `src/window.*` — `Gtk::ApplicationWindow` with HeaderBar, difficulty popover (radio-style `CheckButton`s), leaderboard popover, timer; owns the `shared_ptr` and swaps it on new game. Keyboard shortcuts via `Gtk::EventControllerKey` (R/Ctrl+N new game, 1-4 difficulties, L leaderboard). +- `src/leaderboard.*` — persistent best times per difficulty via `Glib::KeyFile` at `~/.config/nomines/leaderboard.ini` (recorded on win, shown in a header-bar popover; falls back to the pre-1.0 `~/.config/minesweeper/` path for migration). NOTE: uses the glibmm 2.80 refcounted `KeyFile::create()` API and gtkmm 4.14 `CheckButton` (a `Widget`+`Actionable`, not a `Button` — use `signal_toggled()` + `get_active()`, and `set_group(CheckButton&)`). +- glibmm 2.80 quirk: `SignalProxy::connect()` (non-void return signals like `signal_key_pressed`) requires the `after` bool explicitly — `connect(slot, false)`. Void signals default it to true. +- `src/main.cpp` — single `Gtk::Application` (`io.github.bemagri.nomines`); `make_window_and_run`. + +## Release + +- Version `1.0.0` is stamped in FOUR places: `meson.build`, `flake.nix`, `default.nix`, and the `` entry in `resources/io.github.bemagri.nomines.metainfo.xml`. Bump all four together. +- Release sanity: `nix build .` (validates the flake), `desktop-file-validate` + `appstreamcli validate --no-net` on the resources (wired up as meson tests). +- The metainfo screenshot URL points at `raw.githubusercontent.com/bemagri/nomines/main/...` — it 404s until the repo is published; replace if the repo moves. + +## Gotchas + +- `resources/gresource.xml` + the `gnome.compile_resources` step in `meson.build` compile unused code: the app never loads these SVGs/textures (the `confetti.png` is also unused). All art is Cairo-drawn. Don't extend the GResource to implement visual features; draw with Cairo instead. +- README's feature list is mostly accurate but not trustworthy in detail; verify against code. (An older `leaderboard.txt` CSV may linger in `~/.config/minesweeper/` from a previous experiment — the app now uses `~/.config/nomines/leaderboard.ini`.) +- Code style: snake_case methods/fields, trailing `_` on private members, `#pragma once`, nested-struct headers. C++20 stdlib only, no third-party libs beyond gtkmm/sigc++. +- Board colors are theme-aware: `BoardWidget::resolve_palette()` maps GTK CSS named colors (`theme_bg_color`, `theme_base_color`, ...) to a light/dark palette each draw. Don't hardcode light grays in `draw_*` helpers; thread the `Palette` through instead. +- Meson source list is explicit in `meson.build` — new source files must be added there. +- `.direnv/` and `build*/` are gitignored; flake builds require files to be committed/tracked. diff --git a/README.md b/README.md index f8f9e7c..2c09fe5 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,18 @@ -# MineSweeper +# NoMines -A modern GTK4/C++ implementation of the classic Minesweeper game with multiple difficulty levels, customizable board sizes, animations, and a leaderboard system. +A modern GTK4/gtkmm implementation of the classic Minesweeper game with multiple difficulty levels, a persistent leaderboard, keyboard shortcuts, and light/dark theme support. ![Game Screenshot](screenshots/screen1.png) ## Features - Multiple difficulty levels: Beginner, Intermediate, Expert, and Master -- Custom board size and mine count configuration -- Win/lose animations -- Persistent leaderboard to track best scores -- Modern GTK4 user interface +- Persistent leaderboard tracking the best time per difficulty +- Follows the system light/dark theme +- Win/lose animations, including a confetti win celebration +- Keyboard shortcuts: `R` / `Ctrl+N` new game, `1`–`4` difficulty, `L` leaderboard +- Right-click chording on revealed cells +- Modern GTK4 user interface with client-side decorations - Modern C++20 implementation ## Building from Source @@ -52,8 +54,8 @@ sudo pacman -S base-devel meson ninja gtkmm-4.0 libsigc++-3.0 Clone the repository: ```bash -git clone https://github.com/username/minesweeper.git -cd minesweeper +git clone https://github.com/bemagri/nomines +cd nomines ``` Configure and build with Meson: @@ -66,7 +68,7 @@ meson compile -C build Run the game: ```bash -./build/minesweeper +./build/nomines ``` ### Installing @@ -82,14 +84,20 @@ meson install -C build If you use the Nix package manager, you can build and run the application with: ```bash -nix-build -./result/bin/minesweeper +nix build +./result/bin/nomines ``` -Or install it with: +Or run it directly from the flake: ```bash -nix-env -i -f . +nix run +``` + +The Nix flake also provides a development shell with all dependencies: + +```bash +nix develop ``` ## Contributing @@ -98,4 +106,4 @@ Contributions are welcome! Please feel free to submit a Pull Request. ## License -This project is licensed under the MIT License - see the LICENSE file for details. +This project is licensed under the GNU General Public License v3.0 - see the LICENSE file for details. diff --git a/default.nix b/default.nix index 94a6fc9..84bd3b4 100644 --- a/default.nix +++ b/default.nix @@ -11,15 +11,15 @@ }: stdenv.mkDerivation rec { - pname = "minesweeper"; - version = "0.2.0"; + pname = "nomines"; + version = "1.0.0"; src = ./.; # If you want to use fetchFromGitHub instead: # src = fetchFromGitHub { - # owner = "username"; - # repo = "minesweeper"; + # owner = "bemagri"; + # repo = "nomines"; # rev = "v${version}"; # hash = "sha256-0000000000000000000000000000000000000000000="; # }; @@ -39,10 +39,10 @@ stdenv.mkDerivation rec { meta = with lib; { description = "Modern Minesweeper game with GTK4 interface"; - homepage = "https://github.com/username/minesweeper"; - license = licenses.mit; + homepage = "https://github.com/bemagri/nomines"; + license = licenses.gpl3Plus; platforms = platforms.linux; maintainers = with maintainers; [ /* your name here */ ]; - mainProgram = "minesweeper"; + mainProgram = "nomines"; }; } diff --git a/flake.nix b/flake.nix index 81b5f4b..de448fb 100644 --- a/flake.nix +++ b/flake.nix @@ -18,8 +18,8 @@ { # Main package packages.default = pkgs.stdenv.mkDerivation rec { - pname = "minesweeper"; - version = "0.1"; + pname = "nomines"; + version = "1.0.0"; src = self; strictDeps = true; @@ -53,18 +53,18 @@ ''; meta = with pkgs.lib; { - description = "A simple GTKmm4 Minesweeper game"; - homepage = "https://example.org/minesweeper"; + description = "NoMines - a modern GTK4 Minesweeper game"; + homepage = "https://github.com/bemagri/nomines"; license = licenses.gpl3Plus; platforms = platforms.linux; - mainProgram = "minesweeper"; + mainProgram = "nomines"; }; }; # nix run support apps.${system}.default = { type = "app"; - program = "${self.packages.${system}.default}/bin/minesweeper"; + program = "${self.packages.${system}.default}/bin/nomines"; }; # Dev shell for hacking diff --git a/meson.build b/meson.build index 4d00418..e86beea 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ -project('minesweeper', 'cpp', - version : '0.2.0', +project('nomines', 'cpp', + version : '1.0.0', default_options : ['warning_level=3', 'cpp_std=c++20']) gnome = import('gnome') @@ -25,24 +25,45 @@ src = [ 'src/minefield.cpp', 'src/board_widget.hpp', 'src/board_widget.cpp', + 'src/leaderboard.hpp', + 'src/leaderboard.cpp', res ] # Executable -executable('minesweeper', +executable('nomines', src, dependencies : deps, install : true ) -# Install icons +# Install icon install_data( - 'resources/minesweeper.svg', + 'resources/nomines.svg', install_dir: join_paths(get_option('datadir'), 'icons/hicolor/scalable/apps') ) # Install desktop file install_data( - 'resources/org.gtkmm.minesweeper.desktop', + 'resources/io.github.bemagri.nomines.desktop', install_dir: join_paths(get_option('datadir'), 'applications') ) + +# Install AppStream metadata +install_data( + 'resources/io.github.bemagri.nomines.metainfo.xml', + install_dir: join_paths(get_option('datadir'), 'metainfo') +) + +# Release validation (best-effort, only when the tools are installed) +desktop_file_validate = find_program('desktop-file-validate', required: false) +if desktop_file_validate.found() + test('validate-desktop', desktop_file_validate, + args: ['resources/io.github.bemagri.nomines.desktop']) +endif + +appstreamcli = find_program('appstreamcli', required: false) +if appstreamcli.found() + test('validate-metainfo', appstreamcli, + args: ['validate', '--no-net', 'resources/io.github.bemagri.nomines.metainfo.xml']) +endif diff --git a/resources/gresource.xml b/resources/gresource.xml index c221533..f0d7122 100644 --- a/resources/gresource.xml +++ b/resources/gresource.xml @@ -1,6 +1,6 @@ - + bomb-solid.svg explosion-solid.svg flag-regular.svg diff --git a/resources/org.gtkmm.minesweeper.desktop b/resources/io.github.bemagri.nomines.desktop similarity index 79% rename from resources/org.gtkmm.minesweeper.desktop rename to resources/io.github.bemagri.nomines.desktop index e991c1b..4cb4394 100644 --- a/resources/org.gtkmm.minesweeper.desktop +++ b/resources/io.github.bemagri.nomines.desktop @@ -1,10 +1,10 @@ [Desktop Entry] -Name=MineSweeper +Name=NoMines GenericName=Minesweeper Game Comment=Classic minesweeper game with modern GTK UI Keywords=game;puzzle;mine; -Exec=minesweeper -Icon=minesweeper +Exec=nomines +Icon=nomines Terminal=false Type=Application Categories=Game;LogicGame; diff --git a/resources/io.github.bemagri.nomines.metainfo.xml b/resources/io.github.bemagri.nomines.metainfo.xml new file mode 100644 index 0000000..796ee39 --- /dev/null +++ b/resources/io.github.bemagri.nomines.metainfo.xml @@ -0,0 +1,52 @@ + + + io.github.bemagri.nomines + NoMines + A classic minesweeper game with a modern GTK4 interface + CC0-1.0 + GPL-3.0-or-later + + Bernardo Magri + + +

NoMines is a modern, theme-aware implementation of the classic Minesweeper game, built with GTK4 and gtkmm.

+

Features:

+
    +
  • Four difficulty levels, from Beginner to Master
  • +
  • Persistent leaderboard of best times per difficulty
  • +
  • Follows the system light/dark theme
  • +
  • Keyboard shortcuts, right-click chording, and a confetti win animation
  • +
+
+ io.github.bemagri.nomines.desktop + https://github.com/bemagri/nomines + https://github.com/bemagri/nomines/issues + + + NoMines in action on a Beginner board + https://raw.githubusercontent.com/bemagri/nomines/main/screenshots/screen1.png + + + + + +

First public release.

+
    +
  • Four difficulty presets with per-difficulty default window sizing
  • +
  • Persistent best-time leaderboard
  • +
  • Theme-aware board that follows the system light/dark preference
  • +
  • Keyboard shortcuts (R, Ctrl+N, 1-4, L) and right-click chording
  • +
  • Confetti win animation and game-over dialog with new-record notice
  • +
+
+
+
+ + none + none + none + none + none + none + +
diff --git a/resources/minesweeper.svg b/resources/nomines.svg similarity index 100% rename from resources/minesweeper.svg rename to resources/nomines.svg diff --git a/src/board_widget.cpp b/src/board_widget.cpp index 7e283db..f18ccd5 100644 --- a/src/board_widget.cpp +++ b/src/board_widget.cpp @@ -1,7 +1,55 @@ #include "board_widget.hpp" +#include +#include #include #include #include +#include +#include +#include + +namespace { +Gdk::RGBA make_rgba(double r, double g, double b, double a = 1.0) { + Gdk::RGBA c; + c.set_red(r); + c.set_green(g); + c.set_blue(b); + c.set_alpha(a); + return c; +} + +Gdk::RGBA rgba_lerp(const Gdk::RGBA& a, const Gdk::RGBA& b, double t) { + return make_rgba( + a.get_red() + (b.get_red() - a.get_red()) * t, + a.get_green() + (b.get_green() - a.get_green()) * t, + a.get_blue() + (b.get_blue() - a.get_blue()) * t, + a.get_alpha() + (b.get_alpha() - a.get_alpha()) * t); +} + +double rgba_luminance(const Gdk::RGBA& c) { + return 0.299 * c.get_red() + 0.587 * c.get_green() + 0.114 * c.get_blue(); +} + +Gdk::RGBA rgba_lighten(const Gdk::RGBA& c, double t) { + return make_rgba( + c.get_red() + (1.0 - c.get_red()) * t, + c.get_green() + (1.0 - c.get_green()) * t, + c.get_blue() + (1.0 - c.get_blue()) * t, + c.get_alpha()); +} + +Gdk::RGBA rgba_darken(const Gdk::RGBA& c, double t) { + return make_rgba( + c.get_red() * (1.0 - t), + c.get_green() * (1.0 - t), + c.get_blue() * (1.0 - t), + c.get_alpha()); +} + +void set_source(const Cairo::RefPtr& cr, const Gdk::RGBA& c) { + cr->set_source_rgba(c.get_red(), c.get_green(), c.get_blue(), c.get_alpha()); +} +} // namespace BoardWidget::BoardWidget() { set_hexpand(true); @@ -37,6 +85,8 @@ BoardWidget::~BoardWidget() { void BoardWidget::set_minefield(std::shared_ptr field) { field_ = field; particles_.clear(); + hover_x_ = -1; + hover_y_ = -1; if (tick_id_ > 0) { remove_tick_callback(tick_id_); tick_id_ = 0; @@ -46,25 +96,45 @@ void BoardWidget::set_minefield(std::shared_ptr field) { void BoardWidget::start_confetti() { particles_.clear(); + last_frame_time_us_ = 0; std::random_device rd; std::mt19937 gen(rd()); - std::uniform_real_distribution<> pos_dist(0.0, 1.0); // Relative to window - std::uniform_real_distribution<> vel_dist(-2.0, 2.0); - std::uniform_real_distribution<> color_dist(0.0, 1.0); - std::uniform_real_distribution<> size_dist(3.0, 8.0); + std::uniform_real_distribution<> pos_x(0.0, 1.0); + std::uniform_real_distribution<> pos_y(0.0, 0.4); + std::uniform_real_distribution<> vel_x(-250.0, 250.0); + std::uniform_real_distribution<> vel_y(-300.0, 150.0); // Burst upward first + std::uniform_real_distribution<> angle(0.0, 2.0 * M_PI); + std::uniform_real_distribution<> vrot(-5.0, 5.0); + std::uniform_real_distribution<> size_dist(4.0, 10.0); + std::uniform_real_distribution<> phase_dist(0.0, 2.0 * M_PI); + std::uniform_real_distribution<> life_dist(2.5, 4.0); + std::uniform_int_distribution<> color_idx(0, 7); + std::uniform_int_distribution<> shape(0, 3); + + // Classic confetti palette + static constexpr double palette[8][3] = { + {0.91, 0.20, 0.20}, {0.20, 0.55, 0.90}, {0.30, 0.80, 0.30}, + {0.95, 0.80, 0.20}, {0.95, 0.55, 0.15}, {0.65, 0.30, 0.85}, + {0.95, 0.40, 0.70}, {0.35, 0.85, 0.85}}; int w = get_width(); int h = get_height(); - for (int i = 0; i < 200; ++i) { + for (int i = 0; i < 250; ++i) { + const auto& c = palette[color_idx(gen)]; particles_.push_back({ - pos_dist(gen) * w, - pos_dist(gen) * h * 0.5, // Start from top half - vel_dist(gen), - vel_dist(gen) + 2.0, // Fall down - 1.0, // Life - color_dist(gen), color_dist(gen), color_dist(gen), - size_dist(gen) + pos_x(gen) * w, + pos_y(gen) * h, + vel_x(gen), + vel_y(gen), + angle(gen), + vrot(gen), + 1.0, // life + life_dist(gen), // lifetime (seconds) + c[0], c[1], c[2], + size_dist(gen), + phase_dist(gen), + shape(gen) == 0 // 1 in 4 are circles }); } @@ -76,28 +146,44 @@ void BoardWidget::start_confetti() { } bool BoardWidget::on_animation_tick(const Glib::RefPtr& clock) { - return process_animation(); // Overload + // Frame-rate independent animation from real elapsed time + gint64 now = clock->get_frame_time(); + double dt = (last_frame_time_us_ > 0) ? (now - last_frame_time_us_) / 1e6 : 1.0 / 60.0; + last_frame_time_us_ = now; + dt = std::min(dt, 0.1); // Clamp long pauses (e.g. drag under a dialog) + return process_animation(dt); } -bool BoardWidget::process_animation() { - if (particles_.empty()) return false; - - bool alive = false; - for (auto& p : particles_) { - p.x += p.vx; - p.y += p.vy; - p.vy += 0.1; // Gravity - p.life -= 0.01; - - if (p.life > 0) alive = true; +bool BoardWidget::process_animation(double dt) { + if (particles_.empty()) { + tick_id_ = 0; + return false; } + constexpr double gravity = 800.0; // px/s^2 + constexpr double terminal_v = 550.0; // px/s + constexpr double sway_freq = 18.0; // rad/s + constexpr double sway_amp = 60.0; // px/s + + for (auto& p : particles_) { + p.life -= dt / p.lifetime; + if (p.life <= 0) continue; + + p.vy = std::min(p.vy + gravity * dt, terminal_v); + p.x += (p.vx + sway_amp * std::sin(p.phase + (1.0 - p.life) * sway_freq)) * dt; + p.y += p.vy * dt; + p.rotation += p.vrot * dt; + } + + std::erase_if(particles_, [](const Particle& p) { return p.life <= 0; }); + + if (particles_.empty()) tick_id_ = 0; queue_draw(); - return alive; + return !particles_.empty(); } std::pair BoardWidget::get_cell_at(double x, double y) { - if (!field_) return {-1, -1}; + if (!field_ || cell_size_ <= 0) return {-1, -1}; // Use stored metrics from last draw if (x < offset_x_ || x >= offset_x_ + field_->cols() * cell_size_ || @@ -110,7 +196,7 @@ std::pair BoardWidget::get_cell_at(double x, double y) { return {cx, cy}; } -void BoardWidget::on_click_pressed(int n_press, double x, double y) { +void BoardWidget::on_click_pressed(int /*n_press*/, double x, double y) { if (!field_) return; auto [cx, cy] = get_cell_at(x, y); if (cx == -1) return; @@ -131,13 +217,25 @@ void BoardWidget::on_click_pressed(int n_press, double x, double y) { } } -void BoardWidget::on_right_click_pressed(int n_press, double x, double y) { +void BoardWidget::on_right_click_pressed(int /*n_press*/, double x, double y) { if (!field_) return; auto [cx, cy] = get_cell_at(x, y); if (cx == -1) return; - if (field_->toggle_flag(cx, cy)) { + bool changed = false; + const Cell& cell = field_->get_cell(cx, cy); + if (cell.is_revealed) { + // Classic behavior: right-click chords a revealed number cell + changed = field_->chord_cell(cx, cy); + } else { + changed = field_->toggle_flag(cx, cy); + } + + if (changed) { signal_state_changed.emit(); + if (field_->state() == GameState::Won) { + start_confetti(); + } queue_draw(); } } @@ -149,17 +247,75 @@ void BoardWidget::on_motion(double x, double y) { hover_y_ = cy; queue_draw(); } + + bool over = cx != -1; + if (over != cursor_pointer_) { + cursor_pointer_ = over; + if (over) { + set_cursor(Gdk::Cursor::create("pointer")); + } else { + set_cursor(Glib::ustring{}); + } + } } void BoardWidget::on_leave() { hover_x_ = -1; hover_y_ = -1; + if (cursor_pointer_) { + cursor_pointer_ = false; + set_cursor(Glib::ustring{}); + } queue_draw(); } +BoardWidget::Palette BoardWidget::resolve_palette() { + Palette p; + auto style = get_style_context(); + + Gdk::RGBA bg, fg, base, sel; + bool has_bg = style->lookup_color("theme_bg_color", bg); + bool has_fg = style->lookup_color("theme_fg_color", fg); + bool has_base = style->lookup_color("theme_base_color", base); + bool has_sel = style->lookup_color("theme_selected_bg_color", sel); + + bool dark = has_bg && rgba_luminance(bg) < 0.45; + if (!has_bg) bg = make_rgba(dark ? 0.12 : 0.95, dark ? 0.12 : 0.95, dark ? 0.12 : 0.95); + if (!has_fg) fg = make_rgba(dark ? 0.88 : 0.1, dark ? 0.88 : 0.1, dark ? 0.88 : 0.1); + if (!has_base) base = make_rgba(dark ? 0.16 : 0.92, dark ? 0.16 : 0.92, dark ? 0.16 : 0.92); + if (!has_sel) sel = make_rgba(0.25, 0.5, 1.0); + + p.bg = bg; + p.revealed = base; + p.unrevealed_a = rgba_lerp(base, fg, 0.10); + p.unrevealed_b = rgba_lerp(base, fg, 0.20); + p.bevel_light = rgba_lighten(p.unrevealed_a, 0.55); + p.bevel_dark = rgba_darken(p.unrevealed_a, 0.45); + p.sunken = rgba_darken(base, 0.25); + p.sunken_light = rgba_lighten(base, 0.20); + p.hover = rgba_lerp(base, sel, 0.55); + p.shadow = make_rgba(0, 0, 0, dark ? 0.35 : 0.12); + p.flag_pole = rgba_lerp(base, fg, 0.75); + + // Classic 1-8 digit colors, brightened for dark themes + static const double light_digits[8][3] = { + {0.2, 0.4, 1.0}, {0.2, 0.6, 0.2}, {0.9, 0.2, 0.2}, {0.1, 0.1, 0.6}, + {0.6, 0.1, 0.1}, {0.1, 0.6, 0.6}, {0.1, 0.1, 0.1}, {0.5, 0.5, 0.5}}; + static const double dark_digits[8][3] = { + {0.45, 0.65, 1.0}, {0.45, 0.85, 0.5}, {1.0, 0.42, 0.42}, {0.62, 0.62, 1.0}, + {1.0, 0.5, 0.35}, {0.4, 0.85, 0.85}, {0.85, 0.85, 0.9}, {0.72, 0.72, 0.75}}; + for (int i = 0; i < 8; ++i) { + const auto& c = dark ? dark_digits[i] : light_digits[i]; + p.digits[i] = make_rgba(c[0], c[1], c[2]); + } + return p; +} + void BoardWidget::on_draw(const Cairo::RefPtr& cr, int width, int height) { + Palette pal = resolve_palette(); + // Background - cr->set_source_rgb(0.95, 0.95, 0.95); + set_source(cr, pal.bg); cr->paint(); if (field_) { @@ -184,13 +340,13 @@ void BoardWidget::on_draw(const Cairo::RefPtr& cr, int width, in cr->translate(offset_x_, offset_y_); // Shadow for the board - cr->set_source_rgba(0.0, 0.0, 0.0, 0.1); + set_source(cr, pal.shadow); cr->rectangle(5, 5, board_w_px, board_h_px); cr->fill(); for (int y = 0; y < rows; ++y) { for (int x = 0; x < cols; ++x) { - draw_cell(cr, x, y, cell_size_, field_->get_cell(x, y)); + draw_cell(cr, x, y, cell_size_, field_->get_cell(x, y), pal); } } cr->restore(); @@ -201,61 +357,80 @@ void BoardWidget::on_draw(const Cairo::RefPtr& cr, int width, in } } -void BoardWidget::draw_cell(const Cairo::RefPtr& cr, int x, int y, double size, const Cell& cell) { +void BoardWidget::draw_cell(const Cairo::RefPtr& cr, int x, int y, double size, const Cell& cell, const Palette& pal) { double px = x * size; double py = y * size; + bool is_hover = (x == hover_x_ && y == hover_y_ && field_->state() == GameState::Playing); + bool revealed = cell.is_revealed; + double lw = std::clamp(size * 0.12, 1.0, 4.0); + // Base shape cr->rectangle(px, py, size, size); - bool is_hover = (x == hover_x_ && y == hover_y_ && field_->state() == GameState::Playing); - bool revealed = cell.is_revealed; - if (revealed) { if (cell.is_exploded) { cr->set_source_rgb(0.9, 0.3, 0.3); // Red background + cr->fill(); } else { - cr->set_source_rgb(0.92, 0.92, 0.92); // Revealed background (lighter gray) + set_source(cr, pal.revealed); + cr->fill(); + + // Sunken bevel: dark top/left, light bottom/right + set_source(cr, pal.sunken); + cr->rectangle(px, py, size, lw * 0.5); + cr->fill(); + cr->rectangle(px, py, lw * 0.5, size); + cr->fill(); + set_source(cr, pal.sunken_light); + cr->rectangle(px, py + size - lw * 0.5, size, lw * 0.5); + cr->fill(); + cr->rectangle(px + size - lw * 0.5, py, lw * 0.5, size); + cr->fill(); } } else { if (is_hover) { - cr->set_source_rgb(0.75, 0.85, 1.0); // Hover light blue + set_source(cr, pal.hover); // Hover highlight } else { // Gradient for unrevealed cells auto pat = Cairo::LinearGradient::create(px, py, px + size, py + size); - pat->add_color_stop_rgba(0, 0.8, 0.8, 0.8, 1); - pat->add_color_stop_rgba(1, 0.7, 0.7, 0.7, 1); + pat->add_color_stop_rgba(0, pal.unrevealed_a.get_red(), pal.unrevealed_a.get_green(), pal.unrevealed_a.get_blue(), pal.unrevealed_a.get_alpha()); + pat->add_color_stop_rgba(1, pal.unrevealed_b.get_red(), pal.unrevealed_b.get_green(), pal.unrevealed_b.get_blue(), pal.unrevealed_b.get_alpha()); cr->set_source(pat); } - } - if (!revealed && !is_hover) cr->fill(); - else cr->fill_preserve(); - - if (revealed || is_hover) { - // Border - cr->set_source_rgb(0.6, 0.6, 0.6); - cr->set_line_width(1.0); - cr->stroke(); + cr->fill(); + + // Raised bevel: light top/left, dark bottom/right + set_source(cr, pal.bevel_light); + cr->rectangle(px, py, size, lw); + cr->fill(); + cr->rectangle(px, py, lw, size); + cr->fill(); + set_source(cr, pal.bevel_dark); + cr->rectangle(px, py + size - lw, size, lw); + cr->fill(); + cr->rectangle(px + size - lw, py, lw, size); + cr->fill(); } // Content if (cell.is_flagged) { - draw_flag(cr, px, py, size); + draw_flag(cr, px, py, size, pal); } else if (revealed) { if (cell.is_bomb) { - draw_bomb(cr, px, py, size, cell.is_exploded); + draw_bomb(cr, px, py, size, cell.is_exploded, pal); } else if (cell.nearby_bombs > 0) { - draw_digit(cr, cell.nearby_bombs, px, py, size); + draw_digit(cr, cell.nearby_bombs, px, py, size, pal); } } } -void BoardWidget::draw_flag(const Cairo::RefPtr& cr, double x, double y, double size) { +void BoardWidget::draw_flag(const Cairo::RefPtr& cr, double x, double y, double size, const Palette& pal) { double cx = x + size / 2.0; double cy = y + size / 2.0; // Pole - cr->set_source_rgb(0.2, 0.2, 0.2); + set_source(cr, pal.flag_pole); cr->set_line_width(2.0); cr->move_to(cx - size * 0.1, cy + size * 0.3); cr->line_to(cx - size * 0.1, cy - size * 0.3); @@ -268,9 +443,16 @@ void BoardWidget::draw_flag(const Cairo::RefPtr& cr, double x, d cr->line_to(cx - size * 0.1, cy); cr->close_path(); cr->fill(); + + // Pole cap and base + cr->set_source_rgb(0.2, 0.2, 0.2); + cr->arc(cx - size * 0.1, cy - size * 0.3, size * 0.04, 0, 2 * M_PI); + cr->fill(); + cr->rectangle(cx - size * 0.14, cy + size * 0.3, size * 0.08, size * 0.07); + cr->fill(); } -void BoardWidget::draw_bomb(const Cairo::RefPtr& cr, double x, double y, double size, bool exploded) { +void BoardWidget::draw_bomb(const Cairo::RefPtr& cr, double x, double y, double size, bool exploded, const Palette& pal) { double cx = x + size / 2.0; double cy = y + size / 2.0; double r = size * 0.25; @@ -283,8 +465,13 @@ void BoardWidget::draw_bomb(const Cairo::RefPtr& cr, double x, d cr->arc(cx, cy, r, 0, 2 * M_PI); cr->fill(); + // Specular highlight + cr->set_source_rgba(1.0, 1.0, 1.0, 0.35); + cr->arc(cx - r * 0.35, cy - r * 0.35, r * 0.28, 0, 2 * M_PI); + cr->fill(); + // Spikes/Fuse - cr->set_source_rgb(0.1, 0.1, 0.1); + set_source(cr, pal.flag_pole); cr->set_line_width(2.0); cr->move_to(cx, cy - r); cr->line_to(cx, cy - r - size * 0.1); @@ -298,16 +485,9 @@ void BoardWidget::draw_bomb(const Cairo::RefPtr& cr, double x, d } } -void BoardWidget::draw_digit(const Cairo::RefPtr& cr, int number, double x, double y, double size) { - switch (number) { - case 1: cr->set_source_rgb(0.2, 0.4, 1.0); break; // Blue - case 2: cr->set_source_rgb(0.2, 0.6, 0.2); break; // Green - case 3: cr->set_source_rgb(0.9, 0.2, 0.2); break; // Red - case 4: cr->set_source_rgb(0.1, 0.1, 0.6); break; // Dark Blue - case 5: cr->set_source_rgb(0.6, 0.1, 0.1); break; // Maroon - case 6: cr->set_source_rgb(0.1, 0.6, 0.6); break; // Cyan - case 7: cr->set_source_rgb(0.1, 0.1, 0.1); break; // Black - case 8: cr->set_source_rgb(0.5, 0.5, 0.5); break; // Gray +void BoardWidget::draw_digit(const Cairo::RefPtr& cr, int number, double x, double y, double size, const Palette& pal) { + if (number >= 1 && number <= 8) { + set_source(cr, pal.digits[number - 1]); } cr->select_font_face("Sans", Cairo::ToyFontFace::Slant::NORMAL, Cairo::ToyFontFace::Weight::BOLD); @@ -326,7 +506,15 @@ void BoardWidget::draw_particles(const Cairo::RefPtr& cr) { for (const auto& p : particles_) { if (p.life <= 0) continue; cr->set_source_rgba(p.r, p.g, p.b, p.life); - cr->rectangle(p.x, p.y, p.size, p.size); + cr->save(); + cr->translate(p.x, p.y); + cr->rotate(p.rotation); + if (p.circle) { + cr->arc(0, 0, p.size * 0.5, 0, 2 * M_PI); + } else { + cr->rectangle(-p.size * 0.5, -p.size * 0.3, p.size, p.size * 0.6); + } cr->fill(); + cr->restore(); } } diff --git a/src/board_widget.hpp b/src/board_widget.hpp index 364f7b2..0649a8f 100644 --- a/src/board_widget.hpp +++ b/src/board_widget.hpp @@ -3,7 +3,7 @@ #include #include #include -#include +#include #include #include #include @@ -12,9 +12,14 @@ struct Particle { double x, y; double vx, vy; - double life; + double rotation; + double vrot; + double life; // 1.0 -> 0.0 + double lifetime; // seconds double r, g, b; double size; + double phase; // sway phase + bool circle; }; class BoardWidget : public Gtk::DrawingArea { @@ -30,23 +35,39 @@ public: // Animation void start_confetti(); bool on_animation_tick(const Glib::RefPtr& clock); - bool process_animation(); // Internal helper + bool process_animation(double dt); // Internal helper private: void on_draw(const Cairo::RefPtr& cr, int width, int height); // Input handlers - void on_click_pressed(int n_press, double x, double y); - void on_right_click_pressed(int n_press, double x, double y); + void on_click_pressed(int /*n_press*/, double x, double y); + void on_right_click_pressed(int /*n_press*/, double x, double y); void on_motion(double x, double y); void on_leave(); // Helpers + struct Palette { + Gdk::RGBA bg; + Gdk::RGBA revealed; + Gdk::RGBA unrevealed_a; + Gdk::RGBA unrevealed_b; + Gdk::RGBA bevel_light; + Gdk::RGBA bevel_dark; + Gdk::RGBA sunken; + Gdk::RGBA sunken_light; + Gdk::RGBA hover; + Gdk::RGBA shadow; + Gdk::RGBA flag_pole; + Gdk::RGBA digits[8]; + }; + + Palette resolve_palette(); std::pair get_cell_at(double x, double y); - void draw_cell(const Cairo::RefPtr& cr, int x, int y, double size, const Cell& cell); - void draw_digit(const Cairo::RefPtr& cr, int number, double x, double y, double size); - void draw_flag(const Cairo::RefPtr& cr, double x, double y, double size); - void draw_bomb(const Cairo::RefPtr& cr, double x, double y, double size, bool exploded); + void draw_cell(const Cairo::RefPtr& cr, int x, int y, double size, const Cell& cell, const Palette& pal); + void draw_digit(const Cairo::RefPtr& cr, int number, double x, double y, double size, const Palette& pal); + void draw_flag(const Cairo::RefPtr& cr, double x, double y, double size, const Palette& pal); + void draw_bomb(const Cairo::RefPtr& cr, double x, double y, double size, bool exploded, const Palette& pal); void draw_particles(const Cairo::RefPtr& cr); std::shared_ptr field_; @@ -57,10 +78,12 @@ private: double offset_y_ = 0; int hover_x_ = -1; int hover_y_ = -1; + bool cursor_pointer_ = false; // Particles std::vector particles_; guint tick_id_ = 0; + gint64 last_frame_time_us_ = 0; // Controllers Glib::RefPtr left_click_controller_; diff --git a/src/leaderboard.cpp b/src/leaderboard.cpp new file mode 100644 index 0000000..e47503a --- /dev/null +++ b/src/leaderboard.cpp @@ -0,0 +1,67 @@ +#include "leaderboard.hpp" +#include +#include +#include +#include + +namespace { +std::string file_path() { + return Glib::build_filename( + Glib::get_user_config_dir(), "nomines", "leaderboard.ini"); +} +} // namespace + +void Leaderboard::load() { + if (loaded_) return; + loaded_ = true; + + // Migrate scores from pre-1.0 builds that stored them under minesweeper/ + auto path = file_path(); + auto legacy = Glib::build_filename( + Glib::get_user_config_dir(), "minesweeper", "leaderboard.ini"); + if (!Glib::file_test(path, Glib::FileTest::EXISTS) && + Glib::file_test(legacy, Glib::FileTest::EXISTS)) { + path = legacy; + } + + try { + auto kf = Glib::KeyFile::create(); + kf->load_from_file(path); + for (const auto& group : kf->get_groups()) { + if (kf->has_key(group, "best")) { + times_[group.raw()] = kf->get_integer(group, "best"); + } + } + } catch (const Glib::Error&) { + // No file yet or unreadable — start with an empty leaderboard + } +} + +bool Leaderboard::record(const std::string& difficulty, int seconds) { + load(); + auto it = times_.find(difficulty); + if (it != times_.end() && it->second <= seconds) return false; + times_[difficulty] = seconds; + save(); + return true; +} + +std::optional Leaderboard::best_time(const std::string& difficulty) const { + auto it = times_.find(difficulty); + if (it == times_.end()) return std::nullopt; + return it->second; +} + +void Leaderboard::save() { + auto kf = Glib::KeyFile::create(); + for (const auto& [difficulty, seconds] : times_) { + kf->set_integer(difficulty, "best", seconds); + } + try { + auto dir = Glib::path_get_dirname(file_path()); + g_mkdir_with_parents(dir.c_str(), 0700); + kf->save_to_file(file_path()); + } catch (const Glib::Error&) { + // Best-effort persistence; a failed save is not fatal + } +} diff --git a/src/leaderboard.hpp b/src/leaderboard.hpp new file mode 100644 index 0000000..9c363db --- /dev/null +++ b/src/leaderboard.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include + +// Persistent best times, stored as a GKeyFile under the user config dir: +// ~/.config/minesweeper/leaderboard.ini (gitignored) +class Leaderboard { +public: + // Records a finished game. Returns true when it is a new best time + // for the difficulty; the file is saved immediately. + bool record(const std::string& difficulty, int seconds); + + // Best time in seconds for the difficulty, if one exists. + std::optional best_time(const std::string& difficulty) const; + +private: + void load(); + void save(); + + std::map times_; + bool loaded_ = false; +}; diff --git a/src/main.cpp b/src/main.cpp index 1886f18..9687cdd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,7 +2,7 @@ #include int main(int argc, char* argv[]) { - auto app = Gtk::Application::create("org.gtkmm.minesweeper"); + auto app = Gtk::Application::create("io.github.bemagri.nomines"); return app->make_window_and_run(argc, argv); } diff --git a/src/minefield.cpp b/src/minefield.cpp index f083937..71ade5c 100644 --- a/src/minefield.cpp +++ b/src/minefield.cpp @@ -10,6 +10,12 @@ const GameDifficulty Minefield::DifficultyExpert = {"Master", 30, 20, 145}; Minefield::Minefield(int cols, int rows, int mines) : cols_(cols), rows_(rows), total_mines_(mines), remaining_flags_(mines) { + if (cols <= 0 || rows <= 0) { + throw std::invalid_argument("Minefield: board dimensions must be positive"); + } + if (mines < 0 || mines >= cols * rows) { + throw std::invalid_argument("Minefield: mine count must be within the board"); + } cells_.resize(cols * rows); } diff --git a/src/minefield.hpp b/src/minefield.hpp index 3f97f16..56f44ea 100644 --- a/src/minefield.hpp +++ b/src/minefield.hpp @@ -5,6 +5,8 @@ #include #include #include +#include +#include struct GameDifficulty { std::string name; diff --git a/src/window.cpp b/src/window.cpp index a77575c..bf73ff1 100644 --- a/src/window.cpp +++ b/src/window.cpp @@ -1,13 +1,64 @@ #include "window.hpp" +#include #include #include +#include +#include +#include + +namespace { +std::string format_time(int secs) { + char buf[16]; + snprintf(buf, sizeof(buf), "%02d:%02d", secs / 60, secs % 60); + return buf; +} +} // namespace MainWindow::MainWindow() { - set_title("Minesweeper"); + set_title("NoMines"); set_default_size(800, 600); setup_header_bar(); setup_board(); + + // Keyboard shortcuts + auto key_controller = Gtk::EventControllerKey::create(); + key_controller->signal_key_pressed().connect([this](guint keyval, guint /*keycode*/, Gdk::ModifierType mods) { + const Gdk::ModifierType none = Gdk::ModifierType(0); + bool plain = (mods & Gdk::ModifierType::SHIFT_MASK) == none && + (mods & Gdk::ModifierType::CONTROL_MASK) == none && + (mods & Gdk::ModifierType::ALT_MASK) == none && + (mods & Gdk::ModifierType::META_MASK) == none; + switch (keyval) { + case GDK_KEY_r: + if (plain) { start_new_game(current_difficulty_); return true; } + break; + case GDK_KEY_n: + if ((mods & Gdk::ModifierType::CONTROL_MASK) != none) { start_new_game(current_difficulty_); return true; } + break; + case GDK_KEY_1: + if (plain) { start_new_game(Minefield::DifficultyEasy); return true; } + break; + case GDK_KEY_2: + if (plain) { start_new_game(Minefield::DifficultyMedium); return true; } + break; + case GDK_KEY_3: + if (plain) { start_new_game(Minefield::DifficultyHard); return true; } + break; + case GDK_KEY_4: + if (plain) { start_new_game(Minefield::DifficultyExpert); return true; } + break; + case GDK_KEY_l: + if (plain) { + if (menu_leaderboard_.get_visible()) menu_leaderboard_.popdown(); + else menu_leaderboard_.popup(); + return true; + } + break; + } + return false; + }, false); + add_controller(key_controller); // Start initial game start_new_game(Minefield::DifficultyEasy); @@ -15,6 +66,7 @@ MainWindow::MainWindow() { MainWindow::~MainWindow() { timer_conn_.disconnect(); + dialog_timeout_conn_.disconnect(); } void MainWindow::setup_header_bar() { @@ -28,43 +80,90 @@ void MainWindow::setup_header_bar() { header_bar_.pack_start(btn_new_game_); // Difficulty Menu - btn_difficulty_.set_label("Difficulty"); - + btn_difficulty_.set_label(Minefield::DifficultyEasy.name); + box_difficulty_.set_orientation(Gtk::Orientation::VERTICAL); box_difficulty_.set_margin(10); box_difficulty_.set_spacing(5); - + + auto group = Gtk::make_managed(Minefield::DifficultyEasy.name); + group->add_css_class("radio"); + group->signal_toggled().connect([this, group]() { + if (!group->get_active()) return; + start_new_game(Minefield::DifficultyEasy); + btn_difficulty_.popdown(); + }); + box_difficulty_.append(*group); + difficulty_buttons_.emplace_back(Minefield::DifficultyEasy, group); + auto add_diff_btn = [&](const GameDifficulty& diff) { - auto* btn = Gtk::make_managed(diff.name); - btn->signal_clicked().connect([this, diff]() { + auto* btn = Gtk::make_managed(diff.name); + btn->add_css_class("radio"); + btn->set_group(*group); + btn->signal_toggled().connect([this, btn, diff]() { + if (!btn->get_active()) return; // Only react when this one is chosen start_new_game(diff); btn_difficulty_.popdown(); }); box_difficulty_.append(*btn); + difficulty_buttons_.emplace_back(diff, btn); }; - add_diff_btn(Minefield::DifficultyEasy); add_diff_btn(Minefield::DifficultyMedium); add_diff_btn(Minefield::DifficultyHard); add_diff_btn(Minefield::DifficultyExpert); + group->set_active(true); btn_difficulty_.set_popover(menu_difficulty_); menu_difficulty_.set_child(box_difficulty_); header_bar_.pack_start(btn_difficulty_); + // Leaderboard + btn_leaderboard_.set_label("Leaderboard"); + box_leaderboard_.set_orientation(Gtk::Orientation::VERTICAL); + box_leaderboard_.set_margin(10); + box_leaderboard_.set_spacing(4); + btn_leaderboard_.set_popover(menu_leaderboard_); + menu_leaderboard_.set_child(box_leaderboard_); + menu_leaderboard_.signal_show().connect(sigc::mem_fun(*this, &MainWindow::refresh_leaderboard)); + header_bar_.pack_start(btn_leaderboard_); + // Status Labels lbl_flags_.set_margin_end(10); lbl_time_.set_margin_end(10); - // Make them monospaced and bold - lbl_flags_.set_markup("🚩 0"); - lbl_time_.set_markup("⏱️ 00:00"); + // Monospaced and bold so the numbers don't jitter + lbl_flags_.set_markup("Flags: 0"); + lbl_time_.set_markup("Time: 00:00"); header_bar_.pack_end(lbl_time_); header_bar_.pack_end(lbl_flags_); } +void MainWindow::refresh_leaderboard() { + while (auto* child = box_leaderboard_.get_first_child()) { + box_leaderboard_.remove(*child); + } + + auto* title = Gtk::make_managed(); + title->set_markup("Best Times"); + box_leaderboard_.append(*title); + + for (const auto& diff : {Minefield::DifficultyEasy, Minefield::DifficultyMedium, + Minefield::DifficultyHard, Minefield::DifficultyExpert}) { + auto* row = Gtk::make_managed(); + row->set_xalign(0.0f); + auto best = leaderboard_.best_time(diff.name); + std::string time_str = best ? format_time(*best) : "—"; + row->set_text(diff.name + ": " + time_str); + box_leaderboard_.append(*row); + } +} + void MainWindow::setup_board() { + // Minimum size so the largest board (30x20, Master) fits at the 16px + // cell floor even when tiled very small. + board_widget_.set_size_request(520, 360); set_child(board_widget_); board_widget_.signal_state_changed.connect(sigc::mem_fun(*this, &MainWindow::on_game_state_changed)); } @@ -72,9 +171,24 @@ void MainWindow::setup_board() { void MainWindow::start_new_game(const GameDifficulty& difficulty) { current_difficulty_ = difficulty; + // Reflect the active difficulty in the menu button and popover + btn_difficulty_.set_label(difficulty.name); + for (const auto& [diff, btn] : difficulty_buttons_) { + btn->set_active(diff.name == difficulty.name); + } + + // Cancel any pending game-over dialog from the finished game + dialog_timeout_conn_.disconnect(); + minefield_ = std::make_shared(difficulty.cols, difficulty.rows, difficulty.mines); board_widget_.set_minefield(minefield_); + // Size the window to fit the board at a comfortable cell size. + // Ignored once the user has resized the window (or under a tiling WM). + int w = difficulty.cols * 36 + 160; + int h = difficulty.rows * 36 + 200; // Extra height for the header bar + set_default_size(std::clamp(w, 480, 1280), std::clamp(h, 420, 1000)); + // Reset timer timer_conn_.disconnect(); timer_conn_ = Glib::signal_timeout().connect(sigc::mem_fun(*this, &MainWindow::on_timer_tick), 100); @@ -86,13 +200,19 @@ void MainWindow::on_game_state_changed() { if (!minefield_) return; // Update Flags - lbl_flags_.set_markup("🚩 " + std::to_string(minefield_->remaining_flags()) + ""); + lbl_flags_.set_markup("Flags: " + std::to_string(minefield_->remaining_flags()) + ""); // Check Game Over GameState state = minefield_->state(); + bool new_best = false; + int secs = 0; if (state == GameState::Won || state == GameState::Lost) { timer_conn_.disconnect(); - show_game_over_dialog(state == GameState::Won); + if (state == GameState::Won) { + secs = std::chrono::duration_cast(minefield_->get_elapsed_time()).count(); + new_best = leaderboard_.record(current_difficulty_.name, secs); + } + show_game_over_dialog(state == GameState::Won, new_best, secs); } // Update timer immediately to reflect end time if stopped @@ -105,33 +225,47 @@ bool MainWindow::on_timer_tick() { auto elapsed = minefield_->get_elapsed_time(); auto secs = std::chrono::duration_cast(elapsed).count(); - int mm = secs / 60; - int ss = secs % 60; - - char buf[32]; - snprintf(buf, sizeof(buf), "⏱️ %02d:%02d", mm, ss); - lbl_time_.set_markup(buf); + lbl_time_.set_markup("Time: " + format_time(secs) + ""); return true; // Keep calling } -void MainWindow::show_game_over_dialog(bool won) { - auto dialog = Gtk::make_managed( - *this, - won ? "Congratulations!" : "Game Over", - false, - Gtk::MessageType::INFO, - Gtk::ButtonsType::OK, - true - ); - - dialog->set_secondary_text(won ? "You cleared the minefield!" : "Better luck next time."); - dialog->signal_response().connect([dialog, this](int response) { - dialog->hide(); - if (response == Gtk::ResponseType::OK) { - // Optional: restart? +void MainWindow::show_game_over_dialog(bool won, bool new_best, int seconds) { + // Delay so the win animation/board state is visible before the modal + // dialog covers it. + if (dialog_timeout_conn_.connected()) return; + dialog_timeout_conn_ = Glib::signal_timeout().connect([this, won, new_best, seconds]() { + dialog_timeout_conn_.disconnect(); + + // A new game may have started while the dialog was pending + if (!minefield_ || minefield_->state() == GameState::Ready) return false; + + // Reuse a single dialog instance: creating a new make_managed dialog + // per game over leaks (it's never added to a container, so it's never + // freed). + if (!game_over_dialog_) { + game_over_dialog_ = Gtk::make_managed( + *this, "", false, + Gtk::MessageType::INFO, + Gtk::ButtonsType::OK, + true + ); + game_over_dialog_->signal_response().connect([this](int response) { + game_over_dialog_->hide(); + if (response == Gtk::ResponseType::OK) { + start_new_game(current_difficulty_); + } + }); } - }); - - dialog->show(); + + game_over_dialog_->set_title(won ? "Congratulations!" : "Game Over"); + game_over_dialog_->set_message(won ? "Congratulations!" : "Game Over"); + std::string secondary = won + ? "You cleared the minefield in " + format_time(seconds) + + (new_best ? " — new best time!" : ".") + : "Better luck next time."; + game_over_dialog_->set_secondary_text(secondary); + game_over_dialog_->show(); + return false; // One-shot + }, 1500); } diff --git a/src/window.hpp b/src/window.hpp index e1a0a3c..f38d0af 100644 --- a/src/window.hpp +++ b/src/window.hpp @@ -2,8 +2,11 @@ #include #include +#include +#include #include "minefield.hpp" #include "board_widget.hpp" +#include "leaderboard.hpp" class MainWindow : public Gtk::ApplicationWindow { public: @@ -14,12 +17,13 @@ private: // UI Setup void setup_header_bar(); void setup_board(); + void refresh_leaderboard(); // Actions void start_new_game(const GameDifficulty& difficulty); void on_game_state_changed(); bool on_timer_tick(); - void show_game_over_dialog(bool won); + void show_game_over_dialog(bool won, bool new_best, int seconds); // Widgets Gtk::HeaderBar header_bar_; @@ -27,14 +31,23 @@ private: Gtk::MenuButton btn_difficulty_; Gtk::Popover menu_difficulty_; Gtk::Box box_difficulty_; // Content for popover - + std::vector> difficulty_buttons_; + + Gtk::MenuButton btn_leaderboard_; + Gtk::Popover menu_leaderboard_; + Gtk::Box box_leaderboard_; // Content for popover + Gtk::Label lbl_time_; Gtk::Label lbl_flags_; BoardWidget board_widget_; + + Leaderboard leaderboard_; // Game State std::shared_ptr minefield_; sigc::connection timer_conn_; + sigc::connection dialog_timeout_conn_; GameDifficulty current_difficulty_ = Minefield::DifficultyEasy; + Gtk::MessageDialog* game_over_dialog_ = nullptr; };