working game, needs some touch up
This commit is contained in:
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