├── resources ├── logo.png ├── editor.ico └── player.ico ├── requirements.txt ├── .gitignore ├── .windows ├── README.md ├── make_runners.cmd ├── runner.c └── make_bundle.sh ├── config.py ├── util.py ├── README.md ├── universal_qt └── __init__.py ├── solver.py ├── player.py ├── common.py ├── generator.py ├── editor.py └── LICENSE /resources/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chozabu/sixcells/master/resources/logo.png -------------------------------------------------------------------------------- /resources/editor.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chozabu/sixcells/master/resources/editor.ico -------------------------------------------------------------------------------- /resources/player.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chozabu/sixcells/master/resources/player.ico -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PuLP==3.3.0 2 | PyQt5==5.15.11 3 | PyQt5-Qt5==5.15.2 4 | PyQt5_sip==12.17.0 5 | sageattention==1.0.6 6 | SCons==4.9.1 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | !.gitmodules 4 | !*.py 5 | !/universal_qt 6 | !/resources 7 | !/resources/* 8 | !/.windows 9 | !/.windows/runner.c 10 | !/.windows/*.cmd 11 | !/.windows/*.sh 12 | !*.md 13 | !README* 14 | !LICENSE* 15 | -------------------------------------------------------------------------------- /.windows/README.md: -------------------------------------------------------------------------------- 1 | This folder contains scripts that produce the Windows releases of SixCells. 2 | 3 | **Step 1:** Run *make_runners.cmd* on Windows with Visual Studio installed (adjust its location as needed) to produce *editor.exe* and *player.exe* which simply run `python\python.exe editor.py` etc. 4 | Or just copy these from a previous release. 5 | 6 | **Step 2:** Run *make_bundle.sh* on Linux to produce a *sixcells* folder one level above, with all the files needed to run SixCells on Windows. 7 | 8 | Requirements: *wget*, *wine*, *unzip*, *git* 9 | -------------------------------------------------------------------------------- /.windows/make_runners.cmd: -------------------------------------------------------------------------------- 1 | rem "Creates editor.exe and player.exe which simply run the bundled Python" 2 | 3 | call "C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\vcvarsall.bat" 4 | 5 | for %%e in (editor player) do ( 6 | copy ..\resources\%%e.ico . 7 | echo MAINICON ICON "%%e.ico" > %%e.rc 8 | rc %%e.rc 9 | 10 | echo #define CMD "python\\pythonw.exe %%e.py" > %%e.c 11 | echo #include "runner.c" >> %%e.c 12 | cl /nologo /c /O1 /Os /GL /D /MT /GR- /TC %%e.c 13 | 14 | link /NOLOGO /LTCG /INCREMENTAL:NO /MANIFEST:NO /MACHINE:X86 %%e.obj %%e.res 15 | ) 16 | -------------------------------------------------------------------------------- /.windows/runner.c: -------------------------------------------------------------------------------- 1 | // Minimal Win32 application that runs a process relative to the folder it's in. 2 | 3 | #include 4 | 5 | int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) 6 | { 7 | STARTUPINFO si; 8 | PROCESS_INFORMATION pi; 9 | char path[2048]; 10 | long path_size; 11 | 12 | // Get absolute path to this executable 13 | path_size = GetModuleFileName(NULL, path, sizeof path); 14 | 15 | // Drop the executable name itself 16 | while (path[--path_size] != '\\'); 17 | 18 | // Append a different path 19 | lstrcpy(path + path_size + 1, CMD); 20 | 21 | // Create default STARTUPINFO 22 | ZeroMemory(&si, sizeof(si)); 23 | si.cb = sizeof(si); 24 | 25 | // Create default PROCESS_INFORMATION 26 | ZeroMemory(&pi, sizeof(pi)); 27 | 28 | // Run the process 29 | CreateProcess( 30 | NULL, path, 31 | NULL, NULL, FALSE, 32 | 0, NULL, NULL, 33 | &si, &pi 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /.windows/make_bundle.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd "$(dirname "$0")" 4 | 5 | dest="$PWD/../sixcells" 6 | mkdir -p "$dest" 7 | 8 | # Copy runners to destination 9 | cp *.exe "$dest" 10 | 11 | function py { 12 | # Run Python Windows executable through Wine 13 | # Filter out numerous 'fixme' messages (Wine bugs) 14 | wine "$dest/python/python.exe" "$@" 2>&1 | grep -vE '^(fixme|err):' 15 | } 16 | 17 | if ! test -d "$dest/python"; then 18 | py_ver=3.5.3 19 | 20 | mkdir "$dest/python" 21 | pushd "$dest/python" 22 | 23 | # Download and extract Python portable binaries 24 | fn="python-$py_ver-embed-win32.zip" 25 | wget "https://www.python.org/ftp/python/$py_ver/$fn" 26 | unzip "$fn" 27 | rm "$fn" 28 | 29 | # Some libs don't work with the standard library in an archive, extract it 30 | fn=(python*.zip) # Need the array so the '*' is expanded 31 | mv "$fn" Lib.zip 32 | mkdir "$fn" 33 | unzip -d "$fn" Lib.zip 34 | rm Lib.zip 35 | 36 | # Install pip 37 | wget https://bootstrap.pypa.io/get-pip.py 38 | py get-pip.py 39 | rm get-pip.py 40 | 41 | py -m pip install pyqt5 https://github.com/oprypin/pulp/archive/master.zip 42 | 43 | # Obtain solver from GLPK 44 | wget https://sourceforge.net/projects/winglpk/files/latest/download --content-disposition 45 | fn=(winglpk-*.zip) # Need the array so the '*' is expanded 46 | glpk_ver=${fn%.*} # Drop extension 47 | glpk_ver=${glpk_ver##*-} # Start from the dash to get just the version 48 | unzip -j "$fn" "glpk-$glpk_ver/"{w32/glpsol.exe,w32/glpk_${glpk_ver/./_}.dll,COPYING} -d "Lib/site-packages/pulp/solverdir" 49 | rm "$fn" 50 | 51 | # Configure PuLP to use this solver on Windows 52 | rm Lib/site-packages/pulp/*.cfg.* 53 | rm -r Lib/site-packages/pulp/solverdir/cbc 54 | echo $'[locations]\nGlpkPath = %(here)s\solverdir\glpsol' > Lib/site-packages/pulp/pulp.cfg.win 55 | 56 | # Remove largest unneeded files 57 | rm -r Lib/site-packages/PyQt5/Qt/{translations,qml,bin/{*WebEngine*,Qt5{Designer,Quick,Qml,XmlPatterns,CLucene,QuickWidgets}.dll},plugins/{sqldrivers,position,geoservices,sensorgestures,sceneparsers},resources} 58 | rm -r Lib/site-packages/PyQt5/{*WebEngine*,Qt{Designer,Quick,Qml,XmlPatterns,QuickWidgets}.pyd} 59 | 60 | py -m pip uninstall --yes setuptools wheel pip 61 | rm -r Scripts 62 | 63 | popd 64 | fi 65 | 66 | pushd .. 67 | 68 | find . -type d -name '__pycache__' -exec rm -r {} \; 69 | 70 | cp --parents -- $(git ls-files) "$dest" 71 | # Clean unneeded files 72 | rm -r "$dest/"{.gitignore,.windows} 73 | 74 | # Make a versioned archive 75 | sixcells_ver="$(py -c 'from common import __version__; print(__version__)')" 76 | zip -r "sixcells-$sixcells_ver-win32.zip" sixcells 77 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014-2016 Oleh Prypin 2 | # 3 | # This file is part of SixCells. 4 | # 5 | # SixCells is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # SixCells is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with SixCells. If not, see . 17 | 18 | 19 | import collections as _collections 20 | import os as _os 21 | import os.path as _path 22 | 23 | from util import here, exec_ as _exec 24 | 25 | 26 | class _ObjLocals(object): 27 | def __init__(self, obj): 28 | self.obj = obj 29 | def __getitem__(self, key): 30 | try: 31 | return getattr(self.obj, key) 32 | except AttributeError: 33 | raise KeyError() 34 | def __setitem__(self, key, value): 35 | try: 36 | setattr(self.obj, key, value) 37 | except AttributeError: 38 | raise KeyError() 39 | 40 | def _parse_config_format(config_format): 41 | lines = ('{0} = {0}; {0} = v'.format(line.strip()) if ' = ' not in line else line for line in config_format.strip().splitlines()) 42 | lines = (line.strip().split(' = ', 1) for line in lines) 43 | return _collections.OrderedDict((k, v.split('; ')) for k, v in lines) 44 | 45 | 46 | def save_config(obj, config_format): 47 | config_format = _parse_config_format(config_format) 48 | 49 | result = [] 50 | for name, (getter, setter) in config_format.items(): 51 | value = eval(getter, None, _ObjLocals(obj)) 52 | result.append('{} = {!r}'.format(name, value)) 53 | 54 | return '\n'.join(result) 55 | 56 | def load_config(obj, config_format, config): 57 | config_format = _parse_config_format(config_format) 58 | 59 | class Locals(object): 60 | def __setitem__(self, key, value): 61 | try: 62 | stmt = config_format[key][1] 63 | except KeyError: 64 | return 65 | _exec(stmt, locals=_ObjLocals(obj), globals={'v': value}) 66 | def __getitem__(self, key): 67 | raise KeyError() 68 | def __delitem__(self, key): 69 | raise KeyError() 70 | 71 | _exec(config, locals=Locals()) 72 | 73 | 74 | def user_config_location(folder_name, file_name): 75 | from qt.core import QSettings 76 | 77 | name, ext = _path.splitext(file_name) 78 | target = QSettings(QSettings.IniFormat, QSettings.UserScope, folder_name, name).fileName() 79 | target, _ = _path.splitext(target) 80 | target += ext 81 | return target 82 | 83 | def makedirs(path): 84 | path = _path.dirname(path) 85 | if not _path.exists(path): 86 | _os.makedirs(path) 87 | 88 | 89 | 90 | def save_config_to_file(obj, config_format, folder_name, file_name): 91 | try: 92 | cfg = save_config(obj, config_format) 93 | try: 94 | f = open(here(file_name), 'w') 95 | except OSError: 96 | loc = user_config_location(folder_name, file_name) 97 | makedirs(loc) 98 | f = open(loc, 'w') 99 | f.write(cfg) 100 | f.close() 101 | return True 102 | except (OSError, IOError): 103 | return False 104 | 105 | def load_config_from_file(obj, config_format, folder_name, file_name): 106 | try: 107 | try: 108 | f = open(here(file_name)) 109 | except OSError: 110 | loc = user_config_location(folder_name, file_name) 111 | f = open(loc) 112 | cfg = f.read() 113 | f.close() 114 | load_config(obj, config_format, cfg) 115 | return True 116 | except (OSError, IOError): 117 | return False 118 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014-2015 Oleh Prypin 2 | # 3 | # This file is part of SixCells. 4 | # 5 | # SixCells is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # SixCells is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with SixCells. If not, see . 17 | 18 | 19 | import sys as _sys 20 | import os.path as _path 21 | import math as _math 22 | import collections as _collections 23 | 24 | 25 | def minmax(*args, **kwargs): 26 | return min(*args, **kwargs), max(*args, **kwargs) 27 | 28 | 29 | def all_grouped(items, key): 30 | """Are all the items in one group or not? 31 | `key` should be a function that says whether 2 items are connected.""" 32 | try: 33 | grouped = {next(iter(items))} 34 | except StopIteration: 35 | return True 36 | anything_to_add = True 37 | while anything_to_add: 38 | anything_to_add = False 39 | for a in items - grouped: 40 | if any(key(a, b) for b in grouped): 41 | anything_to_add = True 42 | grouped.add(a) 43 | return len(grouped) == len(items) 44 | 45 | 46 | def distance(a, b, squared=False): 47 | "Distance between two items" 48 | try: 49 | ax, ay = a 50 | except TypeError: 51 | ax, ay = a.x(), a.y() 52 | try: 53 | bx, by = b 54 | except TypeError: 55 | bx, by = b.x(), b.y() 56 | r = (ax-bx)**2 + (ay-by)**2 57 | if not squared: 58 | r = _math.sqrt(r) 59 | return r 60 | 61 | def angle(a, b=None): 62 | """Angle between two items: 0 if b is above a, tau/4 if b is to the right of a... 63 | If b is not supplied, this becomes the angle between (0, 0) and a.""" 64 | try: 65 | ax, ay = a 66 | except TypeError: 67 | ax, ay = a.x(), a.y() 68 | if b is None: 69 | return _math.atan2(ax, -ay) 70 | try: 71 | bx, by = b 72 | except TypeError: 73 | bx, by = b.x(), b.y() 74 | return _math.atan2(bx-ax, ay-by) 75 | 76 | 77 | Point = _collections.namedtuple('Point', 'x, y') 78 | 79 | 80 | class Entity(object): 81 | def __init__(self, name): 82 | self.name = name 83 | def __repr__(self): 84 | return self.name 85 | 86 | 87 | 88 | try: 89 | _script_name = __FILE__ 90 | except NameError: 91 | _script_name = _sys.argv[0] 92 | _script_path = _path.dirname(_path.realpath(_path.abspath(_script_name))) 93 | 94 | def here(*args): 95 | return _path.join(_script_path, *args) 96 | 97 | 98 | 99 | class cached_property(object): 100 | "Attribute that is calculated and stored upon first access." 101 | def __init__(self, fget): 102 | self.__doc__ = fget.__doc__ 103 | self.fget = fget 104 | self.attr = fget.__name__ 105 | 106 | def __get__(self, obj, objtype=None): 107 | if obj is None: 108 | return self 109 | value = self.fget(obj) 110 | obj.__dict__[self.attr] = value 111 | return value 112 | 113 | 114 | class setter_property(object): 115 | "Attribute that is based only on a setter function; the getter just returns the value" 116 | def __init__(self, fset): 117 | self.__doc__ = fset.__doc__ 118 | self.fset = fset 119 | self.attr = '_' + fset.__name__ 120 | 121 | def __get__(self, obj, objtype=None): 122 | if obj is None: 123 | return self 124 | return getattr(obj, self.attr) 125 | 126 | def __set__(self, obj, value): 127 | it = self.fset(obj, value) 128 | try: 129 | it = iter(it) 130 | except TypeError: pass 131 | else: 132 | for value in it: 133 | setattr(obj, self.attr, value) 134 | 135 | class event_property(setter_property): 136 | """An ordinary attribute that can you can get and set, 137 | but a function without arguments is called when setting it.""" 138 | def __set__(self, obj, value): 139 | setattr(obj, self.attr, value) 140 | self.fset(obj) 141 | 142 | 143 | 144 | 145 | 146 | # Python 2.7 + 3.x compatibility 147 | 148 | try: 149 | unicode 150 | except NameError: 151 | unicode = str 152 | 153 | try: 154 | basestring 155 | except NameError: 156 | basestring = (str, bytes) 157 | 158 | if isinstance(round(0), float): 159 | _round = round 160 | def round(number, ndigits=None): 161 | if ndigits is None: 162 | return int(_round(number)) 163 | else: 164 | return _round(number, ndigits) 165 | 166 | def exec_(expression, globals=None, locals=None): 167 | eval(compile(expression, '', 'exec'), globals, locals) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SixCells 2 | 3 | Level editor for [Hexcells](http://store.steampowered.com/app/304410/). 4 | 5 | ![Logo](https://raw.githubusercontent.com/oprypin/sixcells/master/resources/logo.png) 6 | 7 | --- 8 | 9 | ### Contents 10 | 11 | - [How to Use](#usage) 12 | - [Player](#player) 13 | - [Editor](#editor) 14 | - [Installation](#installation) 15 | - [Windows](#windows) 16 | - [Linux](#linux) 17 | - [Mac](#mac) 18 | - [Sharing Levels](#sharing-levels) 19 | - [Technical Details](#technical-details) 20 | - [Level File Structure](#level-file-structure) 21 | 22 | --- 23 | 24 | ## How to Use 25 | 26 | ### Player 27 | 28 | Open a level or paste one from clipboard and play it. 29 | Loading multiple levels at once is supported. Use the tab bar to switch between them. 30 | 31 | Left-click/right-click an orange cell to reveal it as blue/black. In case of a mistake the cell will not be revealed. 32 | Press Z to undo. 33 | 34 | Shift + left-click/right-click an orange cell to annotate it as blue/black. Repeat this to clear the annotation. 35 | Annotations don't affect anything, they're just marks. Revealing a cell will clear the annotation regardless of its color. 36 | *Edit* menu also contains options to clear all the annotations, confirm them (as if all of the annotated cells were clicked with a matching color) or deny them (...clicked with the opposite color). 37 | See also: [Text annotations](#annotations) 38 | 39 | When you close a level, you will have an option to save the current progress. It will be loaded automatically next time. There is an option to clear progress. 40 | 41 | If you use the *Player* to playtest right from *Editor*, it will save state between sessions. 42 | Right click to revert a cell to yellow. 43 | 44 | Full auto-solving capabilities are present. 45 | 46 | ### Editor 47 | 48 | [Video demonstration](http://youtu.be/fFq36x8fSew) 49 | 50 | ##### Creating and Deleting Items 51 | 52 | Action | Button 53 | ------------------------- | ------------------------------------ 54 | Create blue cell | Left-click 55 | Create black cell | Right-click 56 | Create column number | Left-click on cell and drag outwards 57 | Delete cell/column number | Right-click 58 | 59 | ##### Modifying Items 60 | 61 | Action | Button 62 | --------------------------------- | -------------------------------- 63 | Cycle through information display | Left-click on cell/column number 64 | Mark/unmark cell as revealed | Ctrl + left-click on cell 65 | 66 | ##### Selection 67 | 68 | Action | Button 69 | ---------------------- | -------------------------- 70 | Freehand selection | Shift + drag on empty space 71 | Select/deselect a cell | Shift + left-click on cell 72 | Deselect all | Shift + left-click on empty space 73 | Drag and drop selected | Left-click and drag 74 | 75 | ##### Navigation 76 | 77 | Action | Button 78 | ------------ | -------------------------- 79 | Pan the view | Press and drag mouse wheel 80 | Zoom in/out | Mouse wheel up/down 81 | 82 | ##### Play Test Mode 83 | 84 | Action | Button 85 | -------------------- | ------ 86 | Toggle playtest mode | Tab 87 | Play from start | Shift + Tab 88 | 89 | #### Annotations 90 | 91 | Hover over a cell and press a number on the keyboard (or hold Shift and type any text) to add up to 3 characters of annotations. 92 | Press Backspace or Tilde `~ to delete. 93 | 94 | Additional color annotations are available in [Player](#player). 95 | 96 | #### Alternative Controls 97 | 98 | All mouse actions (except for pointer movement) can be replaced with keyboard presses: 99 | 100 | Action | Button 101 | ----------- | ------ 102 | Left-click | Q 103 | Right-click | W 104 | Pan | E 105 | Zoom in | + 106 | Zoom out | - 107 | 108 | 109 | --- 110 | 111 | ## Installation 112 | 113 | ### Windows 114 | 115 | Download the newest *-win32.zip* [**release**](https://github.com/oprypin/sixcells/releases), extract the folder and you're ready to go! 116 | 117 | ### Linux 118 | 119 | Install `git`, `python-pyqt5` or `python-pyside`, `python-pulp` (`pip install pulp`), optionally `glpk`: 120 | 121 | - Debian, Ubuntu: 122 | 123 | ```bash 124 | sudo apt-get update 125 | sudo apt-get install git python-pyqt5 glpk-utils python-pip 126 | sudo pip install pulp 127 | ``` 128 | 129 | Go to a folder where you would like *SixCells* to be and obtain the source code (a subdirectory "sixcells" will be created): 130 | ```bash 131 | git clone https://github.com/oprypin/sixcells 132 | ``` 133 | 134 | Now you can start `editor.py` and `player.py` by opening them in a file explorer or from command line. 135 | 136 | To update *SixCells* to the latest version without deleting and redownloading, execute `git pull` inside its directory. 137 | 138 | #### Arch Linux 139 | 140 | [**sixcells**](https://aur.archlinux.org/packages/sixcells/) on *AUR*. Optional dependency is needed for solving. 141 | 142 | ### Mac 143 | 144 | Make sure you have [installed *command line developer tools*](http://osxdaily.com/2014/02/12/install-command-line-tools-mac-os-x/). 145 | 146 | [Install *Homebrew*](http://brew.sh/#install). 147 | 148 | Use *Homebrew* and *pip* to install Python and the needed libraries: 149 | ```bash 150 | brew install python3 151 | pip3 install pyqt5 pulp 152 | ``` 153 | 154 | Go to a folder where you would like *SixCells* to be and obtain the source code (a subdirectory "sixcells" will be created): 155 | ```bash 156 | git clone https://github.com/oprypin/sixcells 157 | cd sixcells 158 | ``` 159 | 160 | Now you can run `python3 editor.py` and `python3 player.py`. 161 | 162 | To update *SixCells* to the latest version without deleting and redownloading, execute `git pull` inside its directory. 163 | 164 | --- 165 | 166 | ## Sharing Levels 167 | 168 | To find levels to play and share your own, visit [reddit.com/r/hexcellslevels](http://reddit.com/r/hexcellslevels). 169 | 170 | --- 171 | 172 | ## Technical Details 173 | 174 | *SixCells* is written using [Python](http://python.org/) and [Qt](http://qt-project.org/). 175 | [PuLP](https://pypi.python.org/pypi/PuLP) is used for solving. 176 | 177 | It is guaranteed to work on Python 3.3 and later; Versions 2.7 and 3.* should also work. 178 | 179 | *SixCells* supports Qt 4 and Qt 5, and can work with either [PySide](http://pyside.org/), [PyQt4](http://www.riverbankcomputing.co.uk/software/pyqt/download) or [PyQt5](http://www.riverbankcomputing.co.uk/software/pyqt/download5). 180 | 181 | License: GNU General Public License Version 3.0 (GPLv3) 182 | 183 | 184 | ### Level File Structure 185 | 186 | #### *.hexcells format 187 | 188 | Encoding: UTF-8 189 | 190 | A level is a sequence of 38 lines, separated with '\n' character: 191 | 192 | - "Hexcells level v1" 193 | - Level title 194 | - Author 195 | - Level custom text, part 1 196 | - Level custom text, part 2 197 | - 33 level lines follow: 198 | - A line is a sequence of 33 2-character groups. 199 | - '.' = nothing, 'o' = black, 'O' = black revealed, 'x' = blue, 'X' = blue revealed, '\','|','/' = column number at 3 different angles (-60, 0, 60) 200 | - '.' = blank, '+' = has number, 'c' = consecutive, 'n' = not consecutive 201 | -------------------------------------------------------------------------------- /universal_qt/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014-2016 Oleh Prypin 2 | # 3 | # This file is part of UniversalQt. 4 | # 5 | # UniversalQt is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # UniversalQt is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with UniversalQt. If not, see . 17 | 18 | 19 | import sys as _sys 20 | 21 | 22 | defaults = ['PyQt5', 'PySide', 'PyQt4'] 23 | qt = None 24 | 25 | 26 | class QtSelector(object): 27 | """Implements import hooks for `universal_qt.*`. 28 | Selects a Qt implementation to be used in the `qt` module. 29 | For example, `import universal_qt.PyQt5` will select PyQt5""" 30 | 31 | @staticmethod 32 | def find_module(fullname, path=None): 33 | if fullname.split('.', 1)[0] == 'universal_qt': 34 | return QtSelector 35 | 36 | @staticmethod 37 | def load_module(fullname): 38 | if fullname in _sys.modules: 39 | return _sys.modules[fullname] 40 | 41 | _, name = fullname.split('.') 42 | 43 | global qt 44 | 45 | if qt: 46 | # If a Qt implementation was already selected previously 47 | # and the selected module is the same as the one being imported, 48 | # then return it, otherwise False. 49 | if qt.__name__ == name: 50 | _sys.modules[fullname] = qt; return qt 51 | else: 52 | _sys.modules[fullname] = False; return False 53 | 54 | if name == 'PyQt4' and _sys.version_info[0] == 2: 55 | # Select API version 2, this is needed only for PyQt4 on Python 2.x 56 | try: 57 | import sip 58 | for api in ['QDate', 'QDateTime', 'QString', 'QTextStream', 59 | 'QTime', 'QUrl', 'QVariant']: 60 | try: 61 | sip.setapi(api, 2) 62 | except Exception: 63 | pass 64 | except Exception: 65 | pass 66 | 67 | # The selection of Qt implementation is successful only if its QtCore 68 | # module can be imported. 69 | try: 70 | qt = __import__(name + '.QtCore') 71 | except ImportError: 72 | _sys.modules[fullname] = False; return False 73 | 74 | core = qt.QtCore 75 | 76 | # Turn `QtCore.Qt` object into a package, 77 | # because `import qt` will actually give this object. 78 | core.Qt.__path__ = [] 79 | core.Qt.__package__ = 'qt' 80 | 81 | # Put some additional attributes into `qt`. 82 | core.Qt.module = name 83 | core.Qt.version_str = core.qVersion() 84 | core.Qt.major = int(core.Qt.version_str.split('.', 1)[0]) 85 | if name.startswith('PyQt'): 86 | core.Qt.module_version_str = core.PYQT_VERSION_STR 87 | core.Signal = core.pyqtSignal 88 | core.Slot = core.pyqtSlot 89 | else: 90 | core.Qt.module_version_str = qt.__version__ 91 | core.Qt.Signal = core.Signal 92 | core.Qt.Slot = core.Slot 93 | 94 | _sys.modules[fullname] = qt; return qt 95 | 96 | 97 | class QtImporter(object): 98 | """Implements import hooks for `qt`, `qt.*`. 99 | For `import qt` returns the `Qt` object of some Qt implementation, e.g. 100 | `PySide.QtCore.Qt`, but that object is pre-populated to act like a package. 101 | For `import qt.*` returns the corresponding module, with capitalized parts. 102 | In Qt 5 some modules were split, e.g. QtGui -> QtGui+QtWidgets, so for Qt 4 103 | `Qt*Widgets` imports are turned into their combined module, 104 | e.g. `import qt.web_kit_widgets` gives `PyQt5.QtWebKitWidgets` if PyQt5 was 105 | selected or `PySide.QtWebKit` if PySide was selected.""" 106 | 107 | @staticmethod 108 | def find_module(fullname, path=None): 109 | if fullname.split('.', 1)[0] == 'qt': 110 | return QtImporter 111 | 112 | @staticmethod 113 | def load_module(fullname): 114 | if fullname in _sys.modules: 115 | return _sys.modules[fullname] 116 | 117 | if not qt: 118 | # If Qt hasn't been selected yet (or none of the attempts were 119 | # successful), try to select any from the list of defaults. 120 | for d in defaults: 121 | QtSelector.load_module('universal_qt.' + d) 122 | if not qt: 123 | raise ImportError("Couldn't import any Qt implementation") 124 | 125 | if fullname == 'qt': 126 | # `import qt` will try to import QtCore (but return QtCore.Qt) 127 | module = 'core' 128 | else: 129 | # `import qt.*`: we're getting the `*` part. 130 | _, module = fullname.split('.') 131 | # Split by underscore and capitalize each part. 132 | # 'web_kit_widgets' -> 'WebKitWidgets' 133 | to_load = renamed = ''.join( 134 | part[0].upper() + part[1:] 135 | for part in module.split('_') 136 | ) 137 | 138 | try: 139 | top = __import__(qt.__name__ + '.Qt' + to_load, level=0) 140 | # e.g. 'PyQt5.QtWidgets', but it returns the `PyQt5` package, 141 | # hence the name `top`. 142 | except ImportError as e: 143 | if to_load.endswith('Widgets'): 144 | # If failed to import, try to import the non-'Widgets' 145 | # counterpart, e.g. `PySide.QtWidgets` -> `PySide.QtGui`. 146 | to_load = to_load[:-7] or 'Gui' 147 | try: 148 | top = __import__(qt.__name__ + '.Qt' + to_load, level=0) 149 | except ImportError: 150 | raise e 151 | else: 152 | raise e 153 | # Get the actual module from the top-level package 154 | result = getattr(top, 'Qt' + to_load) 155 | 156 | if renamed == 'Widgets': 157 | if qt.__name__.startswith('PyQt'): 158 | __import__(qt.__name__ + '.uic', level=0) 159 | result.load_ui = qt.uic.loadUi 160 | elif qt.__name__.startswith('PySide'): 161 | def load_ui(filename): 162 | __import__(qt.__name__ + '.QtUiTools', level=0) 163 | loader = qt.QtUiTools.QUiLoader() 164 | uifile = qt.QtCore.QFile(filename) 165 | uifile.open(qt.QtCore.QFile.ReadOnly) 166 | ui = loader.load(uifile) 167 | uifile.close() 168 | return ui 169 | result.load_ui = load_ui 170 | 171 | # As mentioned before, `import qt` returns `QtCore.Qt`. 172 | if fullname == 'qt': 173 | result = result.Qt 174 | 175 | _sys.modules[fullname] = result; return result 176 | 177 | 178 | # Add/activate the import hooks 179 | _sys.meta_path.insert(0, QtSelector) 180 | _sys.meta_path.insert(0, QtImporter) 181 | -------------------------------------------------------------------------------- /solver.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 Stefan Walzer 2 | # 3 | # This file is part of SixCells. 4 | # 5 | # SixCells is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # SixCells is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with SixCells. If not, see . 17 | 18 | 19 | """A linear programming solver for Hexcells""" 20 | # i.e.: throwing the big-boy-tools at innocent little Hexcells levels 21 | 22 | from __future__ import division, print_function 23 | 24 | import itertools 25 | 26 | from pulp import GLPK, LpProblem, LpMinimize, LpVariable, lpSum, value 27 | 28 | from common import * 29 | 30 | 31 | # Should return the solver that will be 32 | # invoked by PuLP to solve the MILPs. 33 | def get_solver(): 34 | global solver 35 | try: 36 | return solver 37 | except NameError: pass 38 | 39 | solver = GLPK(None, msg=False, options=['--cuts']) 40 | if solver.available(): 41 | print("Using solver from:", solver.path) 42 | return solver 43 | 44 | # There may be no glpsol. Let PuLP try to find another solver. 45 | print("Couldn't find 'glpsol' solver; a default may be found") 46 | solver = None 47 | 48 | 49 | def solve(scene): 50 | cells = scene.all_cells 51 | columns = scene.all_columns 52 | known = [cell for cell in cells if cell.display is not Cell.unknown] 53 | unknown = [cell for cell in cells if cell.display is Cell.unknown] 54 | 55 | #################################################### 56 | # -- Equivalence Class Optimisation -- 57 | #################################################### 58 | 59 | # We say, two unknown cells are equivalent if they are subject 60 | # to the same constraints (not just equal, but the same) 61 | # if a cell can be blue/black then an equivalent cell has those 62 | # options too, since they can switch places without affecting constraints 63 | # *Unless* there are togetherness constraints involved (see below). 64 | # Idea: Have one variable for each class with range 0 to the size of the class 65 | # This models the number of cells in the class that are blue. 66 | # The cells of the class are blue (black) 67 | # iff we can prove the variable assumes its max (min) 68 | 69 | # cell_constraints: Maps a cell to all relevant 70 | # constraints (cells and columns) that it is a member of 71 | cell_constraints = collections.defaultdict(set) 72 | for cur in itertools.chain(known, columns): 73 | # Ignore uninformative constraints 74 | if cur.value is None: 75 | continue 76 | for x in cur.members: 77 | cell_constraints[x].add(cur) 78 | 79 | # Cells are now equivalent iff their cell_constraints match. 80 | # The leftmost cell in the collection is the representative, 81 | # i.e. rep_of[cell] points to the leftmost cell that is equivalent to cell. 82 | # note that this is well-defined since cells are equivalent to themselves. 83 | # rep_of[cell] is the representative of the equivalence class of cell. 84 | rep_of = {} 85 | for cell1 in unknown: 86 | cc1 = cell_constraints[cell1] 87 | for cell2 in unknown: 88 | if cc1 == cell_constraints[cell2]: 89 | rep_of[cell1] = cell2 90 | 91 | # since cells subject to togetherness constraints cannot swap places (they are a special case) 92 | # they must be their own representative and cannot be considered equivalent 93 | # to anyone but themselves. 94 | for cell in unknown: 95 | for constraint in cell_constraints[cell]: 96 | if constraint.together is not None: 97 | rep_of[cell] = cell 98 | 99 | # from now on it will suffice to find information on equivalence classes 100 | # a class is a pair of one representative and the size of the class 101 | classes = {rep: sum(1 for cell in unknown if rep_of[cell] is rep) for rep in unknown if rep_of[rep] is rep} 102 | 103 | #################################################### 104 | # -- The MILP Problem (managed by PuLP) -- 105 | #################################################### 106 | 107 | solver = get_solver() 108 | problem = LpProblem('HexcellsMILP', LpMinimize) 109 | 110 | # For every equivalence class of cells there is a integer variable, 111 | # modeling the number of blue cells in that class. 112 | # The class is blue (or black) iff we can prove that the variable 113 | # is necessarily the size of the class (or 0) 114 | # This Dictionary maps a cell id to the respective variable. 115 | dic = {rep.id: LpVariable('v'+str(rep.id), 0, size, 'Integer') for rep, size in classes.items()} 116 | 117 | # Convenience: Maps a cell to the corresponding variable or constant: 118 | # Note that the cells of a class will appear in the same constraints, 119 | # so we ignore every cell but the representative. 120 | def get_var(cell): 121 | if cell.display is not Cell.unknown: #cell is constant 122 | return 1 if cell.display is Cell.full else 0 123 | elif rep_of[cell] is cell: #cell is representative 124 | return dic[cell.id] 125 | else: # cell is non representative 126 | return 0 127 | 128 | # The number of remaining blue cells is known 129 | problem += lpSum(get_var(cell) for cell in unknown) == scene.remaining 130 | 131 | # Constraints from column number information 132 | for col in columns: 133 | # The sum of all cells in that column is the column value 134 | problem += lpSum(get_var(cell) for cell in col.members) == col.value 135 | 136 | # Additional information (together/seperated) available? 137 | if col.together is not None: 138 | if col.together: 139 | # For {n}: cells that are at least n appart cannot be both blue. 140 | # Example: For {3}, the configurations X??X, X???X, X????X, ... are impossible. 141 | for span in range(col.value, len(col.members)): 142 | for start in range(len(col.members)-span): 143 | problem += lpSum([get_var(col.members[start]), get_var(col.members[start+span])]) <= 1 144 | else: 145 | # For -n-, the sum of any range of n cells may contain at most n-1 blues 146 | for offset in range(len(col.members)-col.value+1): 147 | problem += lpSum(get_var(col.members[offset+i]) for i in range(col.value)) <= col.value-1 148 | 149 | # Constraints from cell number information 150 | for cell in known: 151 | # If the displays a number, the sum of its neighbourhood (radius 1 or 2) is known 152 | if cell.value is not None: 153 | problem += lpSum(get_var(neighbour) for neighbour in cell.members) == cell.value 154 | 155 | # Additional togetherness information available? 156 | # Note: Only relevant if value between 2 and 4. 157 | # In fact: The following code would do nonsense for 0,1,5,6! 158 | if cell.together is not None and cell.value >= 2 and cell.value <= 4: 159 | # Note: Cells are ordered clockwise. 160 | # Convenience: Have it wrap around. 161 | m = cell.members+cell.members 162 | 163 | if cell.together: 164 | # note how togetherness is equivalent to the following 165 | # two patterns not occuring: "-X-" and the "X-X" 166 | # in other words: No lonely blue cell and no lonely gap 167 | for i in range(len(cell.members)): 168 | # No lonely cell condition: 169 | # Say m[i] is a blue. 170 | # Then m[i-1] or m[i+1] must be blue. 171 | # That means: -m[i-1] +m[i] -m[i+1] <= 0 172 | # Note that m[i+1] and m[i-1] only count 173 | # if they are real neighbours. 174 | cond = get_var(m[i]) 175 | if m[i].is_neighbor(m[i-1]): 176 | cond -= get_var(m[i-1]) 177 | if m[i].is_neighbor(m[i+1]): 178 | cond -= get_var(m[i+1]) 179 | 180 | # no isolated cell 181 | problem += cond <= 0 182 | # no isolated gap (works by a similar argument) 183 | problem += cond >= -1 184 | else: 185 | # -n-: any circular range of n cells contains at most n-1 blues. 186 | for i in range(len(cell.members)): 187 | # the range m[i], ..., m[i+n-1] may not all be blue if they are consecutive 188 | if all(m[i+j].is_neighbor(m[i+j+1]) for j in range(cell.value-1)): 189 | problem += lpSum(get_var(m[i+j]) for j in range(cell.value)) <= cell.value-1 190 | 191 | # First, get any solution. 192 | # Default solver can't handle no objective, so invent one: 193 | spam = LpVariable('spam', 0, 1, 'binary') 194 | problem += (spam == 1) 195 | problem.setObjective(spam) # no optimisation function yet 196 | problem.solve(solver) 197 | 198 | def get_true_false_classes(): 199 | true_set = set() 200 | false_set = set() 201 | 202 | for rep, size in classes.items(): 203 | if value(get_var(rep)) == 0: 204 | false_set.add(rep) 205 | elif value(get_var(rep)) == size: 206 | true_set.add(rep) 207 | return true_set, false_set 208 | 209 | # get classes that are fully true or false 210 | # they are candidates for solvable classes 211 | true, false = get_true_false_classes() 212 | 213 | while true or false: 214 | # Now try to vary as much away from the 215 | # initial solution as possible: 216 | # We try to make the variables True, that were False before 217 | # and vice versa. If no change could be achieved, then 218 | # the remaining variables have their unique possible value. 219 | problem.setObjective(lpSum(get_var(t) for t in true)-lpSum(get_var(f) for f in false)) 220 | problem.solve(solver) 221 | 222 | # all true variables stayed true and false stayed false? 223 | # Then they have their unique value and we are done! 224 | if value(problem.objective) == sum(classes[rep] for rep in true): 225 | for tf_set, kind in [(true, Cell.full), (false, Cell.empty)]: 226 | for rep in tf_set: 227 | for cell in unknown: 228 | if rep_of[cell] is rep: 229 | yield cell, kind 230 | return 231 | 232 | true_new, false_new = get_true_false_classes() 233 | 234 | # remember only those classes that subbornly kept their pure trueness/falseness 235 | true &= true_new 236 | false &= false_new 237 | 238 | 239 | 240 | def solve_simple(scene): 241 | for cur in itertools.chain(scene.all_cells, scene.all_columns): 242 | if isinstance(cur, Cell) and cur.display is Cell.unknown: 243 | continue 244 | if cur.value is not None and any(x.display is Cell.unknown for x in cur.members): 245 | # Fill up remaining fulls 246 | if cur.value == sum(1 for x in cur.members if x.display is not Cell.empty): 247 | for x in cur.members: 248 | if x.display is Cell.unknown: 249 | yield x, Cell.full 250 | # Fill up remaining empties 251 | if len(cur.members)-cur.value == sum(1 for x in cur.members if x.display is not Cell.full): 252 | for x in cur.members: 253 | if x.display is Cell.unknown: 254 | yield x, Cell.empty 255 | -------------------------------------------------------------------------------- /player.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright (C) 2014-2016 Oleh Prypin 4 | # 5 | # This file is part of SixCells. 6 | # 7 | # SixCells is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # SixCells is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with SixCells. If not, see . 19 | 20 | 21 | from __future__ import division, print_function 22 | 23 | import sys 24 | import os.path 25 | import contextlib 26 | try: 27 | import sqlite3 28 | except ImportError: 29 | pass 30 | 31 | import common 32 | from common import * 33 | try: 34 | from solver import * 35 | except ImportError: 36 | solve = None 37 | 38 | from qt import Signal 39 | from qt.core import QMargins, QRectF, QTimer 40 | from qt.gui import QBrush, QIcon, QKeySequence, QPainter, QPen, QPolygonF, QTransform 41 | from qt.widgets import QHBoxLayout, QLabel, QShortcut, QTabBar, QVBoxLayout, QWidget 42 | 43 | 44 | @contextlib.contextmanager 45 | def db_connection(file_name): 46 | try: 47 | con = sqlite3.connect(here(file_name)) 48 | except sqlite3.OperationalError: 49 | loc = user_config_location('sixcells', file_name) 50 | makedirs(loc) 51 | con = sqlite3.connect(loc) 52 | yield con 53 | con.close() 54 | 55 | 56 | class Cell(common.Cell): 57 | def __init__(self): 58 | common.Cell.__init__(self) 59 | 60 | self.flower = False 61 | self.hidden = False 62 | self.guess = None 63 | self._display = Cell.unknown 64 | 65 | def upd(self, first=False): 66 | common.Cell.upd(self, first) 67 | if self.guess: 68 | self.setBrush(Color.blue_border if self.guess == Cell.full else Color.black_border) 69 | 70 | def mousePressEvent(self, e): 71 | if e.button() == qt.RightButton and self.scene().playtest and self.display is not Cell.unknown: 72 | self.display = Cell.unknown 73 | self.upd() 74 | return 75 | if self.display is Cell.full and self.value is not None: 76 | if e.button() == qt.LeftButton: 77 | self.flower = not self.flower 78 | return 79 | if e.button() == qt.RightButton: 80 | self.hidden = not self.hidden 81 | self.flower = False 82 | return 83 | buttons = [qt.LeftButton, qt.RightButton] 84 | if self.scene().swap_buttons: 85 | buttons.reverse() 86 | if e.button() == buttons[0]: 87 | want = Cell.full 88 | elif e.button() == buttons[1]: 89 | want = Cell.empty 90 | else: 91 | return 92 | if e.modifiers() & qt.ShiftModifier: 93 | self.guess = None if self.guess == want else want 94 | self.upd() 95 | return 96 | if self.display is Cell.unknown: 97 | if self.kind == want: 98 | self.display = self.kind 99 | self.scene().undo_history.append([self]) 100 | self.upd() 101 | else: 102 | self.scene().mistakes += 1 103 | 104 | 105 | @setter_property 106 | def display(self, value): 107 | rem = 0 108 | try: 109 | if self.display is not Cell.full and value is Cell.full: 110 | rem = -1 111 | if self.display is Cell.full and value is not Cell.full: 112 | rem = 1 113 | except AttributeError: 114 | pass 115 | yield value 116 | if rem and self.placed: 117 | self.scene().remaining += rem 118 | self.guess = None 119 | self.flower = False 120 | self.extra_text = '' 121 | 122 | @event_property 123 | def flower(self): 124 | if self.scene(): 125 | self.scene().update() 126 | 127 | @property 128 | def hidden(self): 129 | return self._text.opacity() < 1 130 | @hidden.setter 131 | def hidden(self, value): 132 | self._text.setOpacity(0.2 if value else 1) 133 | self.update() 134 | 135 | def reset_cache(self): 136 | pass 137 | 138 | 139 | 140 | class Column(common.Column): 141 | def __init__(self): 142 | common.Column.__init__(self) 143 | self.beam = False 144 | 145 | @event_property 146 | def beam(self): 147 | if self.scene(): 148 | self.scene().update() 149 | 150 | @property 151 | def hidden(self): 152 | return self.opacity() < 1 153 | @hidden.setter 154 | def hidden(self, value): 155 | self.setOpacity(0.2 if value else 1) 156 | 157 | def mousePressEvent(self, e): 158 | if e.button() == qt.LeftButton: 159 | self.beam = not self.beam 160 | elif e.button() == qt.RightButton: 161 | self.hidden = not self.hidden 162 | self.beam = False 163 | 164 | def reset_cache(self): 165 | pass 166 | 167 | 168 | 169 | def _flower_poly(): 170 | result = QPolygonF() 171 | for i1 in range(6): 172 | a1 = i1*tau/6 173 | for i2 in range(6): 174 | a2 = i2*tau/6 175 | result = result.united(hex1.translated(math.sin(a1) + math.sin(a2), -math.cos(a1) - math.cos(a2))) 176 | return result 177 | _flower_poly = _flower_poly() 178 | 179 | class Scene(common.Scene): 180 | text_changed = Signal() 181 | 182 | def __init__(self): 183 | common.Scene.__init__(self) 184 | 185 | self.swap_buttons = False 186 | 187 | self.remaining = 0 188 | self.mistakes = 0 189 | 190 | self.solving = 0 191 | 192 | self.undo_history = [] 193 | 194 | @event_property 195 | def remaining(self): 196 | self.text_changed.emit() 197 | 198 | @event_property 199 | def mistakes(self): 200 | self.text_changed.emit() 201 | 202 | def set_swap_buttons(self, value): 203 | self.swap_buttons = value 204 | 205 | def drawForeground(self, g, rect): 206 | g.setBrush(Color.flower) 207 | g.setPen(no_pen) 208 | for it in self.all(Cell): 209 | if it.flower: 210 | poly = _flower_poly.translated(it.scenePos()) 211 | g.drawPolygon(poly) 212 | 213 | g.setBrush(QBrush(qt.NoBrush)) 214 | pen = QPen(Color.flower_border, 1.5) 215 | pen.setCosmetic(True) 216 | g.setPen(pen) 217 | for it in self.all(Cell): 218 | if it.flower: 219 | poly = _flower_poly.translated(it.scenePos()) 220 | g.drawPolygon(poly) 221 | 222 | g.setPen(no_pen) 223 | g.setBrush(Color.beam) 224 | for it in self.all(Column): 225 | if it.beam: 226 | poly = QPolygonF(QRectF(-0.045, 0.525, 0.09, 1e6)) 227 | poly = QTransform().translate(it.scenePos().x(), it.scenePos().y()).rotate(it.rotation()).map(poly) 228 | poly = poly.intersected(QPolygonF(rect)) 229 | g.drawConvexPolygon(poly) 230 | 231 | @cached_property 232 | def all_cells(self): 233 | return list(self.all(Cell)) 234 | 235 | @cached_property 236 | def all_columns(self): 237 | return list(self.all(Column)) 238 | 239 | def reset_cache(self): 240 | for attr in ['all_cells', 'all_columns']: 241 | try: 242 | delattr(self, attr) 243 | except AttributeError: pass 244 | 245 | def solve_step(self): 246 | """Derive everything that can be concluded from the current state. 247 | Return whether progress has been made.""" 248 | if self.solving: 249 | return 250 | 251 | self.confirm_guesses() 252 | self.solving += 1 253 | app.processEvents() 254 | progress = False 255 | undo_step = [] 256 | for cell, value in solve(self): 257 | assert cell.kind is value 258 | cell.guess = value 259 | cell.upd() 260 | progress = True 261 | undo_step.append(cell) 262 | self.undo_history.append(undo_step) 263 | self.solving -= 1 264 | 265 | return progress 266 | 267 | def solve_complete(self): 268 | """Continue solving until stuck. 269 | Return whether the entire level could be uncovered.""" 270 | self.solving = 1 271 | while self.solving: 272 | self.confirm_guesses() 273 | 274 | progress = True 275 | while progress: 276 | progress = False 277 | for cell, value in solve_simple(self): 278 | progress = True 279 | assert cell.kind is value 280 | cell.display = cell.kind 281 | cell.upd() 282 | self.solving -= 1 283 | if not self.solve_step(): 284 | break 285 | self.solving += 1 286 | 287 | self.solving = 0 288 | # If it identified all blue cells, it'll have the rest uncovered as well 289 | return self.remaining == 0 290 | 291 | def clear_guesses(self): 292 | for cell in self.all(Cell): 293 | if cell.guess: 294 | cell.guess = None 295 | cell.upd() 296 | def confirm_guesses(self, opposite=False): 297 | correct = [] 298 | for cell in self.all(Cell): 299 | if cell.guess and cell.display is Cell.unknown: 300 | if (cell.kind == cell.guess) ^ opposite: 301 | cell.display = cell.kind 302 | cell.upd() 303 | correct.append(cell) 304 | else: 305 | self.mistakes += 1 306 | self.undo_history.append(correct) 307 | def confirm_opposite_guesses(self): 308 | self.confirm_guesses(opposite=True) 309 | 310 | def undo(self): 311 | if not self.undo_history: 312 | return 313 | last = self.undo_history.pop() 314 | found = False 315 | for cell in last: 316 | if cell.display == Cell.unknown and not cell.guess: 317 | continue 318 | cell.display = Cell.unknown 319 | cell.upd() 320 | found = True 321 | if not found: 322 | self.undo() 323 | 324 | def highlight_all_columns(self): 325 | for col in self.all(Column): 326 | if not col.hidden: 327 | col.beam = True 328 | def highlight_all_flowers(self): 329 | for cell in self.all(Cell): 330 | if not cell.hidden and cell.display is Cell.full and cell.value is not None: 331 | cell.flower = True 332 | 333 | 334 | class View(common.View): 335 | def __init__(self, scene): 336 | common.View.__init__(self, scene) 337 | self.setMouseTracking(True) # fix for not updating position for simulated events 338 | self.scene.text_changed.connect(self.viewport().update) # ensure a full redraw 339 | self.progress_loaded_timer = QTimer() 340 | self.progress_loaded_timer.setInterval(1500) 341 | self.progress_loaded_timer.setSingleShot(True) 342 | self.progress_loaded_timer.timeout.connect(self.viewport().update) 343 | 344 | def resizeEvent(self, e): 345 | common.View.resizeEvent(self, e) 346 | if not self.scene.playtest: 347 | self.fit() 348 | 349 | def fit(self): 350 | rect = self.scene.itemsBoundingRect().adjusted(-0.3, -0.3, 0.3, 0.3) 351 | self.setSceneRect(rect) 352 | self.fitInView(rect, qt.KeepAspectRatio) 353 | zoom = self.transform().mapRect(QRectF(0, 0, 1, 1)).width() 354 | if zoom > 100: 355 | self.resetTransform() 356 | self.scale(100, 100) 357 | 358 | def paintEvent(self, e): 359 | common.View.paintEvent(self, e) 360 | g = QPainter(self.viewport()) 361 | g.setRenderHints(self.renderHints()) 362 | area = self.viewport().rect().adjusted(5, 2, -5, -2) 363 | 364 | if self.progress_loaded_timer.isActive(): 365 | g.setPen(QPen(Color.dark_text)) 366 | g.drawText(area, qt.AlignTop | qt.AlignLeft, "Progress loaded") 367 | 368 | try: 369 | self._info_font 370 | except AttributeError: 371 | self._info_font = g.font() 372 | multiply_font_size(self._info_font, 3) 373 | 374 | try: 375 | txt = ('{r} ({m})' if self.scene.mistakes else '{r}').format(r=self.scene.remaining, m=self.scene.mistakes) 376 | g.setFont(self._info_font) 377 | g.setPen(QPen(Color.dark_text)) 378 | g.drawText(area, qt.AlignTop | qt.AlignRight, txt) 379 | except AttributeError: pass 380 | 381 | def wheelEvent(self, e): 382 | pass 383 | 384 | 385 | class MainWindow(common.MainWindow): 386 | title = "SixCells Player" 387 | Cell = Cell 388 | Column = Column 389 | 390 | def __init__(self, playtest=False): 391 | common.MainWindow.__init__(self) 392 | 393 | if not playtest: 394 | self.resize(1280, 720) 395 | self.setWindowIcon(QIcon(here('resources', 'player.ico'))) 396 | 397 | self.scene = Scene() 398 | 399 | self.central_widget = QWidget() 400 | self.setCentralWidget(self.central_widget) 401 | layout = QVBoxLayout() 402 | layout.setContentsMargins(QMargins()) 403 | layout.setSpacing(0) 404 | self.central_widget.setLayout(layout) 405 | 406 | self.levels_bar = QTabBar() 407 | layout.addWidget(self.levels_bar) 408 | self.levels_bar.currentChanged.connect(self.level_change) 409 | 410 | top_layout = QHBoxLayout() 411 | layout.addLayout(top_layout) 412 | 413 | self.author_align_label = QLabel() 414 | self.author_align_label.setStyleSheet('color: rgba(0,0,0,0%)') 415 | top_layout.addWidget(self.author_align_label, 0) 416 | 417 | self.title_label = QLabel() 418 | self.title_label.setAlignment(qt.AlignHCenter) 419 | update_font(self.title_label, lambda f: multiply_font_size(f, 1.8)) 420 | top_layout.addWidget(self.title_label, 1) 421 | 422 | self.author_label = QLabel() 423 | self.author_label.setAlignment(qt.AlignRight) 424 | top_layout.addWidget(self.author_label, 0) 425 | 426 | 427 | self.view = View(self.scene) 428 | layout.addWidget(self.view, 1) 429 | 430 | self.information_label = QLabel() 431 | self.information_label.setAlignment(qt.AlignHCenter) 432 | self.information_label.setWordWrap(True) 433 | self.information_label.setContentsMargins(5, 5, 5, 5) 434 | update_font(self.information_label, lambda f: multiply_font_size(f, 1.5)) 435 | layout.addWidget(self.information_label) 436 | 437 | self.scene.playtest = self.playtest = playtest 438 | 439 | 440 | menu = self.menuBar().addMenu("&File") 441 | 442 | if not playtest: 443 | action = menu.addAction("&Open...", self.load_file, QKeySequence.Open) 444 | menu.addSeparator() 445 | 446 | self.copy_action = action = menu.addAction("&Copy State to Clipboard", lambda: self.copy(display=True), QKeySequence('Ctrl+C')) 447 | action.setStatusTip("Copy the current state of the level into clipboard, in a text-based .hexcells format, padded with Tab characters.") 448 | if not playtest: 449 | action = menu.addAction("&Paste from Clipboard", self.paste, QKeySequence('Ctrl+V')) 450 | action.setStatusTip("Load a level in text-based .hexcells format that is currently in the clipboard.") 451 | menu.addSeparator() 452 | 453 | 454 | action = menu.addAction("&Quit", self.close, QKeySequence('Tab') if playtest else QKeySequence.Quit) 455 | if playtest: 456 | QShortcut(QKeySequence.Quit, self, action.trigger) 457 | 458 | 459 | menu = self.menuBar().addMenu("&Edit") 460 | 461 | action = menu.addAction("&Undo", self.scene.undo, QKeySequence.Undo) 462 | QShortcut(QKeySequence('Z'), self, action.trigger) 463 | action.setStatusTip("Cover the last uncovered cell.") 464 | action = menu.addAction("Clear &Progress", self.clear_progress) 465 | menu.addSeparator() 466 | 467 | menu.addAction("&Clear Annotations", self.scene.clear_guesses, QKeySequence("X")) 468 | menu.addAction("Con&firm Annotated Guesses", self.scene.confirm_guesses, QKeySequence("C")) 469 | menu.addAction("&Deny Annotated Guesses", self.scene.confirm_opposite_guesses, QKeySequence("D")) 470 | menu.addSeparator() 471 | 472 | menu.addAction("Highlight All C&olumn Hints", self.scene.highlight_all_columns) 473 | menu.addAction("Highlight All F&lower Hints", self.scene.highlight_all_flowers) 474 | 475 | 476 | menu = self.menuBar().addMenu("&Solve") 477 | menu.setEnabled(solve is not None) 478 | 479 | menu.addAction("&One Step", self.scene.solve_step, QKeySequence("S")) 480 | action = menu.addAction("Con&firm Solved", self.scene.confirm_guesses, QKeySequence("C")) 481 | action.setShortcutContext(qt.WidgetWithChildrenShortcut) # To prevent "ambiguous shortcut" 482 | action = menu.addAction("&Clear Solved", self.scene.clear_guesses, QKeySequence("X")) 483 | action.setShortcutContext(qt.WidgetWithChildrenShortcut) 484 | 485 | menu.addSeparator() 486 | 487 | menu.addAction("&Solve Completely", self.scene.solve_complete) 488 | 489 | 490 | menu = self.menuBar().addMenu("&Preferences") 491 | 492 | self.swap_buttons_action = action = make_check_action("&Swap Buttons", self, self.scene, 'swap_buttons') 493 | menu.addAction(action) 494 | 495 | 496 | menu = self.menuBar().addMenu("&Help") 497 | 498 | action = menu.addAction("&Instructions", self.help, QKeySequence.HelpContents) 499 | action = menu.addAction("&About", self.about) 500 | 501 | 502 | self.last_used_folder = None 503 | 504 | self.close_file() 505 | 506 | load_config_from_file(self, self.config_format, 'sixcells', 'player.cfg') 507 | 508 | 509 | config_format = ''' 510 | swap_buttons = swap_buttons_action.isChecked(); swap_buttons_action.setChecked(v) 511 | antialiasing = view.antialiasing; view.antialiasing = v 512 | last_used_folder 513 | window_geometry_qt = save_geometry_qt(); restore_geometry_qt(v) 514 | ''' 515 | 516 | def close_file(self): 517 | if not self.playtest: 518 | total = 0 519 | revealed = 0 520 | for cell in self.scene.all(Cell): 521 | if not cell.revealed: 522 | total += 1 523 | if cell.display is not Cell.unknown: 524 | revealed += 1 525 | clearing = hasattr(self, 'clearing') 526 | if 0 < revealed < total or clearing: 527 | try: 528 | saved = save(self.scene, display=True, padding=False) 529 | do_save = self.original_level != saved 530 | if do_save: 531 | if not clearing: 532 | msg = "Would you like to save your progress for this level?" 533 | btn = QMessageBox.warning(self, "Unsaved progress", msg, QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save) 534 | else: 535 | msg = "Are you sure you want to clear progress for this level?" 536 | btn = QMessageBox.warning(self, "Clear progress", msg, QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Discard) 537 | if btn == QMessageBox.Discard: 538 | do_save = False 539 | elif btn == QMessageBox.Cancel: 540 | return 541 | with db_connection('sixcells.sqlite3') as con: 542 | with con: 543 | con.execute('CREATE TABLE IF NOT EXISTS `saves` (`level` TEXT PRIMARY KEY, `save` TEXT, `mistakes` INT)') 544 | con.execute('DELETE FROM `saves` WHERE `level` = ?', (self.original_level,)) 545 | if do_save: 546 | con.execute('INSERT INTO `saves` (`level`, `save`, `mistakes`) VALUES (?, ?, ?)', (self.original_level, saved, self.scene.mistakes)) 547 | except Exception as e: 548 | pass 549 | self.current_file = None 550 | self.scene.clear() 551 | self.scene.remaining = 0 552 | self.scene.mistakes = 0 553 | self.scene.reset_cache() 554 | for it in [self.title_label, self.author_align_label, self.author_label, self.information_label]: 555 | it.hide() 556 | self.copy_action.setEnabled(False) 557 | self.undo_history = [] 558 | self.view.progress_loaded_timer.stop() 559 | self.view.viewport().repaint() 560 | return True 561 | 562 | def clear_progress(self): 563 | self.clearing = True 564 | self.load_one(self.original_level) 565 | delattr(self, 'clearing') 566 | 567 | @event_property 568 | def current_file(self): 569 | title = self.title 570 | if self.current_file: 571 | title = os.path.basename(self.current_file) + ' - ' + title 572 | self.setWindowTitle(("Playtest" + ' - ' if self.playtest else '') + title) 573 | 574 | def prepare(self): 575 | if not self.playtest: 576 | self.view.fit() 577 | remaining = 0 578 | for i, cell in enumerate(self.scene.all(Cell)): 579 | cell.id = i 580 | if cell.kind is Cell.full and not cell.revealed: 581 | remaining += 1 582 | cell._display = cell.kind if cell.revealed else Cell.unknown 583 | for i, col in enumerate(self.scene.all(Column)): 584 | col.id = i 585 | self.scene.remaining = remaining 586 | self.scene.mistakes = 0 587 | author_text = ("by {}" if self.scene.author else "").format(self.scene.author) 588 | for txt, it in [ 589 | (self.scene.title, self.title_label), 590 | (author_text, self.author_label), 591 | (author_text, self.author_align_label), 592 | (self.scene.information, self.information_label), 593 | ]: 594 | if txt: 595 | it.setText(txt) 596 | it.show() 597 | else: 598 | it.hide() 599 | self.scene.full_upd() 600 | self.copy_action.setEnabled(True) 601 | 602 | def load_one(self, level): 603 | if common.MainWindow.load(self, level): 604 | self.original_level = save(self.scene, padding=False) 605 | try: 606 | with db_connection('sixcells.sqlite3') as con: 607 | with con: 608 | [(saved, mistakes)] = con.execute('SELECT `save`, `mistakes` FROM `saves` WHERE `level` = ?', (self.original_level,)) 609 | common.MainWindow.load(self, saved) 610 | self.scene.mistakes = mistakes 611 | self.view.progress_loaded_timer.start() 612 | self.view.viewport().update() 613 | except Exception: 614 | pass 615 | self.view.setFocus() 616 | return True 617 | 618 | def load(self, level): 619 | while self.levels_bar.count(): 620 | self.levels_bar.removeTab(0) 621 | self.levels_bar.hide() 622 | levels = [] 623 | lines = level.splitlines() 624 | start = None 625 | skip = 0 626 | for i, line in enumerate(lines + [None]): 627 | if skip: 628 | skip -= 1 629 | continue 630 | if line is None or line.strip() == 'Hexcells level v1': 631 | if start is not None: 632 | level_lines = lines[start:i] 633 | levels.append(('\n'.join(level_lines), level_lines[1])) 634 | start = i 635 | skip = 4 636 | self.current_level = 0 637 | if len(levels) > 1: 638 | self.levels_bar.show() 639 | self.load_one(levels[0][0]) 640 | for level, title in levels: 641 | self.levels_bar.addTab(title) 642 | self.levels_bar.setTabData(self.levels_bar.count()-1, level) 643 | else: 644 | self.load_one(level) 645 | 646 | def level_change(self, index): 647 | if index >= 0 and index != self.current_level: 648 | level = self.levels_bar.tabData(index) 649 | if level: 650 | if self.load_one(level): 651 | self.current_level = index 652 | else: 653 | self.levels_bar.setCurrentIndex(self.current_level) 654 | 655 | 656 | def closeEvent(self, e): 657 | if not self.close_file(): 658 | e.ignore() 659 | return 660 | self.scene.solving = 0 661 | 662 | save_config_to_file(self, self.config_format, 'sixcells', 'player.cfg') 663 | 664 | 665 | 666 | def main(f=None): 667 | global window 668 | 669 | window = MainWindow() 670 | window.show() 671 | 672 | if not f and len(sys.argv[1:]) == 1: 673 | f = sys.argv[1] 674 | if f: 675 | f = os.path.abspath(f) 676 | QTimer.singleShot(0, lambda: window.load_file(f)) 677 | 678 | app.exec_() 679 | 680 | if __name__ == '__main__': 681 | main() 682 | -------------------------------------------------------------------------------- /common.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014-2016 Oleh Prypin 2 | # 3 | # This file is part of SixCells. 4 | # 5 | # SixCells is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # SixCells is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU General Public License 16 | # along with SixCells. If not, see . 17 | 18 | 19 | from __future__ import division, print_function 20 | 21 | __version__ = '2.4.3' 22 | 23 | import sys 24 | import os.path 25 | import math 26 | import collections 27 | import itertools 28 | import contextlib 29 | 30 | from util import * 31 | 32 | from universal_qt import PySide, PyQt4, PyQt5 33 | import qt 34 | from qt.core import QByteArray, QEvent, QPointF, QRect, QUrl 35 | from qt.gui import QBrush, QColor, QCursor, QDesktopServices, QMouseEvent, QPainter, QPen, QPolygonF 36 | from qt.widgets import QAction, QActionGroup, QApplication, QFileDialog, QGraphicsPolygonItem, QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QMainWindow, QMessageBox, QGraphicsItem 37 | 38 | from config import * 39 | 40 | app = QApplication(sys.argv) 41 | 42 | 43 | 44 | tau = 2*math.pi # 360 degrees is better than 180 degrees 45 | cos30 = math.cos(tau/12) 46 | 47 | class Color(object): 48 | background = QColor(231, 231, 231) 49 | yellow = QColor(255, 175, 41) 50 | yellow_border = QColor(255, 159, 0) 51 | blue = QColor(5, 164, 235) 52 | blue_border = QColor(20, 156, 216) 53 | black = QColor(62, 62, 62) 54 | black_border = QColor(44, 47, 49) 55 | light_text = QColor(255, 255, 255) 56 | dark_text = QColor(73, 73, 73) 57 | border = qt.white 58 | beam = QColor(255, 255, 255, 110) 59 | flower = QColor(255, 255, 255, 90) 60 | flower_border = QColor(128, 128, 128, 192) 61 | revealed_border = QColor(0, 230, 80) 62 | selection = qt.black 63 | 64 | 65 | no_pen = QPen(qt.transparent, 1e-10, qt.NoPen) 66 | 67 | 68 | def fit_inside(parent, item, k): 69 | "Fit one QGraphicsItem inside another, scale by height and center it" 70 | sb = parent.boundingRect() 71 | tb = item.boundingRect() 72 | item.setScale(sb.height()/tb.height()*k) 73 | tb = item.mapRectToItem(parent, item.boundingRect()) 74 | item.setPos(sb.center() - QPointF(tb.size().width()/2, tb.size().height()/2)) 75 | 76 | def update_font(obj, f): 77 | font = obj.font() 78 | r = f(font) 79 | if r: font = r 80 | obj.setFont(font) 81 | 82 | def multiply_font_size(font, k): 83 | if font.pointSizeF() > 0: 84 | font.setPointSizeF(font.pointSizeF()*k) 85 | else: 86 | font.setPixelSize(round(font.pixelSize()*k)) 87 | 88 | 89 | def make_check_action(text, obj, *args): 90 | action = QAction(text, obj) 91 | action.setCheckable(True) 92 | if args: 93 | if len(args) == 1: 94 | args = (obj,) + args 95 | def set_attribute(value): 96 | setattr(*(args + (value,))) 97 | action.toggled.connect(set_attribute) 98 | return action 99 | 100 | def make_action_group(parent, menu, obj, attribute, items): 101 | group = QActionGroup(parent) 102 | group.setExclusive(True) 103 | result = collections.OrderedDict() 104 | for it in items: 105 | try: 106 | text, value = it 107 | tip = None 108 | except ValueError: 109 | text, value, tip = it 110 | action = make_check_action(text, parent) 111 | if tip: 112 | action.setStatusTip(tip) 113 | group.addAction(action) 114 | menu.addAction(action) 115 | def set_attribute(truth, value=value): 116 | if truth: 117 | setattr(obj, attribute, value) 118 | action.toggled.connect(set_attribute) 119 | result[value] = action 120 | return result 121 | 122 | 123 | 124 | def hex1(): 125 | result = QPolygonF() 126 | l = 0.5/cos30 127 | for i in range(6): 128 | a = i*tau/6 - tau/12 129 | result.append(QPointF(l*math.sin(a), -l*math.cos(a))) 130 | return result 131 | hex1 = hex1() 132 | 133 | class Item(object): 134 | placed = False 135 | 136 | def _remove_from_grid(self): 137 | try: 138 | if self.scene().grid[tuple(self.coord)] is self: 139 | del self.scene().grid[tuple(self.coord)] 140 | except (AttributeError, KeyError): 141 | pass 142 | 143 | @setter_property 144 | def coord(self, value): 145 | x, y = value 146 | yield Point(x, y) 147 | self.setPos(x*cos30, y/2) 148 | 149 | def place(self, coord=None): 150 | self._remove_from_grid() 151 | if coord is not None: 152 | self.coord = coord 153 | self.scene().grid[self.coord.x, self.coord.y] = self 154 | try: 155 | del self.scene().grid_bounds 156 | except AttributeError: 157 | pass 158 | self.placed = True 159 | 160 | def remove(self): 161 | self._remove_from_grid() 162 | if self.scene(): 163 | self.scene().removeItem(self) 164 | self.placed = False 165 | 166 | def _find_neighbors(self, deltas, cls): 167 | try: 168 | x, y = self.coord 169 | except AttributeError: 170 | return 171 | for dx, dy in deltas: 172 | it = self.scene().grid.get((x + dx, y + dy)) 173 | if isinstance(it, cls): 174 | yield it 175 | 176 | @property 177 | def overlapping(self): 178 | return list(self._find_neighbors(_colliding_deltas, (Cell, Column))) 179 | 180 | 181 | def _cell_polys(): 182 | poly = QPolygonF() 183 | l = 0.46/cos30 184 | inner_poly = QPolygonF() 185 | il = 0.75*l 186 | for i in range(6): 187 | a = i*tau/6 - tau/12 188 | poly.append(QPointF(l*math.sin(a), -l*math.cos(a))) 189 | inner_poly.append(QPointF(il*math.sin(a), -il*math.cos(a))) 190 | return poly, inner_poly 191 | _cell_outer, _cell_inner = _cell_polys() 192 | 193 | _flower_deltas = [ # order: (clockwise, closest) starting from north 194 | ( 0, -2), ( 0, -4), ( 1, -3), 195 | ( 1, -1), ( 2, -2), ( 2, 0), 196 | ( 1, 1), ( 2, 2), ( 1, 3), 197 | ( 0, 2), ( 0, 4), (-1, 3), 198 | (-1, 1), (-2, 2), (-2, 0), 199 | (-1, -1), (-2, -2), (-1, -3), 200 | ] 201 | _neighbors_deltas = _flower_deltas[::3] # order: clockwise starting from north 202 | _columns_deltas = _neighbors_deltas[-1], _neighbors_deltas[0], _neighbors_deltas[1] 203 | _colliding_deltas = [(0, 0), (0, -1), (0, 1), (-1, 0), (1, 0)] 204 | 205 | class Cell(QGraphicsPolygonItem, Item): 206 | "Hexagonal cell" 207 | unknown = Entity('Cell.unknown') 208 | empty = Entity('Cell.empty') 209 | full = Entity('Cell.full') 210 | 211 | def __init__(self): 212 | QGraphicsPolygonItem.__init__(self, _cell_outer) 213 | 214 | self._inner = QGraphicsPolygonItem(_cell_inner) 215 | self._inner.setPen(no_pen) 216 | 217 | pen = QPen(Color.border, 0.03) 218 | pen.setJoinStyle(qt.MiterJoin) 219 | self.setPen(pen) 220 | 221 | self._text = QGraphicsSimpleTextItem('{?}') 222 | self._text.setBrush(Color.light_text) 223 | update_font(self._text, lambda f: f.setWeight(55)) 224 | 225 | self._extra_text = QGraphicsSimpleTextItem('') 226 | 227 | self.kind = Cell.unknown 228 | self.show_info = 0 229 | 230 | @property 231 | def display(self): 232 | return self.kind 233 | 234 | @cached_property 235 | def neighbors(self): 236 | return list(self._find_neighbors(_neighbors_deltas, Cell)) 237 | @cached_property 238 | def flower_neighbors(self): 239 | return list(self._find_neighbors(_flower_deltas, Cell)) 240 | @cached_property 241 | def columns(self): 242 | result = [] 243 | for col in self._find_neighbors(_columns_deltas, Column): 244 | sgn = col.angle//60 245 | if sgn == col.coord.x-self.coord.x: 246 | result.append(col) 247 | return result 248 | 249 | @cached_property 250 | def members(self): 251 | if self.show_info: 252 | if self.kind is Cell.empty: 253 | return self.neighbors 254 | if self.kind is Cell.full: 255 | return self.flower_neighbors 256 | 257 | def is_neighbor(self, other): 258 | return other in self.neighbors 259 | 260 | @cached_property 261 | def value(self): 262 | if self.show_info: 263 | return sum(1 for it in self.members if it.kind is Cell.full) 264 | 265 | @cached_property 266 | def together(self): 267 | if self.show_info == 2: 268 | full_items = {it for it in self.members if it.kind is Cell.full} 269 | return all_grouped(full_items, key=Cell.is_neighbor) 270 | 271 | def reset_cache(self): 272 | for attr in ['neighbors', 'flower_neighbors', 'columns', 'members', 'value', 'together']: 273 | try: 274 | delattr(self, attr) 275 | except AttributeError: pass 276 | 277 | @property 278 | def extra_text(self): 279 | return self._extra_text.text().replace('\n', '') 280 | @extra_text.setter 281 | def extra_text(self, value): 282 | value = value[:3] 283 | self._extra_text.setText(value) 284 | self.upd() 285 | 286 | def keyPressEvent(self, e): 287 | for c in e.text(): 288 | c = c.upper() 289 | if c.isdigit() or e.modifiers() & (qt.ShiftModifier): 290 | if c not in ['Q', 'W']: 291 | self.extra_text += c 292 | if e.key() in [qt.Key_Backspace, qt.Key_QuoteLeft, qt.Key_AsciiTilde] or\ 293 | e.text() in [u'`', u'~', u'^', u'\\', u'\N{SECTION SIGN}']: 294 | if not (e.modifiers() & qt.ShiftModifier): 295 | self.guess = None 296 | self.extra_text = '' 297 | 298 | def upd(self, first=False): 299 | self.reset_cache() 300 | 301 | if self.display is Cell.unknown: 302 | self.setBrush(Color.yellow_border) 303 | self._inner.setBrush(Color.yellow) 304 | self._text.setText('') 305 | elif self.display is Cell.empty: 306 | self.setBrush(Color.black_border) 307 | self._inner.setBrush(Color.black) 308 | elif self.display is Cell.full: 309 | self.setBrush(Color.blue_border) 310 | self._inner.setBrush(Color.blue) 311 | 312 | if not self.placed: 313 | return 314 | 315 | if self.display is not Cell.unknown and self.value is not None: 316 | txt = str(self.value) 317 | if self.together is not None: 318 | txt = ('{{{}}}' if self.together else '-{}-').format(txt) 319 | else: 320 | txt = '?' if self.display is Cell.empty else '' 321 | 322 | self._text.setText(txt) 323 | if txt: 324 | fit_inside(self, self._text, 0.48) 325 | 326 | if self.extra_text: 327 | unknown = self.display is Cell.unknown 328 | fit_inside(self, self._extra_text, 0.31) 329 | self._extra_text.setPos(self._extra_text.pos() + QPointF(0, -0.2)) 330 | self._extra_text.setBrush(Color.dark_text if unknown else Color.light_text) 331 | 332 | if txt and self.extra_text: 333 | self._text.setPos(self._text.pos() + QPointF(0, 0.1)) 334 | 335 | self.update() 336 | 337 | if first: 338 | with self.upd_neighbors(): 339 | pass 340 | 341 | @contextlib.contextmanager 342 | def upd_neighbors(self): 343 | neighbors = list(self.flower_neighbors) 344 | scene = self.scene() 345 | yield 346 | for it in neighbors: 347 | it.upd() 348 | for it in scene.all(Column): 349 | it.upd() 350 | 351 | def paint(self, g, option, widget): 352 | QGraphicsPolygonItem.paint(self, g, option, widget) 353 | self._inner.paint(g, option, widget) 354 | transform = g.transform() 355 | g.setTransform(self._extra_text.sceneTransform(), True) 356 | self._extra_text.paint(g, option, widget) 357 | g.setTransform(transform) 358 | g.setTransform(self._text.sceneTransform(), True) 359 | g.setOpacity(self._text.opacity()) 360 | self._text.paint(g, option, widget) 361 | 362 | def __repr__(self, first=True): 363 | r = [self.display] 364 | if self.display!=self.kind: 365 | r.append('({})'.format(repr(self.kind).split('.')[1])) 366 | r.append(self._text.text()) 367 | try: 368 | r.append('#{}'.format(self.id)) 369 | except AttributeError: pass 370 | if first: 371 | r.append('neighbors:[{}]'.format(' '.join(m.__repr__(False) for m in self.neighbors))) 372 | if self.members: 373 | r.append('members:[{}]'.format(' '.join(m.__repr__(False) for m in self.members))) 374 | return '<{}>'.format(' '.join(str(p) for p in r if str(p))) 375 | 376 | 377 | _col_poly = QPolygonF() 378 | for x, y in [(-0.25, 0.48), (-0.25, 0.02), (0.25, 0.02), (0.25, 0.48)]: 379 | _col_poly.append(QPointF(x, y)) 380 | 381 | _col_angle_deltas = {-60: (1, 1), 0: (0, 1), 60: (-1, 1)} 382 | 383 | class Column(QGraphicsPolygonItem, Item): 384 | "Column number marker" 385 | def __init__(self): 386 | QGraphicsPolygonItem.__init__(self, _col_poly) 387 | 388 | self.show_info = False 389 | 390 | self.setBrush(QColor(255, 255, 255, 0)) 391 | self.setPen(no_pen) 392 | 393 | self._text = QGraphicsSimpleTextItem('v') 394 | self._text.setBrush(Color.dark_text) 395 | update_font(self._text, lambda f: f.setWeight(55)) 396 | fit_inside(self, self._text, 0.86) 397 | #self._text.setY(self._text.y()+0.2) 398 | 399 | @setter_property 400 | def angle(self, value): 401 | if value not in (-60, 0, 60): 402 | raise ValueError(value) 403 | yield value 404 | 405 | @property 406 | def cell(self): 407 | return self.members[0] 408 | 409 | @cached_property 410 | def members(self): 411 | try: 412 | x, y = self.coord 413 | except AttributeError: 414 | return 415 | result = [] 416 | dx, dy = _col_angle_deltas[self.angle] 417 | while True: 418 | x += dx 419 | y += dy 420 | it = self.scene().grid.get((x, y)) 421 | if not it and not self.scene().grid_bounds.contains(x, y, False): 422 | break 423 | if isinstance(it, Cell): 424 | result.append(it) 425 | return result 426 | 427 | @cached_property 428 | def value(self): 429 | return sum(1 for it in self.members if it.kind is Cell.full) 430 | 431 | @cached_property 432 | def together(self): 433 | if self.show_info: 434 | groups = itertools.groupby(self.members, key=lambda it: it.kind is Cell.full) 435 | return sum(1 for full, _ in groups if full) <= 1 436 | 437 | def reset_cache(self): 438 | for attr in ['members', 'value', 'together']: 439 | try: 440 | delattr(self, attr) 441 | except AttributeError: pass 442 | 443 | def upd(self): 444 | self.reset_cache() 445 | 446 | self.setRotation(self.angle or 1e-3) # not zero so font doesn't look different from rotated variants 447 | 448 | if not self.placed: 449 | return 450 | 451 | txt = str(self.value) 452 | together = self.together 453 | if together is not None: 454 | txt = ('{{{}}}' if together else '-{}-').format(txt) 455 | self._text.setText(txt) 456 | if txt: 457 | self._text.setX(-self._text.boundingRect().width()*self._text.scale()/2) 458 | 459 | self.update() 460 | 461 | def paint(self, g, option, widget): 462 | QGraphicsPolygonItem.paint(self, g, option, widget) 463 | g.setTransform(self._text.sceneTransform(), True) 464 | self._text.paint(g, option, widget) 465 | 466 | def __repr__(self): 467 | r = ['Column'] 468 | r.append(self._text.text()) 469 | try: 470 | r.append('#{}'.format(self.id)) 471 | except AttributeError: pass 472 | r.append('members:[{}]'.format(' '.join(m.__repr__(False) for m in self.members))) 473 | return '<{}>'.format(' '.join(str(p) for p in r if str(p))) 474 | 475 | 476 | class Scene(QGraphicsScene): 477 | def __init__(self): 478 | QGraphicsScene.__init__(self) 479 | self.grid = dict() 480 | 481 | def all(self, types=(Cell, Column)): 482 | return (it for it in self.grid.values() if isinstance(it, types)) 483 | 484 | @cached_property 485 | def grid_bounds(self): 486 | #return QRect(-100, -100, 200, 200) 487 | it = iter(self.grid) 488 | try: 489 | minx, miny = next(it) 490 | maxx = minx 491 | maxy = miny 492 | except StopIteration: 493 | return QRect() 494 | for x, y in it: 495 | if x < minx: minx = x 496 | elif x > maxx: maxx = x 497 | if y < miny: miny = y 498 | elif y > maxy: maxy = y 499 | return QRect(minx, miny, maxx-minx+1, maxy-miny+1) 500 | 501 | def full_upd(self): 502 | for cell in self.all(Cell): 503 | cell.upd(False) 504 | for col in self.all(Column): 505 | col.upd() 506 | 507 | def clear(self): 508 | self.grid = dict() 509 | QGraphicsScene.clear(self) 510 | 511 | 512 | 513 | class View(QGraphicsView): 514 | def __init__(self, scene): 515 | QGraphicsView.__init__(self, scene) 516 | self.scene = scene 517 | self.setBackgroundBrush(QBrush(Color.background)) 518 | self.antialiasing = True 519 | self.setHorizontalScrollBarPolicy(qt.ScrollBarAlwaysOff) 520 | self.setVerticalScrollBarPolicy(qt.ScrollBarAlwaysOff) 521 | 522 | @property 523 | def antialiasing(self): 524 | return bool(self.renderHints() & QPainter.Antialiasing) 525 | @antialiasing.setter 526 | def antialiasing(self, value): 527 | self.setRenderHint(QPainter.Antialiasing, value) 528 | self.setRenderHint(QPainter.TextAntialiasing, value) 529 | 530 | def _get_event(self, e, typ): 531 | if e.isAutoRepeat(): 532 | return None 533 | try: 534 | btn = { 535 | qt.Key_Q: qt.LeftButton, 536 | qt.Key_W: qt.RightButton, 537 | qt.Key_E: qt.MiddleButton, 538 | }[e.key()] 539 | except KeyError: 540 | return None 541 | pos = self.mapFromGlobal(QCursor.pos()) 542 | return QMouseEvent(typ, pos, btn, btn, e.modifiers()) 543 | 544 | def keyPressEvent(self, e): 545 | evt = self._get_event(e, QEvent.MouseButtonPress) 546 | if evt: 547 | self.mousePressEvent(evt) 548 | else: 549 | QGraphicsView.keyPressEvent(self, e) 550 | item = self.itemAt(self.mapFromGlobal(QCursor.pos())) 551 | if item: 552 | item.keyPressEvent(e) 553 | 554 | def keyReleaseEvent(self, e): 555 | evt = self._get_event(e, QEvent.MouseButtonRelease) 556 | if evt: 557 | self.mouseReleaseEvent(evt) 558 | else: 559 | QGraphicsView.keyReleaseEvent(self, e) 560 | 561 | 562 | hexcells_ui_area = [ 563 | ' ************************* ', 564 | ' *#######################* ', 565 | ' *########################* ', 566 | ' *########################* ', 567 | ' *#########################* ', 568 | ' ##########################* ', 569 | ' *##########################****', 570 | ' *###############################', 571 | ' *###############################', 572 | '*################################', 573 | '*################################' 574 | ] + [ 575 | '#'*33 576 | ]*22 577 | 578 | def save(scene, display=False, padding=True): 579 | ret = None 580 | 581 | grid = scene.grid 582 | all_cells = [(x, y) for (x, y), it in grid.items() if isinstance(it, Cell)] 583 | min_x, max_x = minmax([x for x, y in grid] or [0]) 584 | min_y, max_y = minmax([y for x, y in grid] or [0]) 585 | if padding: 586 | mid_x, mid_y = (min_x + max_x)//2, (min_y + max_y)//2 587 | max_tx = max_ty = 32 588 | 589 | if max_x - min_x > max_tx: 590 | ret = "This level is too wide to fit into Hexcells format." 591 | if max_y - min_y > max_tx: 592 | ret = "This level is too high to fit into Hexcells format." 593 | if ret: 594 | ret += '\n' + "The data will be malformed, but still readable by SixCells." 595 | max_tx = max_x - min_x 596 | max_ty = max_y - min_y 597 | 598 | mid_t = (0 + max_tx)//2, (0 + max_ty)//2 599 | mid_d = mid_t[0] - mid_x, mid_t[1] - mid_y 600 | 601 | ui_area = list(hexcells_ui_area) 602 | d = len(scene.information.splitlines())*2 - 2 603 | if d > 0: 604 | ui_area[-d:] = [' '*33]*d 605 | 606 | possibilities = [] 607 | for dy in range(-min_y, -min_y + max_ty - (max_y - min_y) + 1): 608 | for dx in range(-min_x, -min_x + max_tx - (max_x - min_x) + 1): 609 | overlaps = 0 610 | if not ret: 611 | for (x, y), it in grid.items(): 612 | c = ui_area[y+dy][x+dx] 613 | if isinstance(it, Cell): 614 | overlaps += {'#': 0, '*': 0.9, ' ': 1}[c] 615 | if isinstance(it, Column): 616 | overlaps += {'#': 0, '*': 0.001, ' ': 0.85}[c] 617 | dist = ( 618 | sum(distance(mid_t, (x+dx, y+dy), squared=True) for x, y in all_cells)/(len(all_cells) or 1)+ 619 | distance(mid_d, (dx, dy), squared=True)/2 620 | ) 621 | possibilities.append((overlaps, dist, (dy, dx))) 622 | assert possibilities 623 | overlaps, _, (dy, dx) = min(possibilities) 624 | global level_center 625 | level_center = (16-dx, 16-dy) 626 | if overlaps > 0.8: 627 | ret = "This level (barely) fits, but may overlap some UI elements of Hexcells." 628 | else: 629 | dx, dy = -min_x, -min_y 630 | max_tx, max_ty = max_x+dx, max_y+dy 631 | 632 | level = [[['.', '.'] for x in range(max_tx+1)] for y in range(max_ty+1)] 633 | for (x, y), it in grid.items(): 634 | r = level[y+dy][x+dx] 635 | if isinstance(it, Column): 636 | r[0] = {-60: '\\', 0: '|', 60: '/'}[int(it.angle)] 637 | else: 638 | kind = it.display if display and it.display is not Cell.unknown else it.kind 639 | r[0] = 'x' if kind is Cell.full else 'o' 640 | if it.value is not None: 641 | if it.together is not None: 642 | r[1] = 'c' if it.together else 'n' 643 | else: 644 | r[1] = '+' 645 | if isinstance(it, Cell) and (it.revealed or (display and it.display is not Cell.unknown)): 646 | r[0] = r[0].upper() 647 | level = [''.join(''.join(part) for part in line) for line in level] 648 | 649 | headers = [ 650 | 'Hexcells level v1', 651 | scene.title, 652 | scene.author, 653 | ('\n' if '\n' not in scene.information else '') + scene.information, 654 | ] 655 | 656 | level = '\n'.join(headers + level) 657 | if padding: 658 | return level, ret 659 | else: 660 | return level 661 | 662 | def load(level, scene, Cell=Cell, Column=Column): 663 | lines = iter(level.strip().splitlines()) 664 | 665 | try: 666 | header = next(lines).strip() 667 | if header != 'Hexcells level v1': 668 | raise ValueError("Can read only Hexcells level v1") 669 | 670 | scene.title = next(lines).strip() 671 | scene.author = next(lines).strip() 672 | scene.information = '\n'.join(line for line in [next(lines).strip(), next(lines).strip()] if line) 673 | except StopIteration: 674 | raise ValueError("Level data stopped abruptly") 675 | 676 | for y, line in enumerate(lines): 677 | line = line.strip().replace(' ', '') 678 | 679 | row = [] 680 | 681 | for x in range(0, len(line)//2): 682 | kind, value = line[x*2:x*2+2] 683 | 684 | if kind.lower() in 'ox': 685 | item = Cell() 686 | elif kind in '\\|/': 687 | item = Column() 688 | else: 689 | continue 690 | 691 | if isinstance(item, Cell): 692 | item.kind = Cell.full if kind.lower() == 'x' else Cell.empty 693 | item.revealed = kind.isupper() 694 | item.show_info = 0 if value == '.' else 1 if value == '+' else 2 695 | else: 696 | item.angle = (-60 if kind == '\\' else 60 if kind == '/' else 0) 697 | item.show_info = False if value == '+' else True 698 | 699 | scene.addItem(item) 700 | item.place((x, y)) 701 | 702 | scene.full_upd() 703 | 704 | 705 | class MainWindow(QMainWindow): 706 | def load(self, level): 707 | if not self.close_file(): 708 | return 709 | self.status = "Loading a level..." 710 | try: 711 | load(level, self.scene, Cell=self.Cell, Column=self.Column) 712 | except ValueError as e: 713 | QMessageBox.critical(None, "Error", str(e)) 714 | self.status = "Failed", 1 715 | return 716 | self.prepare() 717 | self.status = "Done", 1 718 | return True 719 | 720 | def load_file(self, fn=None): 721 | if not fn: 722 | try: 723 | dialog = QFileDialog.getOpenFileNameAndFilter 724 | except AttributeError: 725 | dialog = QFileDialog.getOpenFileName 726 | fn, _ = dialog(self, "Open", self.last_used_folder, "Hexcells Level (*.hexcells)") 727 | if not fn: 728 | return 729 | self.status = "Loading a level..." 730 | with open(fn, 'rb') as f: 731 | level = f.read().decode('utf-8') 732 | if self.load(level): 733 | if isinstance(fn, basestring): 734 | self.current_file = fn 735 | self.last_used_folder = os.path.dirname(fn) 736 | return True 737 | 738 | def paste(self): 739 | return self.load(app.clipboard().text()) 740 | 741 | def copy(self, padded=True, **kwargs): 742 | self.status = "Copying to clipboard..." 743 | try: 744 | level, status = save(self.scene, **kwargs) 745 | except Exception as e: 746 | QMessageBox.critical(None, "Error", str(e)) 747 | self.status = "Failed", 1 748 | return 749 | if status: 750 | QMessageBox.warning(None, "Warning", status + '\n' + "Copied anyway.") 751 | if padded: 752 | level = '\t' + level.replace('\n', '\n\t') 753 | app.clipboard().setText(level) 754 | self.status = "Done", 1 755 | return True 756 | 757 | def save_geometry_qt(self): 758 | return str(self.saveGeometry().toBase64().data().decode('ascii')) 759 | def restore_geometry_qt(self, value): 760 | self.restoreGeometry(QByteArray.fromBase64(value.encode('ascii'))) 761 | 762 | def about(self): 763 | try: 764 | import pulp 765 | except ImportError: 766 | pulp_version = "(missing!)" 767 | else: 768 | pulp_version = pulp.VERSION 769 | try: 770 | import sqlite3 771 | except ImportError: 772 | sqlite_version = "(missing!)" 773 | else: 774 | sqlite_version = sqlite3.sqlite_version 775 | 776 | QMessageBox.information(None, "About", """ 777 |

{}

778 |

Version {}

779 | 780 |

© 2014-2016 Oleh Prypin <blaxpirit@gmail.com>
781 | © 2014 Stefan Walzer <sekti@gmx.net>

782 | 783 |

License: GNU General Public License Version 3

784 | 785 | Using: 786 |
    787 |
  • Python {} 788 |
  • Qt {} 789 |
  • {} {} 790 |
  • PuLP {} 791 |
  • SQLite {} 792 |
793 | """.format( 794 | self.title, __version__, 795 | sys.version.split(' ', 1)[0], 796 | qt.version_str, 797 | qt.module, qt.module_version_str, 798 | pulp_version, 799 | sqlite_version, 800 | )) 801 | 802 | def help(self): 803 | QDesktopServices.openUrl(QUrl('https://github.com/oprypin/sixcells/tree/v{}#readme'.format(__version__))) 804 | -------------------------------------------------------------------------------- /generator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright (C) 2025 Alex PB 5 | # 6 | # This file is part of SixCells. 7 | """ 8 | Hexcells Level Generator for SixCells 9 | Generates procedural Hexcells levels with automatic clue minimization 10 | """ 11 | 12 | import random 13 | from typing import List, Tuple, Optional 14 | 15 | import common 16 | from common import * 17 | from solver import * 18 | 19 | 20 | class HexCell: 21 | """Represents a single hexagonal cell""" 22 | def __init__(self, x, y, is_blue=False, info_type='+'): 23 | self.x = x 24 | self.y = y 25 | self.is_blue = is_blue 26 | self.revealed = False 27 | self.info_type = info_type # '.', '+', 'c', 'n' 28 | 29 | def to_string(self): 30 | """Convert to 2-character level format""" 31 | if self.is_blue: 32 | cell_char = 'X' if self.revealed else 'x' 33 | else: 34 | cell_char = 'O' if self.revealed else 'o' 35 | return cell_char + self.info_type 36 | 37 | 38 | class ColumnHint: 39 | """Represents a column/line hint""" 40 | def __init__(self, x, y, direction, consecutive=None): 41 | self.x = x 42 | self.y = y 43 | self.direction = direction # '\\', '|', '/' 44 | self.consecutive = consecutive # None, 'c', or 'n' 45 | 46 | def to_string(self): 47 | """Convert to 2-character level format""" 48 | info = '+' 49 | if self.consecutive == 'c': 50 | info = 'c' 51 | elif self.consecutive == 'n': 52 | info = 'n' 53 | return self.direction + info 54 | 55 | 56 | # Direction deltas: (dx, dy) for moving in a column hint's direction 57 | direction_deltas = { 58 | '\\': (1, 1), # diagonal down-right 59 | '|': (0, 2), # straight down 60 | '/': (-1, 1) # diagonal down-left 61 | } 62 | 63 | #============================== from player.py 64 | 65 | class Cell(common.Cell): 66 | def __init__(self): 67 | common.Cell.__init__(self) 68 | 69 | self.flower = False 70 | self.hidden = False 71 | self.guess = None 72 | self._display = Cell.unknown 73 | 74 | def upd(self, first=False): 75 | common.Cell.upd(self, first) 76 | if self.guess: 77 | self.setBrush(Color.blue_border if self.guess == Cell.full else Color.black_border) 78 | 79 | @setter_property 80 | def display(self, value): 81 | rem = 0 82 | try: 83 | if self.display is not Cell.full and value is Cell.full: 84 | rem = -1 85 | if self.display is Cell.full and value is not Cell.full: 86 | rem = 1 87 | except AttributeError: 88 | pass 89 | yield value 90 | if rem and self.placed: 91 | self.scene().remaining += rem 92 | self.guess = None 93 | self.flower = False 94 | self.extra_text = '' 95 | 96 | def reset_cache(self): 97 | pass 98 | 99 | 100 | class Column(common.Column): 101 | def reset_cache(self): 102 | pass 103 | 104 | 105 | 106 | 107 | 108 | class Scene(common.Scene): 109 | 110 | def __init__(self): 111 | common.Scene.__init__(self) 112 | 113 | self.swap_buttons = False 114 | 115 | self.remaining = 0 116 | self.mistakes = 0 117 | 118 | self.solving = 0 119 | 120 | self.undo_history = [] 121 | 122 | def prepare(self): 123 | remaining = 0 124 | for i, cell in enumerate(self.all(Cell)): 125 | cell.id = i 126 | if cell.kind is Cell.full and not cell.revealed: 127 | remaining += 1 128 | cell._display = cell.kind if cell.revealed else Cell.unknown 129 | for i, col in enumerate(self.all(Column)): 130 | col.id = i 131 | self.remaining = remaining 132 | self.mistakes = 0 133 | 134 | self.full_upd() 135 | 136 | 137 | @cached_property 138 | def all_cells(self): 139 | return list(self.all(Cell)) 140 | 141 | @cached_property 142 | def all_columns(self): 143 | return list(self.all(Column)) 144 | 145 | def reset_cache(self): 146 | for attr in ['all_cells', 'all_columns']: 147 | try: 148 | delattr(self, attr) 149 | except AttributeError: 150 | pass 151 | 152 | def solve_step(self): 153 | """Derive everything that can be concluded from the current state. 154 | Return whether progress has been made.""" 155 | if self.solving: 156 | print("already solving") 157 | return 158 | 159 | self.confirm_guesses() 160 | self.solving += 1 161 | app.processEvents() 162 | progress = False 163 | undo_step = [] 164 | for cell, value in solve(self): 165 | assert cell.kind is value 166 | cell.guess = value 167 | cell.upd() 168 | progress = True 169 | undo_step.append(cell) 170 | self.undo_history.append(undo_step) 171 | self.solving -= 1 172 | 173 | return progress 174 | 175 | def solve_complete(self): 176 | """Continue solving until stuck. 177 | Return whether the entire level could be uncovered.""" 178 | self.solving = 1 179 | while self.solving: 180 | self.confirm_guesses() 181 | 182 | progress = True 183 | while progress: 184 | progress = False 185 | for cell, value in solve_simple(self): 186 | progress = True 187 | assert cell.kind is value 188 | cell.display = cell.kind 189 | cell.upd() 190 | self.solving -= 1 191 | if not self.solve_step(): 192 | break 193 | self.solving += 1 194 | 195 | self.solving = 0 196 | # If it identified all blue cells, it'll have the rest uncovered as well 197 | return self.remaining == 0 198 | 199 | def clear_guesses(self): 200 | for cell in self.all(Cell): 201 | if cell.guess: 202 | cell.guess = None 203 | cell.upd() 204 | 205 | def confirm_guesses(self, opposite=False): 206 | correct = [] 207 | for cell in self.all(Cell): 208 | if cell.guess and cell.display is Cell.unknown: 209 | if (cell.kind == cell.guess) ^ opposite: 210 | cell.display = cell.kind 211 | cell.upd() 212 | correct.append(cell) 213 | else: 214 | self.mistakes += 1 215 | self.undo_history.append(correct) 216 | 217 | 218 | 219 | #============================ 220 | 221 | 222 | class GeneratedLevel: 223 | """Internal representation of a generated level""" 224 | def __init__(self): 225 | self.grid = [[None for _ in range(33)] for _ in range(33)] 226 | self.column_hints = [] 227 | self.title = "Generated Level" 228 | self.author = "Generator" 229 | 230 | def get_neighbors(self, x, y) -> List[Tuple[int, int]]: 231 | """Get hexagonal neighbors of a cell using the game's coordinate system""" 232 | # These are the 6 immediate neighbors in the game's hex layout 233 | # From common.py: _neighbors_deltas = [(0, -2), (1, -1), (1, 1), (0, 2), (-1, 1), (-1, -1)] 234 | offsets = [(0, -2), (1, -1), (1, 1), (0, 2), (-1, 1), (-1, -1)] 235 | 236 | neighbors = [] 237 | for dx, dy in offsets: 238 | nx, ny = x + dx, y + dy 239 | if 0 <= nx < 33 and 0 <= ny < 33: 240 | neighbors.append((nx, ny)) 241 | return neighbors 242 | 243 | def are_neighbors(self, pos1: Tuple[int, int], pos2: Tuple[int, int]) -> bool: 244 | """Check if two positions are neighbors""" 245 | return pos2 in self.get_neighbors(pos1[0], pos1[1]) 246 | 247 | def all_grouped(self, positions: set) -> bool: 248 | """Check if all positions form one connected group""" 249 | if not positions: 250 | return True 251 | 252 | # Start with one position 253 | grouped = {next(iter(positions))} 254 | anything_to_add = True 255 | 256 | while anything_to_add: 257 | anything_to_add = False 258 | for pos in positions - grouped: 259 | if any(self.are_neighbors(pos, grouped_pos) for grouped_pos in grouped): 260 | anything_to_add = True 261 | grouped.add(pos) 262 | 263 | return len(grouped) == len(positions) 264 | 265 | def get_line_cells(self, x, y, direction) -> List[Tuple[int, int]]: 266 | """Get all cells in a line from a given position and direction 267 | 268 | Returns positions of all non-None cells (both HexCells and ColumnHints). 269 | This is needed for check_consecutive to properly calculate indices. 270 | """ 271 | cells = [] 272 | 273 | delta = direction_deltas.get(direction) 274 | if not delta: 275 | print("Invalid direction:", direction) 276 | return cells 277 | 278 | dx, dy = delta 279 | 280 | cx, cy = x, y 281 | 282 | # Move forward collecting all non-None cells 283 | while 0 <= cx < 33 and 0 <= cy < 33: 284 | if self.grid[cy][cx]: 285 | cells.append((cx, cy)) 286 | cx += dx 287 | cy += dy 288 | 289 | return cells 290 | 291 | def get_hex_cells_in_line(self, x, y, direction) -> List[Tuple[int, int]]: 292 | """Get only HexCell positions in a line (filters out ColumnHints)""" 293 | line_cells = self.get_line_cells(x, y, direction) 294 | return [(cx, cy) for cx, cy in line_cells 295 | if isinstance(self.grid[cy][cx], HexCell)] 296 | 297 | def to_level_string(self) -> str: 298 | """Convert to Hexcells level format""" 299 | lines = [ 300 | "Hexcells level v1", 301 | self.title, 302 | self.author, 303 | "", 304 | "" 305 | ] 306 | # Build 33x33 grid 307 | for y in range(33): 308 | row = "" 309 | for x in range(33): 310 | cell = self.grid[y][x] 311 | if cell: 312 | row += cell.to_string() 313 | else: 314 | row += ".." 315 | lines.append(row) 316 | return "\n".join(lines) 317 | 318 | def set_black_cell_info_types(self): 319 | """Set info_type (c/n) for black cells based on their blue neighbors""" 320 | for y in range(33): 321 | for x in range(33): 322 | cell = self.grid[y][x] 323 | if not isinstance(cell, HexCell) or cell.is_blue or cell.info_type == '.': 324 | continue 325 | 326 | # Get all blue neighbors 327 | blue_neighbors = set() 328 | for nx, ny in self.get_neighbors(x, y): 329 | neighbor = self.grid[ny][nx] 330 | if isinstance(neighbor, HexCell) and neighbor.is_blue: 331 | blue_neighbors.add((nx, ny)) 332 | 333 | # If there are multiple blue neighbors, check if they're consecutive 334 | if len(blue_neighbors) > 1: 335 | if self.all_grouped(blue_neighbors): 336 | cell.info_type = 'c' 337 | else: 338 | cell.info_type = 'n' 339 | 340 | class LevelGenerator: 341 | """Main generator class""" 342 | 343 | def __init__(self, 344 | width=10, 345 | height=10, 346 | constrain_by_radius=True, 347 | blue_density=0.4, 348 | cell_spawn_chance=0.95, 349 | column_hint_chance=0.6, 350 | min_columns_removed=0, 351 | max_columns_removed=2, 352 | reveal_density=7, 353 | blue_info_weight_plus=0.3, 354 | blue_info_weight_none=0.7, 355 | clue_removal_ratio=0.95): 356 | """Initialize the level generator with configuration parameters 357 | 358 | Args: 359 | width: Width of the pattern (max 30) 360 | height: Height of the pattern (max ~31) 361 | constrain_by_radius: Whether to constrain cells to a circular radius 362 | blue_density: Probability of a cell being blue (0.0-1.0) 363 | cell_spawn_chance: Probability of a cell spawning in valid positions (0.0-1.0) 364 | column_hint_chance: Probability of adding a column hint per cell (0.0-1.0) 365 | min_columns_removed: Minimum number of column hints to remove 366 | max_columns_removed: Maximum number of column hints to remove 367 | reveal_density: Divisor for revealed cell density (1/reveal_density cells revealed) 368 | blue_info_weight_plus: Weight for '+' info type on blue cells 369 | blue_info_weight_none: Weight for '.' info type on blue cells 370 | clue_removal_ratio: Ratio of clues to attempt removing (0.0-1.0, lower=easier, higher=harder) 371 | """ 372 | self.width = width 373 | self.height = height 374 | self.constrain_by_radius = constrain_by_radius 375 | self.blue_density = blue_density 376 | self.cell_spawn_chance = cell_spawn_chance 377 | self.column_hint_chance = column_hint_chance 378 | self.min_columns_removed = min_columns_removed 379 | self.max_columns_removed = max_columns_removed 380 | self.reveal_density = reveal_density 381 | self.blue_info_weight_plus = blue_info_weight_plus 382 | self.blue_info_weight_none = blue_info_weight_none 383 | self.clue_removal_ratio = clue_removal_ratio 384 | 385 | def generate(self, max_attempts=20) -> Optional[GeneratedLevel]: 386 | """Generate a complete level with minimized clues""" 387 | for attempt in range(max_attempts): 388 | print(f"Generation attempt {attempt + 1}/{max_attempts}...") 389 | level = self.create_pattern() 390 | 391 | if self.is_solvable(level): 392 | print("Pattern is solvable, minimizing clues...") 393 | self.minimize_clues(level) 394 | print(f"Generated level with {self.count_clues(level)} clues") 395 | self._set_level_metadata(level) 396 | return level 397 | print("Pattern not solvable, retrying...") 398 | 399 | print(f"Failed to generate solvable level after {max_attempts} attempts, returning None") 400 | return None 401 | 402 | def _set_level_metadata(self, level: GeneratedLevel): 403 | """Set the level title and author based on generation parameters""" 404 | # Default values for comparison 405 | defaults = { 406 | 'width': 10, 'height': 10, 'constrain_by_radius': True, 407 | 'blue_density': 0.4, 'cell_spawn_chance': 0.95, 408 | 'column_hint_chance': 0.6, 'min_columns_removed': 0, 409 | 'max_columns_removed': 2, 'reveal_density': 7, 410 | 'blue_info_weight_plus': 0.3, 'blue_info_weight_none': 0.7, 411 | 'clue_removal_ratio': 0.75 412 | } 413 | 414 | # Build title from non-default size parameters 415 | title_parts = [] 416 | if self.width != defaults['width'] or self.height != defaults['height']: 417 | title_parts.append(f"{self.width}x{self.height}") 418 | if not self.constrain_by_radius: 419 | title_parts.append("Rect") 420 | 421 | # Build author from non-default gameplay parameters 422 | author_parts = [] 423 | if self.blue_density != defaults['blue_density']: 424 | author_parts.append(f"blue:{self.blue_density:.2f}") 425 | if self.cell_spawn_chance != defaults['cell_spawn_chance']: 426 | author_parts.append(f"spawn:{self.cell_spawn_chance:.2f}") 427 | if self.column_hint_chance != defaults['column_hint_chance']: 428 | author_parts.append(f"hints:{self.column_hint_chance:.2f}") 429 | if (self.min_columns_removed != defaults['min_columns_removed'] or 430 | self.max_columns_removed != defaults['max_columns_removed']): 431 | author_parts.append(f"rm:{self.min_columns_removed}-{self.max_columns_removed}") 432 | if self.reveal_density != defaults['reveal_density']: 433 | author_parts.append(f"reveal:1/{self.reveal_density}") 434 | if (self.blue_info_weight_plus != defaults['blue_info_weight_plus'] or 435 | self.blue_info_weight_none != defaults['blue_info_weight_none']): 436 | author_parts.append(f"info:{self.blue_info_weight_plus:.1f}/{self.blue_info_weight_none:.1f}") 437 | if self.clue_removal_ratio != defaults['clue_removal_ratio']: 438 | author_parts.append(f"clue_rm:{self.clue_removal_ratio:.2f}") 439 | 440 | # Set title and author 441 | level.title = " ".join(title_parts) if title_parts else "Generated Level" 442 | level.author = " ".join(author_parts) if author_parts else "Generator" 443 | 444 | def create_pattern(self) -> GeneratedLevel: 445 | """Create a properly aligned hex pattern of blue/black cells""" 446 | level = GeneratedLevel() 447 | grid_width, grid_height = 33, 33 448 | 449 | width, height = self.width, self.height 450 | center_x, center_y = grid_width // 2, grid_height // 2 451 | radius = min(width, height) // 2 452 | 453 | for tx in range(0, width, 2): 454 | for ty in range(height): 455 | x = tx + (-width // 2) + grid_width // 2 456 | y = ty + (-height // 2) + grid_height // 2 457 | # Offset every other row (hex staggering) 458 | grid_x = x + (y % 2) 459 | grid_y = y 460 | 461 | dx = grid_x - center_x 462 | dy = grid_y - center_y 463 | dist = math.sqrt(dx * dx + dy * dy) 464 | 465 | # Apply radius constraint if enabled 466 | radius_check = (dist <= radius * 1.0) if self.constrain_by_radius else True 467 | 468 | if radius_check and random.random() < self.cell_spawn_chance: 469 | is_blue = random.random() < self.blue_density 470 | info_type = '+' 471 | if is_blue: 472 | info_type = random.choices(['+', '.'], 473 | weights=[self.blue_info_weight_plus, 474 | self.blue_info_weight_none])[0] 475 | level.grid[grid_y][grid_x] = HexCell(grid_x, grid_y, is_blue, info_type=info_type) 476 | 477 | 478 | 479 | 480 | # Reveal a few random non-blue cells 481 | all_cells = [cell for row in level.grid for cell in row if cell is not None and not cell.is_blue] 482 | num_to_reveal = max(1, len(all_cells) // self.reveal_density) 483 | for cell in random.sample(all_cells, num_to_reveal): 484 | cell.revealed = True 485 | 486 | 487 | 488 | self.add_column_hints(level) 489 | self.remove_random_column_hint(level) 490 | 491 | self.recheck_hints(level) 492 | 493 | # Set info types for black cells (c/n based on blue neighbor grouping) 494 | level.set_black_cell_info_types() 495 | 496 | return level 497 | 498 | def recheck_hints(self, level: 'GeneratedLevel'): 499 | """Recheck and fix column hints after cells have been removed""" 500 | hints_to_remove = [] 501 | 502 | for hint in level.column_hints: 503 | # Get all cells in this line 504 | hex_cells = level.get_hex_cells_in_line(hint.x, hint.y, hint.direction) 505 | 506 | # Check if the column still has any members 507 | has_members = len(hex_cells) > 0 508 | 509 | if not has_members: 510 | # Remove hint from grid and mark for removal from list 511 | level.grid[hint.y][hint.x] = None 512 | hints_to_remove.append(hint) 513 | continue 514 | 515 | # Try to move hint into empty space in its direction 516 | dx, dy = direction_deltas[hint.direction] 517 | 518 | # Try moving the hint up to 3 times 519 | hint_removed = False 520 | for _ in range(3): 521 | new_x, new_y = hint.x + dx, hint.y + dy 522 | if not (0 <= new_x < 33 and 0 <= new_y < 33): 523 | break 524 | 525 | if level.grid[new_y][new_x] is None: 526 | # Move the hint 527 | level.grid[hint.y][hint.x] = None 528 | hint.x = new_x 529 | hint.y = new_y 530 | level.grid[new_y][new_x] = hint 531 | elif isinstance(level.grid[new_y][new_x], ColumnHint): 532 | # Collision with another hint, remove this one 533 | level.grid[hint.y][hint.x] = None 534 | hints_to_remove.append(hint) 535 | hint_removed = True 536 | break 537 | else: 538 | # Cell is occupied by something else, stop trying to move 539 | break 540 | 541 | if hint_removed: 542 | continue 543 | 544 | # Recalculate blue cells and consecutive info 545 | blue_cells = [(cx, cy) for cx, cy in hex_cells 546 | if level.grid[cy][cx].is_blue] 547 | 548 | # Recalculate consecutive/non-consecutive 549 | if len(blue_cells) <= 1: 550 | # Single cell or no cells - no togetherness info needed 551 | hint.consecutive = None 552 | else: 553 | # Check if blue cells are consecutive 554 | hint.consecutive = self.check_consecutive(hex_cells, blue_cells, level) 555 | 556 | # Remove hints that fail checks above 557 | for hint in hints_to_remove: 558 | level.column_hints.remove(hint) 559 | 560 | 561 | def add_column_hints(self, level: 'GeneratedLevel'): 562 | """Add column/line hints above each HexCell if there is space (up+left, up two spaces, up+right).""" 563 | height = len(level.grid) 564 | width = len(level.grid[0]) if height > 0 else 0 565 | # Directions: (dx, dy, direction symbol) 566 | directions = [ 567 | (-1, -1, '\\'), # up+left 568 | (0, -2, '|'), # up two 569 | (1, -1, '/'), # up+right 570 | ] 571 | for y in range(height): 572 | for x in range(width): 573 | cell = level.grid[y][x] 574 | if not (cell and isinstance(cell, HexCell)): 575 | continue 576 | if random.random() < self.column_hint_chance: 577 | continue 578 | for dx, dy, direction in directions: 579 | hx, hy = x + dx, y + dy 580 | if 0 <= hx < width and 0 <= hy < height: 581 | if level.grid[hy][hx] is None: 582 | hint = ColumnHint(hx, hy, direction, consecutive=None) 583 | level.column_hints.append(hint) 584 | level.grid[hy][hx] = hint 585 | 586 | def check_consecutive(self, line_cells, blue_cells, level) -> Optional[str]: 587 | """Check if blue cells in a line are consecutive""" 588 | if not blue_cells: 589 | return None 590 | blue_indices = [] 591 | for i, (cx, cy) in enumerate(line_cells): 592 | cell = level.grid[cy][cx] 593 | if isinstance(cell, HexCell) and cell.is_blue: 594 | blue_indices.append(i) 595 | if not blue_indices: 596 | return None 597 | # Check if consecutive 598 | is_consecutive = all(blue_indices[i] + 1 == blue_indices[i + 1] 599 | for i in range(len(blue_indices) - 1)) 600 | if len(blue_indices) > 1: 601 | return 'c' if is_consecutive else 'n' 602 | return None 603 | 604 | def remove_random_column_hint(self, level: 'GeneratedLevel'): 605 | """Remove random column hints and all their members from the grid""" 606 | 607 | if not level.column_hints: 608 | return 609 | removenum = random.randint(self.min_columns_removed, self.max_columns_removed) 610 | for r in range(removenum): 611 | if not level.column_hints: 612 | return 613 | # Pick a random column hint 614 | hint = random.choice(level.column_hints) 615 | 616 | # Get all cells in this line 617 | line_cells = level.get_line_cells(hint.x, hint.y, hint.direction) 618 | 619 | # Remove all cells in the line from the grid 620 | for cx, cy in line_cells: 621 | level.grid[cy][cx] = None 622 | 623 | # Remove the hint itself from the grid 624 | level.grid[hint.y][hint.x] = None 625 | 626 | # Remove the hint from the column_hints list 627 | level.column_hints.remove(hint) 628 | 629 | def minimize_clues(self, level: GeneratedLevel): 630 | """Remove redundant clues while maintaining solvability""" 631 | clues = [] 632 | # Collect column hints and flower hints 633 | for i in enumerate(level.column_hints): 634 | clues.append(('column', i[0])) 635 | for y in range(33): 636 | for x in range(33): 637 | cell = level.grid[y][x] 638 | if isinstance(cell, HexCell) and cell.info_type != '.': 639 | clues.append(('flower' if cell.is_blue else 'blackcell', x, y)) 640 | 641 | # Shuffle for random removal order 642 | random.shuffle(clues) 643 | clues = clues[:int(len(clues) * self.clue_removal_ratio)] # limit number of clues to try removing 644 | 645 | # Try removing each clue 646 | removed_count = 0 647 | for clue in clues: 648 | # Temporarily remove 649 | backup = self.remove_clue(level, clue) 650 | 651 | # Check if still solvable 652 | if self.is_solvable(level): 653 | removed_count += 1 654 | #print("+++ removal successful: ", clue) 655 | else: 656 | # Restore clue 657 | self.restore_clue(level, clue, backup) 658 | #print("--- removal failed, clue restored: ", clue) 659 | print(f"Removed {removed_count} redundant clues") 660 | 661 | def remove_clue(self, level: GeneratedLevel, clue) -> any: 662 | """Temporarily remove a clue and return backup. For column hints, also remove from grid.""" 663 | if clue[0] == 'flower' or clue[0] == 'blackcell': 664 | _, x, y = clue 665 | cell = level.grid[y][x] 666 | backup = cell.info_type 667 | cell.info_type = '.' 668 | return backup 669 | elif clue[0] == 'column': 670 | _, idx = clue 671 | if idx < len(level.column_hints): 672 | backup = level.column_hints[idx] 673 | if backup is not None: 674 | # Remove from grid as well 675 | level.grid[backup.y][backup.x] = None 676 | level.column_hints[idx] = None 677 | return backup 678 | return None 679 | 680 | def restore_clue(self, level: GeneratedLevel, clue, backup): 681 | """Restore a previously removed clue. For column hints, also restore to grid.""" 682 | if backup is None: 683 | return 684 | if clue[0] == 'flower' or clue[0] == 'blackcell': 685 | _, x, y = clue 686 | cell = level.grid[y][x] 687 | cell.info_type = backup 688 | elif clue[0] == 'column': 689 | _, idx = clue 690 | if idx < len(level.column_hints): 691 | level.column_hints[idx] = backup 692 | if backup is not None: 693 | # Restore to grid as well 694 | level.grid[backup.y][backup.x] = backup 695 | 696 | def count_clues(self, level: GeneratedLevel) -> int: 697 | """Count total number of clues in level""" 698 | count = 0 699 | for y in range(33): 700 | for x in range(33): 701 | cell = level.grid[y][x] 702 | if isinstance(cell, HexCell) and cell.info_type != '.': 703 | count += 1 704 | count += sum(1 for h in level.column_hints if h is not None) 705 | return count 706 | 707 | def is_solvable(self, level: GeneratedLevel): 708 | """Check if a level is solvable using the same logic as solve_complete in player.py""" 709 | try: 710 | from solver import solve, solve_simple 711 | except ImportError: 712 | print("Warning: solver module not available, assuming solvable") 713 | return True 714 | 715 | # Create a scene from the generated level 716 | scene = Scene() 717 | level_string = level.to_level_string() 718 | common.load(level_string, scene, Cell=Cell, Column=Column) 719 | scene.prepare() 720 | result = scene.solve_complete() 721 | return result 722 | 723 | def main(): 724 | """Command-line interface for the generator""" 725 | import argparse 726 | import os 727 | 728 | parser = argparse.ArgumentParser(description='Generate Hexcells levels') 729 | parser.add_argument('--count', type=int, default=1, 730 | help='Number of levels to generate (default: 1)') 731 | parser.add_argument('--width', type=int, default=10, 732 | help='Width of the pattern (default: 10, max: 30)') 733 | parser.add_argument('--height', type=int, default=10, 734 | help='Height of the pattern (default: 10, max: ~31)') 735 | parser.add_argument('--no-radius', action='store_true', 736 | help='Disable radius constraint (fills rectangular area)') 737 | parser.add_argument('--blue-density', type=float, default=0.4, 738 | help='Probability of a cell being blue (default: 0.4)') 739 | parser.add_argument('--cell-spawn-chance', type=float, default=0.95, 740 | help='Probability of a cell spawning (default: 0.95)') 741 | parser.add_argument('--column-hint-chance', type=float, default=0.6, 742 | help='Probability of adding column hints (default: 0.6)') 743 | parser.add_argument('--min-columns-removed', type=int, default=0, 744 | help='Minimum column hints to remove (default: 0)') 745 | parser.add_argument('--max-columns-removed', type=int, default=2, 746 | help='Maximum column hints to remove (default: 2)') 747 | parser.add_argument('--reveal-density', type=int, default=7, 748 | help='Reveal 1/N non-blue cells (default: 7)') 749 | parser.add_argument('--blue-info-plus', type=float, default=0.3, 750 | help='Weight for + info type on blue cells (default: 0.3)') 751 | parser.add_argument('--blue-info-none', type=float, default=0.7, 752 | help='Weight for no info type on blue cells (default: 0.7)') 753 | parser.add_argument('--clue-removal-ratio', type=float, default=0.75, 754 | help='Ratio of clues to attempt removing, lower=easier (default: 0.75)') 755 | parser.add_argument('--name', type=str, default='generated', 756 | help='Base name for generated files (default: generated)') 757 | parser.add_argument('--output-dir', type=str, default='generated_levels', 758 | help='Output directory for generated levels (default: generated_levels)') 759 | 760 | args = parser.parse_args() 761 | 762 | # Create output directory 763 | os.makedirs(args.output_dir, exist_ok=True) 764 | 765 | # Print generation parameters 766 | print("=" * 60) 767 | print("LEVEL GENERATION PARAMETERS") 768 | print("=" * 60) 769 | print(f"Count: {args.count}") 770 | print(f"Width: {args.width}") 771 | print(f"Height: {args.height}") 772 | print(f"Constrain by radius: {not args.no_radius}") 773 | print(f"Blue density: {args.blue_density}") 774 | print(f"Cell spawn chance: {args.cell_spawn_chance}") 775 | print(f"Column hint chance: {args.column_hint_chance}") 776 | print(f"Columns removed: {args.min_columns_removed}-{args.max_columns_removed}") 777 | print(f"Reveal density: 1/{args.reveal_density}") 778 | print(f"Blue info weights: +:{args.blue_info_plus}, .:{args.blue_info_none}") 779 | print(f"Clue removal ratio: {args.clue_removal_ratio} (lower=easier)") 780 | print(f"Output directory: {args.output_dir}") 781 | print(f"Base name: {args.name}") 782 | print("=" * 60) 783 | 784 | generator = LevelGenerator( 785 | width=args.width, 786 | height=args.height, 787 | constrain_by_radius=not args.no_radius, 788 | blue_density=args.blue_density, 789 | cell_spawn_chance=args.cell_spawn_chance, 790 | column_hint_chance=args.column_hint_chance, 791 | min_columns_removed=args.min_columns_removed, 792 | max_columns_removed=args.max_columns_removed, 793 | reveal_density=args.reveal_density, 794 | blue_info_weight_plus=args.blue_info_plus, 795 | blue_info_weight_none=args.blue_info_none, 796 | clue_removal_ratio=args.clue_removal_ratio 797 | ) 798 | 799 | for i in range(args.count): 800 | print(f"\n=== Generating level {i+1}/{args.count} ===") 801 | level = generator.generate() 802 | if level: 803 | filename = os.path.join(args.output_dir, f"{args.name}_{i+1}.hexcells") 804 | with open(filename, 'w', encoding='utf-8') as f: 805 | f.write(level.to_level_string()) 806 | print(f"Saved to {filename}", " title ", level.title, " by ", level.author) 807 | else: 808 | print(f"Failed to generate level {i+1}") 809 | 810 | print(f"\n{'=' * 60}") 811 | print(f"Generation complete! Levels saved to: {args.output_dir}") 812 | print(f"{'=' * 60}") 813 | 814 | 815 | if __name__ == '__main__': 816 | main() -------------------------------------------------------------------------------- /editor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Copyright (C) 2014-2016 Oleh Prypin 4 | # 5 | # This file is part of SixCells. 6 | # 7 | # SixCells is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # SixCells is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with SixCells. If not, see . 19 | 20 | 21 | from __future__ import division, print_function 22 | 23 | import sys 24 | import math 25 | import os.path 26 | 27 | import common 28 | from common import * 29 | 30 | from qt.core import QPoint, QPointF, QRectF, QTimer 31 | from qt.gui import QIcon, QKeySequence, QMouseEvent, QPainterPath, QPen, QTransform, QPolygonF 32 | from qt.widgets import QDialog, QDialogButtonBox, QFileDialog, QGraphicsPathItem, QGraphicsView, QLabel, QLineEdit, QMessageBox, QShortcut, QVBoxLayout 33 | 34 | 35 | 36 | class Cell(common.Cell): 37 | def __init__(self): 38 | common.Cell.__init__(self) 39 | 40 | self.revealed = False 41 | self.preview = None 42 | 43 | @property 44 | def selected(self): 45 | return self in self.scene().selection 46 | @selected.setter 47 | def selected(self, value): 48 | if value: 49 | self.scene().selection.add(self) 50 | self.setOpacity(0.5) 51 | else: 52 | try: 53 | self.scene().selection.remove(self) 54 | except KeyError: pass 55 | self.setOpacity(1) 56 | 57 | def upd(self, first=False): 58 | common.Cell.upd(self, first) 59 | 60 | if self.revealed: 61 | self.setBrush(Color.revealed_border) 62 | 63 | def mousePressEvent(self, e): 64 | if e.button() == qt.LeftButton and e.modifiers() & qt.ShiftModifier: 65 | self.selected = not self.selected 66 | e.ignore() 67 | if self.scene().selection: 68 | return 69 | if e.button() == qt.LeftButton and e.modifiers() & (qt.AltModifier | qt.ControlModifier): 70 | self.revealed = not self.revealed 71 | self.upd() 72 | e.ignore() 73 | 74 | def mouseMoveEvent(self, e): 75 | if self.scene().selection: 76 | if self.selected: 77 | x, y = convert_pos(e.scenePos().x(), e.scenePos().y()) 78 | x, y = round(x), round(y) 79 | dx = x-self.coord.x 80 | dy = y-self.coord.y 81 | if dx or dy: 82 | full_selection = set(self.scene().selection) 83 | for cell in self.scene().selection: 84 | full_selection |= set(cell.columns) 85 | for it in full_selection: 86 | new = Cell() 87 | self.scene().addItem(new) 88 | new.coord = (it.coord.x+dx, it.coord.y+dy) 89 | overlapping = set(new.overlapping)-full_selection 90 | new.remove() 91 | if overlapping: 92 | break 93 | else: 94 | for cell in self.scene().selection: 95 | for it in [cell] + cell.columns: 96 | it.place((it.coord.x+dx, it.coord.y+dy)) 97 | 98 | elif not self.contains(e.pos()): # mouse was dragged outside 99 | if not self.preview: 100 | self.preview = Column() 101 | self.scene().addItem(self.preview) 102 | 103 | a = angle(e.pos())*360/tau 104 | x, y = self.coord 105 | if -30 < a < 30: 106 | self.preview.coord = x, y-2 107 | self.preview.angle = 0 108 | elif -90 < a < -30: 109 | self.preview.coord = x-1, y-1 110 | self.preview.angle = -60 111 | elif 30 < a < 90: 112 | self.preview.coord = x+1, y-1 113 | self.preview.angle = 60 114 | else: 115 | self.preview.remove() 116 | self.preview = None 117 | if self.preview: 118 | self.preview.upd() 119 | 120 | def mouseReleaseEvent(self, e): 121 | if self.scene().ignore_release: 122 | self.scene().ignore_release = False 123 | return 124 | if self.scene().supress: 125 | return 126 | if self.scene().selection: 127 | self.scene().full_upd() 128 | self.scene().undo_step() 129 | 130 | if e.modifiers() & (qt.ShiftModifier | qt.AltModifier | qt.ControlModifier) or self.scene().selection: 131 | e.ignore() 132 | return 133 | if not self.preview: 134 | if self.contains(e.pos()): # mouse was not dragged outside 135 | if e.button() == qt.LeftButton: 136 | self.show_info = (self.show_info+1)%(3 if self.kind is Cell.empty else 2) 137 | self.upd() 138 | self.scene().undo_step(self) 139 | elif e.button() == qt.RightButton: 140 | for col in self.columns: 141 | col.remove() 142 | scene = self.scene() 143 | with self.upd_neighbors(): 144 | self.remove() 145 | scene.undo_step() 146 | else: 147 | for it in self.preview.overlapping: 148 | self.preview.remove() 149 | self.preview = None 150 | break 151 | else: 152 | self.preview.place() 153 | self.preview.upd() 154 | self.preview = None 155 | 156 | def copyattrs(self, new): 157 | new.kind = self.kind 158 | new.show_info = self.show_info 159 | new.revealed = self.revealed 160 | new.extra_text = self.extra_text 161 | 162 | 163 | class Column(common.Column): 164 | def __init__(self): 165 | common.Column.__init__(self) 166 | 167 | def mousePressEvent(self, e): 168 | pass 169 | 170 | def mouseReleaseEvent(self, e): 171 | if self.scene().supress: 172 | return 173 | if self.contains(e.pos()): # mouse was not dragged outside 174 | if e.button() == qt.LeftButton: 175 | self.show_info = not self.show_info 176 | self.upd() 177 | self.scene().undo_step(self) 178 | elif e.button() == qt.RightButton: 179 | scene = self.scene() 180 | self.remove() 181 | scene.undo_step(self) 182 | 183 | def copyattrs(self, new): 184 | new.angle = self.angle 185 | new.show_info = self.show_info 186 | 187 | 188 | 189 | def convert_pos(x, y): 190 | return x/cos30, y*2 191 | 192 | 193 | class Scene(common.Scene): 194 | def __init__(self): 195 | common.Scene.__init__(self) 196 | self.reset() 197 | self.swap_buttons = False 198 | self.use_rightclick = False 199 | self.ignore_release = False 200 | self.undo_history_length = 16 201 | self.undo_step() 202 | 203 | def reset(self): 204 | self.clear() 205 | self.preview = None 206 | self.selection = set() 207 | self.selection_path_item = None 208 | self.supress = False 209 | self.title = self.author = self.information = '' 210 | self.undo_history = [] 211 | self.undo_pos = -1 212 | 213 | def _place(self, p, kind=Cell.unknown): 214 | if not self.preview: 215 | self.preview = Cell() 216 | self.preview.kind = kind 217 | self.preview.setOpacity(0.4) 218 | self.addItem(self.preview) 219 | x, y = convert_pos(p.x(), p.y()) 220 | x = round(x) 221 | for yy in [round(y), int(math.floor(y - 1e-4)), int(math.ceil(y + 1e-4))]: 222 | self.preview.coord = (x, yy) 223 | if not any(isinstance(it, Cell) for it in self.preview.overlapping): 224 | break 225 | else: 226 | self.preview.coord = (round(x), round(y)) 227 | self.preview.upd() 228 | self.preview._text.setText('') 229 | 230 | def mousePressEvent(self, e): 231 | if self.supress: 232 | return 233 | 234 | self.last_press = self.itemAt(e.scenePos(), QTransform()) 235 | 236 | if self.selection: 237 | if (e.button() == qt.LeftButton and not self.itemAt(e.scenePos(), QTransform())) or e.button() == qt.RightButton: 238 | old_selection = self.selection 239 | self.selection = set() 240 | for it in old_selection: 241 | try: 242 | it.selected = False 243 | except AttributeError: pass 244 | if not self.itemAt(e.scenePos(), QTransform()): 245 | if e.button() == qt.LeftButton: 246 | if e.modifiers() & qt.ShiftModifier: 247 | self.selection_path_item = QGraphicsPathItem() 248 | self.selection_path = path = QPainterPath() 249 | self.selection_path_item.setPen(QPen(Color.selection, 0, qt.DashLine)) 250 | path.moveTo(e.scenePos()) 251 | self.selection_path_item.setPath(path) 252 | self.addItem(self.selection_path_item) 253 | if e.button() == qt.LeftButton or (self.use_rightclick and e.button() == qt.RightButton): 254 | if not e.modifiers() & qt.ShiftModifier: 255 | self._place(e.scenePos(), Cell.full if (e.button() == qt.LeftButton) ^ self.swap_buttons else Cell.empty) 256 | else: 257 | common.Scene.mousePressEvent(self, e) 258 | 259 | def mouseMoveEvent(self, e): 260 | if self.supress: 261 | return 262 | if self.selection_path_item: 263 | p = self.selection_path 264 | p.lineTo(e.scenePos()) 265 | p2 = QPainterPath(p) 266 | p2.lineTo(p.pointAtPercent(0)) 267 | self.selection_path_item.setPath(p2) 268 | elif self.preview: 269 | self._place(e.scenePos()) 270 | else: 271 | common.Scene.mouseMoveEvent(self, e) 272 | 273 | 274 | def mouseReleaseEvent(self, e): 275 | if self.supress: 276 | return 277 | if self.selection_path_item: 278 | p = self.selection_path 279 | p.lineTo(p.pointAtPercent(0)) 280 | for it in self.items(p, qt.IntersectsItemShape): 281 | if isinstance(it, Cell): 282 | it.selected = True 283 | self.removeItem(self.selection_path_item) 284 | self.selection_path_item = None 285 | 286 | elif self.preview: 287 | col = None 288 | for it in self.preview.overlapping: 289 | if isinstance(it, Column): 290 | if it.coord == self.preview.coord: 291 | col = it 292 | continue 293 | if isinstance(it, Cell) or abs(it.coord.y - self.preview.coord.y) == 1: 294 | self.preview.remove() 295 | break 296 | else: 297 | if col: 298 | old_cell = col.cell 299 | p = (col.coord.x - old_cell.coord.x + self.preview.coord.x, col.coord.y - old_cell.coord.y + self.preview.coord.y) 300 | if not self.grid.get(p): 301 | old_cell = col.cell 302 | col.place((col.coord.x - old_cell.coord.x + self.preview.coord.x, col.coord.y - old_cell.coord.y + self.preview.coord.y)) 303 | col.upd() 304 | col = None 305 | if not col: 306 | self.preview.setOpacity(1) 307 | self.preview.place() 308 | self.undo_step() 309 | self.preview.show_info = self.black_show_info if self.preview.kind is Cell.empty else self.blue_show_info 310 | self.preview.upd(True) 311 | else: 312 | self.preview.remove() 313 | 314 | self.preview = None 315 | else: 316 | common.Scene.mouseReleaseEvent(self, e) 317 | 318 | def mouseDoubleClickEvent(self, e): 319 | it = self.itemAt(e.scenePos(), QTransform()) 320 | if not it: 321 | self.mousePressEvent(e) 322 | return 323 | if not isinstance(it, Cell): 324 | return 325 | if self.last_press is None and not self.use_rightclick: 326 | if it.kind is Cell.full: 327 | it.kind = Cell.empty 328 | it.show_info = self.black_show_info 329 | else: 330 | it.kind = Cell.full 331 | it.show_info = self.blue_show_info 332 | it.upd(True) 333 | self.ignore_release = True 334 | common.Scene.mouseDoubleClickEvent(self, e) 335 | 336 | def undo_step(self, it=None): 337 | step = dict(self.grid) 338 | if it is not None: 339 | new = type(it)() 340 | it.copyattrs(new) 341 | step[tuple(it.coord)] = new 342 | self.undo_history[self.undo_pos+1:] = [step] 343 | self.undo_pos = len(self.undo_history) - 1 344 | if self.undo_history_length and len(self.undo_history) > self.undo_history_length: 345 | del self.undo_history[0] 346 | self.undo_pos -= 1 347 | 348 | def undo(self, step=-1): 349 | self.undo_pos += step 350 | try: 351 | if self.undo_pos < 0: 352 | raise IndexError() 353 | grid = self.undo_history[self.undo_pos] 354 | except IndexError: 355 | self.undo_pos -= step 356 | return 357 | for it in self.items(): 358 | self.removeItem(it) 359 | self.grid = {} 360 | for (x, y), it in grid.items(): 361 | self.addItem(it) 362 | it.place((x, y)) 363 | self.full_upd() 364 | return True 365 | 366 | def redo(self): 367 | self.undo(1) 368 | 369 | 370 | class View(common.View): 371 | def __init__(self, scene): 372 | common.View.__init__(self, scene) 373 | self.setResizeAnchor(QGraphicsView.AnchorViewCenter) 374 | self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) 375 | inf = -1e10 376 | self.setSceneRect(QRectF(QPointF(-inf, -inf), QPointF(inf, inf))) 377 | self.scale(50, 50) #*1.00955 378 | self.hexcells_ui = False 379 | 380 | 381 | def mousePressEvent(self, e): 382 | if e.button() == qt.MidButton or (e.button() == qt.RightButton and not self.scene.use_rightclick and not self.scene.itemAt(self.mapToScene(e.pos()), QTransform())): 383 | fake = QMouseEvent(e.type(), e.pos(), qt.LeftButton, qt.LeftButton, e.modifiers()) 384 | self.scene.supress = True 385 | self.setDragMode(QGraphicsView.ScrollHandDrag) 386 | common.View.mousePressEvent(self, fake) 387 | else: 388 | common.View.mousePressEvent(self, e) 389 | 390 | 391 | def mouseReleaseEvent(self, e): 392 | if e.button() == qt.MidButton or (e.button() == qt.RightButton and self.scene.supress): 393 | fake = QMouseEvent(e.type(), e.pos(), qt.LeftButton, qt.LeftButton, e.modifiers()) 394 | common.View.mouseReleaseEvent(self, fake) 395 | self.setDragMode(QGraphicsView.NoDrag) 396 | self.scene.supress = False 397 | else: 398 | common.View.mouseReleaseEvent(self, e) 399 | 400 | def zoom(self, d): 401 | zoom = self.transform().scale(d, d).mapRect(QRectF(0, 0, 1, 1)).width() 402 | if zoom < 10 and d < 1: 403 | return 404 | elif zoom > 350 and d > 1: 405 | return 406 | 407 | self.scale(d, d) 408 | 409 | def wheelEvent(self, e): 410 | try: 411 | d = e.angleDelta().y() 412 | except AttributeError: 413 | d = e.delta() 414 | self.zoom(1.0015**d) #1.00005 415 | 416 | @event_property 417 | def hexcells_ui(self): 418 | self.viewport().update() 419 | self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate if self.hexcells_ui else QGraphicsView.MinimalViewportUpdate) 420 | 421 | def drawBackground(self, g, rect): 422 | common.View.drawBackground(self, g, rect) 423 | if self.hexcells_ui: 424 | pts = [(-13.837, 8.321), (-13.837, -4.232), (-9.843, -8.274), (11.713, -8.274), (11.713, -5.421), (13.837, -5.421), (13.837, 8.321)] 425 | poly = QPolygonF([rect.center() + QPointF(*p) for p in pts]) 426 | pen = QPen(qt.gray, 1) 427 | pen.setCosmetic(True) 428 | g.setPen(pen) 429 | g.drawPolygon(poly) 430 | 431 | 432 | 433 | class MainWindow(common.MainWindow): 434 | title = "SixCells Editor" 435 | Cell = Cell 436 | Column = Column 437 | 438 | def __init__(self): 439 | common.MainWindow.__init__(self) 440 | 441 | self.resize(1280, 720) 442 | self.setWindowIcon(QIcon(here('resources', 'editor.ico'))) 443 | 444 | self.scene = Scene() 445 | 446 | self.view = View(self.scene) 447 | self.setCentralWidget(self.view) 448 | 449 | self.statusBar() 450 | 451 | menu = self.menuBar().addMenu("&File") 452 | action = menu.addAction("&New", self.close_file, QKeySequence.New) 453 | action.setStatusTip("Close the current level and start with an empty one.") 454 | action = menu.addAction("&Open...", self.load_file, QKeySequence.Open) 455 | action.setStatusTip("Close the current level and load one from a file.") 456 | action = menu.addAction("&Save", lambda: self.save_file(self.current_file), QKeySequence.Save) 457 | action.setStatusTip("Save the level, overwriting the current file.") 458 | action = menu.addAction("Save &As...", self.save_file, QKeySequence('Ctrl+Shift+S')) 459 | action.setStatusTip("Save the level into a different file.") 460 | 461 | menu.addSeparator() 462 | 463 | action = menu.addAction("&Copy to Clipboard", self.copy, QKeySequence('Ctrl+C')) 464 | action.setStatusTip("Copy the current level into clipboard, in a text-based .hexcells format, padded with Tab characters.") 465 | action = menu.addAction("&Paste from Clipboard", self.paste, QKeySequence('Ctrl+V')) 466 | action.setStatusTip("Load a level in text-based .hexcells format that is currently in the clipboard.") 467 | 468 | menu.addSeparator() 469 | 470 | action = menu.addAction("&Quit", self.close, QKeySequence.Quit) 471 | action.setStatusTip("Close SixCells Editor.") 472 | 473 | 474 | menu = self.menuBar().addMenu("&Edit") 475 | action = menu.addAction("&Undo", self.scene.undo, QKeySequence.Undo) 476 | action.setStatusTip("Cancel the last action.") 477 | action = menu.addAction("&Redo", self.scene.redo, QKeySequence.Redo) 478 | action.setStatusTip("Repeat the last cancelled action.") 479 | 480 | menu.addSeparator() 481 | 482 | action = menu.addAction("Level &Information", self.set_information, QKeySequence('Ctrl+D')) 483 | action.setStatusTip("Add or change the level's title, author's name and custom text hints.") 484 | 485 | 486 | menu = self.menuBar().addMenu("&Play") 487 | action = menu.addAction("From &Start", self.play, QKeySequence('Shift+Tab')) 488 | QShortcut(QKeySequence('Ctrl+Tab'), self, action.trigger) 489 | action.setStatusTip("Playtest this level from the beginning (discarding all progress).") 490 | action = menu.addAction("&Resume", lambda: self.play(resume=True), QKeySequence('Tab')) 491 | action.setStatusTip("Continue playtesting this level from where you left off.") 492 | 493 | 494 | menu = self.menuBar().addMenu("Preference&s") 495 | 496 | self.swap_buttons_group = make_action_group(self, menu, self.scene, 'swap_buttons', [ 497 | ("&Left Click Places Blue", False, "A blue cell will be placed when left mouse button is clicked. Black will then be the secondary color."), 498 | ("&Left Click Places Black", True, "A black cell will be placed when left mouse button is clicked. Blue will then be the secondary color."), 499 | ]) 500 | self.swap_buttons_group[False].setChecked(True) 501 | 502 | menu.addSeparator() 503 | 504 | self.secondary_action_group = make_action_group(self, menu, self.scene, 'use_rightclick', [ 505 | ("&Right Click Places Secondary", True, "A cell with color opposite to the above choice will be placed when right mouse button is clicked."), 506 | ("&Double Click Places Secondary", False, "A cell with color opposite to the above choice will be placed when left mouse button is double-clicked."), 507 | ]) 508 | self.secondary_action_group[True].setChecked(True) 509 | 510 | menu.addSeparator() 511 | 512 | states = [ 513 | ("&Blank", 0, "When placed, these cells will not contain a number."), 514 | ("With &Number", 1, "When placed, these cells will contain a number, for example, \"2\"."), 515 | ("With &Connection Info", 2, "When placed, these cells will contain a number and connection information, for example, \"{2}\" or \"-3-\"."), 516 | ] 517 | submenu = menu.addMenu("Place Blac&ks") 518 | submenu.setStatusTip("Black cells, when placed, will be...") 519 | self.black_show_info_group = make_action_group(self, submenu, self.scene, 'black_show_info', states) 520 | self.black_show_info_group[1].setChecked(True) 521 | submenu = menu.addMenu("Place &Blues") 522 | submenu.setStatusTip("Blue cells, when placed, will be...") 523 | self.blue_show_info_group = make_action_group(self, submenu, self.scene, 'blue_show_info', states[:-1]) 524 | self.blue_show_info_group[0].setChecked(True) 525 | self.blue_show_info_group[2] = self.blue_show_info_group[0] # for config backwards compatibility 526 | 527 | menu.addSeparator() 528 | 529 | self.enable_hexcells_ui_action = action = make_check_action("Show Hexcells &UI", self, 'hexcells_ui') 530 | action.setChecked(False) 531 | action.setStatusTip("Show the borders of Hexcells UI to see the limit of level size.") 532 | menu.addAction(action) 533 | self.enable_statusbar_action = action = make_check_action("Show &Status Bar", self, 'statusbar_visible') 534 | action.setChecked(True) 535 | menu.addAction(action) 536 | 537 | 538 | menu = self.menuBar().addMenu("&Help") 539 | action = menu.addAction("&Instructions", self.help, QKeySequence.HelpContents) 540 | action.setStatusTip("View README on the project's webpage.") 541 | action = menu.addAction("&About", self.about) 542 | action.setStatusTip("About SixCells Editor.") 543 | 544 | 545 | action = QAction("Zoom In", self) 546 | action.setShortcut(QKeySequence.ZoomIn) 547 | QShortcut(QKeySequence('+'), self, action.trigger) 548 | QShortcut(QKeySequence('='), self, action.trigger) 549 | action.triggered.connect(lambda: self.view.zoom(1.2)) 550 | self.addAction(action) 551 | action = QAction("Zoom Out", self) 552 | action.setShortcut(QKeySequence.ZoomOut) 553 | QShortcut(QKeySequence('-'), self, action.trigger) 554 | action.triggered.connect(lambda: self.view.zoom(0.85)) 555 | self.addAction(action) 556 | 557 | 558 | self.current_file = None 559 | self.any_changes = False 560 | self.scene.changed.connect(self.changed) 561 | 562 | self.last_used_folder = None 563 | self.swap_buttons = False 564 | self.default_author = None 565 | 566 | load_config_from_file(self, self.config_format, 'sixcells', 'editor.cfg') 567 | 568 | config_format = ''' 569 | swap_buttons = next(v for v, a in swap_buttons_group.items() if a.isChecked()); swap_buttons_group[v].setChecked(True) 570 | secondary_cell_action = 'double' if next(v for v, a in secondary_action_group.items() if a.isChecked()) else 'right'; secondary_action_group[v=='double'].setChecked(True) 571 | default_black = next(v for v, a in black_show_info_group.items() if a.isChecked()); black_show_info_group[v].setChecked(True) 572 | default_blue = next(v for v, a in blue_show_info_group.items() if a.isChecked()); blue_show_info_group[v].setChecked(True) 573 | hexcells_ui = enable_hexcells_ui_action.isChecked(); enable_hexcells_ui_action.setChecked(v) 574 | status_bar = enable_statusbar_action.isChecked(); enable_statusbar_action.setChecked(v) 575 | undo_history_length = scene.undo_history_length; scene.undo_history_length = v 576 | antialiasing = view.antialiasing; view.antialiasing = v 577 | default_author 578 | last_used_folder 579 | window_geometry_qt = save_geometry_qt(); restore_geometry_qt(v) 580 | ''' 581 | 582 | 583 | def changed(self, rects=None): 584 | if rects is None or any((rect.width() or rect.height()) for rect in rects): 585 | self.any_changes = True 586 | def no_changes(self): 587 | self.any_changes = False 588 | def no_changes(): 589 | self.any_changes = False 590 | QTimer.singleShot(0, no_changes) 591 | 592 | @property 593 | def status(self): 594 | return self.statusBar().currentMessage() 595 | @status.setter 596 | def status(self, value): 597 | if not value: 598 | self.statusBar().clearMessage() 599 | elif isinstance(value, tuple): 600 | self.statusBar().showMessage(value[0], int(value[1]*1000)) 601 | else: 602 | self.statusBar().showMessage(value) 603 | app.processEvents() 604 | 605 | @property 606 | def hexcells_ui(self): 607 | self.view.hexcells_ui 608 | @hexcells_ui.setter 609 | def hexcells_ui(self, value): 610 | self.view.hexcells_ui = value 611 | 612 | @property 613 | def statusbar_visible(self): 614 | return self.statusBar().isVisible() 615 | @statusbar_visible.setter 616 | def statusbar_visible(self, value): 617 | self.statusBar().setVisible(value) 618 | 619 | @event_property 620 | def current_file(self): 621 | title = self.title 622 | if self.current_file: 623 | title = os.path.basename(self.current_file) + ' - ' + title 624 | self.setWindowTitle(title) 625 | 626 | def close_file(self): 627 | result = False 628 | if not self.any_changes: 629 | result = True 630 | else: 631 | if self.current_file: 632 | msg = "The level \"{}\" has been modified. Do you want to save it?".format(self.current_file) 633 | else: 634 | msg = "Do you want to save this level?" 635 | btn = QMessageBox.warning(self, "Unsaved changes", msg, QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel, QMessageBox.Save) 636 | if btn == QMessageBox.Save: 637 | if self.save_file(self.current_file): 638 | result = True 639 | elif btn == QMessageBox.Discard: 640 | result = True 641 | if result: 642 | self.current_file = None 643 | self.scene.reset() 644 | self.no_changes() 645 | self.scene.undo_step() 646 | return result 647 | 648 | 649 | def set_information(self, desc=None): 650 | dialog = QDialog() 651 | dialog.setWindowTitle("Level Information") 652 | layout = QVBoxLayout() 653 | dialog.setLayout(layout) 654 | 655 | layout.addWidget(QLabel("Title:")) 656 | title_field = QLineEdit(self.scene.title) 657 | title_field.setMaxLength(50) 658 | layout.addWidget(title_field) 659 | 660 | layout.addWidget(QLabel("Author name:")) 661 | author_field = QLineEdit(self.scene.author or self.default_author) 662 | author_field.setMaxLength(20) 663 | layout.addWidget(author_field) 664 | old_author = author_field.text() 665 | 666 | information = (self.scene.information).splitlines() 667 | layout.addWidget(QLabel("Custom text hints:")) 668 | information1_field = QLineEdit(information[0] if information else '') 669 | information1_field.setMaxLength(120) 670 | layout.addWidget(information1_field) 671 | information2_field = QLineEdit(information[1] if len(information) > 1 else '') 672 | information2_field.setMaxLength(120) 673 | layout.addWidget(information2_field) 674 | 675 | layout.addWidget(QLabel("This text will be displayed within the level")) 676 | 677 | def accepted(): 678 | self.scene.title = title_field.text().strip() 679 | self.scene.author = author_field.text().strip() 680 | if self.scene.author and self.scene.author!=old_author: 681 | self.default_author = self.scene.author 682 | self.scene.information = '\n'.join(line for line in [information1_field.text().strip(), information2_field.text().strip()] if line) 683 | self.changed() 684 | dialog.close() 685 | 686 | button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 687 | button_box.rejected.connect(dialog.close) 688 | button_box.accepted.connect(accepted) 689 | layout.addWidget(button_box) 690 | 691 | dialog.exec_() 692 | 693 | def center_on(self, x, y): 694 | self.view.centerOn(x*cos30, y/2 + 0.3) 695 | 696 | def copy(self): 697 | common.MainWindow.copy(self) 698 | self.center_on(*common.level_center) 699 | 700 | def save_file(self, fn=None): 701 | if not fn: 702 | try: 703 | dialog = QFileDialog.getSaveFileNameAndFilter 704 | except AttributeError: 705 | dialog = QFileDialog.getSaveFileName 706 | fn, _ = dialog(self, "Save", self.last_used_folder, "Hexcells level (*.hexcells)") 707 | if not fn: 708 | return 709 | self.status = "Saving..." 710 | try: 711 | level, status = save(self.scene) 712 | with open(fn, 'wb') as f: 713 | f.write(level.encode('utf-8')) 714 | if isinstance(status, basestring): 715 | QMessageBox.warning(None, "Warning", status + '\n' + "Saved anyway.") 716 | self.no_changes() 717 | self.current_file = fn 718 | self.last_used_folder = os.path.dirname(fn) 719 | self.status = "Done", 1 720 | self.center_on(*common.level_center) 721 | return True 722 | except ValueError as e: 723 | QMessageBox.critical(None, "Error", str(e)) 724 | self.status = "Failed", 1 725 | 726 | 727 | def prepare(self): 728 | self.view.fitInView(self.scene.itemsBoundingRect().adjusted(-0.5, -0.5, 0.5, 0.5), qt.KeepAspectRatio) 729 | self.center_on(16, 16) 730 | self.no_changes() 731 | self.scene.undo_step() 732 | 733 | 734 | def play(self, resume=False): 735 | self.status = "Switching to Player..." 736 | 737 | import player 738 | 739 | player.app = app 740 | 741 | window = player.MainWindow(playtest=True) 742 | window.setWindowModality(qt.ApplicationModal) 743 | window.setWindowState(self.windowState()) 744 | window.setGeometry(self.geometry()) 745 | 746 | window.scene.author = self.scene.author 747 | window.scene.title = self.scene.title 748 | window.scene.information = self.scene.information 749 | 750 | corresponding_cells = [] 751 | 752 | for cell in self.scene.all(Cell): 753 | new = player.Cell() 754 | window.scene.addItem(new) 755 | new.place(cell.coord) 756 | cell.copyattrs(new) 757 | if resume: 758 | try: 759 | new.revealed = new.revealed or cell.revealed_resume 760 | except AttributeError: pass 761 | corresponding_cells.append((cell, new)) 762 | 763 | for col in self.scene.all(Column): 764 | new = player.Column() 765 | window.scene.addItem(new) 766 | new.place(col.coord) 767 | col.copyattrs(new) 768 | 769 | window.prepare() 770 | 771 | windowcloseevent = window.closeEvent 772 | def closeevent(e): 773 | windowcloseevent(e) 774 | for edcell, plcell in corresponding_cells: 775 | edcell.revealed_resume = plcell.display is not Cell.unknown 776 | edcell.extra_text = plcell.extra_text 777 | window.closeEvent = closeevent 778 | 779 | window.show() 780 | app.processEvents() 781 | window.view.setSceneRect(self.view.sceneRect()) 782 | window.view.setTransform(self.view.transform()) 783 | window.view.horizontalScrollBar().setValue(self.view.horizontalScrollBar().value()) 784 | delta = window.view.mapTo(window.central_widget, QPoint(0, 0)) 785 | window.view.verticalScrollBar().setValue(self.view.verticalScrollBar().value() + delta.y()) 786 | window.view.setFocus() 787 | 788 | self.status = "Done", 1 789 | 790 | def closeEvent(self, e): 791 | if not self.close_file(): 792 | e.ignore() 793 | return 794 | 795 | save_config_to_file(self, self.config_format, 'sixcells', 'editor.cfg') 796 | 797 | 798 | 799 | def main(f=None): 800 | global window 801 | 802 | window = MainWindow() 803 | window.show() 804 | 805 | if not f and len(sys.argv[1:]) == 1: 806 | f = sys.argv[1] 807 | if f: 808 | f = os.path.abspath(f) 809 | QTimer.singleShot(50, lambda: window.load_file(f)) 810 | 811 | app.exec_() 812 | 813 | if __name__ == '__main__': 814 | main() 815 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | --------------------------------------------------------------------------------