Skip to content
This repository has been archived by the owner on Jun 23, 2024. It is now read-only.

Commit

Permalink
新功能:分离 UI 设计与游戏核心功能 (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
Dragon1573 authored Apr 27, 2024
1 parent 17a2703 commit 4d7c265
Show file tree
Hide file tree
Showing 8 changed files with 358 additions and 278 deletions.
12 changes: 12 additions & 0 deletions .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@ on:
tags:
# 要求版本号必须带有前导字符 v
- 'v*.*.*'
branches:
- main
pull_request:
branches:
- main
paths-ignore:
# 下面这些路径和编译的最终产物没有关系,
# 更改它们不会改变最终产物,可以忽略编译构建
- assests/
- LICENSE
- README.md
- .github/
# 保留手动触发的能力
workflow_dispatch:

Expand Down
64 changes: 2 additions & 62 deletions src/tk_nonogram/__init__.py
Original file line number Diff line number Diff line change
@@ -1,69 +1,9 @@
from tkinter.filedialog import askopenfilename
from tkinter.messagebox import askyesno, showerror, showinfo
from tkinter.simpledialog import askinteger

from numpy import array
from numpy.dtypes import Int8DType
from numpy.random import randint

from .entities import Nonogram # type: ignore[import-untyped]
from .utils import Puzzle, generate_clues # type: ignore[import-untyped]
from .interface.utils import GameUI

__version__ = "0.3.0"
__all__ = ["main"]


def generate_puzzle() -> Puzzle:
"""
随机生成数织题解
【注意】此种随机生成无法保证题目存在唯一逻辑解,甚至可能不存在逻辑解(逻辑求解算法无解)
Returns:
Puzzle: 随机 0-1 二维 NumPy 矩阵
"""
if (level := askinteger("Input", "请输入难度级别:")) and level > 0:
return randint(0, 2, (level, level))
showinfo("Tips", "已默认设置为 10×10 难度!")
return randint(0, 2, (10, 10))


def load_file() -> Puzzle:
"""
加载题解,根据题解还原谜题
Returns:
Puzzle: 题解二维 NumPy 矩阵
"""
if filename := askopenfilename(filetypes=[("Text files", "*.txt")]):
with open(filename, encoding="UTF-8") as file:
data = file.readlines()
return array([[int(col) for col in row.strip()] for row in data], dtype=Int8DType)
return generate_puzzle()


def pick_puzzle_type() -> Puzzle:
"""
选择数织谜题来源,根据来源获取题解矩阵
Returns:
Puzzle: 二维 NumPy 矩阵
"""
return load_file() if askyesno("题目模式", "是否从题解文件加载生成题目?") else generate_puzzle()


def ask_cell_size() -> int:
size = askinteger("Input", "请输入单个方块的边长(px):")
return size if (size and size > 0) else 25


def main() -> None:
"""应用程序入口"""
answer = pick_puzzle_type()
if answer.shape[0] != answer.shape[1]:
showerror("Error", "题解数据不合理或不是方阵!")
showerror("Exit", "因意外情况而退出!")
exit(-1)
clues = generate_clues(answer)
cell_size = ask_cell_size()
Nonogram(clues, answer, cell_size)
GameUI()
Empty file.
192 changes: 192 additions & 0 deletions src/tk_nonogram/core/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
from collections import deque
from typing import Sequence

from numpy import array, ndarray, zeros
from numpy.dtypes import Int8DType
from numpy.random import randint

from ..utils import Activity

MatrixLike = Sequence[Sequence[int]]
Puzzle = ndarray[tuple[int, int], Int8DType]
Row = ndarray[tuple[int], Int8DType]
LogStack = deque[Activity]


class GameCore:
"""
游戏内核
Properties:
answer (Puzzle): 题解
clues (Clues): 线索
grid (Puzzle): 当前游戏面板
undo_log (LogStack): 撤回操作栈
redo_log (LogStack): 重做操作栈
"""

class Clues:
"""线索"""

def __init__(
self, rows: Sequence[tuple[int]] | None = None, columns: Sequence[tuple[int]] | None = None
) -> None:
self.rows: Sequence[tuple[int]] = rows or []
self.columns: Sequence[tuple[int]] = columns or []

def __eq__(self, value: object) -> bool:
return (
(self.rows == value.rows and self.columns == value.columns)
if isinstance(value, GameCore.Clues)
else False
)

def __repr__(self) -> str:
return f"Clues(rows={self.rows}, columns={self.columns})"

def __init__(self, level: int = 10) -> None:
self.grid: Puzzle = zeros((level, level), dtype=Int8DType)
self.undo_log: deque[Activity] = deque()
self.redo_log: deque[Activity] = deque()

def count_continues(self, _row: Row) -> tuple[int]:
"""
统计 NumPy 矩阵单行/列中连续 1 的数量
Args:
_array (Row): NumPy 二维矩阵
Returns:
tuple[int]: 数量列表,用作解题线索
"""
from numpy import append, diff, insert, where

# 参考文献: [计算连续出现次数](https://geek-docs.com/numpy/numpy-ask-answer/231_numpy_count_consecutive_occurences_of_values_varying_in_length_in_a_numpy_array.html#ftoc-heading-2) ## noqa: B950
# 找到数组中所有的1
ones = where(_row == 1)[0]
# 计算连续1的起始索引和结束索引
diff = diff(ones) # type: ignore[assignment]
starts = insert(where(diff != 1)[0] + 1, 0, 0)
ends = append(where(diff != 1)[0], len(ones) - 1)
# 计算连续1的长度
lengths = ends - starts + 1

return tuple(lengths.tolist())

def generate_clues(self, _matrix: Puzzle | MatrixLike | None = None) -> Clues:
"""
生成线索(谜面)
Args:
_matrix (Puzzle | MatrixLike): 游戏板状态二维矩阵
Returns:
Clues: 线索字典
"""
from numpy import array

if _matrix is None:
_matrix = self.answer
elif not isinstance(_matrix, ndarray):
# 当传入参数不是 NumPy 矩阵时,转换为矩阵
_matrix = array(_matrix)
# 提取矩阵规模
rows, cols = _matrix.shape
if rows <= 0 or cols <= 0:
# 当矩阵某一个维度为 0 ,不可能存在谜面
return self.Clues()
row_clues = [self.count_continues(_matrix[_, :]) for _ in range(rows)]
col_clues = [self.count_continues(_matrix[:, _]) for _ in range(cols)]
self.clues = self.Clues(row_clues, col_clues)
return self.clues

def generate_puzzle(self, level: int = 10) -> None:
"""
随机生成数织题解
【注意】此种随机生成无法保证题目存在唯一逻辑解,甚至可能不存在逻辑解(逻辑求解算法无解)
Returns:
Puzzle: 随机 0-1 二维 NumPy 矩阵
"""
self.answer = randint(0, 2, (level, level))

def load_file(self, filename: str | None) -> bool:
"""
加载题解,根据题解还原谜题
Returns:
(bool): 文件是否解析成功?
"""
if filename:
with open(filename, encoding="UTF-8") as file:
data = file.readlines()
try:
self.answer = array([[int(col) for col in row.strip()] for row in data], dtype=Int8DType)
return True
except Exception:
pass
return False

def toggle_cell(self, activity: Activity) -> None:
"""
切换单元格状态
Args:
activity (Activity): 用户操作
"""
if activity.is_context:
self.grid[activity.y, activity.x] = 0 if self.grid[activity.y, activity.x] == 2 else 2
else:
self.grid[activity.y, activity.x] = 0 if self.grid[activity.y, activity.x] == 1 else 1

def undo(self) -> Activity | None:
"""
撤销至上一步
Returns:
(Activity | None): 能够撤销时,返回本次撤销的操作;无法撤销时返回 `None`
"""
if self.undo_log:
# pop() 操作会在空双向队列是触发 IndexError
activity = self.undo_log.pop()
self.toggle_cell(activity)
self.redo_log.append(activity)
return activity
return None

def redo(self) -> Activity | None:
"""
重做下一步
Returns:
(Activity | None): 能够重做时,返回本次重做的操作;无法重做时返回 `None`
"""
if self.redo_log:
activity = self.redo_log.pop()
self.toggle_cell(activity)
self.undo_log.append(activity)
return activity
return None

def move(self, activity: Activity) -> None:
"""
执行一次游戏移动
「移动」特指用户的一次交互,不管是用户左击还是右击
Args:
activity (Activity): 用户操作
"""
self.toggle_cell(activity)
self.undo_log.append(activity)
self.redo_log.clear()

def is_solved(self) -> bool:
"""检查谜题是否已被用户求解成功"""
current = self.generate_clues(self.grid)
return current == self.clues

def get_answer(self) -> str:
"""获取字符串形式的题解"""
return "\n".join("".join(map(lambda _: "[X]" if _ else "[ ]", r)) for r in self.answer)
Empty file.
Loading

0 comments on commit 4d7c265

Please sign in to comment.