Compare commits

10 Commits
Author SHA1 Message Date
bernardo 0eee76c333 Pin flatpak source to the final v1.0.0 state 2026-08-01 17:21:52 +01:00
bernardo 06b692b140 Leaderboard: two-column grid with right-aligned LCD time panels
Difficulty names left-aligned, times right-aligned in compact
status-panel chips so all columns line up; separator under the title.
2026-08-01 17:11:48 +01:00
bernardo 62a09e4261 Leaderboard: clear button and hygiene for migrated legacy data
A scratch test earlier in development wrote a fake 42s Beginner record into
the legacy ~/.config/minesweeper/leaderboard.ini, which the migration imported;
as a stored best it can never be beaten by slower real games, so the popover
showed a lower time than actually played.

- Add a 'Clear scores' button to the leaderboard popover
- Delete the legacy file after a successful migration so stale data can't
  be re-imported
2026-08-01 17:09:08 +01:00
bernardo 6c69556b25 KABOOM!: replace translucent panel with a layered drop shadow 2026-08-01 16:43:15 +01:00
bernardo cee916224a Replace loss dialog with KABOOM! overlay; bevel the New Game button
- Losing no longer pops a dialog: the board draws a tilted KABOOM! banner
  (theme-independent, auto-scaling, dark translucent panel) over the revealed
  mines; restart via the New Game button or R/Ctrl+N
- Win dialog unchanged (time + new-record note, delayed for the confetti)
- New Game button gets an explicit theme-aware bevel (gradient, border,
  hover/active) so it reads as a button even with themes that flatten widgets
2026-08-01 16:40:55 +01:00
bernardo 9a9d9f3fea Move flags/timer out of the header bar into a status bar above the grid
Header bar now holds only Difficulty + Leaderboard. A status bar above the
board shows the flag counter (left), the New Game button (center) and the
timer (right) as LCD-style panels styled with theme-aware CSS.
2026-08-01 16:30:46 +01:00
bernardo 08ae7cd1ef Pin flatpak source commit to the popover fix 2026-08-01 16:00:46 +01:00
bernardo 49651fd42d Fix transparent popover backgrounds
Some GTK themes (and newer GTK versions) leave popover surfaces
transparent. Add the canonical 'menu' class to both popovers and a
theme-aware CSS override so the difficulty/leaderboard menus always
draw an opaque background.
2026-08-01 16:00:33 +01:00
bernardo 97565e415d Add flatpak build plumbing
- io.github.bemagri.nomines.yml: builds libsigc++/glibmm/cairomm/pangomm/gtkmm
  from tarballs against the GNOME 46 runtime, then the app from the v1.0.0 tag
- Icon: app-id-named PNGs (128/512) for flatpak export, svg kept for native
  installs; desktop file Icon updated
- README/AGENTS.md: flatpak build instructions and version traps
2026-08-01 15:34:28 +01:00
bernardo 2dbf64a2e8 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
2026-08-01 14:06:43 +01:00
22 changed files with 1036 additions and 168 deletions
+9
View File
@@ -1,10 +1,14 @@
# Build directories # Build directories
build/ build/
build-*/
_build/ _build/
bin/ bin/
lib/ lib/
obj/ obj/
# Flatpak
.flatpak-builder/
# Prerequisites # Prerequisites
*.d *.d
@@ -37,6 +41,7 @@ obj/
*.exe *.exe
*.out *.out
*.app *.app
nomines
minesweeper minesweeper
# CMake and build system # CMake and build system
@@ -141,8 +146,12 @@ $RECYCLE.BIN/
*.gresource *.gresource
# Leaderboard and config files # Leaderboard and config files
.config/nomines/
.config/minesweeper/ .config/minesweeper/
#MacOS files #MacOS files
.cache/ .cache/
.DS_Store .DS_Store
# direnv
.direnv/
+43
View File
@@ -0,0 +1,43 @@
# 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 + leaderboard popovers, radio-style `CheckButton`s) and a status bar above the grid (LCD-style flags/timer panels via the `status-panel` CSS class, New Game button centered); owns the `shared_ptr<Minefield>` and swaps it on new game. Keyboard shortcuts via `Gtk::EventControllerKey` (R/Ctrl+N new game, 1-4 difficulties, L leaderboard). App-wide CSS (popover backgrounds, status bar) lives in a `Gtk::CssProvider` in the constructor.
- `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<R(T...)>::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<MainWindow>`.
## Release
- Version `1.0.0` is stamped in FOUR places: `meson.build`, `flake.nix`, `default.nix`, and the `<releases>` 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.
- Flatpak: `io.github.bemagri.nomines.yml` builds libsigc++/glibmm/cairomm/pangomm/gtkmm from tarballs against the GNOME 46 runtime (matches the gtkmm 4.14/glib 2.80 stack we develop against), then the app from the `v1.0.0` git tag. The pinned `commit:` must match the pushed tag. The leaderboard needs the `--filesystem=xdg-config/nomines:create` finish-arg — don't drop it. Local builds land in `.flatpak-builder/` (gitignored); `flatpak-builder --user --install --force-clean build-flatpak io.github.bemagri.nomines.yml`.
- Flatpak module version traps (all verified by building): gtkmm 4.14 needs pangomm >= 2.50 (which still installs `pangomm-2.48.pc`; 2.48.x is too old), and cairomm tarballs live on `cairographics.org/releases`, not download.gnome.org. `appstream-compose: false` is set in the manifest because flatpak-builder's compose fetches the (still-404) metainfo screenshot URL — remove it once the repo is public. Flatpak's icon validation can't decode SVGs, so PNG icons (128/512) are installed under the app-id name and the svg is cleanup-removed; keep that convention.
## 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.
+36 -14
View File
@@ -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) ![Game Screenshot](screenshots/screen1.png)
## Features ## Features
- Multiple difficulty levels: Beginner, Intermediate, Expert, and Master - Multiple difficulty levels: Beginner, Intermediate, Expert, and Master
- Custom board size and mine count configuration - Persistent leaderboard tracking the best time per difficulty
- Win/lose animations - Follows the system light/dark theme
- Persistent leaderboard to track best scores - Win/lose animations, including a confetti win celebration
- Modern GTK4 user interface - 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 - Modern C++20 implementation
## Building from Source ## Building from Source
@@ -52,8 +54,8 @@ sudo pacman -S base-devel meson ninja gtkmm-4.0 libsigc++-3.0
Clone the repository: Clone the repository:
```bash ```bash
git clone https://github.com/username/minesweeper.git git clone https://github.com/bemagri/nomines
cd minesweeper cd nomines
``` ```
Configure and build with Meson: Configure and build with Meson:
@@ -66,7 +68,7 @@ meson compile -C build
Run the game: Run the game:
```bash ```bash
./build/minesweeper ./build/nomines
``` ```
### Installing ### Installing
@@ -82,20 +84,40 @@ meson install -C build
If you use the Nix package manager, you can build and run the application with: If you use the Nix package manager, you can build and run the application with:
```bash ```bash
nix-build nix build
./result/bin/minesweeper ./result/bin/nomines
``` ```
Or install it with: Or run it directly from the flake:
```bash ```bash
nix-env -i -f . nix run
``` ```
The Nix flake also provides a development shell with all dependencies:
```bash
nix develop
```
## Building a Flatpak
NoMines ships a Flatpak manifest (`io.github.bemagri.nomines.yml`) that builds
the gtkmm stack (libsigc++, glibmm, gtkmm) against the GNOME 46 runtime:
```bash
flatpak install -y flathub org.gnome.Platform//46 org.gnome.Sdk//46
flatpak-builder --user --install --force-clean build-flatpak io.github.bemagri.nomines.yml
flatpak run io.github.bemagri.nomines
```
Note: the manifest's `commit` must match the pushed `v1.0.0` tag (update it if
the history is rebased before pushing).
## Contributing ## Contributing
Contributions are welcome! Please feel free to submit a Pull Request. Contributions are welcome! Please feel free to submit a Pull Request.
## License ## 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.
+7 -7
View File
@@ -11,15 +11,15 @@
}: }:
stdenv.mkDerivation rec { stdenv.mkDerivation rec {
pname = "minesweeper"; pname = "nomines";
version = "0.2.0"; version = "1.0.0";
src = ./.; src = ./.;
# If you want to use fetchFromGitHub instead: # If you want to use fetchFromGitHub instead:
# src = fetchFromGitHub { # src = fetchFromGitHub {
# owner = "username"; # owner = "bemagri";
# repo = "minesweeper"; # repo = "nomines";
# rev = "v${version}"; # rev = "v${version}";
# hash = "sha256-0000000000000000000000000000000000000000000="; # hash = "sha256-0000000000000000000000000000000000000000000=";
# }; # };
@@ -39,10 +39,10 @@ stdenv.mkDerivation rec {
meta = with lib; { meta = with lib; {
description = "Modern Minesweeper game with GTK4 interface"; description = "Modern Minesweeper game with GTK4 interface";
homepage = "https://github.com/username/minesweeper"; homepage = "https://github.com/bemagri/nomines";
license = licenses.mit; license = licenses.gpl3Plus;
platforms = platforms.linux; platforms = platforms.linux;
maintainers = with maintainers; [ /* your name here */ ]; maintainers = with maintainers; [ /* your name here */ ];
mainProgram = "minesweeper"; mainProgram = "nomines";
}; };
} }
+6 -6
View File
@@ -18,8 +18,8 @@
{ {
# Main package # Main package
packages.default = pkgs.stdenv.mkDerivation rec { packages.default = pkgs.stdenv.mkDerivation rec {
pname = "minesweeper"; pname = "nomines";
version = "0.1"; version = "1.0.0";
src = self; src = self;
strictDeps = true; strictDeps = true;
@@ -53,18 +53,18 @@
''; '';
meta = with pkgs.lib; { meta = with pkgs.lib; {
description = "A simple GTKmm4 Minesweeper game"; description = "NoMines - a modern GTK4 Minesweeper game";
homepage = "https://example.org/minesweeper"; homepage = "https://github.com/bemagri/nomines";
license = licenses.gpl3Plus; license = licenses.gpl3Plus;
platforms = platforms.linux; platforms = platforms.linux;
mainProgram = "minesweeper"; mainProgram = "nomines";
}; };
}; };
# nix run support # nix run support
apps.${system}.default = { apps.${system}.default = {
type = "app"; type = "app";
program = "${self.packages.${system}.default}/bin/minesweeper"; program = "${self.packages.${system}.default}/bin/nomines";
}; };
# Dev shell for hacking # Dev shell for hacking
+112
View File
@@ -0,0 +1,112 @@
# Flatpak manifest for NoMines.
#
# The GNOME runtime pins the exact gtk4/glib versions the gtkmm stack is
# built against: 46 = gtk 4.14 + glib 2.80, matching gtkmm 4.14/glibmm 2.80.
#
# Build locally:
# flatpak install -y flathub org.gnome.Platform//46 org.gnome.Sdk//46
# flatpak-builder --user --install --force-clean build-flatpak io.github.bemagri.nomines.yml
# flatpak run io.github.bemagri.nomines
app-id: io.github.bemagri.nomines
runtime: org.gnome.Platform
runtime-version: '46'
sdk: org.gnome.Sdk
command: nomines
# flatpak-builder's appstreamcli compose fetches the metainfo screenshot URL;
# it 404s until the repo is published. Remove this line once the repo is
# public (Flathub CI runs its own compose/validation anyway).
appstream-compose: false
finish-args:
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --device=dri
# Leaderboard persistence (~/.config/nomines/leaderboard.ini)
- --filesystem=xdg-config/nomines:create
build-options:
no-debuginfo: true
cleanup:
- /include
- /lib/pkgconfig
- /lib/cmake
- /lib/girepository-1.0
- /lib/*.a
- /lib/*.la
- /share/aclocal
- /share/doc
- /share/gir-1.0
- /share/gtk-doc
- /share/pkgconfig
# flatpak's icon validation can't decode SVGs; the PNGs suffice
- /share/icons/hicolor/scalable/apps/*.svg
modules:
- name: libsigc++
buildsystem: meson
config-opts:
- -Dbuild-tests=false
- -Dbuild-examples=false
sources:
- type: archive
url: https://download.gnome.org/sources/libsigc++/3.6/libsigc++-3.6.0.tar.xz
sha256: c3d23b37dfd6e39f2e09f091b77b1541fbfa17c4f0b6bf5c89baef7229080e17
- name: glibmm
buildsystem: meson
config-opts:
- -Dmaintainer-mode=false
- -Dbuild-documentation=false
- -Dbuild-examples=false
sources:
- type: archive
url: https://download.gnome.org/sources/glibmm/2.80/glibmm-2.80.0.tar.xz
sha256: 539b0a29e15a96676c4f0594541250566c5ca44da5d4d87a3732fa2d07909e4a
- name: cairomm
buildsystem: meson
config-opts:
- -Dmaintainer-mode=false
- -Dbuild-documentation=false
- -Dbuild-examples=false
- -Dbuild-tests=false
sources:
- type: archive
url: https://www.cairographics.org/releases/cairomm-1.16.2.tar.xz
sha256: 6a63bf98a97dda2b0f55e34d1b5f3fb909ef8b70f9b8d382cb1ff3978e7dc13f
- name: pangomm
buildsystem: meson
config-opts:
- -Dmaintainer-mode=false
- -Dbuild-documentation=false
sources:
- type: archive
# gtkmm 4.14 needs pangomm >= 2.50.0 (which still installs the
# pangomm-2.48.pc name). 2.48.x is too old.
url: https://download.gnome.org/sources/pangomm/2.50/pangomm-2.50.2.tar.xz
sha256: 1bc5ab4ea3280442580d68318226dab36ceedfc3288f9d83711cf7cfab50a9fb
- name: gtkmm
buildsystem: meson
config-opts:
- -Dmaintainer-mode=false
- -Dbuild-documentation=false
- -Dbuild-demos=false
- -Dbuild-tests=false
sources:
- type: archive
url: https://download.gnome.org/sources/gtkmm/4.14/gtkmm-4.14.0.tar.xz
sha256: 9350a0444b744ca3dc69586ebd1b6707520922b6d9f4f232103ce603a271ecda
- name: nomines
buildsystem: meson
sources:
- type: git
url: https://github.com/bemagri/nomines
tag: v1.0.0
commit: 06b692b140ea2057bfc5b8ebf45f370ed64d7a51
+40 -7
View File
@@ -1,5 +1,5 @@
project('minesweeper', 'cpp', project('nomines', 'cpp',
version : '0.2.0', version : '1.0.0',
default_options : ['warning_level=3', 'cpp_std=c++20']) default_options : ['warning_level=3', 'cpp_std=c++20'])
gnome = import('gnome') gnome = import('gnome')
@@ -25,24 +25,57 @@ src = [
'src/minefield.cpp', 'src/minefield.cpp',
'src/board_widget.hpp', 'src/board_widget.hpp',
'src/board_widget.cpp', 'src/board_widget.cpp',
'src/leaderboard.hpp',
'src/leaderboard.cpp',
res res
] ]
# Executable # Executable
executable('minesweeper', executable('nomines',
src, src,
dependencies : deps, dependencies : deps,
install : true install : true
) )
# Install icons # Install icon (named after the app id so flatpak export picks it up;
# PNG sizes are required by flatpak/flathub icon validation)
install_data( install_data(
'resources/minesweeper.svg', 'resources/nomines.svg',
install_dir: join_paths(get_option('datadir'), 'icons/hicolor/scalable/apps') install_dir: join_paths(get_option('datadir'), 'icons/hicolor/scalable/apps'),
rename: 'io.github.bemagri.nomines.svg'
)
install_data(
'resources/nomines-128.png',
install_dir: join_paths(get_option('datadir'), 'icons/hicolor/128x128/apps'),
rename: 'io.github.bemagri.nomines.png'
)
install_data(
'resources/nomines-512.png',
install_dir: join_paths(get_option('datadir'), 'icons/hicolor/512x512/apps'),
rename: 'io.github.bemagri.nomines.png'
) )
# Install desktop file # Install desktop file
install_data( install_data(
'resources/org.gtkmm.minesweeper.desktop', 'resources/io.github.bemagri.nomines.desktop',
install_dir: join_paths(get_option('datadir'), 'applications') 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
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<gresources> <gresources>
<gresource prefix="/minesweeper"> <gresource prefix="/io/github/bemagri/nomines">
<file alias="bomb-solid">bomb-solid.svg</file> <file alias="bomb-solid">bomb-solid.svg</file>
<file alias="explosion-solid">explosion-solid.svg</file> <file alias="explosion-solid">explosion-solid.svg</file>
<file alias="flag-regular">flag-regular.svg</file> <file alias="flag-regular">flag-regular.svg</file>
@@ -1,10 +1,10 @@
[Desktop Entry] [Desktop Entry]
Name=MineSweeper Name=NoMines
GenericName=Minesweeper Game GenericName=Minesweeper Game
Comment=Classic minesweeper game with modern GTK UI Comment=Classic minesweeper game with modern GTK UI
Keywords=game;puzzle;mine; Keywords=game;puzzle;mine;
Exec=minesweeper Exec=nomines
Icon=minesweeper Icon=io.github.bemagri.nomines
Terminal=false Terminal=false
Type=Application Type=Application
Categories=Game;LogicGame; Categories=Game;LogicGame;
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>io.github.bemagri.nomines</id>
<name>NoMines</name>
<summary>A classic minesweeper game with a modern GTK4 interface</summary>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<developer id="io.github.bemagri">
<name>Bernardo Magri</name>
</developer>
<description>
<p>NoMines is a modern, theme-aware implementation of the classic Minesweeper game, built with GTK4 and gtkmm.</p>
<p>Features:</p>
<ul>
<li>Four difficulty levels, from Beginner to Master</li>
<li>Persistent leaderboard of best times per difficulty</li>
<li>Follows the system light/dark theme</li>
<li>Keyboard shortcuts, right-click chording, and a confetti win animation</li>
</ul>
</description>
<launchable type="desktop-id">io.github.bemagri.nomines.desktop</launchable>
<url type="homepage">https://github.com/bemagri/nomines</url>
<url type="bugtracker">https://github.com/bemagri/nomines/issues</url>
<screenshots>
<screenshot type="default">
<caption>NoMines in action on a Beginner board</caption>
<image type="source">https://raw.githubusercontent.com/bemagri/nomines/main/screenshots/screen1.png</image>
</screenshot>
</screenshots>
<releases>
<release version="1.0.0" date="2026-08-01">
<description>
<p>First public release.</p>
<ul>
<li>Four difficulty presets with per-difficulty default window sizing</li>
<li>Persistent best-time leaderboard</li>
<li>Theme-aware board that follows the system light/dark preference</li>
<li>Keyboard shortcuts (R, Ctrl+N, 1-4, L) and right-click chording</li>
<li>Confetti win animation and game-over dialog with new-record notice</li>
</ul>
</description>
</release>
</releases>
<content_rating type="oars-1.1">
<content_attribute id="violence-cartoon">none</content_attribute>
<content_attribute id="social-chat">none</content_attribute>
<content_attribute id="social-info">none</content_attribute>
<content_attribute id="social-audio">none</content_attribute>
<content_attribute id="social-location">none</content_attribute>
<content_attribute id="social-contacts">none</content_attribute>
</content_rating>
</component>
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+304 -66
View File
@@ -1,7 +1,55 @@
#include "board_widget.hpp" #include "board_widget.hpp"
#include <gtkmm/stylecontext.h>
#include <gdkmm/frameclock.h>
#include <iostream> #include <iostream>
#include <cmath> #include <cmath>
#include <random> #include <random>
#include <string>
#include <utility>
#include <algorithm>
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<Cairo::Context>& cr, const Gdk::RGBA& c) {
cr->set_source_rgba(c.get_red(), c.get_green(), c.get_blue(), c.get_alpha());
}
} // namespace
BoardWidget::BoardWidget() { BoardWidget::BoardWidget() {
set_hexpand(true); set_hexpand(true);
@@ -37,6 +85,8 @@ BoardWidget::~BoardWidget() {
void BoardWidget::set_minefield(std::shared_ptr<Minefield> field) { void BoardWidget::set_minefield(std::shared_ptr<Minefield> field) {
field_ = field; field_ = field;
particles_.clear(); particles_.clear();
hover_x_ = -1;
hover_y_ = -1;
if (tick_id_ > 0) { if (tick_id_ > 0) {
remove_tick_callback(tick_id_); remove_tick_callback(tick_id_);
tick_id_ = 0; tick_id_ = 0;
@@ -46,25 +96,45 @@ void BoardWidget::set_minefield(std::shared_ptr<Minefield> field) {
void BoardWidget::start_confetti() { void BoardWidget::start_confetti() {
particles_.clear(); particles_.clear();
last_frame_time_us_ = 0;
std::random_device rd; std::random_device rd;
std::mt19937 gen(rd()); std::mt19937 gen(rd());
std::uniform_real_distribution<> pos_dist(0.0, 1.0); // Relative to window std::uniform_real_distribution<> pos_x(0.0, 1.0);
std::uniform_real_distribution<> vel_dist(-2.0, 2.0); std::uniform_real_distribution<> pos_y(0.0, 0.4);
std::uniform_real_distribution<> color_dist(0.0, 1.0); std::uniform_real_distribution<> vel_x(-250.0, 250.0);
std::uniform_real_distribution<> size_dist(3.0, 8.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 w = get_width();
int h = get_height(); 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({ particles_.push_back({
pos_dist(gen) * w, pos_x(gen) * w,
pos_dist(gen) * h * 0.5, // Start from top half pos_y(gen) * h,
vel_dist(gen), vel_x(gen),
vel_dist(gen) + 2.0, // Fall down vel_y(gen),
1.0, // Life angle(gen),
color_dist(gen), color_dist(gen), color_dist(gen), vrot(gen),
size_dist(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<Gdk::FrameClock>& clock) { bool BoardWidget::on_animation_tick(const Glib::RefPtr<Gdk::FrameClock>& 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() { bool BoardWidget::process_animation(double dt) {
if (particles_.empty()) return false; if (particles_.empty()) {
tick_id_ = 0;
bool alive = false; return 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;
} }
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(); queue_draw();
return alive; return !particles_.empty();
} }
std::pair<int, int> BoardWidget::get_cell_at(double x, double y) { std::pair<int, int> 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 // Use stored metrics from last draw
if (x < offset_x_ || x >= offset_x_ + field_->cols() * cell_size_ || if (x < offset_x_ || x >= offset_x_ + field_->cols() * cell_size_ ||
@@ -110,7 +196,7 @@ std::pair<int, int> BoardWidget::get_cell_at(double x, double y) {
return {cx, cy}; 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; if (!field_) return;
auto [cx, cy] = get_cell_at(x, y); auto [cx, cy] = get_cell_at(x, y);
if (cx == -1) return; 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; if (!field_) return;
auto [cx, cy] = get_cell_at(x, y); auto [cx, cy] = get_cell_at(x, y);
if (cx == -1) return; 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(); signal_state_changed.emit();
if (field_->state() == GameState::Won) {
start_confetti();
}
queue_draw(); queue_draw();
} }
} }
@@ -149,17 +247,75 @@ void BoardWidget::on_motion(double x, double y) {
hover_y_ = cy; hover_y_ = cy;
queue_draw(); 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() { void BoardWidget::on_leave() {
hover_x_ = -1; hover_x_ = -1;
hover_y_ = -1; hover_y_ = -1;
if (cursor_pointer_) {
cursor_pointer_ = false;
set_cursor(Glib::ustring{});
}
queue_draw(); 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<Cairo::Context>& cr, int width, int height) { void BoardWidget::on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height) {
Palette pal = resolve_palette();
// Background // Background
cr->set_source_rgb(0.95, 0.95, 0.95); set_source(cr, pal.bg);
cr->paint(); cr->paint();
if (field_) { if (field_) {
@@ -184,13 +340,13 @@ void BoardWidget::on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, in
cr->translate(offset_x_, offset_y_); cr->translate(offset_x_, offset_y_);
// Shadow for the board // 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->rectangle(5, 5, board_w_px, board_h_px);
cr->fill(); cr->fill();
for (int y = 0; y < rows; ++y) { for (int y = 0; y < rows; ++y) {
for (int x = 0; x < cols; ++x) { 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(); cr->restore();
@@ -199,63 +355,132 @@ void BoardWidget::on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, in
if (!particles_.empty()) { if (!particles_.empty()) {
draw_particles(cr); draw_particles(cr);
} }
if (field_ && field_->state() == GameState::Lost) {
draw_game_over_overlay(cr, width, height);
}
} }
void BoardWidget::draw_cell(const Cairo::RefPtr<Cairo::Context>& cr, int x, int y, double size, const Cell& cell) { void BoardWidget::draw_game_over_overlay(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height) {
const char* msg = "KABOOM!";
cr->save();
// Scale the font to fit both dimensions
double font_size = std::clamp(std::min(width, height) * 0.3, 24.0, 120.0);
cr->select_font_face("Sans", Cairo::ToyFontFace::Slant::NORMAL, Cairo::ToyFontFace::Weight::BOLD);
cr->set_font_size(font_size);
Cairo::TextExtents ext;
cr->get_text_extents(msg, ext);
if (ext.width > width * 0.8) {
font_size *= (width * 0.8) / ext.width;
cr->set_font_size(font_size);
cr->get_text_extents(msg, ext);
}
double cx = (width - ext.width) / 2.0 - ext.x_bearing;
double cy = (height - ext.height) / 2.0 - ext.y_bearing;
// Slight tilt for drama
cr->translate(width / 2.0, height / 2.0);
cr->rotate(-0.06);
cr->translate(-width / 2.0, -height / 2.0);
// Layered drop shadow (fakes softness without blurring), then the text
cr->set_source_rgba(0.0, 0.0, 0.0, 0.35);
cr->move_to(cx + 4, cy + 4);
cr->show_text(msg);
cr->set_source_rgba(0.0, 0.0, 0.0, 0.3);
cr->move_to(cx + 3, cy + 3);
cr->show_text(msg);
cr->set_source_rgba(0.0, 0.0, 0.0, 0.2);
cr->move_to(cx + 2, cy + 2);
cr->show_text(msg);
cr->set_source_rgba(0.0, 0.0, 0.0, 0.1);
cr->move_to(cx + 1, cy + 1);
cr->show_text(msg);
cr->set_source_rgb(1.0, 0.42, 0.08); // Explosive orange
cr->move_to(cx, cy);
cr->show_text(msg);
cr->restore();
}
void BoardWidget::draw_cell(const Cairo::RefPtr<Cairo::Context>& cr, int x, int y, double size, const Cell& cell, const Palette& pal) {
double px = x * size; double px = x * size;
double py = y * 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 // Base shape
cr->rectangle(px, py, size, size); 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 (revealed) {
if (cell.is_exploded) { if (cell.is_exploded) {
cr->set_source_rgb(0.9, 0.3, 0.3); // Red background cr->set_source_rgb(0.9, 0.3, 0.3); // Red background
cr->fill();
} else { } 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 { } else {
if (is_hover) { if (is_hover) {
cr->set_source_rgb(0.75, 0.85, 1.0); // Hover light blue set_source(cr, pal.hover); // Hover highlight
} else { } else {
// Gradient for unrevealed cells // Gradient for unrevealed cells
auto pat = Cairo::LinearGradient::create(px, py, px + size, py + size); 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(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, 0.7, 0.7, 0.7, 1); 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); cr->set_source(pat);
} }
} cr->fill();
if (!revealed && !is_hover) cr->fill();
else cr->fill_preserve();
if (revealed || is_hover) { // Raised bevel: light top/left, dark bottom/right
// Border set_source(cr, pal.bevel_light);
cr->set_source_rgb(0.6, 0.6, 0.6); cr->rectangle(px, py, size, lw);
cr->set_line_width(1.0); cr->fill();
cr->stroke(); 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 // Content
if (cell.is_flagged) { if (cell.is_flagged) {
draw_flag(cr, px, py, size); draw_flag(cr, px, py, size, pal);
} else if (revealed) { } else if (revealed) {
if (cell.is_bomb) { 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) { } 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<Cairo::Context>& cr, double x, double y, double size) { void BoardWidget::draw_flag(const Cairo::RefPtr<Cairo::Context>& cr, double x, double y, double size, const Palette& pal) {
double cx = x + size / 2.0; double cx = x + size / 2.0;
double cy = y + size / 2.0; double cy = y + size / 2.0;
// Pole // Pole
cr->set_source_rgb(0.2, 0.2, 0.2); set_source(cr, pal.flag_pole);
cr->set_line_width(2.0); cr->set_line_width(2.0);
cr->move_to(cx - size * 0.1, cy + size * 0.3); cr->move_to(cx - size * 0.1, cy + size * 0.3);
cr->line_to(cx - size * 0.1, cy - size * 0.3); cr->line_to(cx - size * 0.1, cy - size * 0.3);
@@ -268,9 +493,16 @@ void BoardWidget::draw_flag(const Cairo::RefPtr<Cairo::Context>& cr, double x, d
cr->line_to(cx - size * 0.1, cy); cr->line_to(cx - size * 0.1, cy);
cr->close_path(); cr->close_path();
cr->fill(); 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<Cairo::Context>& cr, double x, double y, double size, bool exploded) { void BoardWidget::draw_bomb(const Cairo::RefPtr<Cairo::Context>& cr, double x, double y, double size, bool exploded, const Palette& pal) {
double cx = x + size / 2.0; double cx = x + size / 2.0;
double cy = y + size / 2.0; double cy = y + size / 2.0;
double r = size * 0.25; double r = size * 0.25;
@@ -283,8 +515,13 @@ void BoardWidget::draw_bomb(const Cairo::RefPtr<Cairo::Context>& cr, double x, d
cr->arc(cx, cy, r, 0, 2 * M_PI); cr->arc(cx, cy, r, 0, 2 * M_PI);
cr->fill(); 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 // 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->set_line_width(2.0);
cr->move_to(cx, cy - r); cr->move_to(cx, cy - r);
cr->line_to(cx, cy - r - size * 0.1); cr->line_to(cx, cy - r - size * 0.1);
@@ -298,16 +535,9 @@ void BoardWidget::draw_bomb(const Cairo::RefPtr<Cairo::Context>& cr, double x, d
} }
} }
void BoardWidget::draw_digit(const Cairo::RefPtr<Cairo::Context>& cr, int number, double x, double y, double size) { void BoardWidget::draw_digit(const Cairo::RefPtr<Cairo::Context>& cr, int number, double x, double y, double size, const Palette& pal) {
switch (number) { if (number >= 1 && number <= 8) {
case 1: cr->set_source_rgb(0.2, 0.4, 1.0); break; // Blue set_source(cr, pal.digits[number - 1]);
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
} }
cr->select_font_face("Sans", Cairo::ToyFontFace::Slant::NORMAL, Cairo::ToyFontFace::Weight::BOLD); cr->select_font_face("Sans", Cairo::ToyFontFace::Slant::NORMAL, Cairo::ToyFontFace::Weight::BOLD);
@@ -326,7 +556,15 @@ void BoardWidget::draw_particles(const Cairo::RefPtr<Cairo::Context>& cr) {
for (const auto& p : particles_) { for (const auto& p : particles_) {
if (p.life <= 0) continue; if (p.life <= 0) continue;
cr->set_source_rgba(p.r, p.g, p.b, p.life); 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->fill();
cr->restore();
} }
} }
+33 -9
View File
@@ -3,7 +3,7 @@
#include <gtkmm/drawingarea.h> #include <gtkmm/drawingarea.h>
#include <gtkmm/gestureclick.h> #include <gtkmm/gestureclick.h>
#include <gtkmm/eventcontrollermotion.h> #include <gtkmm/eventcontrollermotion.h>
#include <gdkmm/texture.h> #include <gdkmm/rgba.h>
#include <memory> #include <memory>
#include <vector> #include <vector>
#include <random> #include <random>
@@ -12,9 +12,14 @@
struct Particle { struct Particle {
double x, y; double x, y;
double vx, vy; double vx, vy;
double life; double rotation;
double vrot;
double life; // 1.0 -> 0.0
double lifetime; // seconds
double r, g, b; double r, g, b;
double size; double size;
double phase; // sway phase
bool circle;
}; };
class BoardWidget : public Gtk::DrawingArea { class BoardWidget : public Gtk::DrawingArea {
@@ -30,24 +35,41 @@ public:
// Animation // Animation
void start_confetti(); void start_confetti();
bool on_animation_tick(const Glib::RefPtr<Gdk::FrameClock>& clock); bool on_animation_tick(const Glib::RefPtr<Gdk::FrameClock>& clock);
bool process_animation(); // Internal helper bool process_animation(double dt); // Internal helper
private: private:
void on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height); void on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height);
// Input handlers // Input handlers
void on_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_right_click_pressed(int /*n_press*/, double x, double y);
void on_motion(double x, double y); void on_motion(double x, double y);
void on_leave(); void on_leave();
// Helpers // 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<int, int> get_cell_at(double x, double y); std::pair<int, int> get_cell_at(double x, double y);
void draw_cell(const Cairo::RefPtr<Cairo::Context>& cr, int x, int y, double size, const Cell& cell); void draw_cell(const Cairo::RefPtr<Cairo::Context>& cr, int x, int y, double size, const Cell& cell, const Palette& pal);
void draw_digit(const Cairo::RefPtr<Cairo::Context>& cr, int number, double x, double y, double size); void draw_digit(const Cairo::RefPtr<Cairo::Context>& cr, int number, double x, double y, double size, const Palette& pal);
void draw_flag(const Cairo::RefPtr<Cairo::Context>& cr, double x, double y, double size); void draw_flag(const Cairo::RefPtr<Cairo::Context>& cr, double x, double y, double size, const Palette& pal);
void draw_bomb(const Cairo::RefPtr<Cairo::Context>& cr, double x, double y, double size, bool exploded); void draw_bomb(const Cairo::RefPtr<Cairo::Context>& cr, double x, double y, double size, bool exploded, const Palette& pal);
void draw_particles(const Cairo::RefPtr<Cairo::Context>& cr); void draw_particles(const Cairo::RefPtr<Cairo::Context>& cr);
void draw_game_over_overlay(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height);
std::shared_ptr<Minefield> field_; std::shared_ptr<Minefield> field_;
@@ -57,10 +79,12 @@ private:
double offset_y_ = 0; double offset_y_ = 0;
int hover_x_ = -1; int hover_x_ = -1;
int hover_y_ = -1; int hover_y_ = -1;
bool cursor_pointer_ = false;
// Particles // Particles
std::vector<Particle> particles_; std::vector<Particle> particles_;
guint tick_id_ = 0; guint tick_id_ = 0;
gint64 last_frame_time_us_ = 0;
// Controllers // Controllers
Glib::RefPtr<Gtk::GestureClick> left_click_controller_; Glib::RefPtr<Gtk::GestureClick> left_click_controller_;
+81
View File
@@ -0,0 +1,81 @@
#include "leaderboard.hpp"
#include <glibmm/fileutils.h>
#include <glibmm/keyfile.h>
#include <glibmm/miscutils.h>
#include <glib.h>
#include <glib/gstdio.h>
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");
bool migrated = false;
if (!Glib::file_test(path, Glib::FileTest::EXISTS) &&
Glib::file_test(legacy, Glib::FileTest::EXISTS)) {
path = legacy;
migrated = true;
}
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");
}
}
if (migrated) {
// Stale legacy files can hold outdated/experimental data; drop it
// so it can never be re-imported after the current file is gone.
g_remove(legacy.c_str());
}
} 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<int> 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::clear() {
load();
times_.clear();
save();
}
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
}
}
+27
View File
@@ -0,0 +1,27 @@
#pragma once
#include <map>
#include <optional>
#include <string>
// 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<int> best_time(const std::string& difficulty) const;
// Removes all recorded scores and rewrites the file.
void clear();
private:
void load();
void save();
std::map<std::string, int> times_;
bool loaded_ = false;
};
+1 -1
View File
@@ -2,7 +2,7 @@
#include <gtkmm/application.h> #include <gtkmm/application.h>
int main(int argc, char* argv[]) { 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<MainWindow>(argc, argv); return app->make_window_and_run<MainWindow>(argc, argv);
} }
+6
View File
@@ -10,6 +10,12 @@ const GameDifficulty Minefield::DifficultyExpert = {"Master", 30, 20, 145};
Minefield::Minefield(int cols, int rows, int mines) Minefield::Minefield(int cols, int rows, int mines)
: cols_(cols), rows_(rows), total_mines_(mines), remaining_flags_(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); cells_.resize(cols * rows);
} }
+2
View File
@@ -5,6 +5,8 @@
#include <random> #include <random>
#include <algorithm> #include <algorithm>
#include <optional> #include <optional>
#include <string>
#include <stdexcept>
struct GameDifficulty { struct GameDifficulty {
std::string name; std::string name;
+249 -45
View File
@@ -1,80 +1,264 @@
#include "window.hpp" #include "window.hpp"
#include <gdk/gdkkeysyms.h>
#include <iostream> #include <iostream>
#include <iomanip> #include <iomanip>
#include <algorithm>
#include <cstdio>
#include <chrono>
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() { MainWindow::MainWindow() {
set_title("Minesweeper"); set_title("NoMines");
set_default_size(800, 600); set_default_size(800, 600);
setup_header_bar(); setup_header_bar();
setup_board(); setup_board();
// App-wide CSS: opaque popovers (some themes/GTK versions leave them
// transparent), plus the status bar above the grid (flags/timer panels).
auto app_css = Gtk::CssProvider::create();
app_css->load_from_data(R"CSS(
popover.background > contents { background-color: @theme_bg_color; }
.status-bar {
padding: 8px 12px;
border-bottom: 1px solid alpha(@theme_fg_color, 0.12);
}
.status-panel {
background-color: alpha(@theme_fg_color, 0.85);
border-radius: 8px;
padding: 4px 14px;
color: @theme_bg_color;
font-family: monospace;
font-weight: bold;
}
.status-panel.compact {
padding: 2px 10px;
font-weight: normal;
}
.status-bar button {
background-image: linear-gradient(to top, alpha(@theme_fg_color, 0.16), alpha(@theme_fg_color, 0.05));
border: 1px solid alpha(@theme_fg_color, 0.3);
border-radius: 8px;
padding: 4px 18px;
color: @theme_fg_color;
}
.status-bar button:hover {
background-image: linear-gradient(to top, alpha(@theme_fg_color, 0.24), alpha(@theme_fg_color, 0.12));
}
.status-bar button:active {
background-image: none;
background-color: alpha(@theme_fg_color, 0.2);
}
)CSS");
Gtk::StyleContext::add_provider_for_display(
get_display(), app_css, GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
// 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 initial game
start_new_game(Minefield::DifficultyEasy); start_new_game(Minefield::DifficultyEasy);
} }
MainWindow::~MainWindow() { MainWindow::~MainWindow() {
timer_conn_.disconnect(); timer_conn_.disconnect();
dialog_timeout_conn_.disconnect();
} }
void MainWindow::setup_header_bar() { void MainWindow::setup_header_bar() {
set_titlebar(header_bar_); set_titlebar(header_bar_);
// New Game Button
btn_new_game_.set_label("New Game");
btn_new_game_.signal_clicked().connect([this]() {
start_new_game(current_difficulty_);
});
header_bar_.pack_start(btn_new_game_);
// Difficulty Menu // Difficulty Menu
btn_difficulty_.set_label("Difficulty"); btn_difficulty_.set_label(Minefield::DifficultyEasy.name);
box_difficulty_.set_orientation(Gtk::Orientation::VERTICAL); box_difficulty_.set_orientation(Gtk::Orientation::VERTICAL);
box_difficulty_.set_margin(10); box_difficulty_.set_margin(10);
box_difficulty_.set_spacing(5); box_difficulty_.set_spacing(5);
auto group = Gtk::make_managed<Gtk::CheckButton>(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 add_diff_btn = [&](const GameDifficulty& diff) {
auto* btn = Gtk::make_managed<Gtk::Button>(diff.name); auto* btn = Gtk::make_managed<Gtk::CheckButton>(diff.name);
btn->signal_clicked().connect([this, diff]() { 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); start_new_game(diff);
btn_difficulty_.popdown(); btn_difficulty_.popdown();
}); });
box_difficulty_.append(*btn); box_difficulty_.append(*btn);
difficulty_buttons_.emplace_back(diff, btn);
}; };
add_diff_btn(Minefield::DifficultyEasy);
add_diff_btn(Minefield::DifficultyMedium); add_diff_btn(Minefield::DifficultyMedium);
add_diff_btn(Minefield::DifficultyHard); add_diff_btn(Minefield::DifficultyHard);
add_diff_btn(Minefield::DifficultyExpert); add_diff_btn(Minefield::DifficultyExpert);
group->set_active(true);
btn_difficulty_.set_popover(menu_difficulty_); btn_difficulty_.set_popover(menu_difficulty_);
menu_difficulty_.set_child(box_difficulty_); menu_difficulty_.set_child(box_difficulty_);
menu_difficulty_.add_css_class("menu");
header_bar_.pack_start(btn_difficulty_); header_bar_.pack_start(btn_difficulty_);
// Status Labels // Leaderboard
lbl_flags_.set_margin_end(10); btn_leaderboard_.set_label("Leaderboard");
lbl_time_.set_margin_end(10); 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_.add_css_class("menu");
menu_leaderboard_.signal_show().connect(sigc::mem_fun(*this, &MainWindow::refresh_leaderboard));
header_bar_.pack_start(btn_leaderboard_);
}
// Make them monospaced and bold void MainWindow::refresh_leaderboard() {
lbl_flags_.set_markup("<b>🚩 0</b>"); while (auto* child = box_leaderboard_.get_first_child()) {
lbl_time_.set_markup("<b>⏱️ 00:00</b>"); box_leaderboard_.remove(*child);
}
header_bar_.pack_end(lbl_time_); auto* title = Gtk::make_managed<Gtk::Label>();
header_bar_.pack_end(lbl_flags_); title->set_markup("<b>Best Times</b>");
title->set_xalign(0.0f);
box_leaderboard_.append(*title);
box_leaderboard_.append(*Gtk::make_managed<Gtk::Separator>(Gtk::Orientation::HORIZONTAL));
auto* grid = Gtk::make_managed<Gtk::Grid>();
grid->set_column_spacing(16);
grid->set_row_spacing(3);
int row = 0;
for (const auto& diff : {Minefield::DifficultyEasy, Minefield::DifficultyMedium,
Minefield::DifficultyHard, Minefield::DifficultyExpert}) {
auto* name = Gtk::make_managed<Gtk::Label>(diff.name);
name->set_xalign(0.0f);
grid->attach(*name, 0, row, 1, 1);
auto* time = Gtk::make_managed<Gtk::Label>();
auto best = leaderboard_.best_time(diff.name);
time->set_text(best ? format_time(*best) : "");
time->set_hexpand(true);
time->set_xalign(1.0f); // Right-align all times in one column
time->add_css_class("status-panel");
time->add_css_class("compact");
grid->attach(*time, 1, row, 1, 1);
++row;
}
box_leaderboard_.append(*grid);
auto* clear_btn = Gtk::make_managed<Gtk::Button>("Clear scores");
clear_btn->set_margin_top(8);
clear_btn->signal_clicked().connect([this]() {
leaderboard_.clear();
menu_leaderboard_.popdown(); // Rebuilds on next show
});
box_leaderboard_.append(*clear_btn);
} }
void MainWindow::setup_board() { void MainWindow::setup_board() {
set_child(board_widget_); vbox_.set_orientation(Gtk::Orientation::VERTICAL);
// Status bar above the grid: flags (left), new game (center), timer (right)
btn_new_game_.set_label("New Game");
btn_new_game_.signal_clicked().connect([this]() {
start_new_game(current_difficulty_);
});
lbl_flags_.set_text("Flags: 0");
lbl_time_.set_text("Time: 00:00");
lbl_flags_.add_css_class("status-panel");
lbl_time_.add_css_class("status-panel");
status_bar_.add_css_class("status-bar");
status_bar_.set_start_widget(lbl_flags_);
status_bar_.set_center_widget(btn_new_game_);
status_bar_.set_end_widget(lbl_time_);
vbox_.append(status_bar_);
vbox_.append(board_widget_);
set_child(vbox_);
board_widget_.signal_state_changed.connect(sigc::mem_fun(*this, &MainWindow::on_game_state_changed)); board_widget_.signal_state_changed.connect(sigc::mem_fun(*this, &MainWindow::on_game_state_changed));
} }
void MainWindow::start_new_game(const GameDifficulty& difficulty) { void MainWindow::start_new_game(const GameDifficulty& difficulty) {
current_difficulty_ = 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<Minefield>(difficulty.cols, difficulty.rows, difficulty.mines); minefield_ = std::make_shared<Minefield>(difficulty.cols, difficulty.rows, difficulty.mines);
board_widget_.set_minefield(minefield_); board_widget_.set_minefield(minefield_);
// Minimum size: let the window shrink down to what the current board
// needs at the 16px cell floor (30x20 Master requires 520x360, but a
// 9x9 Beginner board fits in ~184px).
board_widget_.set_size_request(difficulty.cols * 16 + 40, difficulty.rows * 16 + 40);
// 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 // Reset timer
timer_conn_.disconnect(); timer_conn_.disconnect();
timer_conn_ = Glib::signal_timeout().connect(sigc::mem_fun(*this, &MainWindow::on_timer_tick), 100); timer_conn_ = Glib::signal_timeout().connect(sigc::mem_fun(*this, &MainWindow::on_timer_tick), 100);
@@ -86,13 +270,21 @@ void MainWindow::on_game_state_changed() {
if (!minefield_) return; if (!minefield_) return;
// Update Flags // Update Flags
lbl_flags_.set_markup("<b>🚩 " + std::to_string(minefield_->remaining_flags()) + "</b>"); lbl_flags_.set_text("Flags: " + std::to_string(minefield_->remaining_flags()));
// Check Game Over // Check Game Over
GameState state = minefield_->state(); GameState state = minefield_->state();
bool new_best = false;
int secs = 0;
if (state == GameState::Won || state == GameState::Lost) { if (state == GameState::Won || state == GameState::Lost) {
timer_conn_.disconnect(); timer_conn_.disconnect();
show_game_over_dialog(state == GameState::Won); if (state == GameState::Won) {
secs = std::chrono::duration_cast<std::chrono::seconds>(minefield_->get_elapsed_time()).count();
new_best = leaderboard_.record(current_difficulty_.name, secs);
show_game_over_dialog(new_best, secs);
}
// Lost: no dialog — the board draws a KABOOM! overlay and the
// player restarts via the New Game button.
} }
// Update timer immediately to reflect end time if stopped // Update timer immediately to reflect end time if stopped
@@ -105,33 +297,45 @@ bool MainWindow::on_timer_tick() {
auto elapsed = minefield_->get_elapsed_time(); auto elapsed = minefield_->get_elapsed_time();
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count(); auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
int mm = secs / 60; lbl_time_.set_text("Time: " + format_time(secs));
int ss = secs % 60;
char buf[32];
snprintf(buf, sizeof(buf), "<b>⏱️ %02d:%02d</b>", mm, ss);
lbl_time_.set_markup(buf);
return true; // Keep calling return true; // Keep calling
} }
void MainWindow::show_game_over_dialog(bool won) { void MainWindow::show_game_over_dialog(bool new_best, int seconds) {
auto dialog = Gtk::make_managed<Gtk::MessageDialog>( // Delay so the win animation/board state is visible before the modal
*this, // dialog covers it.
won ? "Congratulations!" : "Game Over", if (dialog_timeout_conn_.connected()) return;
false, dialog_timeout_conn_ = Glib::signal_timeout().connect([this, new_best, seconds]() {
Gtk::MessageType::INFO, dialog_timeout_conn_.disconnect();
Gtk::ButtonsType::OK,
true
);
dialog->set_secondary_text(won ? "You cleared the minefield!" : "Better luck next time."); // A new game may have started while the dialog was pending
dialog->signal_response().connect([dialog, this](int response) { if (!minefield_ || minefield_->state() != GameState::Won) return false;
dialog->hide();
if (response == Gtk::ResponseType::OK) { // Reuse a single dialog instance: creating a new make_managed dialog
// Optional: restart? // 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<Gtk::MessageDialog>(
*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("Congratulations!");
game_over_dialog_->set_message("Congratulations!");
std::string secondary = "You cleared the minefield in " + format_time(seconds) +
(new_best ? " — new best time!" : ".");
game_over_dialog_->set_secondary_text(secondary);
game_over_dialog_->show();
return false; // One-shot
}, 1500);
} }
+17 -2
View File
@@ -2,8 +2,11 @@
#include <gtkmm.h> #include <gtkmm.h>
#include <memory> #include <memory>
#include <utility>
#include <vector>
#include "minefield.hpp" #include "minefield.hpp"
#include "board_widget.hpp" #include "board_widget.hpp"
#include "leaderboard.hpp"
class MainWindow : public Gtk::ApplicationWindow { class MainWindow : public Gtk::ApplicationWindow {
public: public:
@@ -14,27 +17,39 @@ private:
// UI Setup // UI Setup
void setup_header_bar(); void setup_header_bar();
void setup_board(); void setup_board();
void refresh_leaderboard();
// Actions // Actions
void start_new_game(const GameDifficulty& difficulty); void start_new_game(const GameDifficulty& difficulty);
void on_game_state_changed(); void on_game_state_changed();
bool on_timer_tick(); bool on_timer_tick();
void show_game_over_dialog(bool won); void show_game_over_dialog(bool new_best, int seconds);
// Widgets // Widgets
Gtk::HeaderBar header_bar_; Gtk::HeaderBar header_bar_;
Gtk::Button btn_new_game_;
Gtk::MenuButton btn_difficulty_; Gtk::MenuButton btn_difficulty_;
Gtk::Popover menu_difficulty_; Gtk::Popover menu_difficulty_;
Gtk::Box box_difficulty_; // Content for popover Gtk::Box box_difficulty_; // Content for popover
std::vector<std::pair<GameDifficulty, Gtk::CheckButton*>> difficulty_buttons_;
Gtk::MenuButton btn_leaderboard_;
Gtk::Popover menu_leaderboard_;
Gtk::Box box_leaderboard_; // Content for popover
Gtk::Box vbox_; // Main vertical container
Gtk::CenterBox status_bar_; // Flags | New Game | Time
Gtk::Button btn_new_game_;
Gtk::Label lbl_time_; Gtk::Label lbl_time_;
Gtk::Label lbl_flags_; Gtk::Label lbl_flags_;
BoardWidget board_widget_; BoardWidget board_widget_;
Leaderboard leaderboard_;
// Game State // Game State
std::shared_ptr<Minefield> minefield_; std::shared_ptr<Minefield> minefield_;
sigc::connection timer_conn_; sigc::connection timer_conn_;
sigc::connection dialog_timeout_conn_;
GameDifficulty current_difficulty_ = Minefield::DifficultyEasy; GameDifficulty current_difficulty_ = Minefield::DifficultyEasy;
Gtk::MessageDialog* game_over_dialog_ = nullptr;
}; };