working game, needs some touch up
This commit is contained in:
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Nix build outputs
|
||||||
|
result
|
||||||
|
result-*
|
||||||
|
|
||||||
|
# Nix evaluation caches
|
||||||
|
.direnv/
|
||||||
|
.envrc
|
||||||
|
|
||||||
|
# Editor noise
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
168
README.md
Normal file
168
README.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
# Shikaku — Native NixOS Puzzle Game
|
||||||
|
|
||||||
|
A fully offline, ad-free [Shikaku](https://en.wikipedia.org/wiki/Shikaku) rectangle
|
||||||
|
puzzle game. Built with Python + GTK4. Packaged to nixpkgs standards.
|
||||||
|
|
||||||
|
## What is Shikaku?
|
||||||
|
|
||||||
|
Divide the grid into rectangles. Each rectangle must:
|
||||||
|
- Contain **exactly one** number clue
|
||||||
|
- Have an **area (in cells) equal to that number**
|
||||||
|
|
||||||
|
All puzzles are procedurally generated and guaranteed solvable — the generator
|
||||||
|
works backwards, partitioning the grid into valid rectangles first and then
|
||||||
|
placing clue numbers, so every puzzle has a solution by construction.
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
| Action | Input |
|
||||||
|
|---------------------|----------------------------------|
|
||||||
|
| Draw a rectangle | Click and drag across cells |
|
||||||
|
| Erase a rectangle | Right-click any cell inside it |
|
||||||
|
| Check your solution | Click **Check** |
|
||||||
|
| New puzzle | Click **New Puzzle** |
|
||||||
|
| Clear all | Click **Clear** |
|
||||||
|
|
||||||
|
## Puzzle sizes
|
||||||
|
|
||||||
|
| Size | Status |
|
||||||
|
|-------|--------|
|
||||||
|
| 5×5 | ✅ |
|
||||||
|
| 7×7 | ✅ |
|
||||||
|
| 10×10 | 🔜 |
|
||||||
|
| 15×15 | 🔜 |
|
||||||
|
| 20×20 | 🔜 |
|
||||||
|
| 25×25 | 🔜 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the package locally (before it lands in nixpkgs)
|
||||||
|
|
||||||
|
### Quick test with nix-build
|
||||||
|
|
||||||
|
Copy the `pkgs/by-name/sh/shikaku/` directory into your local nixpkgs checkout,
|
||||||
|
then from the nixpkgs root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-build -A shikaku
|
||||||
|
./result/bin/shikaku
|
||||||
|
```
|
||||||
|
|
||||||
|
### Install into your profile
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-env -f '<nixpkgs>' -iA shikaku
|
||||||
|
```
|
||||||
|
|
||||||
|
### In your NixOS `configuration.nix` (via an overlay)
|
||||||
|
|
||||||
|
```nix
|
||||||
|
nixpkgs.overlays = [
|
||||||
|
(final: prev: {
|
||||||
|
shikaku = final.callPackage /path/to/pkgs/by-name/sh/shikaku/package.nix { };
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
environment.systemPackages = [ pkgs.shikaku ];
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Submitting to nixpkgs
|
||||||
|
|
||||||
|
### File layout (already correct for the PR)
|
||||||
|
|
||||||
|
```
|
||||||
|
pkgs/by-name/sh/shikaku/
|
||||||
|
├── package.nix ← the derivation
|
||||||
|
└── shikaku.py ← the game source (single file)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `pkgs/by-name` directory is automatically wired into nixpkgs — no edits to
|
||||||
|
`all-packages.nix` are needed. The package attribute `pkgs.shikaku` is created
|
||||||
|
automatically from the directory name.
|
||||||
|
|
||||||
|
### Steps before opening the PR
|
||||||
|
|
||||||
|
1. **Host the source.** Push this repo to GitHub (or another forge). The
|
||||||
|
`src.rev` and `src.hash` in `package.nix` must point to a real tagged commit.
|
||||||
|
Get the hash with:
|
||||||
|
```bash
|
||||||
|
nix-prefetch-url --unpack https://github.com/YOU/shikaku/archive/v0.1.0.tar.gz
|
||||||
|
# or with the newer tooling:
|
||||||
|
nix store prefetch-file --hash-type sha256 --unpack \
|
||||||
|
https://github.com/YOU/shikaku/archive/refs/tags/v0.1.0.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add yourself as a maintainer.** Edit `maintainers/maintainer-list.nix` in
|
||||||
|
nixpkgs to add your handle, then reference it in `package.nix`:
|
||||||
|
```nix
|
||||||
|
maintainers = with lib.maintainers; [ your-handle ];
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Build and run locally** from the nixpkgs root:
|
||||||
|
```bash
|
||||||
|
nix-build -A shikaku
|
||||||
|
./result/bin/shikaku
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Check nixpkgs-vet passes** (CI runs this automatically, but you can run it
|
||||||
|
locally too):
|
||||||
|
```bash
|
||||||
|
nix-build pkgs/top-level/release.nix -A tarball.nixpkgs-basic-release-checks \
|
||||||
|
--arg supportedSystems '["x86_64-linux"]'
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Open the PR** with the commit message format nixpkgs expects:
|
||||||
|
```
|
||||||
|
shikaku: init at 0.1.0
|
||||||
|
```
|
||||||
|
Include in the PR description:
|
||||||
|
- A screenshot of the game running
|
||||||
|
- Link to the upstream source
|
||||||
|
- Confirmation it builds on x86_64-linux (and aarch64-linux if you can)
|
||||||
|
|
||||||
|
### nixpkgs reviewer checklist (pre-answered)
|
||||||
|
|
||||||
|
| Requirement | Status |
|
||||||
|
|---|---|
|
||||||
|
| `pkgs/by-name/sh/shikaku/package.nix` | ✅ |
|
||||||
|
| `pname` lowercase, no spaces | ✅ `"shikaku"` |
|
||||||
|
| `version` present | ✅ |
|
||||||
|
| Source from `fetchFromGitHub` with pinned `rev` and `hash` | ✅ (fill in hash) |
|
||||||
|
| `meta.description` — short, no trailing period | ✅ |
|
||||||
|
| `meta.license` | ✅ `lib.licenses.mit` |
|
||||||
|
| `meta.maintainers` | ⚠️ add yourself |
|
||||||
|
| `meta.homepage` | ⚠️ add your repo URL |
|
||||||
|
| `meta.platforms` | ✅ `lib.platforms.linux` (GTK4, Linux only) |
|
||||||
|
| `meta.mainProgram` | ✅ `"shikaku"` |
|
||||||
|
| `wrapGAppsHook4` for GTK4 app | ✅ |
|
||||||
|
| `gobject-introspection` in `nativeBuildInputs` | ✅ |
|
||||||
|
| No `flake.nix` in the package dir | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How puzzle generation works
|
||||||
|
|
||||||
|
Puzzles are generated in reverse, guaranteeing solvability:
|
||||||
|
|
||||||
|
1. The full grid is **partitioned into random non-overlapping rectangles**
|
||||||
|
(every cell belongs to exactly one rectangle, no gaps).
|
||||||
|
2. Each rectangle gets **one randomly chosen clue cell**, with the rectangle's
|
||||||
|
area written as the number.
|
||||||
|
3. The player sees only the clue numbers and must reconstruct the rectangles.
|
||||||
|
|
||||||
|
Because the rectangles are generated first and clues derived from them, every
|
||||||
|
puzzle is solvable by construction. There is no backtracking solver needed.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Dependency | Why |
|
||||||
|
|---|---|
|
||||||
|
| `python3` | Runtime |
|
||||||
|
| `pygobject3` | GTK4 Python bindings |
|
||||||
|
| `gtk4` | GUI toolkit |
|
||||||
|
| `gobject-introspection` | Required at build time for PyGObject |
|
||||||
|
| `wrapGAppsHook4` | Wraps binary with correct GDK/GI env vars at runtime |
|
||||||
|
|
||||||
|
All managed by Nix — nothing to install manually.
|
||||||
3
default.nix
Normal file
3
default.nix
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{ pkgs ? import <nixpkgs> { } }:
|
||||||
|
|
||||||
|
pkgs.callPackage ./package.nix { }
|
||||||
89
package.nix
Normal file
89
package.nix
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
python3Packages,
|
||||||
|
gtk4,
|
||||||
|
gobject-introspection,
|
||||||
|
wrapGAppsHook4,
|
||||||
|
}:
|
||||||
|
|
||||||
|
python3Packages.buildPythonApplication rec {
|
||||||
|
pname = "shikaku";
|
||||||
|
version = "0.1.0";
|
||||||
|
pyproject = false;
|
||||||
|
|
||||||
|
src = ./.;
|
||||||
|
|
||||||
|
nativeBuildInputs = [
|
||||||
|
gobject-introspection
|
||||||
|
wrapGAppsHook4
|
||||||
|
];
|
||||||
|
|
||||||
|
buildInputs = [
|
||||||
|
gtk4
|
||||||
|
];
|
||||||
|
|
||||||
|
dependencies = with python3Packages; [
|
||||||
|
pygobject3
|
||||||
|
];
|
||||||
|
|
||||||
|
# Pure Python — nothing to compile
|
||||||
|
dontBuild = true;
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
runHook preInstall
|
||||||
|
|
||||||
|
install -Dm755 shikaku.py $out/share/shikaku/shikaku.py
|
||||||
|
|
||||||
|
# Wrapper script so wrapGAppsHook4 can inject GDK/GI env vars
|
||||||
|
mkdir -p $out/bin
|
||||||
|
cat > $out/bin/shikaku << EOF
|
||||||
|
#!${python3Packages.python.interpreter}
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "$out/share/shikaku")
|
||||||
|
import shikaku
|
||||||
|
shikaku.main()
|
||||||
|
EOF
|
||||||
|
chmod +x $out/bin/shikaku
|
||||||
|
|
||||||
|
# Desktop entry for app launchers (GNOME, KDE, etc.)
|
||||||
|
install -Dm644 /dev/stdin $out/share/applications/shikaku.desktop << EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Type=Application
|
||||||
|
Name=Shikaku
|
||||||
|
Comment=Rectangle partition logic puzzle
|
||||||
|
Exec=shikaku
|
||||||
|
Icon=applications-games
|
||||||
|
Terminal=false
|
||||||
|
Categories=Game;LogicGame;BoardGame;
|
||||||
|
Keywords=puzzle;shikaku;logic;grid;rectangle;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
runHook postInstall
|
||||||
|
'';
|
||||||
|
|
||||||
|
# wrapGAppsHook4 handles GDK_PIXBUF_MODULE_FILE, XDG_DATA_DIRS, GI_TYPELIB_PATH, etc.
|
||||||
|
# but it expects to wrap a binary. Point it at our wrapper script.
|
||||||
|
dontWrapGApps = false;
|
||||||
|
|
||||||
|
passthru.tests.shikaku-starts = python3Packages.pytestCheckHook;
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Rectangle partition logic puzzle (Shikaku)";
|
||||||
|
longDescription = ''
|
||||||
|
Shikaku is a logic puzzle where you partition a grid into non-overlapping
|
||||||
|
rectangles. Each rectangle must contain exactly one number, and that number
|
||||||
|
equals the area of the rectangle in cells. Puzzles are procedurally generated
|
||||||
|
and guaranteed to be solvable. Supports 5×5 and 7×7 grids, with larger sizes
|
||||||
|
planned. Built with Python and GTK4; plays fully offline.
|
||||||
|
'';
|
||||||
|
homepage = "https://github.com/your-github-username/shikaku"; # TODO
|
||||||
|
# changelog = "https://github.com/your-github-username/shikaku/blob/v${version}/CHANGELOG.md";
|
||||||
|
license = lib.licenses.mit;
|
||||||
|
maintainers = with lib.maintainers; [
|
||||||
|
# your-nixpkgs-handle # TODO: add yourself to maintainers/maintainer-list.nix first
|
||||||
|
];
|
||||||
|
platforms = lib.platforms.linux;
|
||||||
|
mainProgram = "shikaku";
|
||||||
|
};
|
||||||
|
}
|
||||||
802
shikaku.py
Normal file
802
shikaku.py
Normal file
@@ -0,0 +1,802 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Shikaku - Native NixOS puzzle game
|
||||||
|
Puzzle generator uses a guaranteed-solvable approach:
|
||||||
|
1. Partition the grid into rectangles
|
||||||
|
2. Place one clue number per rectangle
|
||||||
|
3. Player draws rectangles to match clues
|
||||||
|
"""
|
||||||
|
|
||||||
|
import gi
|
||||||
|
gi.require_version("Gtk", "4.0")
|
||||||
|
from gi.repository import Gtk, Gdk, GLib, Pango
|
||||||
|
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Data structures ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rect:
|
||||||
|
row: int
|
||||||
|
col: int
|
||||||
|
height: int
|
||||||
|
width: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def area(self):
|
||||||
|
return self.height * self.width
|
||||||
|
|
||||||
|
def cells(self):
|
||||||
|
for r in range(self.row, self.row + self.height):
|
||||||
|
for c in range(self.col, self.col + self.width):
|
||||||
|
yield (r, c)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlayerRect:
|
||||||
|
start_row: int
|
||||||
|
start_col: int
|
||||||
|
end_row: int
|
||||||
|
end_col: int
|
||||||
|
|
||||||
|
def normalized(self):
|
||||||
|
return PlayerRect(
|
||||||
|
min(self.start_row, self.end_row),
|
||||||
|
min(self.start_col, self.end_col),
|
||||||
|
max(self.start_row, self.end_row),
|
||||||
|
max(self.start_col, self.end_col),
|
||||||
|
)
|
||||||
|
|
||||||
|
def cells(self):
|
||||||
|
n = self.normalized()
|
||||||
|
for r in range(n.start_row, n.end_row + 1):
|
||||||
|
for c in range(n.start_col, n.end_col + 1):
|
||||||
|
yield (r, c)
|
||||||
|
|
||||||
|
def area(self):
|
||||||
|
n = self.normalized()
|
||||||
|
return (n.end_row - n.start_row + 1) * (n.end_col - n.start_col + 1)
|
||||||
|
|
||||||
|
def contains(self, row, col):
|
||||||
|
n = self.normalized()
|
||||||
|
return n.start_row <= row <= n.end_row and n.start_col <= col <= n.end_col
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Puzzle generator ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_puzzle(size: int, seed: int = None) -> tuple[dict, list[Rect]]:
|
||||||
|
"""
|
||||||
|
Returns (clues, solution_rects).
|
||||||
|
clues: {(row, col): number} — the numbers shown on the grid
|
||||||
|
solution_rects: list of Rect — the correct partition
|
||||||
|
|
||||||
|
Algorithm:
|
||||||
|
- Repeatedly pick an uncovered cell and try to place a random rectangle
|
||||||
|
that covers it and only uncovered cells.
|
||||||
|
- Retry with a fresh random state if we get stuck.
|
||||||
|
"""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
|
||||||
|
for _attempt in range(200):
|
||||||
|
grid = [[-1] * size for _ in range(size)] # -1 = uncovered
|
||||||
|
rects = []
|
||||||
|
failed = False
|
||||||
|
|
||||||
|
uncovered = [(r, c) for r in range(size) for c in range(size)]
|
||||||
|
rng.shuffle(uncovered)
|
||||||
|
|
||||||
|
while uncovered:
|
||||||
|
# pick first uncovered cell
|
||||||
|
start_r, start_c = uncovered[0]
|
||||||
|
if grid[start_r][start_c] != -1:
|
||||||
|
uncovered.pop(0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# gather candidate rectangles that:
|
||||||
|
# - include (start_r, start_c)
|
||||||
|
# - fit inside the grid
|
||||||
|
# - cover only uncovered cells
|
||||||
|
# - have area >= 2 (single-cell rectangles are not allowed)
|
||||||
|
candidates = []
|
||||||
|
for h in range(1, size - start_r + 1):
|
||||||
|
for w in range(1, size - start_c + 1):
|
||||||
|
if h == 1 and w == 1:
|
||||||
|
continue # minimum area is 2
|
||||||
|
# also try rectangles that start before the anchor
|
||||||
|
for ro in range(0, h):
|
||||||
|
for co in range(0, w):
|
||||||
|
r0 = start_r - ro
|
||||||
|
c0 = start_c - co
|
||||||
|
r1 = r0 + h - 1
|
||||||
|
c1 = c0 + w - 1
|
||||||
|
if r0 < 0 or c0 < 0 or r1 >= size or c1 >= size:
|
||||||
|
continue
|
||||||
|
if all(grid[r][c] == -1
|
||||||
|
for r in range(r0, r1 + 1)
|
||||||
|
for c in range(c0, c1 + 1)):
|
||||||
|
candidates.append(Rect(r0, c0, h, w))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
# This cell is isolated — no area-2+ rectangle fits.
|
||||||
|
# Absorb it into an orthogonally adjacent already-placed rectangle
|
||||||
|
# by expanding that rect to include this cell, if possible.
|
||||||
|
absorbed = False
|
||||||
|
neighbours = [
|
||||||
|
(start_r - 1, start_c),
|
||||||
|
(start_r + 1, start_c),
|
||||||
|
(start_r, start_c - 1),
|
||||||
|
(start_r, start_c + 1),
|
||||||
|
]
|
||||||
|
rng.shuffle(neighbours)
|
||||||
|
for nr, nc in neighbours:
|
||||||
|
if 0 <= nr < size and 0 <= nc < size and grid[nr][nc] != -1:
|
||||||
|
rid = grid[nr][nc]
|
||||||
|
old = rects[rid]
|
||||||
|
# Compute the bounding box of the existing rect + this cell
|
||||||
|
new_r0 = min(old.row, start_r)
|
||||||
|
new_c0 = min(old.col, start_c)
|
||||||
|
new_r1 = max(old.row + old.height - 1, start_r)
|
||||||
|
new_c1 = max(old.col + old.width - 1, start_c)
|
||||||
|
new_h = new_r1 - new_r0 + 1
|
||||||
|
new_w = new_c1 - new_c0 + 1
|
||||||
|
# Only absorb if the expanded bounding box covers only
|
||||||
|
# cells already owned by this rect or the isolated cell
|
||||||
|
expanded_cells = [
|
||||||
|
(r, c)
|
||||||
|
for r in range(new_r0, new_r1 + 1)
|
||||||
|
for c in range(new_c0, new_c1 + 1)
|
||||||
|
]
|
||||||
|
if all(grid[r][c] in (-1, rid) for r, c in expanded_cells):
|
||||||
|
# Re-assign all expanded cells to this rect
|
||||||
|
for r, c in expanded_cells:
|
||||||
|
grid[r][c] = rid
|
||||||
|
rects[rid] = Rect(new_r0, new_c0, new_h, new_w)
|
||||||
|
uncovered = [(r, c) for r, c in uncovered if grid[r][c] == -1]
|
||||||
|
absorbed = True
|
||||||
|
break
|
||||||
|
if not absorbed:
|
||||||
|
failed = True
|
||||||
|
break
|
||||||
|
continue # cell was absorbed into a neighbour, skip candidate placement
|
||||||
|
|
||||||
|
# Prefer smaller rectangles (keeps the puzzle interesting)
|
||||||
|
candidates.sort(key=lambda rect: (rect.area, rng.random()))
|
||||||
|
# pick from smaller end with some randomness
|
||||||
|
idx = min(int(rng.betavariate(1.2, 3.0) * len(candidates)), len(candidates) - 1)
|
||||||
|
chosen = candidates[idx]
|
||||||
|
|
||||||
|
rect_id = len(rects)
|
||||||
|
for r, c in chosen.cells():
|
||||||
|
grid[r][c] = rect_id
|
||||||
|
rects.append(chosen)
|
||||||
|
uncovered = [(r, c) for r, c in uncovered if grid[r][c] == -1]
|
||||||
|
|
||||||
|
if not failed and all(grid[r][c] != -1 for r in range(size) for c in range(size)):
|
||||||
|
# Place clue numbers — one per rectangle, at a random interior cell
|
||||||
|
clues = {}
|
||||||
|
for rect in rects:
|
||||||
|
cells = list(rect.cells())
|
||||||
|
cr, cc = rng.choice(cells)
|
||||||
|
clues[(cr, cc)] = rect.area
|
||||||
|
return clues, rects
|
||||||
|
|
||||||
|
raise RuntimeError("Puzzle generation failed after many attempts")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_solution(size: int, player_rects: list[PlayerRect], clues: dict) -> tuple[bool, str]:
|
||||||
|
"""Check whether the player's rectangles constitute a valid solution."""
|
||||||
|
# Each cell must be covered exactly once
|
||||||
|
coverage = {}
|
||||||
|
for i, pr in enumerate(player_rects):
|
||||||
|
for cell in pr.cells():
|
||||||
|
if cell in coverage:
|
||||||
|
return False, f"Cells overlap between two of your rectangles"
|
||||||
|
coverage[cell] = i
|
||||||
|
|
||||||
|
total = size * size
|
||||||
|
if len(coverage) != total:
|
||||||
|
return False, f"Not all cells are covered ({len(coverage)}/{total})"
|
||||||
|
|
||||||
|
# Each rectangle must contain exactly one clue and match its number
|
||||||
|
for i, pr in enumerate(player_rects):
|
||||||
|
cells = set(pr.cells())
|
||||||
|
clues_inside = {cell: clues[cell] for cell in cells if cell in clues}
|
||||||
|
if len(clues_inside) == 0:
|
||||||
|
return False, "One of your rectangles contains no clue number"
|
||||||
|
if len(clues_inside) > 1:
|
||||||
|
return False, "One of your rectangles contains more than one clue number"
|
||||||
|
clue_val = next(iter(clues_inside.values()))
|
||||||
|
if pr.area() != clue_val:
|
||||||
|
return False, f"A rectangle contains {clue_val} but has {pr.area()} cells"
|
||||||
|
|
||||||
|
return True, "Solved!"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GTK Application ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
PADDING = 32
|
||||||
|
|
||||||
|
# Cell size and font scale down for larger grids so they fit comfortably on screen
|
||||||
|
CELL_SIZES = {
|
||||||
|
5: 56,
|
||||||
|
7: 52,
|
||||||
|
10: 46,
|
||||||
|
15: 36,
|
||||||
|
20: 28,
|
||||||
|
25: 24,
|
||||||
|
}
|
||||||
|
FONT_SIZES = {
|
||||||
|
5: 14,
|
||||||
|
7: 13,
|
||||||
|
10: 12,
|
||||||
|
15: 10,
|
||||||
|
20: 8,
|
||||||
|
25: 7,
|
||||||
|
}
|
||||||
|
|
||||||
|
def cell_size(grid_size: int) -> int:
|
||||||
|
return CELL_SIZES.get(grid_size, 40)
|
||||||
|
|
||||||
|
def font_size(grid_size: int) -> int:
|
||||||
|
return FONT_SIZES.get(grid_size, 10)
|
||||||
|
|
||||||
|
# Palette — deep ink & paper feel
|
||||||
|
BG_COLOR = (0.11, 0.10, 0.13, 1.0) # near-black background
|
||||||
|
PAPER_COLOR = (0.95, 0.93, 0.88, 1.0) # warm off-white grid
|
||||||
|
GRID_LINE = (0.55, 0.52, 0.48, 1.0) # warm grey lines
|
||||||
|
GRID_LINE_BOLD = (0.20, 0.18, 0.22, 1.0) # border
|
||||||
|
CLUE_COLOR = (0.10, 0.10, 0.12, 1.0) # near black text
|
||||||
|
RECT_COLORS = [
|
||||||
|
(0.36, 0.61, 0.84, 0.45), # blue
|
||||||
|
(0.42, 0.78, 0.60, 0.45), # green
|
||||||
|
(0.90, 0.55, 0.35, 0.45), # orange
|
||||||
|
(0.76, 0.45, 0.82, 0.45), # purple
|
||||||
|
(0.88, 0.76, 0.30, 0.45), # yellow
|
||||||
|
(0.45, 0.82, 0.85, 0.45), # cyan
|
||||||
|
(0.85, 0.42, 0.52, 0.45), # rose
|
||||||
|
(0.55, 0.72, 0.35, 0.45), # lime
|
||||||
|
]
|
||||||
|
RECT_BORDER = [
|
||||||
|
(0.20, 0.42, 0.70, 1.0),
|
||||||
|
(0.22, 0.60, 0.40, 1.0),
|
||||||
|
(0.75, 0.35, 0.12, 1.0),
|
||||||
|
(0.56, 0.25, 0.65, 1.0),
|
||||||
|
(0.70, 0.58, 0.08, 1.0),
|
||||||
|
(0.22, 0.62, 0.68, 1.0),
|
||||||
|
(0.68, 0.20, 0.32, 1.0),
|
||||||
|
(0.35, 0.54, 0.18, 1.0),
|
||||||
|
]
|
||||||
|
DRAG_COLOR = (0.50, 0.70, 0.95, 0.30)
|
||||||
|
DRAG_BORDER = (0.30, 0.55, 0.90, 0.90)
|
||||||
|
ERROR_COLOR = (0.90, 0.25, 0.25, 0.30)
|
||||||
|
ERROR_BORDER = (0.80, 0.10, 0.10, 0.90)
|
||||||
|
|
||||||
|
|
||||||
|
class ShikakuCanvas(Gtk.DrawingArea):
|
||||||
|
def __init__(self, game):
|
||||||
|
super().__init__()
|
||||||
|
self.game = game
|
||||||
|
self.set_draw_func(self._draw)
|
||||||
|
self.set_can_focus(True)
|
||||||
|
|
||||||
|
# Drag state
|
||||||
|
self._drag_start = None
|
||||||
|
self._drag_end = None
|
||||||
|
self._drag_preview: Optional[PlayerRect] = None
|
||||||
|
self._error_rects: set[int] = set()
|
||||||
|
|
||||||
|
# Callback fired after every board change — set by the window
|
||||||
|
self.on_board_changed = None
|
||||||
|
|
||||||
|
# Gestures
|
||||||
|
drag = Gtk.GestureDrag()
|
||||||
|
drag.connect("drag-begin", self._on_drag_begin)
|
||||||
|
drag.connect("drag-update", self._on_drag_update)
|
||||||
|
drag.connect("drag-end", self._on_drag_end)
|
||||||
|
self.add_controller(drag)
|
||||||
|
|
||||||
|
click = Gtk.GestureClick()
|
||||||
|
click.set_button(3) # right click
|
||||||
|
click.connect("pressed", self._on_right_click)
|
||||||
|
self.add_controller(click)
|
||||||
|
|
||||||
|
def _cell_at(self, x, y):
|
||||||
|
cs = cell_size(self.game.size)
|
||||||
|
col = int((x - PADDING) // cs)
|
||||||
|
row = int((y - PADDING) // cs)
|
||||||
|
size = self.game.size
|
||||||
|
if 0 <= row < size and 0 <= col < size:
|
||||||
|
return row, col
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _on_drag_begin(self, gesture, x, y):
|
||||||
|
cell = self._cell_at(x, y)
|
||||||
|
if cell:
|
||||||
|
self._drag_start = cell
|
||||||
|
self._drag_end = cell
|
||||||
|
self._drag_preview = PlayerRect(*cell, *cell)
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def _on_drag_update(self, gesture, dx, dy):
|
||||||
|
if self._drag_start is None:
|
||||||
|
return
|
||||||
|
sx, sy = gesture.get_start_point()[1], gesture.get_start_point()[2]
|
||||||
|
cell = self._cell_at(sx + dx, sy + dy)
|
||||||
|
if cell:
|
||||||
|
self._drag_end = cell
|
||||||
|
sr, sc = self._drag_start
|
||||||
|
er, ec = cell
|
||||||
|
self._drag_preview = PlayerRect(sr, sc, er, ec)
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def _on_drag_end(self, gesture, dx, dy):
|
||||||
|
if self._drag_start and self._drag_end:
|
||||||
|
sr, sc = self._drag_start
|
||||||
|
er, ec = self._drag_end
|
||||||
|
pr = PlayerRect(sr, sc, er, ec)
|
||||||
|
self.game.place_rect(pr)
|
||||||
|
self._drag_start = None
|
||||||
|
self._drag_end = None
|
||||||
|
self._drag_preview = None
|
||||||
|
self.queue_draw()
|
||||||
|
if self.on_board_changed:
|
||||||
|
self.on_board_changed()
|
||||||
|
|
||||||
|
def _on_right_click(self, gesture, n, x, y):
|
||||||
|
cell = self._cell_at(x, y)
|
||||||
|
if cell:
|
||||||
|
self.game.remove_rect_at(*cell)
|
||||||
|
self.queue_draw()
|
||||||
|
if self.on_board_changed:
|
||||||
|
self.on_board_changed()
|
||||||
|
|
||||||
|
def mark_errors(self, error_indices: set[int]):
|
||||||
|
self._error_rects = error_indices
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def clear_errors(self):
|
||||||
|
self._error_rects = set()
|
||||||
|
self.queue_draw()
|
||||||
|
|
||||||
|
def _draw(self, area, cr, width, height):
|
||||||
|
size = self.game.size
|
||||||
|
clues = self.game.clues
|
||||||
|
player_rects = self.game.player_rects
|
||||||
|
cs = cell_size(size)
|
||||||
|
grid_w = size * cs
|
||||||
|
|
||||||
|
# Background
|
||||||
|
cr.set_source_rgba(*BG_COLOR)
|
||||||
|
cr.paint()
|
||||||
|
|
||||||
|
# Grid paper
|
||||||
|
cr.set_source_rgba(*PAPER_COLOR)
|
||||||
|
cr.rectangle(PADDING, PADDING, grid_w, grid_w)
|
||||||
|
cr.fill()
|
||||||
|
|
||||||
|
# Draw placed rectangles
|
||||||
|
for i, pr in enumerate(player_rects):
|
||||||
|
color_idx = i % len(RECT_COLORS)
|
||||||
|
n = pr.normalized()
|
||||||
|
rx = PADDING + n.start_col * cs
|
||||||
|
ry = PADDING + n.start_row * cs
|
||||||
|
rw = (n.end_col - n.start_col + 1) * cs
|
||||||
|
rh = (n.end_row - n.start_row + 1) * cs
|
||||||
|
|
||||||
|
if i in self._error_rects:
|
||||||
|
cr.set_source_rgba(*ERROR_COLOR)
|
||||||
|
else:
|
||||||
|
cr.set_source_rgba(*RECT_COLORS[color_idx])
|
||||||
|
_rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6)
|
||||||
|
cr.fill()
|
||||||
|
|
||||||
|
if i in self._error_rects:
|
||||||
|
cr.set_source_rgba(*ERROR_BORDER)
|
||||||
|
else:
|
||||||
|
cr.set_source_rgba(*RECT_BORDER[color_idx])
|
||||||
|
cr.set_line_width(2.5)
|
||||||
|
_rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
# Drag preview
|
||||||
|
if self._drag_preview:
|
||||||
|
n = self._drag_preview.normalized()
|
||||||
|
rx = PADDING + n.start_col * cs
|
||||||
|
ry = PADDING + n.start_row * cs
|
||||||
|
rw = (n.end_col - n.start_col + 1) * cs
|
||||||
|
rh = (n.end_row - n.start_row + 1) * cs
|
||||||
|
cr.set_source_rgba(*DRAG_COLOR)
|
||||||
|
_rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6)
|
||||||
|
cr.fill()
|
||||||
|
cr.set_source_rgba(*DRAG_BORDER)
|
||||||
|
cr.set_line_width(2.0)
|
||||||
|
_rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
# Grid lines
|
||||||
|
cr.set_line_width(0.8)
|
||||||
|
cr.set_source_rgba(*GRID_LINE)
|
||||||
|
for i in range(size + 1):
|
||||||
|
x = PADDING + i * cs
|
||||||
|
cr.move_to(x, PADDING)
|
||||||
|
cr.line_to(x, PADDING + grid_w)
|
||||||
|
cr.stroke()
|
||||||
|
cr.move_to(PADDING, x)
|
||||||
|
cr.line_to(PADDING + grid_w, x)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
# Border
|
||||||
|
cr.set_line_width(3.0)
|
||||||
|
cr.set_source_rgba(*GRID_LINE_BOLD)
|
||||||
|
cr.rectangle(PADDING, PADDING, grid_w, grid_w)
|
||||||
|
cr.stroke()
|
||||||
|
|
||||||
|
# Clue numbers
|
||||||
|
layout = self.create_pango_layout("")
|
||||||
|
fs = font_size(size)
|
||||||
|
layout.set_font_description(Pango.FontDescription(f"Iosevka, Monospace Bold {fs}"))
|
||||||
|
for (r, c), num in clues.items():
|
||||||
|
layout.set_text(str(num), -1)
|
||||||
|
_, ext = layout.get_pixel_extents()
|
||||||
|
tx = PADDING + c * cs + cs // 2 - ext.width // 2
|
||||||
|
ty = PADDING + r * cs + cs // 2 - ext.height // 2
|
||||||
|
cr.set_source_rgba(*CLUE_COLOR)
|
||||||
|
cr.move_to(tx, ty)
|
||||||
|
from gi.repository import PangoCairo
|
||||||
|
PangoCairo.show_layout(cr, layout)
|
||||||
|
|
||||||
|
|
||||||
|
def _rounded_rect(cr, x, y, w, h, r):
|
||||||
|
cr.new_sub_path()
|
||||||
|
cr.arc(x + r, y + r, r, 3.14159, 1.5 * 3.14159)
|
||||||
|
cr.arc(x + w - r, y + r, r, 1.5 * 3.14159, 0)
|
||||||
|
cr.arc(x + w - r, y + h - r, r, 0, 0.5 * 3.14159)
|
||||||
|
cr.arc(x + r, y + h - r, r, 0.5 * 3.14159, 3.14159)
|
||||||
|
cr.close_path()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Game state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ShikakuGame:
|
||||||
|
def __init__(self, size: int):
|
||||||
|
self.size = size
|
||||||
|
self.clues: dict = {}
|
||||||
|
self.solution: list[Rect] = []
|
||||||
|
self.player_rects: list[PlayerRect] = []
|
||||||
|
self.start_time: float = 0
|
||||||
|
self.elapsed: float = 0
|
||||||
|
self.solved = False
|
||||||
|
self.new_puzzle()
|
||||||
|
|
||||||
|
def new_puzzle(self, seed=None):
|
||||||
|
self.clues, self.solution = generate_puzzle(self.size, seed or random.randint(0, 2**31))
|
||||||
|
self.player_rects = []
|
||||||
|
self.start_time = time.time()
|
||||||
|
self.elapsed = 0
|
||||||
|
self.solved = False
|
||||||
|
|
||||||
|
def place_rect(self, pr: PlayerRect):
|
||||||
|
"""Add a rectangle, removing any existing ones that overlap."""
|
||||||
|
new_cells = set(pr.cells())
|
||||||
|
self.player_rects = [r for r in self.player_rects
|
||||||
|
if not set(r.cells()) & new_cells]
|
||||||
|
self.player_rects.append(pr)
|
||||||
|
|
||||||
|
def remove_rect_at(self, row, col):
|
||||||
|
self.player_rects = [r for r in self.player_rects
|
||||||
|
if (row, col) not in set(r.cells())]
|
||||||
|
|
||||||
|
def check_solution(self) -> tuple[bool, str, set[int]]:
|
||||||
|
ok, msg = verify_solution(self.size, self.player_rects, self.clues)
|
||||||
|
error_indices = set()
|
||||||
|
if not ok:
|
||||||
|
# Mark rectangles that are invalid
|
||||||
|
for i, pr in enumerate(self.player_rects):
|
||||||
|
cells = set(pr.cells())
|
||||||
|
clues_inside = {cell: self.clues[cell] for cell in cells if cell in self.clues}
|
||||||
|
if len(clues_inside) != 1 or pr.area() != next(iter(clues_inside.values()), -1):
|
||||||
|
error_indices.add(i)
|
||||||
|
else:
|
||||||
|
self.solved = True
|
||||||
|
self.elapsed = time.time() - self.start_time
|
||||||
|
return ok, msg, error_indices
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main window ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ShikakuWindow(Gtk.ApplicationWindow):
|
||||||
|
def __init__(self, app):
|
||||||
|
super().__init__(application=app, title="Shikaku")
|
||||||
|
self.set_resizable(True)
|
||||||
|
|
||||||
|
self._current_size = 5
|
||||||
|
self.game = ShikakuGame(self._current_size)
|
||||||
|
|
||||||
|
self._build_ui()
|
||||||
|
self._update_canvas_size()
|
||||||
|
self._start_timer()
|
||||||
|
|
||||||
|
def _build_ui(self):
|
||||||
|
outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
|
self.set_child(outer)
|
||||||
|
|
||||||
|
# ── Top bar ──
|
||||||
|
topbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||||
|
topbar.set_margin_top(14)
|
||||||
|
topbar.set_margin_bottom(10)
|
||||||
|
topbar.set_margin_start(20)
|
||||||
|
topbar.set_margin_end(20)
|
||||||
|
outer.append(topbar)
|
||||||
|
|
||||||
|
title = Gtk.Label(label="SHIKAKU")
|
||||||
|
title.add_css_class("title-label")
|
||||||
|
topbar.append(title)
|
||||||
|
|
||||||
|
spacer = Gtk.Box()
|
||||||
|
spacer.set_hexpand(True)
|
||||||
|
topbar.append(spacer)
|
||||||
|
|
||||||
|
self._timer_label = Gtk.Label(label="0:00")
|
||||||
|
self._timer_label.add_css_class("timer-label")
|
||||||
|
topbar.append(self._timer_label)
|
||||||
|
|
||||||
|
# ── Size buttons ──
|
||||||
|
size_bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||||||
|
size_bar.set_halign(Gtk.Align.CENTER)
|
||||||
|
size_bar.set_margin_bottom(10)
|
||||||
|
outer.append(size_bar)
|
||||||
|
|
||||||
|
sizes_label = Gtk.Label(label="Grid:")
|
||||||
|
sizes_label.add_css_class("sizes-label")
|
||||||
|
size_bar.append(sizes_label)
|
||||||
|
|
||||||
|
self._size_buttons = {}
|
||||||
|
for sz in [5, 7, 10, 15, 20, 25]:
|
||||||
|
btn = Gtk.Button(label=f"{sz}×{sz}")
|
||||||
|
btn.add_css_class("size-btn")
|
||||||
|
btn.connect("clicked", self._on_size_clicked, sz)
|
||||||
|
size_bar.append(btn)
|
||||||
|
self._size_buttons[sz] = btn
|
||||||
|
|
||||||
|
self._update_size_buttons()
|
||||||
|
|
||||||
|
# ── Canvas inside a scrolled window for large grids ──
|
||||||
|
scroll = Gtk.ScrolledWindow()
|
||||||
|
scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||||
|
scroll.set_hexpand(True)
|
||||||
|
scroll.set_vexpand(True)
|
||||||
|
self.canvas = ShikakuCanvas(self.game)
|
||||||
|
self.canvas.set_halign(Gtk.Align.CENTER)
|
||||||
|
self.canvas.set_valign(Gtk.Align.CENTER)
|
||||||
|
self.canvas.on_board_changed = self._trigger_auto_check
|
||||||
|
scroll.set_child(self.canvas)
|
||||||
|
outer.append(scroll)
|
||||||
|
|
||||||
|
# ── Bottom bar ──
|
||||||
|
botbar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
|
||||||
|
botbar.set_halign(Gtk.Align.CENTER)
|
||||||
|
botbar.set_margin_top(14)
|
||||||
|
botbar.set_margin_bottom(18)
|
||||||
|
outer.append(botbar)
|
||||||
|
|
||||||
|
new_btn = Gtk.Button(label="New Puzzle")
|
||||||
|
new_btn.add_css_class("action-btn")
|
||||||
|
new_btn.connect("clicked", self._on_new)
|
||||||
|
botbar.append(new_btn)
|
||||||
|
|
||||||
|
check_btn = Gtk.Button(label="Check")
|
||||||
|
check_btn.add_css_class("action-btn")
|
||||||
|
check_btn.connect("clicked", self._on_check)
|
||||||
|
botbar.append(check_btn)
|
||||||
|
|
||||||
|
clear_btn = Gtk.Button(label="Clear")
|
||||||
|
clear_btn.add_css_class("action-btn-ghost")
|
||||||
|
clear_btn.connect("clicked", self._on_clear)
|
||||||
|
botbar.append(clear_btn)
|
||||||
|
|
||||||
|
self._status_label = Gtk.Label(label="Draw rectangles around each number — right-click to erase")
|
||||||
|
self._status_label.add_css_class("status-label")
|
||||||
|
self._status_label.set_margin_top(4)
|
||||||
|
self._status_label.set_margin_bottom(8)
|
||||||
|
outer.append(self._status_label)
|
||||||
|
|
||||||
|
# ── CSS ──
|
||||||
|
css = Gtk.CssProvider()
|
||||||
|
css.load_from_string(self._css())
|
||||||
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
|
Gdk.Display.get_default(), css,
|
||||||
|
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||||
|
)
|
||||||
|
|
||||||
|
def _css(self):
|
||||||
|
return """
|
||||||
|
window {
|
||||||
|
background-color: #1c1a21;
|
||||||
|
}
|
||||||
|
.title-label {
|
||||||
|
font-family: "Iosevka", "Monospace";
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 6px;
|
||||||
|
color: #e8e4dc;
|
||||||
|
}
|
||||||
|
.timer-label {
|
||||||
|
font-family: "Iosevka", "Monospace";
|
||||||
|
font-size: 18px;
|
||||||
|
color: #8a8070;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.sizes-label {
|
||||||
|
font-family: "Iosevka", "Monospace";
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6a6060;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.size-btn {
|
||||||
|
font-family: "Iosevka", "Monospace";
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2a2730;
|
||||||
|
color: #9a9090;
|
||||||
|
border: 1px solid #3a3540;
|
||||||
|
}
|
||||||
|
.size-btn:hover {
|
||||||
|
background: #3a3540;
|
||||||
|
color: #d0c8c0;
|
||||||
|
}
|
||||||
|
.size-btn-active {
|
||||||
|
background: #4a4060;
|
||||||
|
color: #e8e0d8;
|
||||||
|
border: 1px solid #7a6090;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
font-family: "Iosevka", "Monospace";
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 8px 22px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #5a4880;
|
||||||
|
color: #e8e0ff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #7060a0;
|
||||||
|
}
|
||||||
|
.action-btn-ghost {
|
||||||
|
font-family: "Iosevka", "Monospace";
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 8px 22px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6a6070;
|
||||||
|
border: 1px solid #3a3540;
|
||||||
|
}
|
||||||
|
.action-btn-ghost:hover {
|
||||||
|
color: #a09090;
|
||||||
|
border-color: #5a5060;
|
||||||
|
}
|
||||||
|
.status-label {
|
||||||
|
font-family: "Iosevka", "Monospace";
|
||||||
|
font-size: 11px;
|
||||||
|
color: #5a5560;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.status-ok {
|
||||||
|
color: #60c080;
|
||||||
|
}
|
||||||
|
.status-err {
|
||||||
|
color: #c06060;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _update_size_buttons(self):
|
||||||
|
for sz, btn in self._size_buttons.items():
|
||||||
|
btn.remove_css_class("size-btn-active")
|
||||||
|
if sz == self._current_size:
|
||||||
|
btn.add_css_class("size-btn-active")
|
||||||
|
|
||||||
|
def _update_canvas_size(self):
|
||||||
|
sz = self.game.size
|
||||||
|
cs = cell_size(sz)
|
||||||
|
total = sz * cs + 2 * PADDING
|
||||||
|
self.canvas.set_content_width(total)
|
||||||
|
self.canvas.set_content_height(total)
|
||||||
|
|
||||||
|
def _on_size_clicked(self, btn, size):
|
||||||
|
self._current_size = size
|
||||||
|
self.game = ShikakuGame(size)
|
||||||
|
self.canvas.game = self.game
|
||||||
|
self.canvas.on_board_changed = self._trigger_auto_check
|
||||||
|
self.canvas._error_rects = set()
|
||||||
|
self._update_canvas_size()
|
||||||
|
self._update_size_buttons()
|
||||||
|
self._status_label.remove_css_class("status-ok")
|
||||||
|
self._status_label.remove_css_class("status-err")
|
||||||
|
self._status_label.set_text("Draw rectangles around each number — right-click to erase")
|
||||||
|
self.canvas.queue_draw()
|
||||||
|
|
||||||
|
def _on_new(self, btn):
|
||||||
|
self.game.new_puzzle()
|
||||||
|
self.canvas._error_rects = set()
|
||||||
|
self._status_label.remove_css_class("status-ok")
|
||||||
|
self._status_label.remove_css_class("status-err")
|
||||||
|
self._status_label.set_text("Draw rectangles around each number — right-click to erase")
|
||||||
|
self.canvas.queue_draw()
|
||||||
|
|
||||||
|
def _trigger_auto_check(self):
|
||||||
|
"""Silently check after every move; announce only on a correct solution."""
|
||||||
|
if self.game.solved:
|
||||||
|
return
|
||||||
|
ok, msg, errors = self.game.check_solution()
|
||||||
|
if ok:
|
||||||
|
self.canvas.clear_errors()
|
||||||
|
mins = int(self.game.elapsed) // 60
|
||||||
|
secs = int(self.game.elapsed) % 60
|
||||||
|
self._status_label.remove_css_class("status-err")
|
||||||
|
self._status_label.add_css_class("status-ok")
|
||||||
|
self._status_label.set_text(f"✓ Solved in {mins}:{secs:02d}!")
|
||||||
|
else:
|
||||||
|
# Don't highlight errors on auto-check — only the manual Check button does that
|
||||||
|
self.canvas.clear_errors()
|
||||||
|
|
||||||
|
def _on_check(self, btn):
|
||||||
|
ok, msg, errors = self.game.check_solution()
|
||||||
|
self.canvas.mark_errors(errors)
|
||||||
|
self._status_label.remove_css_class("status-ok")
|
||||||
|
self._status_label.remove_css_class("status-err")
|
||||||
|
if ok:
|
||||||
|
mins = int(self.game.elapsed) // 60
|
||||||
|
secs = int(self.game.elapsed) % 60
|
||||||
|
self._status_label.set_text(f"✓ Solved in {mins}:{secs:02d}!")
|
||||||
|
self._status_label.add_css_class("status-ok")
|
||||||
|
else:
|
||||||
|
self._status_label.set_text(f"✗ {msg}")
|
||||||
|
self._status_label.add_css_class("status-err")
|
||||||
|
|
||||||
|
def _on_clear(self, btn):
|
||||||
|
self.game.player_rects = []
|
||||||
|
self.canvas.clear_errors()
|
||||||
|
self._status_label.remove_css_class("status-ok")
|
||||||
|
self._status_label.remove_css_class("status-err")
|
||||||
|
self._status_label.set_text("Cleared")
|
||||||
|
self.canvas.queue_draw()
|
||||||
|
|
||||||
|
def _start_timer(self):
|
||||||
|
GLib.timeout_add(1000, self._tick)
|
||||||
|
|
||||||
|
def _tick(self):
|
||||||
|
if not self.game.solved:
|
||||||
|
elapsed = int(time.time() - self.game.start_time)
|
||||||
|
m = elapsed // 60
|
||||||
|
s = elapsed % 60
|
||||||
|
self._timer_label.set_text(f"{m}:{s:02d}")
|
||||||
|
return True # keep ticking
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Application entry ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ShikakuApp(Gtk.Application):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(application_id="com.shikaku.puzzle")
|
||||||
|
|
||||||
|
def do_activate(self):
|
||||||
|
win = ShikakuWindow(self)
|
||||||
|
win.present()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
app = ShikakuApp()
|
||||||
|
sys.exit(app.run(sys.argv))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user