1001 lines
35 KiB
Python
1001 lines
35 KiB
Python
#!/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
|
||
|
||
# Base cell sizes per grid — zoom multiplier is applied on top of these
|
||
BASE_CELL_SIZES = {
|
||
5: 56,
|
||
7: 52,
|
||
10: 46,
|
||
15: 36,
|
||
20: 28,
|
||
25: 24,
|
||
}
|
||
BASE_FONT_SIZES = {
|
||
5: 14,
|
||
7: 13,
|
||
10: 12,
|
||
15: 10,
|
||
20: 8,
|
||
25: 7,
|
||
}
|
||
|
||
ZOOM_MIN = 0.5
|
||
ZOOM_MAX = 3.0
|
||
ZOOM_STEP = 0.15
|
||
|
||
def cell_size(grid_size: int, zoom: float = 1.0) -> int:
|
||
return max(8, int(BASE_CELL_SIZES.get(grid_size, 40) * zoom))
|
||
|
||
def font_size(grid_size: int, zoom: float = 1.0) -> int:
|
||
return max(5, int(BASE_FONT_SIZES.get(grid_size, 10) * zoom))
|
||
|
||
# Approximate height of window chrome (toolbar + size bar + bottom bar + status)
|
||
CHROME_HEIGHT = 160
|
||
|
||
# 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.90, 0.65, 0.80, 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.80, 0.45, 0.65, 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.zoom = 1.0
|
||
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
|
||
# Callback fired when zoom changes — set by the window
|
||
self.on_zoom_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)
|
||
|
||
# Scroll wheel for zoom
|
||
scroll = Gtk.EventControllerScroll()
|
||
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
|
||
scroll.connect("scroll", self._on_scroll)
|
||
self.add_controller(scroll)
|
||
|
||
def _cell_at(self, x, y):
|
||
cs = cell_size(self.game.size, self.zoom)
|
||
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)
|
||
if pr.area() > 1: # enforce minimum area of 2
|
||
self.game.place_rect(pr)
|
||
if self.on_board_changed:
|
||
self.on_board_changed()
|
||
self._drag_start = None
|
||
self._drag_end = None
|
||
self._drag_preview = None
|
||
self.queue_draw()
|
||
|
||
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 _on_scroll(self, controller, dx, dy):
|
||
# Ctrl+scroll or plain scroll both zoom
|
||
old_zoom = self.zoom
|
||
self.zoom = max(ZOOM_MIN, min(ZOOM_MAX, self.zoom - dy * ZOOM_STEP))
|
||
if self.zoom != old_zoom:
|
||
if self.on_zoom_changed:
|
||
self.on_zoom_changed()
|
||
return True # consume event
|
||
|
||
def set_zoom(self, zoom: float):
|
||
self.zoom = max(ZOOM_MIN, min(ZOOM_MAX, zoom))
|
||
if self.on_zoom_changed:
|
||
self.on_zoom_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, self.zoom)
|
||
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
|
||
preview_area = self._drag_preview.area()
|
||
is_invalid = preview_area == 1
|
||
|
||
cr.set_source_rgba(*(ERROR_COLOR if is_invalid else DRAG_COLOR))
|
||
_rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6)
|
||
cr.fill()
|
||
cr.set_source_rgba(*(ERROR_BORDER if is_invalid else DRAG_BORDER))
|
||
cr.set_line_width(2.0)
|
||
_rounded_rect(cr, rx + 2, ry + 2, rw - 4, rh - 4, 6)
|
||
cr.stroke()
|
||
|
||
# Draw cell count label inside the drag preview
|
||
count_layout = self.create_pango_layout("")
|
||
count_fs = max(8, int(font_size(size, self.zoom) * 0.95))
|
||
count_layout.set_font_description(
|
||
Pango.FontDescription(f"Iosevka, Monospace Bold {count_fs}")
|
||
)
|
||
count_layout.set_text(str(preview_area), -1)
|
||
_, ext = count_layout.get_pixel_extents()
|
||
tx = rx + rw // 2 - ext.width // 2
|
||
ty = ry + rh // 2 - ext.height // 2
|
||
# White text with dark shadow for readability on any background
|
||
cr.set_source_rgba(0.1, 0.05, 0.15, 0.6)
|
||
cr.move_to(tx + 1, ty + 1)
|
||
from gi.repository import PangoCairo
|
||
PangoCairo.show_layout(cr, count_layout)
|
||
cr.set_source_rgba(1.0, 1.0, 1.0, 0.95)
|
||
cr.move_to(tx, ty)
|
||
PangoCairo.show_layout(cr, count_layout)
|
||
|
||
# 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, self.zoom)
|
||
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.current_seed = seed or random.randint(0, 2**31)
|
||
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
|
||
self.canvas.on_zoom_changed = self._on_zoom_changed
|
||
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)
|
||
|
||
zoombar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
|
||
zoombar.set_halign(Gtk.Align.CENTER)
|
||
zoombar.set_margin_bottom(10)
|
||
outer.append(zoombar)
|
||
|
||
zoom_out_btn = Gtk.Button(label="−")
|
||
zoom_out_btn.add_css_class("zoom-btn")
|
||
zoom_out_btn.connect("clicked", self._on_zoom_out)
|
||
zoombar.append(zoom_out_btn)
|
||
|
||
self._zoom_label = Gtk.Label(label="100%")
|
||
self._zoom_label.add_css_class("zoom-label")
|
||
self._zoom_label.set_width_chars(5)
|
||
zoombar.append(self._zoom_label)
|
||
|
||
zoom_in_btn = Gtk.Button(label="+")
|
||
zoom_in_btn.add_css_class("zoom-btn")
|
||
zoom_in_btn.connect("clicked", self._on_zoom_in)
|
||
zoombar.append(zoom_in_btn)
|
||
|
||
zoom_reset_btn = Gtk.Button(label="1:1")
|
||
zoom_reset_btn.add_css_class("action-btn-ghost")
|
||
zoom_reset_btn.connect("clicked", self._on_zoom_reset)
|
||
zoombar.append(zoom_reset_btn)
|
||
|
||
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)
|
||
|
||
seed_btn = Gtk.Button(label="Seed")
|
||
seed_btn.add_css_class("action-btn-ghost")
|
||
seed_btn.connect("clicked", self._on_seed)
|
||
botbar.append(seed_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;
|
||
}
|
||
.seed-heading {
|
||
font-family: "Iosevka", "Monospace";
|
||
font-size: 10px;
|
||
letter-spacing: 3px;
|
||
color: #6a6070;
|
||
}
|
||
.seed-value {
|
||
font-family: "Iosevka", "Monospace";
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: #c0b8d8;
|
||
letter-spacing: 2px;
|
||
}
|
||
.seed-entry {
|
||
font-family: "Iosevka", "Monospace";
|
||
font-size: 14px;
|
||
color: #e8e0ff;
|
||
}
|
||
"""
|
||
|
||
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, self.canvas.zoom)
|
||
total = sz * cs + 2 * PADDING
|
||
self.canvas.set_content_width(total)
|
||
self.canvas.set_content_height(total)
|
||
self.set_default_size(total + 80, total + CHROME_HEIGHT + 60)
|
||
|
||
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.canvas.zoom = 1.0 # reset zoom on size change
|
||
self._zoom_label.set_text("100%") # reset zoom label
|
||
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
|
||
|
||
def _on_zoom_changed(self):
|
||
pct = int(self.canvas.zoom * 100)
|
||
self._zoom_label.set_text(f"{pct}%")
|
||
self._update_canvas_size()
|
||
self.canvas.queue_draw()
|
||
|
||
def _on_zoom_in(self, btn):
|
||
self.canvas.set_zoom(self.canvas.zoom + ZOOM_STEP)
|
||
|
||
def _on_zoom_out(self, btn):
|
||
self.canvas.set_zoom(self.canvas.zoom - ZOOM_STEP)
|
||
|
||
def _on_zoom_reset(self, btn):
|
||
self.canvas.set_zoom(1.0)
|
||
|
||
def _on_seed(self, btn):
|
||
dialog = Gtk.Dialog(title="Puzzle Seed", transient_for=self, modal=True)
|
||
dialog.set_resizable(False)
|
||
|
||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||
box.set_margin_top(20)
|
||
box.set_margin_bottom(20)
|
||
box.set_margin_start(24)
|
||
box.set_margin_end(24)
|
||
dialog.get_content_area().append(box)
|
||
|
||
# Current seed display
|
||
current_label = Gtk.Label(label="CURRENT SEED")
|
||
current_label.add_css_class("seed-heading")
|
||
current_label.set_halign(Gtk.Align.START)
|
||
box.append(current_label)
|
||
|
||
seed_val = Gtk.Label(label=str(self.game.current_seed))
|
||
seed_val.add_css_class("seed-value")
|
||
seed_val.set_selectable(True)
|
||
seed_val.set_halign(Gtk.Align.START)
|
||
box.append(seed_val)
|
||
|
||
sep = Gtk.Separator()
|
||
sep.set_margin_top(4)
|
||
sep.set_margin_bottom(4)
|
||
box.append(sep)
|
||
|
||
# Custom seed entry
|
||
entry_label = Gtk.Label(label="ENTER SEED")
|
||
entry_label.add_css_class("seed-heading")
|
||
entry_label.set_halign(Gtk.Align.START)
|
||
box.append(entry_label)
|
||
|
||
entry = Gtk.Entry()
|
||
entry.set_placeholder_text("e.g. 123456789")
|
||
entry.set_input_purpose(Gtk.InputPurpose.DIGITS)
|
||
entry.add_css_class("seed-entry")
|
||
box.append(entry)
|
||
|
||
# Buttons
|
||
btn_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||
btn_box.set_halign(Gtk.Align.END)
|
||
btn_box.set_margin_top(8)
|
||
box.append(btn_box)
|
||
|
||
cancel_btn = Gtk.Button(label="Cancel")
|
||
cancel_btn.add_css_class("action-btn-ghost")
|
||
cancel_btn.connect("clicked", lambda b: dialog.close())
|
||
btn_box.append(cancel_btn)
|
||
|
||
go_btn = Gtk.Button(label="Start Puzzle")
|
||
go_btn.add_css_class("action-btn")
|
||
btn_box.append(go_btn)
|
||
|
||
def _on_go(b):
|
||
text = entry.get_text().strip()
|
||
try:
|
||
seed = int(text)
|
||
except ValueError:
|
||
entry.add_css_class("error")
|
||
return
|
||
dialog.close()
|
||
self.game.new_puzzle(seed=seed)
|
||
self.canvas._error_rects = set()
|
||
self.canvas.zoom = 1.0
|
||
self._zoom_label.set_text("100%")
|
||
self._update_canvas_size()
|
||
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()
|
||
|
||
go_btn.connect("clicked", _on_go)
|
||
entry.connect("activate", _on_go) # Enter key submits
|
||
|
||
dialog.present()
|
||
|
||
|
||
# ─── 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() |