Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06b692b140 | ||
|
|
62a09e4261 | ||
|
|
6c69556b25 | ||
|
|
cee916224a | ||
|
|
9a9d9f3fea | ||
|
|
08ae7cd1ef | ||
|
|
49651fd42d | ||
|
|
97565e415d | ||
|
|
2dbf64a2e8 | ||
|
|
2456814295 | ||
|
|
23316594f7 | ||
|
|
3bc1645721 | ||
|
|
05bc82a5c1 | ||
|
|
039de24da8 | ||
|
|
fe0b39a49d | ||
|
|
c61173308a | ||
|
|
7312e473b8 | ||
|
|
a4dccdc2a3 | ||
|
|
0cdc668629 | ||
|
|
27e4031699 | ||
|
|
16dac0e1f6 | ||
|
|
b5f75a29b7 | ||
|
|
0399aaf94f | ||
|
|
2a1d45f998 | ||
|
|
4ff86eb027 | ||
|
|
b6a6e16d3a | ||
|
|
8c1cf20228 | ||
|
|
8afb29b680 |
+127
-2
@@ -1,4 +1,17 @@
|
||||
# ---> C++
|
||||
# Build directories
|
||||
build/
|
||||
build-*/
|
||||
_build/
|
||||
bin/
|
||||
lib/
|
||||
obj/
|
||||
|
||||
# Flatpak
|
||||
.flatpak-builder/
|
||||
|
||||
# Prerequisites
|
||||
*.d
|
||||
|
||||
# Compiled Object files
|
||||
*.slo
|
||||
*.lo
|
||||
@@ -16,6 +29,7 @@
|
||||
|
||||
# Fortran module files
|
||||
*.mod
|
||||
*.smod
|
||||
|
||||
# Compiled Static libraries
|
||||
*.lai
|
||||
@@ -27,6 +41,117 @@
|
||||
*.exe
|
||||
*.out
|
||||
*.app
|
||||
nomines
|
||||
minesweeper
|
||||
|
||||
# Backup files
|
||||
# CMake and build system
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
compile_commands.json
|
||||
Makefile
|
||||
meson.build.user
|
||||
meson-build/
|
||||
meson-logs/
|
||||
meson-private/
|
||||
|
||||
# IDE and text editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.kdev4
|
||||
.kdev4/
|
||||
|
||||
# Emacs files
|
||||
*~
|
||||
\#*\#
|
||||
/.emacs.desktop
|
||||
/.emacs.desktop.lock
|
||||
*.elc
|
||||
auto-save-list
|
||||
tramp
|
||||
.\#*
|
||||
|
||||
# Emacs directory configuration
|
||||
.dir-locals.el
|
||||
|
||||
# Emacs backup files
|
||||
*~
|
||||
|
||||
# Emacs org-mode
|
||||
.org-id-locations
|
||||
*_archive
|
||||
|
||||
# Emacs flymake
|
||||
*_flymake*
|
||||
|
||||
# Emacs eshell files
|
||||
/eshell/history
|
||||
/eshell/lastdir
|
||||
|
||||
# Emacs elpa packages
|
||||
/elpa/
|
||||
|
||||
# Emacs projectile files
|
||||
.projectile
|
||||
|
||||
# macOS specific
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Linux specific
|
||||
*~
|
||||
.fuse_hidden*
|
||||
.directory
|
||||
.Trash-*
|
||||
.nfs*
|
||||
|
||||
# Windows specific
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
*.stackdump
|
||||
[Dd]esktop.ini
|
||||
$RECYCLE.BIN/
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
*.lnk
|
||||
|
||||
# Project specific
|
||||
.gresource
|
||||
*.gresource
|
||||
|
||||
# Leaderboard and config files
|
||||
.config/nomines/
|
||||
.config/minesweeper/
|
||||
|
||||
#MacOS files
|
||||
.cache/
|
||||
.DS_Store
|
||||
|
||||
# direnv
|
||||
.direnv/
|
||||
|
||||
@@ -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.
|
||||
@@ -1,23 +1,123 @@
|
||||
# minesweeper
|
||||
# NoMines
|
||||
|
||||
Minesweeper game in C++ and GTK4
|
||||
A modern GTK4/gtkmm implementation of the classic Minesweeper game with multiple difficulty levels, a persistent leaderboard, keyboard shortcuts, and light/dark theme support.
|
||||
|
||||
## Instructions to build
|
||||

|
||||
|
||||
Install dependencies
|
||||
## Features
|
||||
|
||||
```
|
||||
sudo apt install libgtkmm-4.0-dev libsigc++-3.0-dev
|
||||
- Multiple difficulty levels: Beginner, Intermediate, Expert, and Master
|
||||
- Persistent leaderboard tracking the best time per difficulty
|
||||
- Follows the system light/dark theme
|
||||
- Win/lose animations, including a confetti win celebration
|
||||
- Keyboard shortcuts: `R` / `Ctrl+N` new game, `1`–`4` difficulty, `L` leaderboard
|
||||
- Right-click chording on revealed cells
|
||||
- Modern GTK4 user interface with client-side decorations
|
||||
- Modern C++20 implementation
|
||||
|
||||
## Building from Source
|
||||
|
||||
### Dependencies
|
||||
|
||||
- GTK 4.0 or later
|
||||
- gtkmm 4.0 or later
|
||||
- sigc++ 3.0 or later
|
||||
- Meson build system
|
||||
- Ninja build system
|
||||
|
||||
### Ubuntu/Debian
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
sudo apt install build-essential meson ninja-build libgtkmm-4.0-dev libsigc++-3.0-dev
|
||||
```
|
||||
|
||||
Go to the project folder
|
||||
```
|
||||
cd minesweeper
|
||||
### Fedora
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
sudo dnf install gcc-c++ meson ninja-build gtkmm4.0-devel libsigc++3-devel
|
||||
```
|
||||
|
||||
Setup meson and compile the project
|
||||
### Arch Linux
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
sudo pacman -S base-devel meson ninja gtkmm-4.0 libsigc++-3.0
|
||||
```
|
||||
|
||||
### Building the Project
|
||||
|
||||
Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/bemagri/nomines
|
||||
cd nomines
|
||||
```
|
||||
|
||||
Configure and build with Meson:
|
||||
|
||||
```bash
|
||||
meson setup build
|
||||
meson compile -C build
|
||||
```
|
||||
|
||||
Run the game:
|
||||
|
||||
```bash
|
||||
./build/nomines
|
||||
```
|
||||
|
||||
### Installing
|
||||
|
||||
To install system-wide:
|
||||
|
||||
```bash
|
||||
meson install -C build
|
||||
```
|
||||
|
||||
## Using Nix
|
||||
|
||||
If you use the Nix package manager, you can build and run the application with:
|
||||
|
||||
```bash
|
||||
nix build
|
||||
./result/bin/nomines
|
||||
```
|
||||
|
||||
Or run it directly from the flake:
|
||||
|
||||
```bash
|
||||
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
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU General Public License v3.0 - see the LICENSE file for details.
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{ lib
|
||||
, stdenv
|
||||
, fetchFromGitHub
|
||||
, meson
|
||||
, ninja
|
||||
, pkg-config
|
||||
, wrapGAppsHook4
|
||||
, gtk4
|
||||
, gtkmm4
|
||||
, libsigcxx
|
||||
}:
|
||||
|
||||
stdenv.mkDerivation rec {
|
||||
pname = "nomines";
|
||||
version = "1.0.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
# If you want to use fetchFromGitHub instead:
|
||||
# src = fetchFromGitHub {
|
||||
# owner = "bemagri";
|
||||
# repo = "nomines";
|
||||
# rev = "v${version}";
|
||||
# hash = "sha256-0000000000000000000000000000000000000000000=";
|
||||
# };
|
||||
|
||||
nativeBuildInputs = [
|
||||
meson
|
||||
ninja
|
||||
pkg-config
|
||||
wrapGAppsHook4
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
gtk4
|
||||
gtkmm4
|
||||
libsigcxx
|
||||
];
|
||||
|
||||
meta = with lib; {
|
||||
description = "Modern Minesweeper game with GTK4 interface";
|
||||
homepage = "https://github.com/bemagri/nomines";
|
||||
license = licenses.gpl3Plus;
|
||||
platforms = platforms.linux;
|
||||
maintainers = with maintainers; [ /* your name here */ ];
|
||||
mainProgram = "nomines";
|
||||
};
|
||||
}
|
||||
Generated
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1735563628,
|
||||
"narHash": "sha256-OnSAY7XDSx7CtDoqNh8jwVwh4xNL/2HaJxGjryLWzX8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "b134951a4c9f3c995fd7be05f3243f8ecd65d798",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-24.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
{
|
||||
description = "GTK4 Minesweeper game";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.05";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
|
||||
# Robust fallbacks across channels
|
||||
wrapGApps = pkgs.wrapGAppsHook4 or pkgs.wrapGAppsHook;
|
||||
adwaitaTheme = pkgs.gnome.adwaita-icon-theme or pkgs.adwaita-icon-theme;
|
||||
in
|
||||
{
|
||||
# Main package
|
||||
packages.default = pkgs.stdenv.mkDerivation rec {
|
||||
pname = "nomines";
|
||||
version = "1.0.0";
|
||||
src = self;
|
||||
|
||||
strictDeps = true;
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
meson
|
||||
ninja
|
||||
pkg-config
|
||||
gobject-introspection
|
||||
wrapGApps
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
gtk4
|
||||
gtkmm4
|
||||
glibmm
|
||||
libsigcxx30
|
||||
gdk-pixbuf
|
||||
librsvg # SVG loader
|
||||
gvfs # GIO modules
|
||||
gsettings-desktop-schemas
|
||||
hicolor-icon-theme
|
||||
adwaitaTheme
|
||||
];
|
||||
|
||||
# Ensure we use Nix-provided GIO modules, not host ones
|
||||
preFixup = ''
|
||||
gappsWrapperArgs+=(
|
||||
--set GIO_EXTRA_MODULES ${pkgs.gvfs}/lib/gio/modules
|
||||
)
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "NoMines - a modern GTK4 Minesweeper game";
|
||||
homepage = "https://github.com/bemagri/nomines";
|
||||
license = licenses.gpl3Plus;
|
||||
platforms = platforms.linux;
|
||||
mainProgram = "nomines";
|
||||
};
|
||||
};
|
||||
|
||||
# nix run support
|
||||
apps.${system}.default = {
|
||||
type = "app";
|
||||
program = "${self.packages.${system}.default}/bin/nomines";
|
||||
};
|
||||
|
||||
# Dev shell for hacking
|
||||
devShells.default = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
meson
|
||||
ninja
|
||||
pkg-config
|
||||
gobject-introspection
|
||||
];
|
||||
buildInputs = with pkgs; [
|
||||
gtk4
|
||||
gtkmm4
|
||||
glibmm
|
||||
libsigcxx30
|
||||
gdk-pixbuf
|
||||
librsvg
|
||||
gvfs
|
||||
gsettings-desktop-schemas
|
||||
hicolor-icon-theme
|
||||
adwaitaTheme
|
||||
];
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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: 49651fd42d9d67ccc91bb45ceacdbd60372141ee
|
||||
+71
-6
@@ -1,6 +1,6 @@
|
||||
project('minesweeper', 'cpp',
|
||||
version : '0.1',
|
||||
default_options : ['warning_level=3'])
|
||||
project('nomines', 'cpp',
|
||||
version : '1.0.0',
|
||||
default_options : ['warning_level=3', 'cpp_std=c++20'])
|
||||
|
||||
gnome = import('gnome')
|
||||
|
||||
@@ -10,7 +10,72 @@ res = gnome.compile_resources(
|
||||
c_name: 'gresources'
|
||||
)
|
||||
|
||||
deps = dependency(['gtkmm-4.0', 'sigc++-3.0'])
|
||||
src = ['src/window.cpp', 'src/window.hpp', 'src/minefield.hpp', 'src/minefield.cpp', res]
|
||||
exe = executable('minesweeper', src, dependencies : deps, install : true)
|
||||
# Dependencies
|
||||
deps = [
|
||||
dependency('gtkmm-4.0'),
|
||||
dependency('sigc++-3.0')
|
||||
]
|
||||
|
||||
# Source files
|
||||
src = [
|
||||
'src/main.cpp',
|
||||
'src/window.cpp',
|
||||
'src/window.hpp',
|
||||
'src/minefield.hpp',
|
||||
'src/minefield.cpp',
|
||||
'src/board_widget.hpp',
|
||||
'src/board_widget.cpp',
|
||||
'src/leaderboard.hpp',
|
||||
'src/leaderboard.cpp',
|
||||
res
|
||||
]
|
||||
|
||||
# Executable
|
||||
executable('nomines',
|
||||
src,
|
||||
dependencies : deps,
|
||||
install : true
|
||||
)
|
||||
|
||||
# Install icon (named after the app id so flatpak export picks it up;
|
||||
# PNG sizes are required by flatpak/flathub icon validation)
|
||||
install_data(
|
||||
'resources/nomines.svg',
|
||||
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_data(
|
||||
'resources/io.github.bemagri.nomines.desktop',
|
||||
install_dir: join_paths(get_option('datadir'), 'applications')
|
||||
)
|
||||
|
||||
# Install AppStream metadata
|
||||
install_data(
|
||||
'resources/io.github.bemagri.nomines.metainfo.xml',
|
||||
install_dir: join_paths(get_option('datadir'), 'metainfo')
|
||||
)
|
||||
|
||||
# Release validation (best-effort, only when the tools are installed)
|
||||
desktop_file_validate = find_program('desktop-file-validate', required: false)
|
||||
if desktop_file_validate.found()
|
||||
test('validate-desktop', desktop_file_validate,
|
||||
args: ['resources/io.github.bemagri.nomines.desktop'])
|
||||
endif
|
||||
|
||||
appstreamcli = find_program('appstreamcli', required: false)
|
||||
if appstreamcli.found()
|
||||
test('validate-metainfo', appstreamcli,
|
||||
args: ['validate', '--no-net', 'resources/io.github.bemagri.nomines.metainfo.xml'])
|
||||
endif
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<gresources>
|
||||
<gresource prefix="/minesweeper">
|
||||
<gresource prefix="/io/github/bemagri/nomines">
|
||||
<file alias="bomb-solid">bomb-solid.svg</file>
|
||||
<file alias="explosion-solid">explosion-solid.svg</file>
|
||||
<file alias="flag-regular">flag-regular.svg</file>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Name=NoMines
|
||||
GenericName=Minesweeper Game
|
||||
Comment=Classic minesweeper game with modern GTK UI
|
||||
Keywords=game;puzzle;mine;
|
||||
Exec=nomines
|
||||
Icon=io.github.bemagri.nomines
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Game;LogicGame;
|
||||
StartupNotify=true
|
||||
@@ -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 |
@@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
width="100"
|
||||
height="100"
|
||||
version="1.1"
|
||||
id="svg9"
|
||||
sodipodi:docname="minesweeper.svg"
|
||||
xml:space="preserve"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs9" /><sodipodi:namedview
|
||||
id="namedview9"
|
||||
pagecolor="#4c524e"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="10.337901"
|
||||
inkscape:cx="34.097831"
|
||||
inkscape:cy="41.207591"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1387"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="25"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg9" /><rect
|
||||
x="4.573761"
|
||||
y="5.6444345"
|
||||
width="90"
|
||||
height="90"
|
||||
rx="15"
|
||||
fill="#f9db9b"
|
||||
stroke="#6d4c41"
|
||||
stroke-width="4"
|
||||
id="rect1" /><rect
|
||||
x="9.4985218"
|
||||
y="10.021999"
|
||||
width="25"
|
||||
height="25"
|
||||
fill="#f44336"
|
||||
rx="5"
|
||||
id="rect2" /><circle
|
||||
cx="54.573761"
|
||||
cy="55.644436"
|
||||
r="18"
|
||||
fill="#4e342e"
|
||||
id="circle2" /><path
|
||||
d="m 59.573761,40.644434 q 5,-10 10,-5"
|
||||
stroke="#4e342e"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
id="path2" /><line
|
||||
x1="69.34153"
|
||||
y1="29.115864"
|
||||
x2="64.542198"
|
||||
y2="27.71357"
|
||||
stroke="#ff9800"
|
||||
stroke-width="2"
|
||||
id="line2" /><line
|
||||
x1="72.705803"
|
||||
y1="25.333492"
|
||||
x2="70.999474"
|
||||
y2="20.633656"
|
||||
stroke="#ff9800"
|
||||
stroke-width="2"
|
||||
id="line3" /><line
|
||||
x1="79.007629"
|
||||
y1="27.03516"
|
||||
x2="82.793045"
|
||||
y2="23.768559"
|
||||
stroke="#ff9800"
|
||||
stroke-width="2"
|
||||
id="line4" /><line
|
||||
x1="79.630043"
|
||||
y1="33.17028"
|
||||
x2="84.25367"
|
||||
y2="35.073471"
|
||||
stroke="#ff9800"
|
||||
stroke-width="2"
|
||||
id="line5" /><line
|
||||
x1="75.123314"
|
||||
y1="35.76083"
|
||||
x2="76.230385"
|
||||
y2="40.63673"
|
||||
stroke="#ff9800"
|
||||
stroke-width="2"
|
||||
id="line6" /><line
|
||||
x1="72.073761"
|
||||
y1="31.314306"
|
||||
x2="74.573761"
|
||||
y2="26.98418"
|
||||
stroke="#ff9800"
|
||||
stroke-width="2"
|
||||
id="line7" /><circle
|
||||
cx="29.573761"
|
||||
cy="75.644432"
|
||||
r="4"
|
||||
fill="#4e342e"
|
||||
id="circle7" /><circle
|
||||
cx="74.573761"
|
||||
cy="75.644432"
|
||||
r="4"
|
||||
fill="#4e342e"
|
||||
id="circle8" /><circle
|
||||
cx="74.573761"
|
||||
cy="30.644434"
|
||||
r="4"
|
||||
fill="#4e342e"
|
||||
id="circle9" /><path
|
||||
d="m 17.36416,15.724382 c 0,-0.571957 -0.462088,-1.034051 -1.034044,-1.034051 -0.571963,0 -1.03405,0.462094 -1.03405,1.034051 v 1.034043 9.823461 3.619167 c 0,0.571963 0.462087,1.03405 1.03405,1.03405 0.571956,0 1.034044,-0.462087 1.034044,-1.03405 v -4.136188 l 2.077796,-0.52026 c 1.328101,-0.332834 2.73376,-0.177725 3.958464,0.433007 1.428274,0.714144 3.085984,0.80139 4.578889,0.239123 l 1.121298,-0.420079 c 0.403926,-0.151878 0.672137,-0.536414 0.672137,-0.969421 v -8.000947 c 0,-0.743225 -0.782001,-1.227935 -1.447674,-0.895095 l -0.31021,0.155103 c -1.496138,0.749688 -3.257253,0.749688 -4.75339,0 -1.134225,-0.568725 -2.436478,-0.710906 -3.667644,-0.403921 l -2.229666,0.559029 z"
|
||||
id="path1"
|
||||
style="fill:#6d4c41;fill-opacity:1;stroke-width:0.032314" /></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,570 @@
|
||||
#include "board_widget.hpp"
|
||||
#include <gtkmm/stylecontext.h>
|
||||
#include <gdkmm/frameclock.h>
|
||||
#include <iostream>
|
||||
#include <cmath>
|
||||
#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() {
|
||||
set_hexpand(true);
|
||||
set_vexpand(true);
|
||||
|
||||
// Setup Draw Function
|
||||
set_draw_func(sigc::mem_fun(*this, &BoardWidget::on_draw));
|
||||
|
||||
// Left Click
|
||||
left_click_controller_ = Gtk::GestureClick::create();
|
||||
left_click_controller_->set_button(GDK_BUTTON_PRIMARY);
|
||||
left_click_controller_->signal_pressed().connect(sigc::mem_fun(*this, &BoardWidget::on_click_pressed));
|
||||
add_controller(left_click_controller_);
|
||||
|
||||
// Right Click
|
||||
right_click_controller_ = Gtk::GestureClick::create();
|
||||
right_click_controller_->set_button(GDK_BUTTON_SECONDARY);
|
||||
right_click_controller_->signal_pressed().connect(sigc::mem_fun(*this, &BoardWidget::on_right_click_pressed));
|
||||
add_controller(right_click_controller_);
|
||||
|
||||
// Motion (Hover)
|
||||
motion_controller_ = Gtk::EventControllerMotion::create();
|
||||
motion_controller_->signal_enter().connect(sigc::mem_fun(*this, &BoardWidget::on_motion));
|
||||
motion_controller_->signal_motion().connect(sigc::mem_fun(*this, &BoardWidget::on_motion));
|
||||
motion_controller_->signal_leave().connect(sigc::mem_fun(*this, &BoardWidget::on_leave));
|
||||
add_controller(motion_controller_);
|
||||
}
|
||||
|
||||
BoardWidget::~BoardWidget() {
|
||||
if (tick_id_ > 0) remove_tick_callback(tick_id_);
|
||||
}
|
||||
|
||||
void BoardWidget::set_minefield(std::shared_ptr<Minefield> field) {
|
||||
field_ = field;
|
||||
particles_.clear();
|
||||
hover_x_ = -1;
|
||||
hover_y_ = -1;
|
||||
if (tick_id_ > 0) {
|
||||
remove_tick_callback(tick_id_);
|
||||
tick_id_ = 0;
|
||||
}
|
||||
queue_draw();
|
||||
}
|
||||
|
||||
void BoardWidget::start_confetti() {
|
||||
particles_.clear();
|
||||
last_frame_time_us_ = 0;
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_real_distribution<> pos_x(0.0, 1.0);
|
||||
std::uniform_real_distribution<> pos_y(0.0, 0.4);
|
||||
std::uniform_real_distribution<> vel_x(-250.0, 250.0);
|
||||
std::uniform_real_distribution<> vel_y(-300.0, 150.0); // Burst upward first
|
||||
std::uniform_real_distribution<> angle(0.0, 2.0 * M_PI);
|
||||
std::uniform_real_distribution<> vrot(-5.0, 5.0);
|
||||
std::uniform_real_distribution<> size_dist(4.0, 10.0);
|
||||
std::uniform_real_distribution<> phase_dist(0.0, 2.0 * M_PI);
|
||||
std::uniform_real_distribution<> life_dist(2.5, 4.0);
|
||||
std::uniform_int_distribution<> color_idx(0, 7);
|
||||
std::uniform_int_distribution<> shape(0, 3);
|
||||
|
||||
// Classic confetti palette
|
||||
static constexpr double palette[8][3] = {
|
||||
{0.91, 0.20, 0.20}, {0.20, 0.55, 0.90}, {0.30, 0.80, 0.30},
|
||||
{0.95, 0.80, 0.20}, {0.95, 0.55, 0.15}, {0.65, 0.30, 0.85},
|
||||
{0.95, 0.40, 0.70}, {0.35, 0.85, 0.85}};
|
||||
|
||||
int w = get_width();
|
||||
int h = get_height();
|
||||
|
||||
for (int i = 0; i < 250; ++i) {
|
||||
const auto& c = palette[color_idx(gen)];
|
||||
particles_.push_back({
|
||||
pos_x(gen) * w,
|
||||
pos_y(gen) * h,
|
||||
vel_x(gen),
|
||||
vel_y(gen),
|
||||
angle(gen),
|
||||
vrot(gen),
|
||||
1.0, // life
|
||||
life_dist(gen), // lifetime (seconds)
|
||||
c[0], c[1], c[2],
|
||||
size_dist(gen),
|
||||
phase_dist(gen),
|
||||
shape(gen) == 0 // 1 in 4 are circles
|
||||
});
|
||||
}
|
||||
|
||||
if (tick_id_ == 0) {
|
||||
tick_id_ = add_tick_callback(
|
||||
sigc::mem_fun(*this, &BoardWidget::on_animation_tick)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool BoardWidget::on_animation_tick(const Glib::RefPtr<Gdk::FrameClock>& clock) {
|
||||
// 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(double dt) {
|
||||
if (particles_.empty()) {
|
||||
tick_id_ = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
constexpr double gravity = 800.0; // px/s^2
|
||||
constexpr double terminal_v = 550.0; // px/s
|
||||
constexpr double sway_freq = 18.0; // rad/s
|
||||
constexpr double sway_amp = 60.0; // px/s
|
||||
|
||||
for (auto& p : particles_) {
|
||||
p.life -= dt / p.lifetime;
|
||||
if (p.life <= 0) continue;
|
||||
|
||||
p.vy = std::min(p.vy + gravity * dt, terminal_v);
|
||||
p.x += (p.vx + sway_amp * std::sin(p.phase + (1.0 - p.life) * sway_freq)) * dt;
|
||||
p.y += p.vy * dt;
|
||||
p.rotation += p.vrot * dt;
|
||||
}
|
||||
|
||||
std::erase_if(particles_, [](const Particle& p) { return p.life <= 0; });
|
||||
|
||||
if (particles_.empty()) tick_id_ = 0;
|
||||
queue_draw();
|
||||
return !particles_.empty();
|
||||
}
|
||||
|
||||
std::pair<int, int> BoardWidget::get_cell_at(double x, double y) {
|
||||
if (!field_ || cell_size_ <= 0) return {-1, -1};
|
||||
|
||||
// Use stored metrics from last draw
|
||||
if (x < offset_x_ || x >= offset_x_ + field_->cols() * cell_size_ ||
|
||||
y < offset_y_ || y >= offset_y_ + field_->rows() * cell_size_) {
|
||||
return {-1, -1};
|
||||
}
|
||||
|
||||
int cx = static_cast<int>((x - offset_x_) / cell_size_);
|
||||
int cy = static_cast<int>((y - offset_y_) / cell_size_);
|
||||
return {cx, cy};
|
||||
}
|
||||
|
||||
void BoardWidget::on_click_pressed(int /*n_press*/, double x, double y) {
|
||||
if (!field_) return;
|
||||
auto [cx, cy] = get_cell_at(x, y);
|
||||
if (cx == -1) return;
|
||||
|
||||
bool changed = false;
|
||||
if (field_->get_cell(cx, cy).is_revealed) {
|
||||
changed = field_->chord_cell(cx, cy);
|
||||
} else {
|
||||
changed = field_->open_cell(cx, cy);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
signal_state_changed.emit();
|
||||
if (field_->state() == GameState::Won) {
|
||||
start_confetti();
|
||||
}
|
||||
queue_draw();
|
||||
}
|
||||
}
|
||||
|
||||
void BoardWidget::on_right_click_pressed(int /*n_press*/, double x, double y) {
|
||||
if (!field_) return;
|
||||
auto [cx, cy] = get_cell_at(x, y);
|
||||
if (cx == -1) return;
|
||||
|
||||
bool changed = false;
|
||||
const Cell& cell = field_->get_cell(cx, cy);
|
||||
if (cell.is_revealed) {
|
||||
// Classic behavior: right-click chords a revealed number cell
|
||||
changed = field_->chord_cell(cx, cy);
|
||||
} else {
|
||||
changed = field_->toggle_flag(cx, cy);
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
signal_state_changed.emit();
|
||||
if (field_->state() == GameState::Won) {
|
||||
start_confetti();
|
||||
}
|
||||
queue_draw();
|
||||
}
|
||||
}
|
||||
|
||||
void BoardWidget::on_motion(double x, double y) {
|
||||
auto [cx, cy] = get_cell_at(x, y);
|
||||
if (cx != hover_x_ || cy != hover_y_) {
|
||||
hover_x_ = cx;
|
||||
hover_y_ = cy;
|
||||
queue_draw();
|
||||
}
|
||||
|
||||
bool over = cx != -1;
|
||||
if (over != cursor_pointer_) {
|
||||
cursor_pointer_ = over;
|
||||
if (over) {
|
||||
set_cursor(Gdk::Cursor::create("pointer"));
|
||||
} else {
|
||||
set_cursor(Glib::ustring{});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BoardWidget::on_leave() {
|
||||
hover_x_ = -1;
|
||||
hover_y_ = -1;
|
||||
if (cursor_pointer_) {
|
||||
cursor_pointer_ = false;
|
||||
set_cursor(Glib::ustring{});
|
||||
}
|
||||
queue_draw();
|
||||
}
|
||||
|
||||
BoardWidget::Palette BoardWidget::resolve_palette() {
|
||||
Palette p;
|
||||
auto style = get_style_context();
|
||||
|
||||
Gdk::RGBA bg, fg, base, sel;
|
||||
bool has_bg = style->lookup_color("theme_bg_color", bg);
|
||||
bool has_fg = style->lookup_color("theme_fg_color", fg);
|
||||
bool has_base = style->lookup_color("theme_base_color", base);
|
||||
bool has_sel = style->lookup_color("theme_selected_bg_color", sel);
|
||||
|
||||
bool dark = has_bg && rgba_luminance(bg) < 0.45;
|
||||
if (!has_bg) bg = make_rgba(dark ? 0.12 : 0.95, dark ? 0.12 : 0.95, dark ? 0.12 : 0.95);
|
||||
if (!has_fg) fg = make_rgba(dark ? 0.88 : 0.1, dark ? 0.88 : 0.1, dark ? 0.88 : 0.1);
|
||||
if (!has_base) base = make_rgba(dark ? 0.16 : 0.92, dark ? 0.16 : 0.92, dark ? 0.16 : 0.92);
|
||||
if (!has_sel) sel = make_rgba(0.25, 0.5, 1.0);
|
||||
|
||||
p.bg = bg;
|
||||
p.revealed = base;
|
||||
p.unrevealed_a = rgba_lerp(base, fg, 0.10);
|
||||
p.unrevealed_b = rgba_lerp(base, fg, 0.20);
|
||||
p.bevel_light = rgba_lighten(p.unrevealed_a, 0.55);
|
||||
p.bevel_dark = rgba_darken(p.unrevealed_a, 0.45);
|
||||
p.sunken = rgba_darken(base, 0.25);
|
||||
p.sunken_light = rgba_lighten(base, 0.20);
|
||||
p.hover = rgba_lerp(base, sel, 0.55);
|
||||
p.shadow = make_rgba(0, 0, 0, dark ? 0.35 : 0.12);
|
||||
p.flag_pole = rgba_lerp(base, fg, 0.75);
|
||||
|
||||
// Classic 1-8 digit colors, brightened for dark themes
|
||||
static const double light_digits[8][3] = {
|
||||
{0.2, 0.4, 1.0}, {0.2, 0.6, 0.2}, {0.9, 0.2, 0.2}, {0.1, 0.1, 0.6},
|
||||
{0.6, 0.1, 0.1}, {0.1, 0.6, 0.6}, {0.1, 0.1, 0.1}, {0.5, 0.5, 0.5}};
|
||||
static const double dark_digits[8][3] = {
|
||||
{0.45, 0.65, 1.0}, {0.45, 0.85, 0.5}, {1.0, 0.42, 0.42}, {0.62, 0.62, 1.0},
|
||||
{1.0, 0.5, 0.35}, {0.4, 0.85, 0.85}, {0.85, 0.85, 0.9}, {0.72, 0.72, 0.75}};
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const auto& c = dark ? dark_digits[i] : light_digits[i];
|
||||
p.digits[i] = make_rgba(c[0], c[1], c[2]);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
void BoardWidget::on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height) {
|
||||
Palette pal = resolve_palette();
|
||||
|
||||
// Background
|
||||
set_source(cr, pal.bg);
|
||||
cr->paint();
|
||||
|
||||
if (field_) {
|
||||
// Calculate scaling
|
||||
int cols = field_->cols();
|
||||
int rows = field_->rows();
|
||||
|
||||
double pad = 20.0;
|
||||
double avail_w = width - 2 * pad;
|
||||
double avail_h = height - 2 * pad;
|
||||
|
||||
cell_size_ = std::min(avail_w / cols, avail_h / rows);
|
||||
cell_size_ = std::min(cell_size_, 48.0);
|
||||
cell_size_ = std::max(cell_size_, 16.0);
|
||||
|
||||
double board_w_px = cols * cell_size_;
|
||||
double board_h_px = rows * cell_size_;
|
||||
offset_x_ = (width - board_w_px) / 2.0;
|
||||
offset_y_ = (height - board_h_px) / 2.0;
|
||||
|
||||
cr->save();
|
||||
cr->translate(offset_x_, offset_y_);
|
||||
|
||||
// Shadow for the board
|
||||
set_source(cr, pal.shadow);
|
||||
cr->rectangle(5, 5, board_w_px, board_h_px);
|
||||
cr->fill();
|
||||
|
||||
for (int y = 0; y < rows; ++y) {
|
||||
for (int x = 0; x < cols; ++x) {
|
||||
draw_cell(cr, x, y, cell_size_, field_->get_cell(x, y), pal);
|
||||
}
|
||||
}
|
||||
cr->restore();
|
||||
}
|
||||
|
||||
if (!particles_.empty()) {
|
||||
draw_particles(cr);
|
||||
}
|
||||
|
||||
if (field_ && field_->state() == GameState::Lost) {
|
||||
draw_game_over_overlay(cr, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
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 py = y * size;
|
||||
|
||||
bool is_hover = (x == hover_x_ && y == hover_y_ && field_->state() == GameState::Playing);
|
||||
bool revealed = cell.is_revealed;
|
||||
double lw = std::clamp(size * 0.12, 1.0, 4.0);
|
||||
|
||||
// Base shape
|
||||
cr->rectangle(px, py, size, size);
|
||||
|
||||
if (revealed) {
|
||||
if (cell.is_exploded) {
|
||||
cr->set_source_rgb(0.9, 0.3, 0.3); // Red background
|
||||
cr->fill();
|
||||
} else {
|
||||
set_source(cr, pal.revealed);
|
||||
cr->fill();
|
||||
|
||||
// Sunken bevel: dark top/left, light bottom/right
|
||||
set_source(cr, pal.sunken);
|
||||
cr->rectangle(px, py, size, lw * 0.5);
|
||||
cr->fill();
|
||||
cr->rectangle(px, py, lw * 0.5, size);
|
||||
cr->fill();
|
||||
set_source(cr, pal.sunken_light);
|
||||
cr->rectangle(px, py + size - lw * 0.5, size, lw * 0.5);
|
||||
cr->fill();
|
||||
cr->rectangle(px + size - lw * 0.5, py, lw * 0.5, size);
|
||||
cr->fill();
|
||||
}
|
||||
} else {
|
||||
if (is_hover) {
|
||||
set_source(cr, pal.hover); // Hover highlight
|
||||
} else {
|
||||
// Gradient for unrevealed cells
|
||||
auto pat = Cairo::LinearGradient::create(px, py, px + size, py + size);
|
||||
pat->add_color_stop_rgba(0, pal.unrevealed_a.get_red(), pal.unrevealed_a.get_green(), pal.unrevealed_a.get_blue(), pal.unrevealed_a.get_alpha());
|
||||
pat->add_color_stop_rgba(1, pal.unrevealed_b.get_red(), pal.unrevealed_b.get_green(), pal.unrevealed_b.get_blue(), pal.unrevealed_b.get_alpha());
|
||||
cr->set_source(pat);
|
||||
}
|
||||
cr->fill();
|
||||
|
||||
// Raised bevel: light top/left, dark bottom/right
|
||||
set_source(cr, pal.bevel_light);
|
||||
cr->rectangle(px, py, size, lw);
|
||||
cr->fill();
|
||||
cr->rectangle(px, py, lw, size);
|
||||
cr->fill();
|
||||
set_source(cr, pal.bevel_dark);
|
||||
cr->rectangle(px, py + size - lw, size, lw);
|
||||
cr->fill();
|
||||
cr->rectangle(px + size - lw, py, lw, size);
|
||||
cr->fill();
|
||||
}
|
||||
|
||||
// Content
|
||||
if (cell.is_flagged) {
|
||||
draw_flag(cr, px, py, size, pal);
|
||||
} else if (revealed) {
|
||||
if (cell.is_bomb) {
|
||||
draw_bomb(cr, px, py, size, cell.is_exploded, pal);
|
||||
} else if (cell.nearby_bombs > 0) {
|
||||
draw_digit(cr, cell.nearby_bombs, px, py, size, pal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 cy = y + size / 2.0;
|
||||
|
||||
// Pole
|
||||
set_source(cr, pal.flag_pole);
|
||||
cr->set_line_width(2.0);
|
||||
cr->move_to(cx - size * 0.1, cy + size * 0.3);
|
||||
cr->line_to(cx - size * 0.1, cy - size * 0.3);
|
||||
cr->stroke();
|
||||
|
||||
// Flag
|
||||
cr->set_source_rgb(0.9, 0.2, 0.2);
|
||||
cr->move_to(cx - size * 0.1, cy - size * 0.3);
|
||||
cr->line_to(cx + size * 0.25, cy - size * 0.15);
|
||||
cr->line_to(cx - size * 0.1, cy);
|
||||
cr->close_path();
|
||||
cr->fill();
|
||||
|
||||
// Pole cap and base
|
||||
cr->set_source_rgb(0.2, 0.2, 0.2);
|
||||
cr->arc(cx - size * 0.1, cy - size * 0.3, size * 0.04, 0, 2 * M_PI);
|
||||
cr->fill();
|
||||
cr->rectangle(cx - size * 0.14, cy + size * 0.3, size * 0.08, size * 0.07);
|
||||
cr->fill();
|
||||
}
|
||||
|
||||
void BoardWidget::draw_bomb(const Cairo::RefPtr<Cairo::Context>& cr, double x, double y, double size, bool exploded, const Palette& pal) {
|
||||
double cx = x + size / 2.0;
|
||||
double cy = y + size / 2.0;
|
||||
double r = size * 0.25;
|
||||
|
||||
// Bomb body
|
||||
auto pat = Cairo::RadialGradient::create(cx - r*0.3, cy - r*0.3, r*0.1, cx, cy, r);
|
||||
pat->add_color_stop_rgba(0, 0.4, 0.4, 0.4, 1);
|
||||
pat->add_color_stop_rgba(1, 0.1, 0.1, 0.1, 1);
|
||||
cr->set_source(pat);
|
||||
cr->arc(cx, cy, r, 0, 2 * M_PI);
|
||||
cr->fill();
|
||||
|
||||
// Specular highlight
|
||||
cr->set_source_rgba(1.0, 1.0, 1.0, 0.35);
|
||||
cr->arc(cx - r * 0.35, cy - r * 0.35, r * 0.28, 0, 2 * M_PI);
|
||||
cr->fill();
|
||||
|
||||
// Spikes/Fuse
|
||||
set_source(cr, pal.flag_pole);
|
||||
cr->set_line_width(2.0);
|
||||
cr->move_to(cx, cy - r);
|
||||
cr->line_to(cx, cy - r - size * 0.1);
|
||||
cr->stroke();
|
||||
|
||||
// Spark if exploded?
|
||||
if (exploded) {
|
||||
cr->set_source_rgb(1.0, 0.5, 0.0);
|
||||
cr->arc(cx, cy - r - size * 0.1, size * 0.05, 0, 2 * M_PI);
|
||||
cr->fill();
|
||||
}
|
||||
}
|
||||
|
||||
void BoardWidget::draw_digit(const Cairo::RefPtr<Cairo::Context>& cr, int number, double x, double y, double size, const Palette& pal) {
|
||||
if (number >= 1 && number <= 8) {
|
||||
set_source(cr, pal.digits[number - 1]);
|
||||
}
|
||||
|
||||
cr->select_font_face("Sans", Cairo::ToyFontFace::Slant::NORMAL, Cairo::ToyFontFace::Weight::BOLD);
|
||||
cr->set_font_size(size * 0.7); // Bigger font
|
||||
|
||||
Cairo::TextExtents extents;
|
||||
std::string text = std::to_string(number);
|
||||
cr->get_text_extents(text, extents);
|
||||
|
||||
cr->move_to(x + (size - extents.width) / 2 - extents.x_bearing,
|
||||
y + (size - extents.height) / 2 - extents.y_bearing);
|
||||
cr->show_text(text);
|
||||
}
|
||||
|
||||
void BoardWidget::draw_particles(const Cairo::RefPtr<Cairo::Context>& cr) {
|
||||
for (const auto& p : particles_) {
|
||||
if (p.life <= 0) continue;
|
||||
cr->set_source_rgba(p.r, p.g, p.b, p.life);
|
||||
cr->save();
|
||||
cr->translate(p.x, p.y);
|
||||
cr->rotate(p.rotation);
|
||||
if (p.circle) {
|
||||
cr->arc(0, 0, p.size * 0.5, 0, 2 * M_PI);
|
||||
} else {
|
||||
cr->rectangle(-p.size * 0.5, -p.size * 0.3, p.size, p.size * 0.6);
|
||||
}
|
||||
cr->fill();
|
||||
cr->restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include <gtkmm/drawingarea.h>
|
||||
#include <gtkmm/gestureclick.h>
|
||||
#include <gtkmm/eventcontrollermotion.h>
|
||||
#include <gdkmm/rgba.h>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <random>
|
||||
#include "minefield.hpp"
|
||||
|
||||
struct Particle {
|
||||
double x, y;
|
||||
double vx, vy;
|
||||
double rotation;
|
||||
double vrot;
|
||||
double life; // 1.0 -> 0.0
|
||||
double lifetime; // seconds
|
||||
double r, g, b;
|
||||
double size;
|
||||
double phase; // sway phase
|
||||
bool circle;
|
||||
};
|
||||
|
||||
class BoardWidget : public Gtk::DrawingArea {
|
||||
public:
|
||||
BoardWidget();
|
||||
virtual ~BoardWidget();
|
||||
|
||||
void set_minefield(std::shared_ptr<Minefield> field);
|
||||
|
||||
// Signal when state changes (for UI updates)
|
||||
sigc::signal<void()> signal_state_changed;
|
||||
|
||||
// Animation
|
||||
void start_confetti();
|
||||
bool on_animation_tick(const Glib::RefPtr<Gdk::FrameClock>& clock);
|
||||
bool process_animation(double dt); // Internal helper
|
||||
|
||||
private:
|
||||
void on_draw(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height);
|
||||
|
||||
// Input handlers
|
||||
void on_click_pressed(int /*n_press*/, double x, double y);
|
||||
void on_right_click_pressed(int /*n_press*/, double x, double y);
|
||||
void on_motion(double x, double y);
|
||||
void on_leave();
|
||||
|
||||
// Helpers
|
||||
struct Palette {
|
||||
Gdk::RGBA bg;
|
||||
Gdk::RGBA revealed;
|
||||
Gdk::RGBA unrevealed_a;
|
||||
Gdk::RGBA unrevealed_b;
|
||||
Gdk::RGBA bevel_light;
|
||||
Gdk::RGBA bevel_dark;
|
||||
Gdk::RGBA sunken;
|
||||
Gdk::RGBA sunken_light;
|
||||
Gdk::RGBA hover;
|
||||
Gdk::RGBA shadow;
|
||||
Gdk::RGBA flag_pole;
|
||||
Gdk::RGBA digits[8];
|
||||
};
|
||||
|
||||
Palette resolve_palette();
|
||||
std::pair<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, const Palette& pal);
|
||||
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, const Palette& pal);
|
||||
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_game_over_overlay(const Cairo::RefPtr<Cairo::Context>& cr, int width, int height);
|
||||
|
||||
std::shared_ptr<Minefield> field_;
|
||||
|
||||
// State
|
||||
double cell_size_ = 32.0;
|
||||
double offset_x_ = 0;
|
||||
double offset_y_ = 0;
|
||||
int hover_x_ = -1;
|
||||
int hover_y_ = -1;
|
||||
bool cursor_pointer_ = false;
|
||||
|
||||
// Particles
|
||||
std::vector<Particle> particles_;
|
||||
guint tick_id_ = 0;
|
||||
gint64 last_frame_time_us_ = 0;
|
||||
|
||||
// Controllers
|
||||
Glib::RefPtr<Gtk::GestureClick> left_click_controller_;
|
||||
Glib::RefPtr<Gtk::GestureClick> right_click_controller_;
|
||||
Glib::RefPtr<Gtk::EventControllerMotion> motion_controller_;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
+5
-27
@@ -1,30 +1,8 @@
|
||||
#include "MineField.hpp"
|
||||
#include <iostream>
|
||||
#include "window.hpp"
|
||||
#include <gtkmm/application.h>
|
||||
|
||||
int main() {
|
||||
int main(int argc, char* argv[]) {
|
||||
auto app = Gtk::Application::create("io.github.bemagri.nomines");
|
||||
|
||||
MineField field(160,160, 100);
|
||||
|
||||
srand(time(NULL));
|
||||
|
||||
int x = rand() % 160;
|
||||
int y = rand() % 160;
|
||||
|
||||
field.initBombs(x, y);
|
||||
|
||||
printf("Opened cell: %i %i\n", x, y);
|
||||
printf("Neighboor bombs: %i\n", field.bombsNearby(x,y));
|
||||
|
||||
while(!field.isGameOver()) {
|
||||
x = rand() % 160;
|
||||
y = rand() % 160;
|
||||
|
||||
if(field.clearCell(x, y)) {
|
||||
printf("Opened cell: %i %i\n", x, y);
|
||||
printf("Neighboor bombs: %i\n", field.bombsNearby(x,y));
|
||||
}
|
||||
else {
|
||||
printf("Bomb found in cell: %i %i\n", x, y);
|
||||
}
|
||||
}
|
||||
return app->make_window_and_run<MainWindow>(argc, argv);
|
||||
}
|
||||
|
||||
+231
-130
@@ -1,141 +1,242 @@
|
||||
#include "minefield.hpp"
|
||||
#include <random>
|
||||
#include <algorithm>
|
||||
#include <queue>
|
||||
|
||||
MineField::MineField(int cols, int rows, int mines): m_rows(rows),
|
||||
m_cols(cols),
|
||||
m_totalMines(mines),
|
||||
m_remainingFlags(mines),
|
||||
m_openCells(0),
|
||||
m_exploded(false) {
|
||||
for(int i=0; i< m_cols*m_rows; i++) {
|
||||
std::shared_ptr<Cell> cell = std::make_shared<Cell>();
|
||||
m_cells.push_back(cell);
|
||||
}
|
||||
}
|
||||
const GameDifficulty Minefield::DifficultyEasy = {"Beginner", 9, 9, 10};
|
||||
const GameDifficulty Minefield::DifficultyMedium = {"Intermediate", 16, 16, 40};
|
||||
const GameDifficulty Minefield::DifficultyHard = {"Expert", 30, 16, 99};
|
||||
const GameDifficulty Minefield::DifficultyExpert = {"Master", 30, 20, 145};
|
||||
|
||||
void MineField::timerTick() {
|
||||
|
||||
auto start = std::chrono::system_clock::now();
|
||||
|
||||
while((m_exploded == false) && (m_gameWon == false)) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
auto now = std::chrono::system_clock::now();
|
||||
const auto duration = now - start;
|
||||
std::chrono::milliseconds ms = std::chrono::duration_cast<std::chrono::milliseconds>(duration);
|
||||
m_time += ms.count();
|
||||
timerSignal.emit(m_time);
|
||||
start = std::chrono::system_clock::now();
|
||||
}
|
||||
//I should use std::duration to represent the time instead
|
||||
}
|
||||
|
||||
void MineField::initBombs(int x, int y) {
|
||||
|
||||
int remainingMines = m_totalMines;
|
||||
int startPos = x + y * m_rows;
|
||||
|
||||
srand(time(NULL)); //initialize rand()
|
||||
|
||||
while(remainingMines > 0) {
|
||||
int position = rand() % (m_cols * m_rows);
|
||||
if(isBomb(position % m_cols, position / m_cols) || position == startPos) {
|
||||
continue;
|
||||
Minefield::Minefield(int cols, int rows, int mines)
|
||||
: cols_(cols), rows_(rows), total_mines_(mines), remaining_flags_(mines) {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
throw std::invalid_argument("Minefield: board dimensions must be positive");
|
||||
}
|
||||
m_cells.at(position)->isBomb = true;
|
||||
--remainingMines;
|
||||
}
|
||||
|
||||
//init the timer to zero and start the timer thread
|
||||
m_time = 0;
|
||||
timerThread = std::thread(&MineField::timerTick, this);
|
||||
timerThread.detach(); //not sure if this is okay (better to call join() when I set the condition to stop the thread)
|
||||
if (mines < 0 || mines >= cols * rows) {
|
||||
throw std::invalid_argument("Minefield: mine count must be within the board");
|
||||
}
|
||||
cells_.resize(cols * rows);
|
||||
}
|
||||
|
||||
bool MineField::openCell(int x, int y) {
|
||||
if(isBomb(x, y)) {
|
||||
m_exploded = true;
|
||||
gameOverSignal.emit();
|
||||
const Cell& Minefield::get_cell(int x, int y) const {
|
||||
static const Cell invalid_cell;
|
||||
if (x < 0 || x >= cols_ || y < 0 || y >= rows_) return invalid_cell;
|
||||
return cells_[y * cols_ + x];
|
||||
}
|
||||
|
||||
std::chrono::milliseconds Minefield::get_elapsed_time() const {
|
||||
if (state_ == GameState::Ready) return std::chrono::milliseconds(0);
|
||||
auto end = (state_ == GameState::Playing) ? std::chrono::steady_clock::now() : end_time_;
|
||||
return std::chrono::duration_cast<std::chrono::milliseconds>(end - start_time_);
|
||||
}
|
||||
|
||||
void Minefield::generate_mines(int safe_x, int safe_y) {
|
||||
std::vector<int> indices(cells_.size());
|
||||
std::iota(indices.begin(), indices.end(), 0);
|
||||
|
||||
// Remove safe zone (3x3 around click)
|
||||
indices.erase(std::remove_if(indices.begin(), indices.end(), [&](int idx) {
|
||||
int cx = idx % cols_;
|
||||
int cy = idx / cols_;
|
||||
return std::abs(cx - safe_x) <= 1 && std::abs(cy - safe_y) <= 1;
|
||||
}), indices.end());
|
||||
|
||||
std::random_device rd;
|
||||
std::mt19937 g(rd());
|
||||
std::shuffle(indices.begin(), indices.end(), g);
|
||||
|
||||
int placed = 0;
|
||||
for (int idx : indices) {
|
||||
if (placed >= total_mines_) break;
|
||||
cells_[idx].is_bomb = true;
|
||||
placed++;
|
||||
}
|
||||
|
||||
// Precompute neighbors
|
||||
for (int y = 0; y < rows_; ++y) {
|
||||
for (int x = 0; x < cols_; ++x) {
|
||||
if (!cells_[y * cols_ + x].is_bomb) {
|
||||
cells_[y * cols_ + x].nearby_bombs = count_nearby_bombs(x, y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int Minefield::count_nearby_bombs(int x, int y) const {
|
||||
int count = 0;
|
||||
for (int dy = -1; dy <= 1; ++dy) {
|
||||
for (int dx = -1; dx <= 1; ++dx) {
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx = x + dx;
|
||||
int ny = y + dy;
|
||||
if (nx >= 0 && nx < cols_ && ny >= 0 && ny < rows_) {
|
||||
if (cells_[ny * cols_ + nx].is_bomb) count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
int Minefield::count_nearby_flags(int x, int y) const {
|
||||
int count = 0;
|
||||
for (int dy = -1; dy <= 1; ++dy) {
|
||||
for (int dx = -1; dx <= 1; ++dx) {
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx = x + dx;
|
||||
int ny = y + dy;
|
||||
if (nx >= 0 && nx < cols_ && ny >= 0 && ny < rows_) {
|
||||
if (cells_[ny * cols_ + nx].is_flagged) count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
bool Minefield::open_cell(int x, int y) {
|
||||
if (state_ == GameState::Won || state_ == GameState::Lost) return false;
|
||||
if (x < 0 || x >= cols_ || y < 0 || y >= rows_) return false;
|
||||
|
||||
Cell& cell = cells_[y * cols_ + x];
|
||||
if (cell.is_flagged || cell.is_revealed) return false;
|
||||
|
||||
if (state_ == GameState::Ready) {
|
||||
start_time_ = std::chrono::steady_clock::now();
|
||||
state_ = GameState::Playing;
|
||||
generate_mines(x, y);
|
||||
}
|
||||
|
||||
if (cell.is_bomb) {
|
||||
cell.is_exploded = true;
|
||||
cell.is_revealed = true;
|
||||
state_ = GameState::Lost;
|
||||
end_time_ = std::chrono::steady_clock::now();
|
||||
|
||||
// Reveal all other bombs
|
||||
for (auto& c : cells_) {
|
||||
if (c.is_bomb && !c.is_flagged) c.is_revealed = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
reveal_recursive(x, y);
|
||||
check_win_condition();
|
||||
return true;
|
||||
}
|
||||
|
||||
void Minefield::reveal_recursive(int x, int y) {
|
||||
// Non-recursive BFS to avoid stack overflow
|
||||
std::vector<std::pair<int, int>> q;
|
||||
q.reserve(cols_ * rows_);
|
||||
q.push_back({x, y});
|
||||
|
||||
// Mark initial as revealed
|
||||
if (!cells_[y * cols_ + x].is_revealed) {
|
||||
cells_[y * cols_ + x].is_revealed = true;
|
||||
revealed_count_++;
|
||||
}
|
||||
|
||||
size_t head = 0;
|
||||
while(head < q.size()){
|
||||
auto [cx, cy] = q[head++];
|
||||
Cell& current = cells_[cy * cols_ + cx];
|
||||
|
||||
if (current.nearby_bombs == 0) {
|
||||
for (int dy = -1; dy <= 1; ++dy) {
|
||||
for (int dx = -1; dx <= 1; ++dx) {
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx = cx + dx;
|
||||
int ny = cy + dy;
|
||||
if (nx >= 0 && nx < cols_ && ny >= 0 && ny < rows_) {
|
||||
Cell& neighbor = cells_[ny * cols_ + nx];
|
||||
if (!neighbor.is_revealed && !neighbor.is_flagged) {
|
||||
neighbor.is_revealed = true;
|
||||
revealed_count_++;
|
||||
q.push_back({nx, ny});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool Minefield::toggle_flag(int x, int y) {
|
||||
if (state_ == GameState::Won || state_ == GameState::Lost) return false;
|
||||
if (x < 0 || x >= cols_ || y < 0 || y >= rows_) return false;
|
||||
|
||||
Cell& cell = cells_[y * cols_ + x];
|
||||
if (cell.is_revealed) return false;
|
||||
|
||||
if (cell.is_flagged) {
|
||||
cell.is_flagged = false;
|
||||
remaining_flags_++;
|
||||
} else {
|
||||
if (remaining_flags_ > 0) {
|
||||
cell.is_flagged = true;
|
||||
remaining_flags_--;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Minefield::chord_cell(int x, int y) {
|
||||
if (state_ != GameState::Playing) return false;
|
||||
if (x < 0 || x >= cols_ || y < 0 || y >= rows_) return false;
|
||||
|
||||
Cell& cell = cells_[y * cols_ + x];
|
||||
if (!cell.is_revealed) return false;
|
||||
if (cell.nearby_bombs == 0) return false;
|
||||
|
||||
if (count_nearby_flags(x, y) == cell.nearby_bombs) {
|
||||
bool changed = false;
|
||||
for (int dy = -1; dy <= 1; ++dy) {
|
||||
for (int dx = -1; dx <= 1; ++dx) {
|
||||
if (dx == 0 && dy == 0) continue;
|
||||
int nx = x + dx;
|
||||
int ny = y + dy;
|
||||
if (nx >= 0 && nx < cols_ && ny >= 0 && ny < rows_) {
|
||||
Cell& neighbor = cells_[ny * cols_ + nx];
|
||||
if (!neighbor.is_revealed && !neighbor.is_flagged) {
|
||||
// Standard open logic
|
||||
if (neighbor.is_bomb) {
|
||||
neighbor.is_exploded = true;
|
||||
neighbor.is_revealed = true;
|
||||
state_ = GameState::Lost;
|
||||
end_time_ = std::chrono::steady_clock::now();
|
||||
// Reveal all bombs
|
||||
for (auto& c : cells_) {
|
||||
if (c.is_bomb && !c.is_flagged) c.is_revealed = true;
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
reveal_recursive(nx, ny);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check_win_condition();
|
||||
return changed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setOpenCell(x, y);
|
||||
|
||||
if (bombsNearby(x, y) == 0) {
|
||||
openNeighboorhood(x, y);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void MineField::computeBombsNearby(int x, int y) {
|
||||
int total = 0;
|
||||
//compute bombs in neighboorhood
|
||||
for(int i=-1; i<2; i++) {
|
||||
for(int j=-1; j<2; j++) {
|
||||
if(x+i >= 0 && x+i < m_cols && y+j >= 0 && y+j < m_rows) {
|
||||
if(isBomb(x+i, y+j)){
|
||||
++total;
|
||||
}
|
||||
}
|
||||
void Minefield::check_win_condition() {
|
||||
if (state_ != GameState::Playing) return;
|
||||
|
||||
// Win if all non-bomb cells are revealed
|
||||
int safe_cells = (cols_ * rows_) - total_mines_;
|
||||
if (revealed_count_ == safe_cells) {
|
||||
state_ = GameState::Won;
|
||||
end_time_ = std::chrono::steady_clock::now();
|
||||
remaining_flags_ = 0; // Visually satisfy flags
|
||||
// Flag all unflagged bombs
|
||||
for (auto& c : cells_) {
|
||||
if (c.is_bomb) c.is_flagged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
m_cells.at(x + y * m_rows)->bombsNearby = total;
|
||||
}
|
||||
|
||||
void MineField::openNeighboorhood(int x, int y) {
|
||||
//compute bombs in neighboorhood
|
||||
for(int i=-1; i<2; i++) {
|
||||
for(int j=-1; j<2; j++) {
|
||||
if(x+i >= 0 && x+i < m_cols && y+j >= 0 && y+j < m_rows) {
|
||||
if((isOpened(x+i, y+j) == false) && (isBomb(x+i, y+j) == false)){
|
||||
setOpenCell((x+i), (y+j));
|
||||
if(bombsNearby(x+i, y+j) == 0) {
|
||||
openNeighboorhood(x+i, y+j);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool MineField::isOpened(int x, int y) {
|
||||
return m_cells.at(x + y * m_rows)->isCleared;
|
||||
}
|
||||
|
||||
bool MineField::isFlagged(int x, int y) {
|
||||
return m_cells.at(x + y * m_rows)->isFlagged;
|
||||
}
|
||||
|
||||
bool MineField::isBomb(int x, int y) {
|
||||
return m_cells.at(x + y * m_rows)->isBomb;
|
||||
}
|
||||
|
||||
int MineField::bombsNearby(int x, int y) {
|
||||
if(m_cells.at(x + y * m_rows)->bombsNearby == -1) {
|
||||
computeBombsNearby(x, y);
|
||||
}
|
||||
return m_cells.at(x + y * m_rows)->bombsNearby;
|
||||
}
|
||||
|
||||
void MineField::setOpenCell(int x, int y) {
|
||||
m_cells.at(x + y * m_rows)->isCleared = true;
|
||||
openCellSignal.emit(x, y);
|
||||
if((++m_openCells == (m_cols * m_rows - m_totalMines)) && (m_exploded == false)) {
|
||||
m_gameWon = true;
|
||||
gameWonSignal.emit();
|
||||
}
|
||||
}
|
||||
|
||||
bool MineField::toggleFlag(int x, int y) {
|
||||
if(m_cells.at(x + y * m_rows)->isFlagged == true) {
|
||||
m_cells.at(x + y * m_rows)->isFlagged = false;
|
||||
++m_remainingFlags;
|
||||
remainingFlagsSignal.emit(m_remainingFlags);
|
||||
return true;
|
||||
}
|
||||
else if(m_remainingFlags > 0) {
|
||||
m_cells.at(x + y * m_rows)->isFlagged = true;
|
||||
--m_remainingFlags;
|
||||
remainingFlagsSignal.emit(m_remainingFlags);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
+67
-46
@@ -1,57 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include <sigc++/signal.h>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <memory>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <random>
|
||||
#include <algorithm>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <stdexcept>
|
||||
|
||||
struct GameDifficulty {
|
||||
std::string name;
|
||||
int cols;
|
||||
int rows;
|
||||
int mines;
|
||||
};
|
||||
|
||||
enum class GameState {
|
||||
Ready,
|
||||
Playing,
|
||||
Won,
|
||||
Lost
|
||||
};
|
||||
|
||||
struct Cell {
|
||||
bool isFlagged;
|
||||
bool isCleared;
|
||||
bool isBomb;
|
||||
int bombsNearby;
|
||||
Cell(): isFlagged(false), isCleared(false), isBomb(false), bombsNearby(-1) {};
|
||||
bool is_bomb : 1 = false;
|
||||
bool is_flagged : 1 = false;
|
||||
bool is_revealed : 1 = false;
|
||||
bool is_exploded : 1 = false; // The specific bomb that killed you
|
||||
uint8_t nearby_bombs : 4 = 0;
|
||||
};
|
||||
|
||||
class MineField {
|
||||
std::vector<std::shared_ptr<Cell>> m_cells;
|
||||
int m_rows;
|
||||
int m_cols;
|
||||
int m_totalMines;
|
||||
int m_remainingFlags;
|
||||
int m_openCells;
|
||||
bool m_exploded;
|
||||
bool m_gameWon;
|
||||
size_t m_time;
|
||||
std::thread timerThread;
|
||||
void computeBombsNearby(int x, int y);
|
||||
void openNeighboorhood(int x, int y);
|
||||
void setOpenCell(int x, int y);
|
||||
void timerTick();
|
||||
|
||||
class Minefield {
|
||||
public:
|
||||
MineField(int cols, int rows, int mines);
|
||||
void initBombs(int x, int y);
|
||||
bool isBomb(int x, int y);
|
||||
bool isFlagged(int x, int y);
|
||||
bool isOpened(int x, int y);
|
||||
bool openCell(int x, int y);
|
||||
int bombsNearby(int x, int y);
|
||||
bool isGameOver() {return m_exploded; };
|
||||
int getCols() {return m_cols; };
|
||||
int getRows() {return m_rows; };
|
||||
bool toggleFlag(int x, int y);
|
||||
int getRemainingFlags() {return m_remainingFlags; };
|
||||
int getTotalMines() {return m_totalMines; };
|
||||
void startNewGame(int cols, int rows, int mines);
|
||||
Minefield(int cols, int rows, int mines);
|
||||
|
||||
sigc::signal<void(int, int)> openCellSignal;
|
||||
sigc::signal<void(int)> remainingFlagsSignal;
|
||||
sigc::signal<void(void)> gameWonSignal;
|
||||
sigc::signal<void(void)> gameOverSignal;
|
||||
sigc::signal<void(unsigned int)> timerSignal;
|
||||
// Core actions
|
||||
// Returns true if state changed
|
||||
bool open_cell(int x, int y);
|
||||
bool toggle_flag(int x, int y);
|
||||
bool chord_cell(int x, int y);
|
||||
|
||||
// Getters
|
||||
int cols() const { return cols_; }
|
||||
int rows() const { return rows_; }
|
||||
int total_mines() const { return total_mines_; }
|
||||
int remaining_flags() const { return remaining_flags_; }
|
||||
GameState state() const { return state_; }
|
||||
const Cell& get_cell(int x, int y) const;
|
||||
|
||||
// Timing
|
||||
std::chrono::milliseconds get_elapsed_time() const;
|
||||
|
||||
// Difficulty presets
|
||||
static const GameDifficulty DifficultyEasy;
|
||||
static const GameDifficulty DifficultyMedium;
|
||||
static const GameDifficulty DifficultyHard;
|
||||
static const GameDifficulty DifficultyExpert;
|
||||
|
||||
private:
|
||||
void generate_mines(int safe_x, int safe_y);
|
||||
void reveal_recursive(int x, int y);
|
||||
void check_win_condition();
|
||||
int count_nearby_flags(int x, int y) const;
|
||||
int count_nearby_bombs(int x, int y) const;
|
||||
|
||||
int cols_;
|
||||
int rows_;
|
||||
int total_mines_;
|
||||
int remaining_flags_;
|
||||
int revealed_count_ = 0;
|
||||
|
||||
GameState state_ = GameState::Ready;
|
||||
std::vector<Cell> cells_;
|
||||
|
||||
std::chrono::steady_clock::time_point start_time_;
|
||||
std::chrono::steady_clock::time_point end_time_;
|
||||
};
|
||||
|
||||
+308
-269
@@ -1,302 +1,341 @@
|
||||
#include "window.hpp"
|
||||
#include "gdkmm/texture.h"
|
||||
#include "sigc++/functors/mem_fun.h"
|
||||
#include <gdk/gdkkeysyms.h>
|
||||
#include <iostream>
|
||||
#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
|
||||
|
||||
//}
|
||||
// void MainWindow::ApplyStyles() {
|
||||
// // Load and apply the CSS file
|
||||
// auto css_provider = Gtk::CssProvider::create();
|
||||
// css_provider->load_from_path("style.css");
|
||||
// Gtk::StyleContext::add_provider_for_display(Gdk::Display::get_default(), css_provider, GTK_STYLE_PROVIDER_PRIORITY_USER);
|
||||
// }
|
||||
MainWindow::MainWindow() {
|
||||
set_title("NoMines");
|
||||
set_default_size(800, 600);
|
||||
|
||||
void MainWindow::OnCellRightClick(int n_press, double n_x, double n_y, int index) {
|
||||
(void)n_press, (void)n_x, (void)n_y;
|
||||
int x = index % field.getCols();
|
||||
int y = index / field.getCols();
|
||||
int pos = x + y * field.getRows();
|
||||
setup_header_bar();
|
||||
setup_board();
|
||||
|
||||
if(field.isOpened(x, y) == false) {
|
||||
field.toggleFlag(x, y);
|
||||
if(field.isFlagged(x, y)) {
|
||||
auto imgflag = Gtk::make_managed<Gtk::Image>();
|
||||
imgflag->set(m_textureFlag);
|
||||
buttons.at(pos)->set_child(*imgflag);
|
||||
buttons.at(pos)->set_active(true);
|
||||
}
|
||||
else {
|
||||
buttons.at(pos)->unset_child();
|
||||
buttons.at(pos)->queue_draw();
|
||||
buttons.at(pos)->set_active(false);
|
||||
}
|
||||
}
|
||||
// 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_new_game(Minefield::DifficultyEasy);
|
||||
}
|
||||
|
||||
void MainWindow::updateFlagsLabel(int flags) {
|
||||
Glib::ustring msg = Glib::ustring::compose("Remaining flags: %1", flags);
|
||||
flagLabel.set_label(msg);
|
||||
MainWindow::~MainWindow() {
|
||||
timer_conn_.disconnect();
|
||||
dialog_timeout_conn_.disconnect();
|
||||
}
|
||||
// void MainWindow::OnNewButtonClick() {
|
||||
// newGame = true;
|
||||
// gameOver = false;
|
||||
|
||||
// for (auto &button : buttons) {
|
||||
// button->set_active(false);
|
||||
// button->set_sensitive(true);
|
||||
// button->set_label("");
|
||||
// }
|
||||
void MainWindow::setup_header_bar() {
|
||||
set_titlebar(header_bar_);
|
||||
|
||||
// //field->remainingFlags = MINES;
|
||||
// Glib::ustring msg = Glib::ustring::compose("Remaining flags: %1", field->remainingFlags);
|
||||
// flagLabel.set_label(msg);
|
||||
// Difficulty Menu
|
||||
btn_difficulty_.set_label(Minefield::DifficultyEasy.name);
|
||||
|
||||
// if (clockConn.connected()) clockConn.disconnect();
|
||||
// elapsedTime = 0;
|
||||
// clockConn = Glib::signal_timeout().connect(sigc::mem_fun(*this, &MainWindow::UpdateClockLabel), 100);
|
||||
// }
|
||||
box_difficulty_.set_orientation(Gtk::Orientation::VERTICAL);
|
||||
box_difficulty_.set_margin(10);
|
||||
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* btn = Gtk::make_managed<Gtk::CheckButton>(diff.name);
|
||||
btn->add_css_class("radio");
|
||||
btn->set_group(*group);
|
||||
btn->signal_toggled().connect([this, btn, diff]() {
|
||||
if (!btn->get_active()) return; // Only react when this one is chosen
|
||||
start_new_game(diff);
|
||||
btn_difficulty_.popdown();
|
||||
});
|
||||
box_difficulty_.append(*btn);
|
||||
difficulty_buttons_.emplace_back(diff, btn);
|
||||
};
|
||||
|
||||
add_diff_btn(Minefield::DifficultyMedium);
|
||||
add_diff_btn(Minefield::DifficultyHard);
|
||||
add_diff_btn(Minefield::DifficultyExpert);
|
||||
group->set_active(true);
|
||||
|
||||
void MainWindow::OnCellClick(int x, int y) {
|
||||
if (newGame) {
|
||||
field.initBombs(x, y);
|
||||
newGame = false;
|
||||
btn_difficulty_.set_popover(menu_difficulty_);
|
||||
menu_difficulty_.set_child(box_difficulty_);
|
||||
menu_difficulty_.add_css_class("menu");
|
||||
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_.add_css_class("menu");
|
||||
menu_leaderboard_.signal_show().connect(sigc::mem_fun(*this, &MainWindow::refresh_leaderboard));
|
||||
header_bar_.pack_start(btn_leaderboard_);
|
||||
}
|
||||
|
||||
void MainWindow::refresh_leaderboard() {
|
||||
while (auto* child = box_leaderboard_.get_first_child()) {
|
||||
box_leaderboard_.remove(*child);
|
||||
}
|
||||
|
||||
if(field.isFlagged(x, y)) {
|
||||
buttons.at(x + y * field.getRows())->set_active(true);
|
||||
auto* title = Gtk::make_managed<Gtk::Label>();
|
||||
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;
|
||||
}
|
||||
else {
|
||||
field.openCell(x, y);
|
||||
if(field.isBomb(x, y)) {
|
||||
openBombs();
|
||||
}
|
||||
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() {
|
||||
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));
|
||||
}
|
||||
|
||||
void MainWindow::start_new_game(const GameDifficulty& difficulty) {
|
||||
current_difficulty_ = difficulty;
|
||||
|
||||
// Reflect the active difficulty in the menu button and popover
|
||||
btn_difficulty_.set_label(difficulty.name);
|
||||
for (const auto& [diff, btn] : difficulty_buttons_) {
|
||||
btn->set_active(diff.name == difficulty.name);
|
||||
}
|
||||
|
||||
// Cancel any pending game-over dialog from the finished game
|
||||
dialog_timeout_conn_.disconnect();
|
||||
|
||||
minefield_ = std::make_shared<Minefield>(difficulty.cols, difficulty.rows, difficulty.mines);
|
||||
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
|
||||
timer_conn_.disconnect();
|
||||
timer_conn_ = Glib::signal_timeout().connect(sigc::mem_fun(*this, &MainWindow::on_timer_tick), 100);
|
||||
|
||||
on_game_state_changed();
|
||||
}
|
||||
|
||||
void MainWindow::openBombs() {
|
||||
for(int i=0; i < field.getCols() * field.getRows(); i++) {
|
||||
int x = i % field.getCols();
|
||||
int y = i / field.getCols();
|
||||
void MainWindow::on_game_state_changed() {
|
||||
if (!minefield_) return;
|
||||
|
||||
buttons.at(i)->set_sensitive(false);
|
||||
// Update Flags
|
||||
lbl_flags_.set_text("Flags: " + std::to_string(minefield_->remaining_flags()));
|
||||
|
||||
if(field.isBomb(x, y)) {
|
||||
if(field.isFlagged(x, y)) {
|
||||
auto imgFlagBomb = std::make_shared<Gtk::Image>();
|
||||
imgFlagBomb->set(m_textureFlagBomb);
|
||||
buttons.at(i)->set_child(*imgFlagBomb);
|
||||
}
|
||||
else {
|
||||
auto imgBomb = std::make_shared<Gtk::Image>();
|
||||
imgBomb->set(m_textureBomb);
|
||||
buttons.at(i)->set_child(*imgBomb);
|
||||
}
|
||||
buttons.at(i)->set_active(true);
|
||||
// Check Game Over
|
||||
GameState state = minefield_->state();
|
||||
bool new_best = false;
|
||||
int secs = 0;
|
||||
if (state == GameState::Won || state == GameState::Lost) {
|
||||
timer_conn_.disconnect();
|
||||
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
|
||||
on_timer_tick();
|
||||
}
|
||||
|
||||
void MainWindow::updateCell(int x, int y) {
|
||||
int pos = x + y * field.getRows();
|
||||
if(field.isOpened(x, y)) {
|
||||
if (field.bombsNearby(x, y) > 0) {
|
||||
switch(field.bombsNearby(x, y)) {
|
||||
case 1:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-1");
|
||||
break;
|
||||
case 2:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-2");
|
||||
break;
|
||||
case 3:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-3");
|
||||
break;
|
||||
case 4:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-4");
|
||||
break;
|
||||
case 5:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-5");
|
||||
break;
|
||||
case 6:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-6");
|
||||
break;
|
||||
case 7:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-7");
|
||||
break;
|
||||
case 8:
|
||||
buttons.at(pos)->get_style_context()->add_class("label-8");
|
||||
break;
|
||||
}
|
||||
buttons.at(pos)->set_label(Glib::ustring::format(field.bombsNearby(x, y)));
|
||||
}
|
||||
buttons.at(pos)->set_active(true);
|
||||
buttons.at(pos)->set_sensitive(false);
|
||||
}
|
||||
}
|
||||
// void MainWindow::ShowGameWonAnimation() {
|
||||
// // Limit the number of confetti images to 10
|
||||
// int confettiCount = 10;
|
||||
// for (int i = 0; i < confettiCount; ++i) {
|
||||
// Glib::signal_timeout().connect_once([this]() {
|
||||
// auto confetti = Gtk::make_managed<Gtk::Image>();
|
||||
// confetti->set_from_resource("/mineSweeper/confetti");
|
||||
// // Randomize position on the grid or overlay.
|
||||
// grid->attach(*confetti, rand() % COLS, rand() % COLS);
|
||||
// grid->queue_draw();
|
||||
// }, i * 100); // Add confetti with a delay of 100ms each
|
||||
// }
|
||||
// }
|
||||
bool MainWindow::on_timer_tick() {
|
||||
if (!minefield_) return true;
|
||||
|
||||
auto elapsed = minefield_->get_elapsed_time();
|
||||
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
|
||||
|
||||
// bool MainWindow::AllCellsOpened()
|
||||
// {
|
||||
// for(int i=0; i<COLS * COLS; i++) {
|
||||
// if (!buttons[i]->get_active())
|
||||
// return false;
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
void MainWindow::gameOver() {
|
||||
clockSignalConn.disconnect();
|
||||
//std::cout << "Signal gameOver emmited\n";
|
||||
lbl_time_.set_text("Time: " + format_time(secs));
|
||||
|
||||
return true; // Keep calling
|
||||
}
|
||||
|
||||
bool MainWindow::updateClockLabel()
|
||||
{
|
||||
++m_elapsedTime;
|
||||
void MainWindow::show_game_over_dialog(bool new_best, int seconds) {
|
||||
// Delay so the win animation/board state is visible before the modal
|
||||
// dialog covers it.
|
||||
if (dialog_timeout_conn_.connected()) return;
|
||||
dialog_timeout_conn_ = Glib::signal_timeout().connect([this, new_best, seconds]() {
|
||||
dialog_timeout_conn_.disconnect();
|
||||
|
||||
int deciseconds = m_elapsedTime % 10;
|
||||
int seconds = (m_elapsedTime / 10) % 60;
|
||||
int minutes = (m_elapsedTime /600) % 60;
|
||||
// A new game may have started while the dialog was pending
|
||||
if (!minefield_ || minefield_->state() != GameState::Won) return false;
|
||||
|
||||
Glib::ustring msg = Glib::ustring::compose("Elapsed time: %1:%2.%3", \
|
||||
Glib::ustring::format(std::setfill(L'0'), std::setw(2), minutes), \
|
||||
Glib::ustring::format(std::setfill(L'0'), std::setw(2), seconds), \
|
||||
Glib::ustring::format(std::setfill(L'0'), std::setw(1), deciseconds));
|
||||
clockLabel.set_label(msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
MainWindow::MainWindow()
|
||||
{
|
||||
// ApplyStyles(); // Load the CSS file
|
||||
m_elapsedTime = 0;
|
||||
newGame = true;
|
||||
set_title("MineSweeper");
|
||||
set_default_size(400, 400);
|
||||
set_resizable(false);
|
||||
|
||||
boxV = Gtk::Box(Gtk::Orientation::VERTICAL);
|
||||
boxH = Gtk::Box(Gtk::Orientation::HORIZONTAL);
|
||||
|
||||
boxH.set_hexpand(true);
|
||||
|
||||
boxV.append(boxH);
|
||||
boxH.set_expand(true);
|
||||
|
||||
Gtk::Label labelMines;
|
||||
labelMines.set_margin_top(12);
|
||||
labelMines.set_margin_start(12);
|
||||
labelMines.set_label(Glib::ustring::compose("Total mines: %1", field.getTotalMines()));
|
||||
//labelMines.set_hexpand(true);
|
||||
|
||||
Glib::ustring msg = Glib::ustring::compose("Remaining flags: %1", field.getRemainingFlags());
|
||||
flagLabel = Gtk::Label(msg);
|
||||
flagLabel.set_margin_top(12);
|
||||
flagLabel.set_margin_start(12);
|
||||
flagLabel.set_margin_end(12);
|
||||
//flagLabel.set_hexpand(true);
|
||||
|
||||
clockLabel.set_margin_top(12);
|
||||
clockLabel.set_margin_start(12);
|
||||
clockLabel.set_margin_end(12);
|
||||
clockLabel.set_hexpand(true);
|
||||
Glib::ustring clockmsg = Glib::ustring::compose("Elapsed time: 00:00.0");
|
||||
clockLabel.set_label(clockmsg);
|
||||
|
||||
boxH.append(labelMines);
|
||||
boxH.append(clockLabel);
|
||||
boxH.append(flagLabel);
|
||||
|
||||
|
||||
//TODO check if it's okay to mix std::shared_ptr with Gdk::ptr
|
||||
m_textureBomb = Gdk::Texture::create_from_resource("/minesweeper/bomb-solid");
|
||||
m_textureFlag = Gdk::Texture::create_from_resource("/minesweeper/flag-solid");
|
||||
m_textureFlagBomb = Gdk::Texture::create_from_resource("/minesweeper/flag-bomb");
|
||||
|
||||
// bombPix.set_from_resource("/minesweeper/bomb-solid");
|
||||
|
||||
auto css_provider = Gtk::CssProvider::create();
|
||||
css_provider->load_from_data(
|
||||
".label-1 { font-weight: bold; font-size: 1.5em; color: Blue; }\
|
||||
.label-2 { font-weight: bold; font-size: 1.5em; color: Green; }\
|
||||
.label-3 { font-weight: bold; font-size: 1.5em; color: Darkorange; }\
|
||||
.label-4 { font-weight: bold; font-size: 1.5em; color: Purple; }\
|
||||
.label-5 { font-weight: bold; font-size: 1.5em; color: Red; }\
|
||||
.label-6 { font-weight: bold; font-size: 1.5em; color: Salmon; }\
|
||||
.label-7 { font-weight: bold; font-size: 1.5em; color: Turquoise; }\
|
||||
.label-8 { font-weight: bold; font-size: 1.5em; color: Magenta; }");
|
||||
|
||||
auto display = Gdk::Display::get_default();
|
||||
Gtk::StyleContext::add_provider_for_display(display, css_provider, GTK_STYLE_PROVIDER_PRIORITY_USER);
|
||||
|
||||
for (int i = 0; i < field.getCols() * field.getRows(); i++) {
|
||||
auto button = std::make_shared<Gtk::ToggleButton>();
|
||||
button->set_size_request(50, 40);
|
||||
button->set_sensitive(true);
|
||||
button->set_active(false);
|
||||
int x = i % field.getCols();
|
||||
int y = i / field.getRows();
|
||||
button->signal_clicked().connect(sigc::bind(sigc::mem_fun(*this, &MainWindow::OnCellClick), x, y));
|
||||
|
||||
//button->get_style_context()->add_class("fixed-button");
|
||||
|
||||
auto gesture = Gtk::GestureClick::create();
|
||||
gesture->set_button(3);
|
||||
gesture->signal_released().connect(sigc::bind(sigc::mem_fun(*this, \
|
||||
&MainWindow::OnCellRightClick), i));
|
||||
button->add_controller(gesture);
|
||||
|
||||
buttons.push_back(button);
|
||||
|
||||
grid.attach(*button, x, y);
|
||||
}
|
||||
|
||||
field.openCellSignal.connect(sigc::bind(sigc::mem_fun(*this, &MainWindow::updateCell)));
|
||||
field.remainingFlagsSignal.connect(sigc::bind(sigc::mem_fun(*this, &MainWindow::updateFlagsLabel)));
|
||||
field.gameOverSignal.connect(sigc::bind(sigc::mem_fun(*this, &MainWindow::gameOver)));
|
||||
//newGameButton.set_label("New");
|
||||
//newGameButton.add_css_class("suggested-action");
|
||||
//newGameButton.signal_clicked().connect(sigc::mem_fun(*this, &MainWindow::OnNewButtonClick));
|
||||
|
||||
//optionButton.set_icon_name("open-menu");
|
||||
|
||||
//if (clockSignalConn.connected()) clockSignalConn.disconnect();
|
||||
//elapsedTime = 0;
|
||||
clockSignalConn = Glib::signal_timeout().connect(sigc::mem_fun(*this, &MainWindow::updateClockLabel), 100);
|
||||
//}
|
||||
//create the minefield
|
||||
//field = new MineField(COLS, MINES);
|
||||
|
||||
//bar.pack_start(newGameButton);
|
||||
//bar.pack_end(optionButton);
|
||||
|
||||
//grid.set_row_homogeneous(false);
|
||||
//grid.set_column_homogeneous(false);
|
||||
grid.set_margin(10);
|
||||
//grid.set_vexpand(true);
|
||||
//grid.set_hexpand(true);
|
||||
// grid.set_fill(false);
|
||||
|
||||
boxV.append(grid);
|
||||
|
||||
this->set_titlebar(bar);
|
||||
this->set_child(boxV);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
auto app = Gtk::Application::create("eu.minesweeper");
|
||||
return app->make_window_and_run<MainWindow>(argc, argv);
|
||||
// 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::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_);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
+47
-43
@@ -1,51 +1,55 @@
|
||||
#pragma once
|
||||
|
||||
#include "minefield.hpp"
|
||||
#include <memory>
|
||||
#include <gtkmm.h>
|
||||
#include <glibmm.h>
|
||||
#include <gdkmm.h>
|
||||
#include <sigc++/sigc++.h>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
#include "minefield.hpp"
|
||||
#include "board_widget.hpp"
|
||||
#include "leaderboard.hpp"
|
||||
|
||||
#define PROJECT_NAME "minesweeper"
|
||||
class MainWindow : public Gtk::ApplicationWindow {
|
||||
public:
|
||||
MainWindow();
|
||||
~MainWindow() override;
|
||||
|
||||
private:
|
||||
// UI Setup
|
||||
void setup_header_bar();
|
||||
void setup_board();
|
||||
void refresh_leaderboard();
|
||||
|
||||
class MainWindow : public Gtk::Window
|
||||
{
|
||||
Gtk::Box boxV{Gtk::Orientation::VERTICAL};
|
||||
Gtk::Box boxH{Gtk::Orientation::HORIZONTAL};
|
||||
std::vector<std::shared_ptr<Gtk::ToggleButton>> buttons;
|
||||
Gtk::Grid grid;
|
||||
Gtk::HeaderBar bar;
|
||||
Gtk::Button newGameButton;
|
||||
Gtk::Button optionButton;
|
||||
Gtk::Label flagLabel;
|
||||
Gtk::Label clockLabel;
|
||||
MineField field {16, 16, 40};
|
||||
int m_elapsedTime;
|
||||
bool newGame;
|
||||
std::shared_ptr<Gdk::Texture> m_textureBomb;
|
||||
std::shared_ptr<Gdk::Texture> m_textureFlag;
|
||||
std::shared_ptr<Gdk::Texture> m_textureFlagBomb;
|
||||
void updateCell(int x, int y);
|
||||
void openBombs();
|
||||
void updateFlagsLabel(int flags);
|
||||
bool updateClockLabel();
|
||||
void gameWon();
|
||||
void gameOver();
|
||||
sigc::connection clockSignalConn;
|
||||
// void OpenNearCells(int index);
|
||||
// void Explode();xo
|
||||
// bool AllCellsOpened();
|
||||
// Actions
|
||||
void start_new_game(const GameDifficulty& difficulty);
|
||||
void on_game_state_changed();
|
||||
bool on_timer_tick();
|
||||
void show_game_over_dialog(bool new_best, int seconds);
|
||||
|
||||
public:
|
||||
MainWindow();
|
||||
// void OnNewButtonClick();
|
||||
void OnCellClick(int x, int y);
|
||||
void OnCellRightClick(int n_press, double n_x, double n_y, int index);
|
||||
// void ShowGameWonAnimation();
|
||||
// void ApplyStyles();
|
||||
// bool UpdateClockLabel();
|
||||
// Widgets
|
||||
Gtk::HeaderBar header_bar_;
|
||||
Gtk::MenuButton btn_difficulty_;
|
||||
Gtk::Popover menu_difficulty_;
|
||||
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_flags_;
|
||||
|
||||
BoardWidget board_widget_;
|
||||
|
||||
Leaderboard leaderboard_;
|
||||
|
||||
// Game State
|
||||
std::shared_ptr<Minefield> minefield_;
|
||||
sigc::connection timer_conn_;
|
||||
sigc::connection dialog_timeout_conn_;
|
||||
GameDifficulty current_difficulty_ = Minefield::DifficultyEasy;
|
||||
Gtk::MessageDialog* game_over_dialog_ = nullptr;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user