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
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
# Build directories
|
# Build directories
|
||||||
build/
|
build/
|
||||||
|
build-*/
|
||||||
_build/
|
_build/
|
||||||
bin/
|
bin/
|
||||||
lib/
|
lib/
|
||||||
@@ -37,6 +38,7 @@ obj/
|
|||||||
*.exe
|
*.exe
|
||||||
*.out
|
*.out
|
||||||
*.app
|
*.app
|
||||||
|
nomines
|
||||||
minesweeper
|
minesweeper
|
||||||
|
|
||||||
# CMake and build system
|
# CMake and build system
|
||||||
@@ -141,8 +143,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/
|
||||||
|
|||||||
@@ -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<Minefield>` 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<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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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,14 +84,20 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
@@ -98,4 +106,4 @@ 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
@@ -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";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+27
-6
@@ -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,45 @@ 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
|
||||||
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')
|
||||||
)
|
)
|
||||||
|
|
||||||
# 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,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>
|
||||||
|
|||||||
+3
-3
@@ -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=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>
|
||||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
+251
-63
@@ -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;
|
||||||
|
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
|
||||||
|
|
||||||
bool alive = false;
|
|
||||||
for (auto& p : particles_) {
|
for (auto& p : particles_) {
|
||||||
p.x += p.vx;
|
p.life -= dt / p.lifetime;
|
||||||
p.y += p.vy;
|
if (p.life <= 0) continue;
|
||||||
p.vy += 0.1; // Gravity
|
|
||||||
p.life -= 0.01;
|
|
||||||
|
|
||||||
if (p.life > 0) alive = true;
|
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();
|
||||||
@@ -201,61 +357,80 @@ void BoardWidget::on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, in
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void BoardWidget::draw_cell(const Cairo::RefPtr<Cairo::Context>& cr, int x, int y, double size, const Cell& cell) {
|
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 +443,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 +465,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 +485,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 +506,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-9
@@ -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,23 +35,39 @@ 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);
|
||||||
|
|
||||||
std::shared_ptr<Minefield> field_;
|
std::shared_ptr<Minefield> field_;
|
||||||
@@ -57,10 +78,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_;
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#include "leaderboard.hpp"
|
||||||
|
#include <glibmm/fileutils.h>
|
||||||
|
#include <glibmm/keyfile.h>
|
||||||
|
#include <glibmm/miscutils.h>
|
||||||
|
#include <glib.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");
|
||||||
|
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<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::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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#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;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void load();
|
||||||
|
void save();
|
||||||
|
|
||||||
|
std::map<std::string, int> times_;
|
||||||
|
bool loaded_ = false;
|
||||||
|
};
|
||||||
+1
-1
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+161
-27
@@ -1,20 +1,72 @@
|
|||||||
#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();
|
||||||
|
|
||||||
|
// 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() {
|
||||||
@@ -28,43 +80,90 @@ void MainWindow::setup_header_bar() {
|
|||||||
header_bar_.pack_start(btn_new_game_);
|
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_);
|
||||||
header_bar_.pack_start(btn_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
|
// Status Labels
|
||||||
lbl_flags_.set_margin_end(10);
|
lbl_flags_.set_margin_end(10);
|
||||||
lbl_time_.set_margin_end(10);
|
lbl_time_.set_margin_end(10);
|
||||||
|
|
||||||
// Make them monospaced and bold
|
// Monospaced and bold so the numbers don't jitter
|
||||||
lbl_flags_.set_markup("<b>🚩 0</b>");
|
lbl_flags_.set_markup("<b><tt>Flags: 0</tt></b>");
|
||||||
lbl_time_.set_markup("<b>⏱️ 00:00</b>");
|
lbl_time_.set_markup("<b><tt>Time: 00:00</tt></b>");
|
||||||
|
|
||||||
header_bar_.pack_end(lbl_time_);
|
header_bar_.pack_end(lbl_time_);
|
||||||
header_bar_.pack_end(lbl_flags_);
|
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<Gtk::Label>();
|
||||||
|
title->set_markup("<b>Best Times</b>");
|
||||||
|
box_leaderboard_.append(*title);
|
||||||
|
|
||||||
|
for (const auto& diff : {Minefield::DifficultyEasy, Minefield::DifficultyMedium,
|
||||||
|
Minefield::DifficultyHard, Minefield::DifficultyExpert}) {
|
||||||
|
auto* row = Gtk::make_managed<Gtk::Label>();
|
||||||
|
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() {
|
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_);
|
set_child(board_widget_);
|
||||||
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));
|
||||||
}
|
}
|
||||||
@@ -72,9 +171,24 @@ void MainWindow::setup_board() {
|
|||||||
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_);
|
||||||
|
|
||||||
|
// 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 +200,19 @@ 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_markup("<b><tt>Flags: " + std::to_string(minefield_->remaining_flags()) + "</tt></b>");
|
||||||
|
|
||||||
// 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(state == GameState::Won, new_best, secs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update timer immediately to reflect end time if stopped
|
// 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 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_markup("<b><tt>Time: " + format_time(secs) + "</tt></b>");
|
||||||
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 won, 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, 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<Gtk::MessageDialog>(
|
||||||
|
*this, "", false,
|
||||||
Gtk::MessageType::INFO,
|
Gtk::MessageType::INFO,
|
||||||
Gtk::ButtonsType::OK,
|
Gtk::ButtonsType::OK,
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
game_over_dialog_->signal_response().connect([this](int response) {
|
||||||
dialog->set_secondary_text(won ? "You cleared the minefield!" : "Better luck next time.");
|
game_over_dialog_->hide();
|
||||||
dialog->signal_response().connect([dialog, this](int response) {
|
|
||||||
dialog->hide();
|
|
||||||
if (response == Gtk::ResponseType::OK) {
|
if (response == Gtk::ResponseType::OK) {
|
||||||
// Optional: restart?
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -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,12 +17,13 @@ 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 won, bool new_best, int seconds);
|
||||||
|
|
||||||
// Widgets
|
// Widgets
|
||||||
Gtk::HeaderBar header_bar_;
|
Gtk::HeaderBar header_bar_;
|
||||||
@@ -27,14 +31,23 @@ private:
|
|||||||
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::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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user