Release 1.0.0: rename to NoMines, add leaderboard, theme support and release metadata
- Rename app/binary/app-id to nomines (io.github.bemagri.nomines), config dir moves to ~/.config/nomines with legacy path fallback - Add persistent best-time leaderboard (Glib::KeyFile) - Theme-aware board following system light/dark preference - Beveled 3D cells, confetti win animation, keyboard shortcuts, right-click chording, per-difficulty window sizing - Add AppStream metainfo, validate desktop file and metainfo via meson tests - Fix license metadata to GPL-3.0-or-later and real homepage
This commit is contained in:
+170
-36
@@ -1,13 +1,64 @@
|
||||
#include "window.hpp"
|
||||
#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
|
||||
|
||||
MainWindow::MainWindow() {
|
||||
set_title("Minesweeper");
|
||||
set_title("NoMines");
|
||||
set_default_size(800, 600);
|
||||
|
||||
setup_header_bar();
|
||||
setup_board();
|
||||
|
||||
// Keyboard shortcuts
|
||||
auto key_controller = Gtk::EventControllerKey::create();
|
||||
key_controller->signal_key_pressed().connect([this](guint keyval, guint /*keycode*/, Gdk::ModifierType mods) {
|
||||
const Gdk::ModifierType none = Gdk::ModifierType(0);
|
||||
bool plain = (mods & Gdk::ModifierType::SHIFT_MASK) == none &&
|
||||
(mods & Gdk::ModifierType::CONTROL_MASK) == none &&
|
||||
(mods & Gdk::ModifierType::ALT_MASK) == none &&
|
||||
(mods & Gdk::ModifierType::META_MASK) == none;
|
||||
switch (keyval) {
|
||||
case GDK_KEY_r:
|
||||
if (plain) { start_new_game(current_difficulty_); return true; }
|
||||
break;
|
||||
case GDK_KEY_n:
|
||||
if ((mods & Gdk::ModifierType::CONTROL_MASK) != none) { start_new_game(current_difficulty_); return true; }
|
||||
break;
|
||||
case GDK_KEY_1:
|
||||
if (plain) { start_new_game(Minefield::DifficultyEasy); return true; }
|
||||
break;
|
||||
case GDK_KEY_2:
|
||||
if (plain) { start_new_game(Minefield::DifficultyMedium); return true; }
|
||||
break;
|
||||
case GDK_KEY_3:
|
||||
if (plain) { start_new_game(Minefield::DifficultyHard); return true; }
|
||||
break;
|
||||
case GDK_KEY_4:
|
||||
if (plain) { start_new_game(Minefield::DifficultyExpert); return true; }
|
||||
break;
|
||||
case GDK_KEY_l:
|
||||
if (plain) {
|
||||
if (menu_leaderboard_.get_visible()) menu_leaderboard_.popdown();
|
||||
else menu_leaderboard_.popup();
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}, false);
|
||||
add_controller(key_controller);
|
||||
|
||||
// Start initial game
|
||||
start_new_game(Minefield::DifficultyEasy);
|
||||
@@ -15,6 +66,7 @@ MainWindow::MainWindow() {
|
||||
|
||||
MainWindow::~MainWindow() {
|
||||
timer_conn_.disconnect();
|
||||
dialog_timeout_conn_.disconnect();
|
||||
}
|
||||
|
||||
void MainWindow::setup_header_bar() {
|
||||
@@ -28,43 +80,90 @@ void MainWindow::setup_header_bar() {
|
||||
header_bar_.pack_start(btn_new_game_);
|
||||
|
||||
// Difficulty Menu
|
||||
btn_difficulty_.set_label("Difficulty");
|
||||
|
||||
btn_difficulty_.set_label(Minefield::DifficultyEasy.name);
|
||||
|
||||
box_difficulty_.set_orientation(Gtk::Orientation::VERTICAL);
|
||||
box_difficulty_.set_margin(10);
|
||||
box_difficulty_.set_spacing(5);
|
||||
|
||||
|
||||
auto group = Gtk::make_managed<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::Button>(diff.name);
|
||||
btn->signal_clicked().connect([this, 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::DifficultyEasy);
|
||||
add_diff_btn(Minefield::DifficultyMedium);
|
||||
add_diff_btn(Minefield::DifficultyHard);
|
||||
add_diff_btn(Minefield::DifficultyExpert);
|
||||
group->set_active(true);
|
||||
|
||||
btn_difficulty_.set_popover(menu_difficulty_);
|
||||
menu_difficulty_.set_child(box_difficulty_);
|
||||
header_bar_.pack_start(btn_difficulty_);
|
||||
|
||||
// Leaderboard
|
||||
btn_leaderboard_.set_label("Leaderboard");
|
||||
box_leaderboard_.set_orientation(Gtk::Orientation::VERTICAL);
|
||||
box_leaderboard_.set_margin(10);
|
||||
box_leaderboard_.set_spacing(4);
|
||||
btn_leaderboard_.set_popover(menu_leaderboard_);
|
||||
menu_leaderboard_.set_child(box_leaderboard_);
|
||||
menu_leaderboard_.signal_show().connect(sigc::mem_fun(*this, &MainWindow::refresh_leaderboard));
|
||||
header_bar_.pack_start(btn_leaderboard_);
|
||||
|
||||
// Status Labels
|
||||
lbl_flags_.set_margin_end(10);
|
||||
lbl_time_.set_margin_end(10);
|
||||
|
||||
// Make them monospaced and bold
|
||||
lbl_flags_.set_markup("<b>🚩 0</b>");
|
||||
lbl_time_.set_markup("<b>⏱️ 00:00</b>");
|
||||
// Monospaced and bold so the numbers don't jitter
|
||||
lbl_flags_.set_markup("<b><tt>Flags: 0</tt></b>");
|
||||
lbl_time_.set_markup("<b><tt>Time: 00:00</tt></b>");
|
||||
|
||||
header_bar_.pack_end(lbl_time_);
|
||||
header_bar_.pack_end(lbl_flags_);
|
||||
}
|
||||
|
||||
void MainWindow::refresh_leaderboard() {
|
||||
while (auto* child = box_leaderboard_.get_first_child()) {
|
||||
box_leaderboard_.remove(*child);
|
||||
}
|
||||
|
||||
auto* title = Gtk::make_managed<Gtk::Label>();
|
||||
title->set_markup("<b>Best Times</b>");
|
||||
box_leaderboard_.append(*title);
|
||||
|
||||
for (const auto& diff : {Minefield::DifficultyEasy, Minefield::DifficultyMedium,
|
||||
Minefield::DifficultyHard, Minefield::DifficultyExpert}) {
|
||||
auto* row = Gtk::make_managed<Gtk::Label>();
|
||||
row->set_xalign(0.0f);
|
||||
auto best = leaderboard_.best_time(diff.name);
|
||||
std::string time_str = best ? format_time(*best) : "—";
|
||||
row->set_text(diff.name + ": " + time_str);
|
||||
box_leaderboard_.append(*row);
|
||||
}
|
||||
}
|
||||
|
||||
void MainWindow::setup_board() {
|
||||
// Minimum size so the largest board (30x20, Master) fits at the 16px
|
||||
// cell floor even when tiled very small.
|
||||
board_widget_.set_size_request(520, 360);
|
||||
set_child(board_widget_);
|
||||
board_widget_.signal_state_changed.connect(sigc::mem_fun(*this, &MainWindow::on_game_state_changed));
|
||||
}
|
||||
@@ -72,9 +171,24 @@ void MainWindow::setup_board() {
|
||||
void MainWindow::start_new_game(const GameDifficulty& difficulty) {
|
||||
current_difficulty_ = difficulty;
|
||||
|
||||
// Reflect the active difficulty in the menu button and popover
|
||||
btn_difficulty_.set_label(difficulty.name);
|
||||
for (const auto& [diff, btn] : difficulty_buttons_) {
|
||||
btn->set_active(diff.name == difficulty.name);
|
||||
}
|
||||
|
||||
// Cancel any pending game-over dialog from the finished game
|
||||
dialog_timeout_conn_.disconnect();
|
||||
|
||||
minefield_ = std::make_shared<Minefield>(difficulty.cols, difficulty.rows, difficulty.mines);
|
||||
board_widget_.set_minefield(minefield_);
|
||||
|
||||
// Size the window to fit the board at a comfortable cell size.
|
||||
// Ignored once the user has resized the window (or under a tiling WM).
|
||||
int w = difficulty.cols * 36 + 160;
|
||||
int h = difficulty.rows * 36 + 200; // Extra height for the header bar
|
||||
set_default_size(std::clamp(w, 480, 1280), std::clamp(h, 420, 1000));
|
||||
|
||||
// Reset timer
|
||||
timer_conn_.disconnect();
|
||||
timer_conn_ = Glib::signal_timeout().connect(sigc::mem_fun(*this, &MainWindow::on_timer_tick), 100);
|
||||
@@ -86,13 +200,19 @@ void MainWindow::on_game_state_changed() {
|
||||
if (!minefield_) return;
|
||||
|
||||
// Update Flags
|
||||
lbl_flags_.set_markup("<b>🚩 " + std::to_string(minefield_->remaining_flags()) + "</b>");
|
||||
lbl_flags_.set_markup("<b><tt>Flags: " + std::to_string(minefield_->remaining_flags()) + "</tt></b>");
|
||||
|
||||
// Check Game Over
|
||||
GameState state = minefield_->state();
|
||||
bool new_best = false;
|
||||
int secs = 0;
|
||||
if (state == GameState::Won || state == GameState::Lost) {
|
||||
timer_conn_.disconnect();
|
||||
show_game_over_dialog(state == GameState::Won);
|
||||
if (state == GameState::Won) {
|
||||
secs = std::chrono::duration_cast<std::chrono::seconds>(minefield_->get_elapsed_time()).count();
|
||||
new_best = leaderboard_.record(current_difficulty_.name, secs);
|
||||
}
|
||||
show_game_over_dialog(state == GameState::Won, new_best, secs);
|
||||
}
|
||||
|
||||
// Update timer immediately to reflect end time if stopped
|
||||
@@ -105,33 +225,47 @@ bool MainWindow::on_timer_tick() {
|
||||
auto elapsed = minefield_->get_elapsed_time();
|
||||
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
|
||||
|
||||
int mm = secs / 60;
|
||||
int ss = secs % 60;
|
||||
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "<b>⏱️ %02d:%02d</b>", mm, ss);
|
||||
lbl_time_.set_markup(buf);
|
||||
lbl_time_.set_markup("<b><tt>Time: " + format_time(secs) + "</tt></b>");
|
||||
|
||||
return true; // Keep calling
|
||||
}
|
||||
|
||||
void MainWindow::show_game_over_dialog(bool won) {
|
||||
auto dialog = Gtk::make_managed<Gtk::MessageDialog>(
|
||||
*this,
|
||||
won ? "Congratulations!" : "Game Over",
|
||||
false,
|
||||
Gtk::MessageType::INFO,
|
||||
Gtk::ButtonsType::OK,
|
||||
true
|
||||
);
|
||||
|
||||
dialog->set_secondary_text(won ? "You cleared the minefield!" : "Better luck next time.");
|
||||
dialog->signal_response().connect([dialog, this](int response) {
|
||||
dialog->hide();
|
||||
if (response == Gtk::ResponseType::OK) {
|
||||
// Optional: restart?
|
||||
void MainWindow::show_game_over_dialog(bool won, bool new_best, int seconds) {
|
||||
// Delay so the win animation/board state is visible before the modal
|
||||
// dialog covers it.
|
||||
if (dialog_timeout_conn_.connected()) return;
|
||||
dialog_timeout_conn_ = Glib::signal_timeout().connect([this, won, new_best, seconds]() {
|
||||
dialog_timeout_conn_.disconnect();
|
||||
|
||||
// A new game may have started while the dialog was pending
|
||||
if (!minefield_ || minefield_->state() == GameState::Ready) return false;
|
||||
|
||||
// Reuse a single dialog instance: creating a new make_managed dialog
|
||||
// per game over leaks (it's never added to a container, so it's never
|
||||
// freed).
|
||||
if (!game_over_dialog_) {
|
||||
game_over_dialog_ = Gtk::make_managed<Gtk::MessageDialog>(
|
||||
*this, "", false,
|
||||
Gtk::MessageType::INFO,
|
||||
Gtk::ButtonsType::OK,
|
||||
true
|
||||
);
|
||||
game_over_dialog_->signal_response().connect([this](int response) {
|
||||
game_over_dialog_->hide();
|
||||
if (response == Gtk::ResponseType::OK) {
|
||||
start_new_game(current_difficulty_);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dialog->show();
|
||||
|
||||
game_over_dialog_->set_title(won ? "Congratulations!" : "Game Over");
|
||||
game_over_dialog_->set_message(won ? "Congratulations!" : "Game Over");
|
||||
std::string secondary = won
|
||||
? "You cleared the minefield in " + format_time(seconds) +
|
||||
(new_best ? " — new best time!" : ".")
|
||||
: "Better luck next time.";
|
||||
game_over_dialog_->set_secondary_text(secondary);
|
||||
game_over_dialog_->show();
|
||||
return false; // One-shot
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user