Source code for apps.SlidingPuzzle

#
##
##  SPDX-FileCopyrightText: © 2007-2024 Benedict Verhegghe <bverheg@gmail.com>
##  SPDX-License-Identifier: GPL-3.0-or-later
##
##  This file is part of pyFormex 3.5  (Thu Feb  8 19:11:13 CET 2024)
##  pyFormex is a tool for generating, manipulating and transforming 3D
##  geometrical models by sequences of mathematical operations.
##  Home page: https://pyformex.org
##  Project page: https://savannah.nongnu.org/projects/pyformex/
##  Development: https://gitlab.com/bverheg/pyformex
##  Distributed under the GNU General Public License version 3 or later.
##
##  This program is free software: you can redistribute it and/or modify
##  it under the terms of the GNU General Public License as published by
##  the Free Software Foundation, either version 3 of the License, or
##  (at your option) any later version.
##
##  This program is distributed in the hope that it will be useful,
##  but WITHOUT ANY WARRANTY; without even the implied warranty of
##  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
##  GNU General Public License for more details.
##
##  You should have received a copy of the GNU General Public License
##  along with this program.  If not, see http://www.gnu.org/licenses/.
##
"""SlidingPuzzle

A sliding puzzle game built with the pyFormex engine.
"""
# from PIL import Image
import numpy as np
import pyformex as pf
from pyformex.gui.widgets import _I
_name = pf.Path(__file__).stem


[docs]def create_grid(nx, ny, cs=64, bg='yellow', fg='black', pt=24, st='normal', wt='normal', sw=1, gr='northwest', lw=3, grid=True, numbers=False, ofs=1, output=None): """Create an image of an (nx,ny) grid with numbered cells. If successful, returns the image filename, else None. """ if output is None: output = f"puzzle_{nx}x{ny}.png" lw2 = lw // 2 c2 = int(cs/2 - pt/2) w = nx * cs + lw h = ny * cs + lw args = ['convert', '-size', f'{w}x{h}', f'xc:{bg}', '-stroke', f'{fg}', '-strokewidth', f"{lw}", '-pointsize', f'{pt}', '-style', f'{st}', '-weight', f'{wt}', '-gravity', f'{gr}', '-fill', 'none', '-draw', f'rectangle 1,1 {w-2},{h-2}', ] if grid: for i in range(1, ny): y = i * cs + lw2 args.extend(['-draw', f'line 0,{y} {w},{y}']) for i in range(1, nx): x = i * cs + lw2 args.extend(['-draw', f'line {x},0 {x},{h}']) if numbers: for i in range(0, ny): y = i * cs + c2 for j in range(0, nx): x = j * cs + c2 k = i * nx + j + ofs args.extend([ '-strokewidth', f"{sw}", '-fill', f'{fg}', '-draw', f"text {x},{y} '{k}'"]) args.append(output) P = pf.command(args, verbose=False, check=False) if P.returncode == 0: return output
[docs]def sliding_puzzle(filename, nx, ny, shownumbers=False, # noqa: 901 level='Random', numcolor='black'): """Create a sliding puzzle of size (nx,ny) from the image filename""" pf.resetAll() pf.flatwire() pf.clear() pf.transparent() ne = nx * ny Base = pf.Formex('4:0123').replic2(nx, ny, 1., 1.) tc = Base.coords[..., :2] * (1./nx, 1./ny) tgt = np.arange(ne) pos = np.arange(ne) invis = ne-1 solvable = level == 'Moves' nmoves = 0 def get_moves(tocell): """Set possible moves to cell""" y, x = divmod(tocell, nx) # print(f"{tocell=} -> {x=}, {y=}") moves = {} if x > 0: moves[tocell-1] = 'r' if x < nx-1: moves[tocell+1] = 'l' if y > 0: moves[tocell-nx] = 'u' if y < ny-1: moves[tocell+nx] = 'd' return moves def adjacent(cell1, cell2): """Check if cells are adjacent""" return cell1 in get_moves(cell2) def move(cell1, cell2): """Interchange cell1 and cell2. Return the incorrect cells""" nonlocal nmoves pos[[cell1, cell2]] = pos[[cell2, cell1]] tc[[cell1, cell2]] = tc[[cell2, cell1]] ok = pos == tgt todo = np.where(~ok)[0] color[:, 3] = 0.5 color[ok, 3] = 1.0 nmoves += 1 return todo def draw(): nonlocal A """Draw the new situation""" A = pf.draw(Base, texture=filename, color=color, texmode=2, texcoords=tc, undraw=A) if shownumbers: showNumbers() def showNumbers(): nonlocal B pf.undraw(B) # todo: add undraw to drawNumbers B = pf.drawNumbers(Base.coords[:, 2], numbers=pos, gravity='sw', color=numcolor, size=30) def shuffle_by_moves(n=0): """Do n random moves without showing""" nonlocal invis while n > 0: n -= 1 moves = get_moves(invis) cell = np.random.choice(list(moves.keys())) pos[[invis, cell]] = pos[[cell, invis]] tc[[invis, cell]] = tc[[cell, invis]] invis = cell def taxicab_parity(invis, nx): """Compute taxicab parity of the invis location""" y, x = divmod(invis, nx) return (y + x) % 2 def get_invariant(pos, invis): """Compute and display parity""" perm_parity = {-1: 1, 1: 0}[pf.at.permutation_parity(pos)] tax_parity = taxicab_parity(invis, nx) invariant = (perm_parity + tax_parity) % 2 # print(f"Invariant = {perm_parity}, {tax_parity}, {invariant}") return invariant def shuffle_random(): """Shuffle cells and find a proper invisible cell""" nonlocal invis np.random.shuffle(pos) tc[:] = tc[pos] invis = np.where(pos == ne-1)[0][0] # print(f"{pos=}, {invis=}") invariant = get_invariant(pos, invis) final_invariant = taxicab_parity(ne-1, nx) # print(f"{final_invariant=}") if invariant != final_invariant: print("UNSOLVABLE: I will swap cells 0 and 1") cell0 = np.where(pos == 0)[0][0] cell1 = np.where(pos == 1)[0][0] pos[cell0] = 1 pos[cell1] = 0 print(f"{pos=}, {invis=}") invariant = get_invariant(pos, invis) if invariant != final_invariant: pf.warning("This puzzle will need a swap at the end") if level == 'Moves': shuffle_by_moves(10*ne) else: shuffle_random() # print(f"STARTING {pos=}, {invis=}") color = pf.colorArray([(1., 1., 1., 1.0)] * ne) invisible = 0.0 # 0.2 to make slightly visible color[invis, 3] = invisible A = pf.draw(Base, texture=filename, color=color, texmode=2, texcoords=tc) B = None pf.lockView() if shownumbers: showNumbers() print("Click on a cell to move it to the free space") while True: moves = get_moves(invis) # print(f"{moves=}") sel = pf.pick('element', prompt='', oneshot=True) # print(sel) if sel: cell = sel[0][0] # print(f"{cell=}") if cell in moves: todo = move(cell, invis) invis = cell # get_invariant(pos, invis) if not solvable and len(todo) == 2 and (todo != invis).all(): print(f"Possible block {todo=} {invis=}") print("Cells are adjacent:", adjacent(*todo)) if pf.ack(f"This puzzle is not solvable. Should I swap " f"cells {todo[0], todo[1]}?"): todo = move(*todo) color[invis, 3] = 1.0 if len(todo) == 0 else invisible draw() if len(todo) == 0: print(f"Congrats, you've solved the puzzle in " f"{nmoves} moves = {nmoves/ne} moves per cell") C = pf.drawText( "CONGRATULATIONS!", pos=(pf.canvas.width()/2, pf.canvas.height()/2), size=100, color='red', gravity='c') pf.sleep(3) if shownumbers: pf.undraw(B) pf.undraw(C) break else: print("Can not move this cell") else: if pf.ask("No cell picked: stop or continue?", ['Stop', 'Continue']) == 'Stop': break
def run(): pf.resetAll() store = _name+'_data' if store in pf.PF: filename = pf.PF[store]['filename'] else: filename = pf.cfg['datadir'] / 'puzzle.png' from pyformex.gui.widgets import ImageView viewer = ImageView(filename) # image viewer widget # TODO: Integrate this in a ImageLoadDialog? or in a LoadImageViewer? def selectImage(field): fn = pf.askImageFile(field.value()) if fn: viewer.showImage(fn) dialog = pf.currentDialog() print(dialog) dialog = field.dialog() print(dialog) if dialog: dialog.updateData({'filename': fn}) return fn res = pf.askItems(store=_name+'_data', modal=False, items=[ _I('filename', filename, text='Image file', itemtype='button', func=selectImage), viewer, # image previewing widget _I('nx', 4, min=2, text='Number of cells in horizontal direction'), _I('ny', 4, min=2, text='Number of cells in vertical direction'), _I('shownumbers', True, text='Show cell numbers'), _I('level', choices=['Random', 'Moves'], text='Shuffling method'), ]) if res: # print(res) if pf.Path(res['filename']).name == 'puzzle.png': filename = create_grid(res['nx'], res['ny'], ofs=1, numbers=not res['shownumbers']) if filename and pf.Path(filename).exists(): res['filename'] = filename sliding_puzzle(**res) if __name__ == '__draw__': run() # End