✨ feat: workable
This commit is contained in:
commit
c284eb8c7d
|
|
@ -0,0 +1,16 @@
|
|||
# 预览图(标定生成,含关号,如 preview_level1.png)
|
||||
*level*.png
|
||||
|
||||
# 标定导出的格子小图
|
||||
cells/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
.venv/
|
||||
src-images/
|
||||
debug
|
||||
|
||||
templates/**/*.png
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"python-envs.defaultEnvManager": "ms-python.python:conda",
|
||||
"python-envs.defaultPackageManager": "ms-python.python:conda",
|
||||
"python-envs.pythonProjects": []
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
# happy-birds-cracker
|
||||
|
||||
基于 [MaaFramework](https://github.com/MaaXYZ/MaaFramework) 的**滑动三消(消消乐)自动求解器**。
|
||||
|
||||
MaaFramework 负责 **截屏 + 图像识别 + ADB 滑动控制**;本项目负责把画面变成棋盘模型、
|
||||
计算最优一步、再把交换映射回屏幕滑动。
|
||||
|
||||
> ⚠️ 仅供学习与离线/单机研究。请遵守目标游戏的服务条款,线上对战/带反作弊的游戏请勿使用。
|
||||
|
||||
## 设计要点
|
||||
|
||||
- **网格动态可变,恒为矩形**:尺寸由 `GridConfig(rows, cols)` 决定,换关卡只需改行列数
|
||||
(或在识别阶段探测后填入),核心算法与尺寸无关。
|
||||
- **三种块类型**(`CellKind`,为后续编程预留 `meta` 扩展字段):
|
||||
- `NORMAL` 正常块:可交换,带 `color`。
|
||||
- `BRICK` 砖块:不可移动的障碍,会打断连线。
|
||||
- `EMPTY` 空块/空洞:无方块,会打断连线。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/hbc/
|
||||
board.py 棋盘数据模型:Cell / CellKind / Board(动态矩形)
|
||||
solver.py 三消求解:找匹配 + 遍历相邻交换搜索最优解
|
||||
config.py GridConfig(像素<->格子映射,可存 JSON)/ 模板比对参数
|
||||
state.py GameState:识别与动作之间共享 Board / 当前关卡
|
||||
templates.py 模板图像库:子图比对识别,支持多种墙(推荐识别方式)
|
||||
recognizer.py 截图 -> Board(可插拔分类器:模板/颜色 + MaaFw 识别桩)
|
||||
level.py 关卡识别:OCR 标题"关卡一" -> 关卡号 -> 载入对应布局
|
||||
calibrate.py 标定工具:框选棋盘生成布局、预览、导出格子图(窗口自适应屏幕)
|
||||
actuator.py Swap -> ADB 滑动(纯函数 + MaaFw 自定义动作 SwapAction)
|
||||
main.py 连接设备、加载资源、注册自定义模块、循环运行(支持 --dry-run)
|
||||
resource/
|
||||
pipeline/
|
||||
pipeline.json MaaFramework 低代码流程:识别棋盘 -> 执行交换 -> 循环
|
||||
templates/ 模板库(你标定后放入):normal/ wall/ empty/
|
||||
layouts/ 各关布局:level1.json level2.json ...(你标定后生成)
|
||||
tests/
|
||||
test_solver.py 求解器与模型测试
|
||||
test_recognition.py 模板识别 + 标定 + 多关卡布局测试(合成图,无需真机)
|
||||
test_level.py 关卡号 OCR 文本解析测试
|
||||
```
|
||||
|
||||
## 如何识别棋盘
|
||||
|
||||
本游戏网格逐关变大且含空格。布局**按关卡分文件**存在 `layouts/` 文件夹下
|
||||
(`layouts/level1.json`、`layouts/level2.json` ...),每关只需标定一次。
|
||||
标定窗口会**自动缩放适配屏幕**(截图比屏幕大也能完整框选)。
|
||||
|
||||
```bash
|
||||
# 1) 框出棋盘 + 填该关行列数 -> 写入 layouts/level1.json(弹窗自动缩放,鼠标拖框回车确认)
|
||||
python -m hbc.calibrate roi --image src-images/level1.jpg --level 1 --rows 5 --cols 5
|
||||
# 窗口仍太大可调上限:--max-display 700
|
||||
# 无 GUI / 想直接给坐标:--box x,y,w,h(按原图像素)
|
||||
|
||||
# 2) 叠加网格预览,确认每个格子对齐(绿框=棋盘,橙线=网格,红点=格子中心)
|
||||
python -m hbc.calibrate preview --image src-images/level1.jpg --level 1 --out preview_level1.png
|
||||
|
||||
# 3) 把每个格子切成小图,导出到 cells/,再人工分类到模板库
|
||||
python -m hbc.calibrate export --image src-images/level1.jpg --level 1 --out cells/
|
||||
|
||||
# 后续关卡同理,换 --level 2/3/... 和对应行列数即可
|
||||
python -m hbc.calibrate roi --image src-images/level2.jpg --level 2 --rows 6 --cols 6
|
||||
```
|
||||
|
||||
运行时用 `--level` 选择关卡:`python -m hbc.main --level 1`。
|
||||
|
||||
### 对齐技巧(棋盘四周有 padding)
|
||||
|
||||
均匀切分基于你框的矩形,所以框要贴着**棋子区域**而不是外层面板:
|
||||
从**左上角棋子的左上角**拖到**右下角棋子的右下角**(把面板留白排除在外),
|
||||
再用 `preview` 看红点是否落在每个棋子中心,不对就重框。反复"框 → 预览"几次即可对齐。
|
||||
|
||||
### 运行时自动识别当前是第几关(OCR)
|
||||
|
||||
游戏顶部写着"关卡一",MaaFramework **自带 OCR**(PaddleOCR 的 ONNX 模型),运行时
|
||||
可直接识别,**不用你预先截图**。流程:标定一次标题区域 ROI -> `LevelRecognition`
|
||||
(`level.py`)OCR 标题 -> `parse_cn_level` 解析成关卡号 -> 自动载入 `layouts/level{N}.json`。
|
||||
若你的 MaaFw 资源里没带 OCR 模型,去掉该节点、改用 `--level` 手动指定即可。
|
||||
|
||||
把导出的小图按类别放进模板库(文件名即类别):
|
||||
|
||||
```
|
||||
templates/
|
||||
normal/red.png green.png blue.png ... 每种正常块一张(文件名->稳定颜色 id)
|
||||
wall/stone.png ice.png chain.png ... 墙可以有多种,每种一张
|
||||
empty/hole.png 空块/空洞
|
||||
```
|
||||
|
||||
### 为什么用模板比对而不是纯颜色
|
||||
|
||||
MaaFramework 的颜色识别对光照/特效/相近色容易误判。本项目识别走**子图比对**:
|
||||
把每个格子缩放后与模板逐像素比相似度,取最高分。相似度对**颜色和形状都敏感**
|
||||
(不像 `TM_CCOEFF_NORMED` 会忽略颜色),更适合消消乐。仍保留 `ColorClassifier` 作为兜底。
|
||||
|
||||
### 冰块 = 低置信度即判为墙
|
||||
|
||||
本游戏除正常生物/空格外只有一种特殊方块——**冰块**(冰下隐约有正常图案)。冰下图案
|
||||
模糊,模板匹配置信度天然偏低,因此采用一条简单规则:**凡是匹配不够自信的格子,
|
||||
一律判为冰墙**(`cell.meta['wall']='ice'`,不可交换、打断连线)。
|
||||
|
||||
冰墙不会被直接交换,但当它旁边发生消除时,游戏里冰会化掉、露出正常生物,下一帧重新
|
||||
识别就变回可用的普通块,于是自然推进。这是有意的取舍:牺牲对"未登记新方块"的健壮性,
|
||||
换取对冰块的零配置支持。阈值在 `TemplateMatchConfig.score_threshold` /
|
||||
`empty_min_score`,需要时可调。
|
||||
|
||||
### 新增正常生物
|
||||
|
||||
后续关卡出现新动物时,把它的一张干净小图放进 `templates/normal/<名字>.png` 即可
|
||||
(每个不同文件名 = 一个独立可消除类型;如灰色魟鱼 `stingray.png` 与青色水母
|
||||
`jellyfish.png` 是两种)。用 `hbc.calibrate export` 可批量导出格子图来挑样本。
|
||||
|
||||
### 多种墙(可选)
|
||||
|
||||
若以后遇到别的固定障碍,可在 `templates/wall/` 放多张墙图(石墙、锁链……),
|
||||
它们都识别为 `CellKind.BRICK`,种类记在 `cell.meta['wall']`,便于针对性写逻辑。
|
||||
|
||||
## 数据流
|
||||
|
||||
```
|
||||
MaaFramework 截屏
|
||||
-> BoardRecognition(recognizer): 按网格切格、判颜色/砖块/空块 -> Board
|
||||
-> SwapAction(actuator): Solver.find_best_swap(board) -> 最优 Swap
|
||||
-> 通过控制器 post_swipe 滑动
|
||||
-> 循环
|
||||
```
|
||||
|
||||
求解与控制是解耦的:`board.py` + `solver.py` 完全不依赖 MaaFramework / 真机,可独立测试。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 安装为可编辑包:之后在项目根目录任何位置都能用 hbc,无需设置 PYTHONPATH 或 cd src
|
||||
pip install -e .
|
||||
|
||||
# 离线验证求解器(不需要设备)
|
||||
python -m pytest tests/ -q
|
||||
|
||||
# 连接设备后运行(需先标定 config 中的 ROI 与颜色,见下)
|
||||
python -m hbc.main # 自动探测第一个 adb 设备
|
||||
python -m hbc.main 127.0.0.1:16384 # 或指定模拟器地址
|
||||
python -m hbc.main --level 2 # 求解第二关
|
||||
```
|
||||
|
||||
## 预留的扩展点
|
||||
|
||||
- `Cell.meta`:放特殊方块(条状/炸弹/彩球)、墙的种类 `wall`、砖块血量、冰冻层数等。
|
||||
- `Solver` 的 `scorer`:自定义打分策略(默认按消除格子数,4/5 连额外加权)。
|
||||
- `Solver._cells_cleared_by`:补充"相邻砖块被消除破坏"等连锁规则。
|
||||
- `GridConfig.rows/cols`:动态调整网格尺寸(每关不同时重新标定/探测)。
|
||||
- `recognizer` 的分类器可插拔:默认模板库,必要时换 `ColorClassifier` 兜底。
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"draw_quality": 85,
|
||||
"logging": true,
|
||||
"save_draw": false,
|
||||
"save_on_error": true,
|
||||
"stdout_level": 2
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import sys
|
||||
import cv2
|
||||
from hbc.config import GridConfig
|
||||
|
||||
image_path, level, r, c, out = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4]), sys.argv[5]
|
||||
img = cv2.imread(image_path)
|
||||
grid = GridConfig.load_json(f"layouts/level{level}.json").for_image(img.shape[1], img.shape[0])
|
||||
x, y, w, h = grid.cell_box(r, c)
|
||||
cv2.imwrite(out, img[y:y + h, x:x + w])
|
||||
print("saved", out, "from cell", (r, c), "box", (x, y, w, h))
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import sys
|
||||
import cv2
|
||||
from hbc.config import GridConfig, TemplateMatchConfig
|
||||
from hbc.templates import TemplateLibrary
|
||||
from hbc.recognizer import recognize_board
|
||||
|
||||
image_path = sys.argv[1]
|
||||
level = int(sys.argv[2])
|
||||
img = cv2.imread(image_path)
|
||||
grid = GridConfig.load_json(f"layouts/level{level}.json")
|
||||
lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir="templates"))
|
||||
board = recognize_board(img, grid, lib)
|
||||
for r in range(board.rows):
|
||||
row = []
|
||||
for c in range(board.cols):
|
||||
cell = board.get(r, c)
|
||||
if cell.is_brick:
|
||||
lab = "ICE"
|
||||
elif cell.is_empty:
|
||||
lab = "."
|
||||
else:
|
||||
lab = cell.meta.get("label", "?")
|
||||
row.append(f"{lab[:6]:>6}:{cell.meta.get('score', '-')}")
|
||||
print(" ".join(row))
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"x": 40,
|
||||
"y": 949,
|
||||
"w": 984,
|
||||
"h": 981,
|
||||
"rows": 5,
|
||||
"cols": 5,
|
||||
"ref_w": 1080,
|
||||
"ref_h": 2400
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"x": 40,
|
||||
"y": 941,
|
||||
"w": 995,
|
||||
"h": 995,
|
||||
"rows": 6,
|
||||
"cols": 6,
|
||||
"ref_w": 1080,
|
||||
"ref_h": 2400
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"x": 40,
|
||||
"y": 944,
|
||||
"w": 997,
|
||||
"h": 989,
|
||||
"rows": 7,
|
||||
"cols": 7,
|
||||
"ref_w": 1080,
|
||||
"ref_h": 2400
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"x": 37,
|
||||
"y": 939,
|
||||
"w": 997,
|
||||
"h": 1000,
|
||||
"rows": 7,
|
||||
"cols": 7,
|
||||
"ref_w": 1080,
|
||||
"ref_h": 2400
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"x": 40,
|
||||
"y": 941,
|
||||
"w": 987,
|
||||
"h": 989,
|
||||
"rows": 8,
|
||||
"cols": 8,
|
||||
"ref_w": 1080,
|
||||
"ref_h": 2400
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"x": 43,
|
||||
"y": 944,
|
||||
"w": 984,
|
||||
"h": 987,
|
||||
"rows": 8,
|
||||
"cols": 8,
|
||||
"ref_w": 1080,
|
||||
"ref_h": 2400
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"x": 40,
|
||||
"y": 941,
|
||||
"w": 992,
|
||||
"h": 989,
|
||||
"rows": 8,
|
||||
"cols": 8,
|
||||
"ref_w": 1080,
|
||||
"ref_h": 2400
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "happy-birds-cracker"
|
||||
version = "0.1.0"
|
||||
description = "基于 MaaFramework 的滑动三消(消消乐)自动求解器"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"numpy>=1.24",
|
||||
"opencv-python>=4.8",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# MaaFramework Python 绑定(截屏 + 图像识别 + ADB 控制)
|
||||
MaaFw>=4.0.0
|
||||
|
||||
# 图像处理(自定义识别里切割网格、颜色判断时使用)
|
||||
numpy>=1.24
|
||||
opencv-python>=4.8
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"解三消": {
|
||||
"recognition": "Custom",
|
||||
"custom_recognition": "BoardRecognition",
|
||||
"action": "Custom",
|
||||
"custom_action": "SwapAction",
|
||||
"next": [
|
||||
"解三消"
|
||||
],
|
||||
"post_delay": 600
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"""happy-birds-cracker
|
||||
|
||||
基于 MaaFramework 的滑动三消(消消乐)自动求解器。
|
||||
|
||||
模块划分:
|
||||
- board: 棋盘数据模型(动态矩形网格 + 块类型)
|
||||
- solver: 三消求解算法(找匹配 + 搜索最优交换)
|
||||
- recognizer: MaaFramework 自定义识别(截屏 -> Board)
|
||||
- actuator: MaaFramework 自定义动作(Swap -> ADB 滑动)
|
||||
- config: 网格与识别配置
|
||||
"""
|
||||
|
||||
from .board import Board, Cell, CellKind
|
||||
from .config import GridConfig, RecognizeConfig, TemplateMatchConfig
|
||||
from .solver import Match, Solver, Swap
|
||||
|
||||
__all__ = [
|
||||
"Board",
|
||||
"Cell",
|
||||
"CellKind",
|
||||
"GridConfig",
|
||||
"Match",
|
||||
"RecognizeConfig",
|
||||
"Solver",
|
||||
"Swap",
|
||||
"TemplateMatchConfig",
|
||||
]
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"""动作:把求解器算出的 Swap 转成屏幕滑动(ADB swipe)。
|
||||
|
||||
两层结构:
|
||||
1. 纯函数 `swap_to_swipe(swap, grid)` —— 把交换转成 (x1,y1,x2,y2),便于离线测试。
|
||||
2. `SwapAction` —— MaaFramework 自定义动作适配器(仅在装了 MaaFw 时可用)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import GridConfig
|
||||
from .solver import Solver, Swap
|
||||
from .state import GameState
|
||||
|
||||
|
||||
def swap_to_swipe(swap: Swap, grid: GridConfig) -> tuple[int, int, int, int]:
|
||||
"""把一次交换映射为屏幕滑动起止像素坐标。
|
||||
|
||||
返回 (x1, y1, x2, y2):从 (x1,y1) 滑到 (x2,y2)。
|
||||
|
||||
重要:**水平交换一律从右往左滑**(起点在右、终点在左)。因为本游戏里
|
||||
从左往右滑会触发返回/退出手势。交换两格的结果与滑动方向无关,所以反向滑同样有效。
|
||||
上下滑动不受影响。
|
||||
"""
|
||||
p1 = grid.cell_center(swap.r1, swap.c1)
|
||||
p2 = grid.cell_center(swap.r2, swap.c2)
|
||||
if swap.r1 == swap.r2 and p1[0] < p2[0]:
|
||||
# 水平交换且当前是左->右,交换起止,强制右->左
|
||||
p1, p2 = p2, p1
|
||||
return p1[0], p1[1], p2[0], p2[1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# MaaFramework 自定义动作适配器
|
||||
# ---------------------------------------------------------------------------#
|
||||
try:
|
||||
from maa.custom_action import CustomAction # type: ignore
|
||||
|
||||
class SwapAction(CustomAction):
|
||||
"""读取共享 Board -> 求最优交换 -> 通过控制器滑动。
|
||||
|
||||
在 pipeline 中以 Custom 动作引用本类。无可行交换时返回 False(pipeline 可据此
|
||||
判定本局结束或刷新棋盘)。
|
||||
"""
|
||||
|
||||
def __init__(self, state: GameState, solver: Solver | None = None,
|
||||
swipe_duration_ms: int = 200) -> None:
|
||||
super().__init__()
|
||||
self.state = state
|
||||
self.solver = solver or Solver()
|
||||
self.swipe_duration_ms = swipe_duration_ms
|
||||
|
||||
def run(self, context, argv) -> bool:
|
||||
board = self.state.board
|
||||
if board is None:
|
||||
return False
|
||||
|
||||
result = self.solver.find_best_swap(board)
|
||||
if result is None:
|
||||
# 没有可行消除:交给 pipeline 处理(如洗牌/重识别)
|
||||
return False
|
||||
|
||||
grid = self.state.grid_runtime or self.state.grid
|
||||
x1, y1, x2, y2 = swap_to_swipe(result.swap, grid)
|
||||
# MaaFw 控制器滑动;不同版本 API 名称可能为 post_swipe / swipe
|
||||
controller = context.tasker.controller
|
||||
controller.post_swipe(x1, y1, x2, y2, self.swipe_duration_ms).wait()
|
||||
return True
|
||||
|
||||
except Exception: # pragma: no cover - 未安装 MaaFw 时跳过
|
||||
SwapAction = None # type: ignore
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
"""棋盘数据模型。
|
||||
|
||||
设计目标:
|
||||
1. 网格尺寸动态可变,但恒为矩形(rows x cols)。每次识别都重新构建一个 Board。
|
||||
2. 每个格子有三种类型,且为后续扩展预留字段:
|
||||
- NORMAL:正常方块,可移动/可交换,带颜色 (color)。
|
||||
- BRICK :砖块,不可移动、不可交换(障碍物)。可能被相邻消除破坏 —— 预留 meta。
|
||||
- EMPTY :空块/空洞,没有方块,不可交换。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from enum import IntEnum
|
||||
from typing import Iterator, Optional
|
||||
|
||||
|
||||
class CellKind(IntEnum):
|
||||
"""格子类型。"""
|
||||
|
||||
EMPTY = 0 # 空块 / 空洞:无方块
|
||||
BRICK = 1 # 砖块:不可移动的障碍
|
||||
NORMAL = 2 # 正常块:可交换,带颜色
|
||||
|
||||
|
||||
# 用于 NORMAL 块的“无颜色”占位(EMPTY / BRICK 的 color 恒为该值)。
|
||||
NO_COLOR: int = -1
|
||||
|
||||
|
||||
@dataclass
|
||||
class Cell:
|
||||
"""单个格子。
|
||||
|
||||
color 仅在 kind == NORMAL 时有意义,其余情况为 NO_COLOR。
|
||||
meta 预留扩展位:例如砖块血量、特殊方块类型(条状/炸弹/彩球)、冰冻层数等。
|
||||
后续编程时往这里塞结构化数据,而无需改动核心模型。
|
||||
"""
|
||||
|
||||
kind: CellKind = CellKind.EMPTY
|
||||
color: int = NO_COLOR
|
||||
meta: dict = field(default_factory=dict)
|
||||
|
||||
# ---- 类型判定(便于求解器阅读) ----
|
||||
@property
|
||||
def is_normal(self) -> bool:
|
||||
return self.kind == CellKind.NORMAL
|
||||
|
||||
@property
|
||||
def is_brick(self) -> bool:
|
||||
return self.kind == CellKind.BRICK
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.kind == CellKind.EMPTY
|
||||
|
||||
@property
|
||||
def is_swappable(self) -> bool:
|
||||
"""只有正常块可以被交换。"""
|
||||
return self.kind == CellKind.NORMAL
|
||||
|
||||
# ---- 便捷构造 ----
|
||||
@staticmethod
|
||||
def normal(color: int, **meta) -> "Cell":
|
||||
return Cell(kind=CellKind.NORMAL, color=color, meta=dict(meta))
|
||||
|
||||
@staticmethod
|
||||
def brick(**meta) -> "Cell":
|
||||
return Cell(kind=CellKind.BRICK, color=NO_COLOR, meta=dict(meta))
|
||||
|
||||
@staticmethod
|
||||
def empty(**meta) -> "Cell":
|
||||
return Cell(kind=CellKind.EMPTY, color=NO_COLOR, meta=dict(meta))
|
||||
|
||||
def clone(self) -> "Cell":
|
||||
return replace(self, meta=dict(self.meta))
|
||||
|
||||
|
||||
class Board:
|
||||
"""矩形棋盘。坐标统一使用 (row, col),行从上到下、列从左到右,均从 0 起。"""
|
||||
|
||||
def __init__(self, rows: int, cols: int, fill: Optional[Cell] = None) -> None:
|
||||
if rows <= 0 or cols <= 0:
|
||||
raise ValueError(f"棋盘尺寸必须为正:rows={rows}, cols={cols}")
|
||||
self.rows = rows
|
||||
self.cols = cols
|
||||
self._grid: list[list[Cell]] = [
|
||||
[(fill.clone() if fill is not None else Cell.empty()) for _ in range(cols)]
|
||||
for _ in range(rows)
|
||||
]
|
||||
|
||||
# ---- 访问 ----
|
||||
def in_bounds(self, r: int, c: int) -> bool:
|
||||
return 0 <= r < self.rows and 0 <= c < self.cols
|
||||
|
||||
def get(self, r: int, c: int) -> Cell:
|
||||
if not self.in_bounds(r, c):
|
||||
raise IndexError(f"越界访问 ({r}, {c}),棋盘为 {self.rows}x{self.cols}")
|
||||
return self._grid[r][c]
|
||||
|
||||
def set(self, r: int, c: int, cell: Cell) -> None:
|
||||
if not self.in_bounds(r, c):
|
||||
raise IndexError(f"越界写入 ({r}, {c}),棋盘为 {self.rows}x{self.cols}")
|
||||
self._grid[r][c] = cell
|
||||
|
||||
def color_at(self, r: int, c: int) -> int:
|
||||
"""返回该格颜色;非 NORMAL 一律返回 NO_COLOR。"""
|
||||
return self._grid[r][c].color if self._grid[r][c].is_normal else NO_COLOR
|
||||
|
||||
def is_swappable(self, r: int, c: int) -> bool:
|
||||
return self.in_bounds(r, c) and self._grid[r][c].is_swappable
|
||||
|
||||
# ---- 遍历 ----
|
||||
def iter_cells(self) -> Iterator[tuple[int, int, Cell]]:
|
||||
for r in range(self.rows):
|
||||
for c in range(self.cols):
|
||||
yield r, c, self._grid[r][c]
|
||||
|
||||
|
||||
# ---- 复制 ----
|
||||
def clone(self) -> "Board":
|
||||
new = Board(self.rows, self.cols)
|
||||
for r in range(self.rows):
|
||||
for c in range(self.cols):
|
||||
new._grid[r][c] = self._grid[r][c].clone()
|
||||
return new
|
||||
|
||||
# ---- 构造助手:从二维颜色矩阵建棋盘 ----
|
||||
@classmethod
|
||||
def from_colors(
|
||||
cls,
|
||||
matrix: list[list[int]],
|
||||
brick: int = -2,
|
||||
empty: int = NO_COLOR,
|
||||
) -> "Board":
|
||||
"""从二维整数矩阵快速构建棋盘(主要用于测试和调试)。
|
||||
|
||||
约定:
|
||||
matrix[r][c] == brick -> 砖块
|
||||
matrix[r][c] == empty -> 空块
|
||||
其它非负整数 -> 正常块,整数即颜色 id
|
||||
"""
|
||||
if not matrix or not matrix[0]:
|
||||
raise ValueError("matrix 不能为空")
|
||||
rows = len(matrix)
|
||||
cols = len(matrix[0])
|
||||
if any(len(row) != cols for row in matrix):
|
||||
raise ValueError("矩阵必须为矩形(每行列数一致)")
|
||||
|
||||
board = cls(rows, cols)
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
v = matrix[r][c]
|
||||
if v == brick:
|
||||
board.set(r, c, Cell.brick())
|
||||
elif v == empty:
|
||||
board.set(r, c, Cell.empty())
|
||||
else:
|
||||
board.set(r, c, Cell.normal(v))
|
||||
return board
|
||||
|
||||
# ---- 调试输出 ----
|
||||
def to_str(self) -> str:
|
||||
"""字符化:. 空块, # 砖块, 数字/字母 正常块颜色。"""
|
||||
lines = []
|
||||
for r in range(self.rows):
|
||||
cells = []
|
||||
for c in range(self.cols):
|
||||
cell = self._grid[r][c]
|
||||
if cell.is_empty:
|
||||
cells.append(".")
|
||||
elif cell.is_brick:
|
||||
cells.append("#")
|
||||
else:
|
||||
# 颜色 0-9 直接显示,>=10 用字母,便于肉眼对齐
|
||||
cells.append(
|
||||
str(cell.color) if cell.color < 10 else chr(ord("A") + cell.color - 10)
|
||||
)
|
||||
lines.append(" ".join(cells))
|
||||
return "\n".join(lines)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Board({self.rows}x{self.cols})\n{self.to_str()}"
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
"""标定工具:从一张棋盘截图生成布局 + 导出格子图供你做模板。
|
||||
|
||||
本游戏共 7 关,网格逐关变大(且可能含空格)。布局**按关卡分文件**保存在
|
||||
layouts/ 文件夹下(layouts/level1.json、layouts/level2.json ...)。
|
||||
你不需要手算坐标,按下面三步走(命令均在项目根目录执行):
|
||||
|
||||
1) 框出棋盘 + 填行列数 -> 写入 layouts/level1.json
|
||||
python -m hbc.calibrate roi --image src-images/level1.jpg --level 1 --rows 5 --cols 5
|
||||
(弹出窗口会自动缩放到适配屏幕,鼠标拖出棋盘矩形,回车确认;
|
||||
没有 GUI / 不想拖,可加 --box x,y,w,h 直接给原图坐标)
|
||||
|
||||
2) 叠加网格预览,确认对齐
|
||||
python -m hbc.calibrate preview --image src-images/level1.jpg --level 1 --out preview_level1.png
|
||||
|
||||
3) 把每个格子切成小图,导出到 cells/,再人工分类到模板库
|
||||
python -m hbc.calibrate export --image src-images/level1.jpg --level 1 --out cells/
|
||||
然后把 cells/*.png 分别拷进 templates/normal、templates/wall、templates/empty
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
from .config import DEFAULT_LAYOUTS_DIR, GridConfig, level_layout_path
|
||||
|
||||
|
||||
def _parse_box(s: str) -> tuple[int, int, int, int]:
|
||||
parts = [int(v) for v in s.split(",")]
|
||||
if len(parts) != 4:
|
||||
raise argparse.ArgumentTypeError("--box 需要 x,y,w,h 四个整数")
|
||||
return parts[0], parts[1], parts[2], parts[3]
|
||||
|
||||
|
||||
def _fit_scale(img_w: int, img_h: int, max_side: int) -> float:
|
||||
"""计算把图片缩到不超过 max_side 的缩放系数(<=1)。"""
|
||||
longest = max(img_w, img_h)
|
||||
return min(1.0, max_side / longest) if longest > 0 else 1.0
|
||||
|
||||
|
||||
def _map_box_to_original(box: tuple[int, int, int, int], scale: float) -> tuple[int, int, int, int]:
|
||||
"""把"缩放图上选的框"换算回原图像素坐标。"""
|
||||
if scale <= 0 or scale >= 1.0:
|
||||
return box
|
||||
x, y, w, h = box
|
||||
return round(x / scale), round(y / scale), round(w / scale), round(h / scale)
|
||||
|
||||
|
||||
def _select_roi_fit(img, max_side: int) -> tuple[int, int, int, int]:
|
||||
"""在自适应屏幕的缩放窗口里框选,返回原图坐标的 (x, y, w, h)。"""
|
||||
h, w = img.shape[:2]
|
||||
scale = _fit_scale(w, h, max_side)
|
||||
disp = img if scale >= 1.0 else cv2.resize(
|
||||
img, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
|
||||
)
|
||||
title = "select board (drag, Enter=OK, c=cancel)"
|
||||
cv2.namedWindow(title, cv2.WINDOW_NORMAL)
|
||||
cv2.resizeWindow(title, disp.shape[1], disp.shape[0])
|
||||
box = tuple(int(v) for v in cv2.selectROI(title, disp, showCrosshair=True))
|
||||
cv2.destroyAllWindows()
|
||||
return _map_box_to_original(box, scale) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def cmd_roi(args) -> int:
|
||||
img = cv2.imread(args.image, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
print(f"读不到图片:{args.image}")
|
||||
return 1
|
||||
|
||||
if args.box:
|
||||
x, y, w, h = args.box
|
||||
else:
|
||||
print("拖动鼠标框出棋盘范围(窗口已按屏幕缩放);回车/空格确认,c 取消。")
|
||||
x, y, w, h = _select_roi_fit(img, args.max_display)
|
||||
if w == 0 or h == 0:
|
||||
print("未选择有效区域。")
|
||||
return 2
|
||||
|
||||
ih, iw = img.shape[:2]
|
||||
grid = GridConfig(x=x, y=y, w=w, h=h, rows=args.rows, cols=args.cols, ref_w=iw, ref_h=ih)
|
||||
|
||||
path = level_layout_path(args.level, args.layouts_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
grid.save_json(path)
|
||||
|
||||
print(f"已写入 {path}:")
|
||||
print(f" ROI=({x},{y},{w},{h}) grid={args.rows}x{args.cols} "
|
||||
f"cell≈{grid.cell_w:.1f}x{grid.cell_h:.1f}")
|
||||
return 0
|
||||
|
||||
|
||||
def _load_grid(args) -> GridConfig:
|
||||
return GridConfig.load_json(level_layout_path(args.level, args.layouts_dir))
|
||||
|
||||
|
||||
def cmd_preview(args) -> int:
|
||||
img = cv2.imread(args.image, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
print(f"读不到图片:{args.image}")
|
||||
return 1
|
||||
grid = _load_grid(args)
|
||||
|
||||
vis = img.copy()
|
||||
cv2.rectangle(vis, (grid.x, grid.y), (grid.x + grid.w, grid.y + grid.h), (0, 255, 0), 2)
|
||||
for r in range(grid.rows + 1):
|
||||
yy = grid.y + int(r * grid.cell_h)
|
||||
cv2.line(vis, (grid.x, yy), (grid.x + grid.w, yy), (0, 200, 255), 1)
|
||||
for c in range(grid.cols + 1):
|
||||
xx = grid.x + int(c * grid.cell_w)
|
||||
cv2.line(vis, (xx, grid.y), (xx, grid.y + grid.h), (0, 200, 255), 1)
|
||||
for r in range(grid.rows):
|
||||
for c in range(grid.cols):
|
||||
px, py = grid.cell_center(r, c)
|
||||
cv2.circle(vis, (px, py), 2, (0, 0, 255), -1)
|
||||
|
||||
cv2.imwrite(args.out, vis)
|
||||
print(f"已写入预览:{args.out}(绿框=棋盘,橙线=网格,红点=格子中心)")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export(args) -> int:
|
||||
img = cv2.imread(args.image, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
print(f"读不到图片:{args.image}")
|
||||
return 1
|
||||
grid = _load_grid(args)
|
||||
out = Path(args.out)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
n = 0
|
||||
for r in range(grid.rows):
|
||||
for c in range(grid.cols):
|
||||
x, y, w, h = grid.cell_box(r, c)
|
||||
cell = img[y : y + h, x : x + w]
|
||||
cv2.imwrite(str(out / f"L{args.level}_r{r:02d}_c{c:02d}.png"), cell)
|
||||
n += 1
|
||||
print(f"已导出 {n} 个格子图到 {out}/")
|
||||
print("下一步:把它们分类放进 templates/normal、templates/wall、templates/empty。")
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="hbc.calibrate", description="棋盘标定与模板导出工具(多关卡)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
pr = sub.add_parser("roi", help="框选棋盘并写入某关卡布局")
|
||||
pr.add_argument("--image", required=True)
|
||||
pr.add_argument("--level", type=int, required=True, help="关卡号 1~7")
|
||||
pr.add_argument("--rows", type=int, required=True)
|
||||
pr.add_argument("--cols", type=int, required=True)
|
||||
pr.add_argument("--layouts-dir", default=DEFAULT_LAYOUTS_DIR)
|
||||
pr.add_argument("--max-display", type=int, default=900,
|
||||
help="框选窗口最长边像素上限(屏幕装不下时自动缩放)")
|
||||
pr.add_argument("--box", type=_parse_box, help="无 GUI 时直接给原图 x,y,w,h")
|
||||
pr.set_defaults(func=cmd_roi)
|
||||
|
||||
pv = sub.add_parser("preview", help="叠加网格预览,检查对齐")
|
||||
pv.add_argument("--image", required=True)
|
||||
pv.add_argument("--level", type=int, required=True)
|
||||
pv.add_argument("--layouts-dir", default=DEFAULT_LAYOUTS_DIR)
|
||||
pv.add_argument("--out", default="preview.png")
|
||||
pv.set_defaults(func=cmd_preview)
|
||||
|
||||
pe = sub.add_parser("export", help="导出每个格子图,供制作模板")
|
||||
pe.add_argument("--image", required=True)
|
||||
pe.add_argument("--level", type=int, required=True)
|
||||
pe.add_argument("--layouts-dir", default=DEFAULT_LAYOUTS_DIR)
|
||||
pe.add_argument("--out", default="cells")
|
||||
pe.set_defaults(func=cmd_export)
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
"""网格与识别配置。
|
||||
|
||||
把"屏幕像素"和"棋盘格子坐标"解耦,便于支持不同分辨率、不同尺寸的棋盘。
|
||||
网格尺寸 (rows, cols) 动态可变,但区域恒为矩形。
|
||||
|
||||
GridConfig 可序列化为 JSON(布局文件)。你只需在一张棋盘截图上框出棋盘范围、
|
||||
填好行列数,就能生成它——无需手算每个格子的像素坐标。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class GridConfig:
|
||||
"""棋盘在屏幕上的几何信息。
|
||||
|
||||
棋盘区域是一个矩形 ROI(左上角 x,y + 宽 w、高 h),被均匀切成 rows x cols 个格子。
|
||||
rows / cols 是可变的:识别阶段可以先探测出实际行列数再填进来。
|
||||
"""
|
||||
|
||||
x: int # 棋盘区域左上角 x(像素)
|
||||
y: int # 棋盘区域左上角 y(像素)
|
||||
w: int # 棋盘区域宽度(像素)
|
||||
h: int # 棋盘区域高度(像素)
|
||||
rows: int # 行数(动态)
|
||||
cols: int # 列数(动态)
|
||||
ref_w: int = 0 # 标定时的图片宽(0=未知,不缩放)
|
||||
ref_h: int = 0 # 标定时的图片高
|
||||
|
||||
# ---- 几何 ----
|
||||
@property
|
||||
def cell_w(self) -> float:
|
||||
return self.w / self.cols
|
||||
|
||||
@property
|
||||
def cell_h(self) -> float:
|
||||
return self.h / self.rows
|
||||
|
||||
def cell_center(self, r: int, c: int) -> tuple[int, int]:
|
||||
"""格子中心点的屏幕像素坐标,用于点击/滑动。"""
|
||||
px = self.x + int((c + 0.5) * self.cell_w)
|
||||
py = self.y + int((r + 0.5) * self.cell_h)
|
||||
return px, py
|
||||
|
||||
def cell_box(self, r: int, c: int) -> tuple[int, int, int, int]:
|
||||
"""格子的像素包围盒 (x, y, w, h),用于从截图裁剪该格做识别。"""
|
||||
x = self.x + int(c * self.cell_w)
|
||||
y = self.y + int(r * self.cell_h)
|
||||
return x, y, int(self.cell_w), int(self.cell_h)
|
||||
|
||||
def for_image(self, img_w: int, img_h: int) -> "GridConfig":
|
||||
"""把按 ref 尺寸标定的 ROI 缩放到实际截图尺寸。
|
||||
|
||||
标定截图与运行时截屏分辨率常常不同(如标定 1080x2400、MaaFw 实时 720x1600)。
|
||||
只要保留了 ref 尺寸,这里就能自动等比缩放,做到分辨率无关。
|
||||
"""
|
||||
if not self.ref_w or not self.ref_h:
|
||||
return self # 未记录参考尺寸:假定坐标已是当前分辨率
|
||||
if img_w == self.ref_w and img_h == self.ref_h:
|
||||
return self
|
||||
sx = img_w / self.ref_w
|
||||
sy = img_h / self.ref_h
|
||||
return GridConfig(
|
||||
x=round(self.x * sx), y=round(self.y * sy),
|
||||
w=round(self.w * sx), h=round(self.h * sy),
|
||||
rows=self.rows, cols=self.cols, ref_w=img_w, ref_h=img_h,
|
||||
)
|
||||
|
||||
# ---- 序列化(布局文件) ----
|
||||
def to_dict(self) -> dict:
|
||||
return {"x": self.x, "y": self.y, "w": self.w, "h": self.h,
|
||||
"rows": self.rows, "cols": self.cols,
|
||||
"ref_w": self.ref_w, "ref_h": self.ref_h}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "GridConfig":
|
||||
return cls(int(d["x"]), int(d["y"]), int(d["w"]), int(d["h"]),
|
||||
int(d["rows"]), int(d["cols"]),
|
||||
int(d.get("ref_w", 0)), int(d.get("ref_h", 0)))
|
||||
|
||||
def save_json(self, path: str | Path) -> None:
|
||||
Path(path).write_text(json.dumps(self.to_dict(), indent=2, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
|
||||
@classmethod
|
||||
def load_json(cls, path: str | Path) -> "GridConfig":
|
||||
return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
DEFAULT_LAYOUTS_DIR = "layouts"
|
||||
|
||||
|
||||
def level_layout_path(level: int, layouts_dir: str | Path = DEFAULT_LAYOUTS_DIR) -> Path:
|
||||
"""某关卡的布局文件路径:<layouts_dir>/level{N}.json。
|
||||
|
||||
7 关网格逐关变大,每关一个文件、文件名带关号,统一放在 layouts/ 文件夹下。
|
||||
"""
|
||||
return Path(layouts_dir) / f"level{level}.json"
|
||||
|
||||
|
||||
def load_level_grid(level: int, layouts_dir: str | Path = DEFAULT_LAYOUTS_DIR) -> GridConfig:
|
||||
p = level_layout_path(level, layouts_dir)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(
|
||||
f"关卡 {level} 尚未标定(缺 {p})。请先运行:\n"
|
||||
f" python -m hbc.calibrate roi --image 截图.png --level {level} --rows R --cols C"
|
||||
)
|
||||
return GridConfig.load_json(p)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecognizeConfig:
|
||||
"""识别相关参数。
|
||||
|
||||
color_refs:颜色 id -> 参考 BGR 值。识别时取格子中心区域均值,找最近的参考色。
|
||||
这是游戏相关的,需要你用真机截图标定后填入。
|
||||
color_dist_threshold:超过该距离视为"无法归类",可判为砖块/空块(结合形状/亮度进一步判断)。
|
||||
sample_ratio:取格子中心多大比例的区域求颜色均值(避免边框干扰)。
|
||||
"""
|
||||
|
||||
color_refs: dict[int, tuple[int, int, int]] = field(default_factory=dict)
|
||||
color_dist_threshold: float = 60.0
|
||||
sample_ratio: float = 0.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateMatchConfig:
|
||||
"""模板比对识别参数(比纯颜色更准,推荐)。
|
||||
|
||||
templates_dir:模板库目录,按子文件夹组织:
|
||||
templates/
|
||||
normal/<label>.png 正常块,每个 label 映射一个稳定颜色 id
|
||||
wall/<label>.png 墙/砖块,可有多种(label 即墙的种类)
|
||||
empty/<label>.png 空块/空洞
|
||||
score_threshold:相似度(0~1,1=完全相同)低于该值视为"无法识别"。
|
||||
逐像素差较严格,真机有光照/特效时可适当调低(如 0.75)。
|
||||
canonical_size:比对前把格子与模板统一缩放到的边长(像素)。
|
||||
crop_ratio:比对时只取格子中心多大比例区域,规避格子间隙/阴影。
|
||||
"""
|
||||
|
||||
templates_dir: str = "templates"
|
||||
score_threshold: float = 0.85
|
||||
canonical_size: int = 48
|
||||
crop_ratio: float = 0.8
|
||||
# 判为"空格"所需的最低分。真空格通常 ~0.99;分数不够高(如冰块被空格模板以
|
||||
# ~0.90 抢走)则判为冰墙而非空格。
|
||||
empty_min_score: float = 0.96
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"""关卡识别:运行时 OCR 标题"关卡一",解析出关卡号。
|
||||
|
||||
MaaFramework 自带 OCR(PaddleOCR 的 ONNX 模型),运行时可直接识别中文标题,
|
||||
**不需要你预先把"关卡一"截成图再做模板**。本模块提供:
|
||||
|
||||
1. parse_cn_level:把 OCR 出来的文本(如"关卡一""第2关")解析成整数关卡号。纯函数,可离线测试。
|
||||
2. LevelRecognition:MaaFw 自定义识别,OCR 标题 ROI -> 解析关卡 -> 载入该关布局到 GameState。
|
||||
(需要 MaaFw 资源里带 OCR 模型;社区资源/官方包通常已自带,没有时去掉本节点即可。)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from .config import load_level_grid
|
||||
|
||||
_CN_DIGIT = {
|
||||
"零": 0, "〇": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4,
|
||||
"五": 5, "六": 6, "七": 7, "八": 8, "九": 9,
|
||||
}
|
||||
|
||||
|
||||
def _cn_to_int(s: str) -> Optional[int]:
|
||||
"""解析 1~99 的中文数字,支持"十""二十""二十一"。仅取关卡够用。"""
|
||||
if not s:
|
||||
return None
|
||||
if "十" in s:
|
||||
left, _, right = s.partition("十")
|
||||
tens = _CN_DIGIT.get(left, 1) if left else 1 # "十" => 10
|
||||
ones = _CN_DIGIT.get(right, 0) if right else 0
|
||||
return tens * 10 + ones
|
||||
val = 0
|
||||
for ch in s:
|
||||
if ch not in _CN_DIGIT:
|
||||
return None
|
||||
val = val * 10 + _CN_DIGIT[ch]
|
||||
return val if s else None
|
||||
|
||||
|
||||
def parse_cn_level(text: str) -> Optional[int]:
|
||||
"""从 OCR 文本中提取关卡号。识别失败返回 None。
|
||||
|
||||
支持:"关卡一"、"关卡 1"、"第二关"、"LEVEL 3" 等。
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
t = text.strip()
|
||||
|
||||
# 1) 阿拉伯数字优先
|
||||
m = re.search(r"\d+", t)
|
||||
if m:
|
||||
return int(m.group())
|
||||
|
||||
# 2) 中文数字:抓"关卡"/"第...关"后面的数字串
|
||||
m = re.search(r"(?:关卡|第)\s*([零〇一二两三四五六七八九十]+)", t)
|
||||
if m:
|
||||
return _cn_to_int(m.group(1))
|
||||
|
||||
# 3) 兜底:文本里任意一段中文数字
|
||||
m = re.search(r"[零〇一二两三四五六七八九十]+", t)
|
||||
if m:
|
||||
return _cn_to_int(m.group())
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# MaaFramework 自定义识别:OCR 标题 -> 关卡号 -> 载入布局
|
||||
# ---------------------------------------------------------------------------#
|
||||
try:
|
||||
from maa.custom_recognition import CustomRecognition # type: ignore
|
||||
|
||||
class LevelRecognition(CustomRecognition):
|
||||
"""OCR 标题 ROI,解析关卡号,并把该关布局写入 GameState。
|
||||
|
||||
title_roi 标题区域像素 (x, y, w, h),标定一次即可(各关位置一致)。
|
||||
layouts_dir 关卡布局文件夹(每关 level{N}.json)。
|
||||
"""
|
||||
|
||||
def __init__(self, state, title_roi: tuple[int, int, int, int],
|
||||
layouts_dir: str = "layouts") -> None:
|
||||
super().__init__()
|
||||
self.state = state
|
||||
self.title_roi = title_roi
|
||||
self.layouts_dir = layouts_dir
|
||||
|
||||
def _ocr_text(self, context, image) -> str:
|
||||
"""用 MaaFw 内置 OCR 读取标题区域文本。API 名称随版本可能微调。"""
|
||||
x, y, w, h = self.title_roi
|
||||
detail = context.run_recognition(
|
||||
"__HBC_LevelOCR__",
|
||||
image,
|
||||
{"__HBC_LevelOCR__": {"recognition": "OCR", "roi": [x, y, w, h]}},
|
||||
)
|
||||
if detail is None:
|
||||
return ""
|
||||
best = getattr(detail, "best_result", None)
|
||||
return getattr(best, "text", "") if best else ""
|
||||
|
||||
def analyze(self, context, argv) -> "CustomRecognition.AnalyzeResult":
|
||||
text = self._ocr_text(context, argv.image)
|
||||
level = parse_cn_level(text)
|
||||
if level is None:
|
||||
return CustomRecognition.AnalyzeResult(box=None, detail=f"未识别关卡: {text!r}")
|
||||
|
||||
self.state.level = level
|
||||
self.state.grid = load_level_grid(level, self.layouts_dir)
|
||||
return CustomRecognition.AnalyzeResult(
|
||||
box=self.title_roi, detail=f"level={level} text={text!r}"
|
||||
)
|
||||
|
||||
except Exception: # pragma: no cover - 未安装 MaaFw 时跳过
|
||||
LevelRecognition = None # type: ignore
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
"""入口:把识别桩 + 动作桩接到 MaaFramework,连接安卓设备循环解三消。
|
||||
|
||||
运行前请先:
|
||||
1. 安装依赖:pip install -r requirements.txt
|
||||
2. 用真机/模拟器截图标定 config 中的棋盘 ROI 与颜色参考值。
|
||||
3. 确保 adb 能连上设备。
|
||||
|
||||
注意:MaaFramework 的具体 API 名称随版本略有差异(Toolkit / AdbController /
|
||||
Resource / Tasker)。本文件给出标准接线骨架,跑不通时对照你安装版本的官方示例微调。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .config import TemplateMatchConfig, load_level_grid
|
||||
from .solver import Solver
|
||||
from .state import GameState
|
||||
from .templates import TemplateLibrary
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
RESOURCE_DIR = PROJECT_ROOT / "resource" # MaaFw 资源包根目录(内含 pipeline/)
|
||||
LAYOUTS_DIR = PROJECT_ROOT / "layouts" # 每关一个 level{N}.json
|
||||
TEMPLATES_DIR = PROJECT_ROOT / "templates" # 模板库目录
|
||||
|
||||
|
||||
def build_state(level: int) -> GameState:
|
||||
"""从 layouts/level{N}.json 读取指定关卡的棋盘几何信息。"""
|
||||
grid = load_level_grid(level, LAYOUTS_DIR)
|
||||
return GameState(grid=grid)
|
||||
|
||||
|
||||
def build_classifier() -> TemplateLibrary:
|
||||
"""加载模板库(模板比对识别,支持多种墙)。"""
|
||||
cfg = TemplateMatchConfig(templates_dir=str(TEMPLATES_DIR))
|
||||
return TemplateLibrary.load(cfg)
|
||||
|
||||
|
||||
def _connect(adb_serial: str):
|
||||
"""连接设备,返回 controller。未指定 serial 时自动探测第一个 adb 设备。"""
|
||||
from maa.controller import AdbController
|
||||
from maa.toolkit import Toolkit
|
||||
|
||||
Toolkit.init_option("./")
|
||||
if not adb_serial:
|
||||
devices = Toolkit.find_adb_devices()
|
||||
if not devices:
|
||||
raise RuntimeError("未找到 adb 设备,请先连接模拟器/真机。")
|
||||
d = devices[0]
|
||||
controller = AdbController(
|
||||
adb_path=d.adb_path, address=d.address,
|
||||
screencap_methods=d.screencap_methods, input_methods=d.input_methods,
|
||||
config=d.config,
|
||||
)
|
||||
else:
|
||||
controller = AdbController(adb_path="adb", address=adb_serial)
|
||||
controller.post_connection().wait()
|
||||
return controller
|
||||
|
||||
|
||||
def dry_run(level: int = 1, adb_serial: str = "") -> int:
|
||||
"""干跑:连接设备 -> 截屏 -> 识别棋盘 -> 打印最优一步。**不执行任何滑动。**"""
|
||||
try:
|
||||
controller = _connect(adb_serial)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"连接失败:{e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
from .actuator import swap_to_swipe
|
||||
from .recognizer import recognize_board
|
||||
|
||||
state = build_state(level)
|
||||
classifier = build_classifier()
|
||||
|
||||
print(f"设备分辨率: {controller.resolution}")
|
||||
controller.post_screencap().wait()
|
||||
image = controller.cached_image
|
||||
if image is None:
|
||||
print("截屏失败(cached_image 为空)。", file=sys.stderr)
|
||||
return 3
|
||||
import cv2
|
||||
cap_path = PROJECT_ROOT / f"live_level{level}.png"
|
||||
cv2.imwrite(str(cap_path), image)
|
||||
print(f"已保存实时截屏: {cap_path}(识别不准时可用它重新标定)")
|
||||
|
||||
h, w = image.shape[:2]
|
||||
grid = state.grid.for_image(w, h)
|
||||
print(f"截图尺寸 (HxW): {image.shape[:2]} 缩放后棋盘ROI: "
|
||||
f"({grid.x},{grid.y},{grid.w},{grid.h}) {grid.rows}x{grid.cols}")
|
||||
|
||||
board = recognize_board(image, grid, classifier)
|
||||
print("\n=== 识别到的棋盘 ===")
|
||||
for r in range(board.rows):
|
||||
cells = []
|
||||
for c in range(board.cols):
|
||||
cell = board.get(r, c)
|
||||
if cell.is_brick:
|
||||
cells.append(f"[{cell.meta.get('wall', 'wall')}]")
|
||||
elif cell.is_empty:
|
||||
cells.append(".")
|
||||
else:
|
||||
cells.append(cell.meta.get("label", "?"))
|
||||
print(" ".join(f"{x:>10}" for x in cells))
|
||||
|
||||
best = Solver().find_best_swap(board)
|
||||
if best is None:
|
||||
print("\n没有可行的消除交换。")
|
||||
return 0
|
||||
x1, y1, x2, y2 = swap_to_swipe(best.swap, grid)
|
||||
print(f"\n最优交换: {best.swap} 预计消除 {best.cleared} 个")
|
||||
print(f"对应滑动(不会执行): ({x1},{y1}) -> ({x2},{y2})")
|
||||
return 0
|
||||
|
||||
|
||||
def _screencap(controller):
|
||||
"""截一帧,返回 BGR ndarray。"""
|
||||
controller.post_screencap().wait()
|
||||
return controller.cached_image
|
||||
|
||||
|
||||
def main(level: int = 1, adb_serial: str = "", max_steps: int = 0,
|
||||
delay: float = 0.9, swipe_ms: int = 200, empty_retries: int = 3) -> int:
|
||||
"""直接循环:截屏 -> 识别 -> 求解 -> 滑动 -> 等动画,循环到无解或达到步数上限。
|
||||
|
||||
max_steps 最多走多少步(0=无限,靠 Ctrl+C 停)。
|
||||
delay 每步滑动后等待动画沉降的秒数。
|
||||
swipe_ms 滑动时长(毫秒)。
|
||||
empty_retries 识别不到可行交换时的重试次数(可能正处于动画中)。
|
||||
"""
|
||||
import time
|
||||
|
||||
from .actuator import swap_to_swipe
|
||||
from .recognizer import recognize_board
|
||||
|
||||
try:
|
||||
controller = _connect(adb_serial)
|
||||
except Exception as e: # noqa: BLE001
|
||||
print(f"连接失败:{e}", file=sys.stderr)
|
||||
return 2
|
||||
if not controller.connected:
|
||||
print("设备连接失败(controller.connected=False)。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
state = build_state(level)
|
||||
classifier = build_classifier()
|
||||
solver = Solver()
|
||||
|
||||
print(f"开始运行 level={level}(Ctrl+C 停止)...")
|
||||
step = 0
|
||||
misses = 0
|
||||
try:
|
||||
while max_steps == 0 or step < max_steps:
|
||||
image = _screencap(controller)
|
||||
if image is None:
|
||||
print("截屏失败,重试...", file=sys.stderr)
|
||||
time.sleep(0.5)
|
||||
continue
|
||||
|
||||
h, w = image.shape[:2]
|
||||
grid = state.grid.for_image(w, h)
|
||||
board = recognize_board(image, grid, classifier)
|
||||
best = solver.find_best_swap(board)
|
||||
|
||||
if best is None:
|
||||
misses += 1
|
||||
if misses > empty_retries:
|
||||
print("连续找不到可行交换,停止(可能本关已清或识别需重标定)。")
|
||||
break
|
||||
print(f"未找到可行交换,等待重试 ({misses}/{empty_retries})...")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
|
||||
misses = 0
|
||||
step += 1
|
||||
x1, y1, x2, y2 = swap_to_swipe(best.swap, grid)
|
||||
print(f"[{step}] {best.swap} 预计消除 {best.cleared} -> 滑动 ({x1},{y1})->({x2},{y2})")
|
||||
controller.post_swipe(x1, y1, x2, y2, swipe_ms).wait()
|
||||
time.sleep(delay)
|
||||
except KeyboardInterrupt:
|
||||
print("\n已手动停止。")
|
||||
print(f"共执行 {step} 步。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
p = argparse.ArgumentParser(prog="hbc.main", description="自动解三消")
|
||||
p.add_argument("--level", type=int, default=1, help="关卡号 1~7(决定用哪套布局)")
|
||||
p.add_argument("--serial", default="", help="adb 设备地址,不填则自动探测")
|
||||
p.add_argument("--dry-run", action="store_true", help="只截屏识别并打印最优一步,不滑动")
|
||||
p.add_argument("--max-steps", type=int, default=0, help="最多走多少步(0=无限)")
|
||||
p.add_argument("--delay", type=float, default=0.9, help="每步等待动画的秒数")
|
||||
p.add_argument("--swipe-ms", type=int, default=200, help="滑动时长(毫秒)")
|
||||
a = p.parse_args()
|
||||
if a.dry_run:
|
||||
raise SystemExit(dry_run(a.level, a.serial))
|
||||
raise SystemExit(main(a.level, a.serial, a.max_steps, a.delay, a.swipe_ms))
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
"""识别:把一帧截图按网格切格、逐格判定,组装成 Board。
|
||||
|
||||
识别方式做成可插拔的"分类器"(Classifier):输入一个格子的图像块,输出一个 Cell。
|
||||
- TemplateLibrary(templates.py):模板子图比对,**推荐**,更准、支持多种墙。
|
||||
- ColorClassifier(本文件):最近参考色,简单但易受光照/特效干扰,作为兜底。
|
||||
|
||||
两层结构:
|
||||
1. 纯函数 `recognize_board(image, grid, classifier)` —— 不依赖 MaaFramework,便于离线测试。
|
||||
2. `BoardRecognition` —— MaaFramework 自定义识别适配器(仅在装了 MaaFw 时可用)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Optional, Protocol
|
||||
|
||||
from .board import Board, Cell, CellKind
|
||||
from .config import GridConfig, RecognizeConfig
|
||||
|
||||
try: # numpy/opencv 可选:纯求解/测试场景不需要
|
||||
import numpy as np
|
||||
except Exception: # pragma: no cover
|
||||
np = None # type: ignore
|
||||
|
||||
|
||||
class CellClassifier(Protocol):
|
||||
"""格子分类器协议:拿到一个格子的 BGR 图像块,返回对应的 Cell。"""
|
||||
|
||||
def classify(self, patch) -> Cell: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# 颜色分类器(兜底方案)
|
||||
# ---------------------------------------------------------------------------#
|
||||
# 砖块/空块决策钩子:拿到该格图像块,返回 CellKind.BRICK 或 CellKind.EMPTY。
|
||||
BrickOrEmptyFn = Callable[["np.ndarray"], CellKind]
|
||||
|
||||
|
||||
def _default_brick_or_empty(patch) -> CellKind:
|
||||
return CellKind.EMPTY
|
||||
|
||||
|
||||
def _mean_color(patch, sample_ratio: float) -> tuple[float, float, float]:
|
||||
h, w = patch.shape[:2]
|
||||
rh, rw = int(h * sample_ratio), int(w * sample_ratio)
|
||||
y0 = (h - rh) // 2
|
||||
x0 = (w - rw) // 2
|
||||
region = patch[y0 : y0 + rh, x0 : x0 + rw]
|
||||
mean = region.reshape(-1, region.shape[-1]).mean(axis=0)
|
||||
return float(mean[0]), float(mean[1]), float(mean[2])
|
||||
|
||||
|
||||
def _nearest_color(
|
||||
bgr: tuple[float, float, float],
|
||||
refs: dict[int, tuple[int, int, int]],
|
||||
) -> tuple[Optional[int], float]:
|
||||
best_id: Optional[int] = None
|
||||
best_dist = float("inf")
|
||||
for cid, ref in refs.items():
|
||||
d = ((bgr[0] - ref[0]) ** 2 + (bgr[1] - ref[1]) ** 2 + (bgr[2] - ref[2]) ** 2) ** 0.5
|
||||
if d < best_dist:
|
||||
best_dist = d
|
||||
best_id = cid
|
||||
return best_id, best_dist
|
||||
|
||||
|
||||
class ColorClassifier:
|
||||
"""按"最近参考色"判颜色,无法归类时交给 brick_or_empty 决策。"""
|
||||
|
||||
def __init__(self, cfg: RecognizeConfig,
|
||||
brick_or_empty: BrickOrEmptyFn = _default_brick_or_empty) -> None:
|
||||
self.cfg = cfg
|
||||
self.brick_or_empty = brick_or_empty
|
||||
|
||||
def classify(self, patch) -> Cell:
|
||||
bgr = _mean_color(patch, self.cfg.sample_ratio)
|
||||
cid, dist = _nearest_color(bgr, self.cfg.color_refs)
|
||||
if cid is not None and dist <= self.cfg.color_dist_threshold:
|
||||
return Cell.normal(cid)
|
||||
kind = self.brick_or_empty(patch)
|
||||
return Cell.brick() if kind == CellKind.BRICK else Cell.empty()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# 通用识别
|
||||
# ---------------------------------------------------------------------------#
|
||||
def recognize_board(image, grid: GridConfig, classifier: CellClassifier) -> Board:
|
||||
"""从整帧截图构建 Board。image 为 BGR ndarray(OpenCV 约定)。
|
||||
|
||||
classifier 可以是 TemplateLibrary(推荐)或 ColorClassifier,
|
||||
任何实现了 .classify(patch)->Cell 的对象都行。
|
||||
"""
|
||||
if np is None:
|
||||
raise RuntimeError("recognize_board 需要 numpy / opencv,请先安装依赖")
|
||||
|
||||
# 自动把标定 ROI 缩放到当前截图分辨率(分辨率无关)
|
||||
h, w = image.shape[:2]
|
||||
grid = grid.for_image(w, h)
|
||||
|
||||
board = Board(grid.rows, grid.cols)
|
||||
for r in range(grid.rows):
|
||||
for c in range(grid.cols):
|
||||
x, y, w, h = grid.cell_box(r, c)
|
||||
patch = image[y : y + h, x : x + w]
|
||||
board.set(r, c, classifier.classify(patch))
|
||||
return board
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------#
|
||||
# MaaFramework 自定义识别适配器
|
||||
# ---------------------------------------------------------------------------#
|
||||
try:
|
||||
from maa.custom_recognition import CustomRecognition # type: ignore
|
||||
|
||||
class BoardRecognition(CustomRecognition):
|
||||
"""把识别出的 Board 写入共享 GameState,并返回棋盘 ROI 让 pipeline 继续。
|
||||
|
||||
在 pipeline 中以 Custom 识别引用本类,识别成功后由 SwapAction 读取 GameState。
|
||||
"""
|
||||
|
||||
def __init__(self, state, grid: GridConfig, classifier: CellClassifier) -> None:
|
||||
super().__init__()
|
||||
self.state = state
|
||||
self.grid = grid
|
||||
self.classifier = classifier
|
||||
|
||||
def analyze(self, context, argv) -> "CustomRecognition.AnalyzeResult":
|
||||
image = argv.image # MaaFw 提供的当前帧(BGR ndarray)
|
||||
h, w = image.shape[:2]
|
||||
grid = self.grid.for_image(w, h)
|
||||
board = recognize_board(image, grid, self.classifier)
|
||||
self.state.board = board
|
||||
self.state.grid_runtime = grid # 供动作桩按当前分辨率滑动
|
||||
box = (grid.x, grid.y, grid.w, grid.h)
|
||||
return CustomRecognition.AnalyzeResult(box=box, detail=board.to_str())
|
||||
|
||||
except Exception: # pragma: no cover - 未安装 MaaFw 时跳过
|
||||
BoardRecognition = None # type: ignore
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
"""三消求解器。
|
||||
|
||||
职责:在给定棋盘上,找出所有"相邻交换"中能产生消除的最优一步。
|
||||
|
||||
核心约束:
|
||||
- 只有 NORMAL(正常块)能被交换;BRICK / EMPTY 会打断连线,也不能交换。
|
||||
- 一次合法交换的判定:交换后至少形成一条 3 连(横或竖)。
|
||||
|
||||
可扩展点(已预留):
|
||||
- score_swap 的打分逻辑可替换(默认按消除格子数;后续可加权特殊方块、砖块破坏等)。
|
||||
- 砖块被相邻消除"破坏"的规则,可在 _cells_cleared_by 中补充。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
from .board import Board, CellKind
|
||||
|
||||
# 形成一次消除所需的最小连续数量(经典三消为 3)。
|
||||
MIN_MATCH: int = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Swap:
|
||||
"""一次相邻交换:把 (r1, c1) 与 (r2, c2) 互换。两格必须四邻相邻。"""
|
||||
|
||||
r1: int
|
||||
c1: int
|
||||
r2: int
|
||||
c2: int
|
||||
|
||||
def is_adjacent(self) -> bool:
|
||||
return abs(self.r1 - self.r2) + abs(self.c1 - self.c2) == 1
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Swap(({self.r1},{self.c1}) <-> ({self.r2},{self.c2}))"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Match:
|
||||
"""一条连续同色线段。cells 为参与消除的格子坐标列表。"""
|
||||
|
||||
color: int
|
||||
cells: tuple[tuple[int, int], ...]
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
return len(self.cells)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SwapResult:
|
||||
"""一次交换的评估结果。"""
|
||||
|
||||
swap: Swap
|
||||
matches: list[Match]
|
||||
cleared: int # 被消除的格子总数(去重后)
|
||||
score: float # 打分(越高越好)
|
||||
|
||||
|
||||
# 打分函数签名:给定交换、匹配列表、被消除格子集合,返回分数。
|
||||
Scorer = Callable[[Swap, list[Match], set[tuple[int, int]]], float]
|
||||
|
||||
|
||||
def default_scorer(swap: Swap, matches: list[Match], cleared: set[tuple[int, int]]) -> float:
|
||||
"""默认打分:消除格子数为主,连线越长(4连/5连)额外加权。"""
|
||||
score = float(len(cleared))
|
||||
for m in matches:
|
||||
if m.length >= 5:
|
||||
score += 5.0 # 5 连通常生成彩球类道具,最优先
|
||||
elif m.length == 4:
|
||||
score += 2.0 # 4 连生成条状道具
|
||||
return score
|
||||
|
||||
|
||||
class Solver:
|
||||
def __init__(self, min_match: int = MIN_MATCH, scorer: Scorer = default_scorer) -> None:
|
||||
self.min_match = min_match
|
||||
self.scorer = scorer
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 找匹配
|
||||
# ------------------------------------------------------------------ #
|
||||
def find_matches(self, board: Board) -> list[Match]:
|
||||
"""扫描整盘,返回所有 >= min_match 的横/竖同色连线。"""
|
||||
matches: list[Match] = []
|
||||
matches.extend(self._scan_lines(board, horizontal=True))
|
||||
matches.extend(self._scan_lines(board, horizontal=False))
|
||||
return matches
|
||||
|
||||
def _scan_lines(self, board: Board, horizontal: bool) -> list[Match]:
|
||||
result: list[Match] = []
|
||||
outer = board.rows if horizontal else board.cols
|
||||
inner = board.cols if horizontal else board.rows
|
||||
|
||||
for a in range(outer):
|
||||
run_color = None
|
||||
run: list[tuple[int, int]] = []
|
||||
|
||||
def flush() -> None:
|
||||
if run_color is not None and len(run) >= self.min_match:
|
||||
result.append(Match(color=run_color, cells=tuple(run)))
|
||||
|
||||
for b in range(inner):
|
||||
r, c = (a, b) if horizontal else (b, a)
|
||||
cell = board.get(r, c)
|
||||
if cell.kind == CellKind.NORMAL:
|
||||
if run_color == cell.color:
|
||||
run.append((r, c))
|
||||
else:
|
||||
flush()
|
||||
run_color = cell.color
|
||||
run = [(r, c)]
|
||||
else:
|
||||
# 砖块/空块打断连线
|
||||
flush()
|
||||
run_color = None
|
||||
run = []
|
||||
flush()
|
||||
return result
|
||||
|
||||
def has_match(self, board: Board) -> bool:
|
||||
return len(self.find_matches(board)) > 0
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 评估单次交换
|
||||
# ------------------------------------------------------------------ #
|
||||
def evaluate_swap(self, board: Board, swap: Swap) -> Optional[SwapResult]:
|
||||
"""模拟一次交换并评估。若该交换非法或不产生消除,返回 None。"""
|
||||
if not swap.is_adjacent():
|
||||
return None
|
||||
if not (board.is_swappable(swap.r1, swap.c1) and board.is_swappable(swap.r2, swap.c2)):
|
||||
return None
|
||||
|
||||
trial = board.clone()
|
||||
a = trial.get(swap.r1, swap.c1)
|
||||
b = trial.get(swap.r2, swap.c2)
|
||||
trial.set(swap.r1, swap.c1, b)
|
||||
trial.set(swap.r2, swap.c2, a)
|
||||
|
||||
matches = self.find_matches(trial)
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
cleared = self._cells_cleared_by(matches)
|
||||
score = self.scorer(swap, matches, cleared)
|
||||
return SwapResult(swap=swap, matches=matches, cleared=len(cleared), score=score)
|
||||
|
||||
def _cells_cleared_by(self, matches: list[Match]) -> set[tuple[int, int]]:
|
||||
"""被消除的格子集合(去重)。
|
||||
|
||||
预留:若游戏中相邻砖块会被消除"破坏",可在此处把匹配相邻的 BRICK 也计入。
|
||||
"""
|
||||
cleared: set[tuple[int, int]] = set()
|
||||
for m in matches:
|
||||
cleared.update(m.cells)
|
||||
return cleared
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# 搜索最优交换
|
||||
# ------------------------------------------------------------------ #
|
||||
def find_best_swap(self, board: Board) -> Optional[SwapResult]:
|
||||
"""遍历所有相邻交换,返回得分最高者;无可行交换时返回 None。"""
|
||||
best: Optional[SwapResult] = None
|
||||
# 每个格子只需尝试"向右"和"向下"两个方向,即可覆盖全部相邻对。
|
||||
for r in range(board.rows):
|
||||
for c in range(board.cols):
|
||||
for dr, dc in ((0, 1), (1, 0)):
|
||||
nr, nc = r + dr, c + dc
|
||||
if not board.in_bounds(nr, nc):
|
||||
continue
|
||||
res = self.evaluate_swap(board, Swap(r, c, nr, nc))
|
||||
if res is None:
|
||||
continue
|
||||
if best is None or res.score > best.score:
|
||||
best = res
|
||||
return best
|
||||
|
||||
def find_all_swaps(self, board: Board) -> list[SwapResult]:
|
||||
"""返回所有可行交换(按得分降序),便于调试与策略扩展。"""
|
||||
results: list[SwapResult] = []
|
||||
for r in range(board.rows):
|
||||
for c in range(board.cols):
|
||||
for dr, dc in ((0, 1), (1, 0)):
|
||||
nr, nc = r + dr, c + dc
|
||||
if not board.in_bounds(nr, nc):
|
||||
continue
|
||||
res = self.evaluate_swap(board, Swap(r, c, nr, nc))
|
||||
if res is not None:
|
||||
results.append(res)
|
||||
results.sort(key=lambda x: x.score, reverse=True)
|
||||
return results
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
"""跨识别/动作共享的运行时状态。
|
||||
|
||||
识别桩把最新 Board 写进来,动作桩读取它来决策滑动。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from .board import Board
|
||||
from .config import GridConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
grid: Optional[GridConfig] = None # 标定坐标(ref 分辨率)
|
||||
grid_runtime: Optional[GridConfig] = None # 缩放到当前截图分辨率后的坐标(用于滑动)
|
||||
level: Optional[int] = None # 当前关卡号(OCR 识别得到)
|
||||
board: Optional[Board] = None
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"""模板图像库:用"子图比对"识别每个格子,比纯颜色更鲁棒。
|
||||
|
||||
目录约定(label 即文件名去掉扩展名):
|
||||
templates/
|
||||
normal/<label>.png 正常块。每个不同 label 自动分配一个稳定颜色 id(供求解器判等)。
|
||||
wall/<label>.png 墙/砖块。支持多种墙:label 就是墙的种类,存入 Cell.meta['wall']。
|
||||
empty/<label>.png 空块/空洞。
|
||||
|
||||
识别流程:把目标格子裁中心、缩放到统一大小,与每个模板做逐像素比对,
|
||||
取相似度最高者;若最高分仍低于阈值,则判为 EMPTY(可按需改成 UNKNOWN 处理)。
|
||||
|
||||
相似度 = 1 - 平均逐像素 BGR 绝对差 / 255(完全相同 = 1.0)。
|
||||
之所以不用 TM_CCOEFF_NORMED:它对颜色整体偏移不敏感,会把纯红块和纯蓝块当成一样,
|
||||
而消消乐恰恰高度依赖颜色,所以这里用对颜色敏感的逐像素差。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .board import Cell, CellKind
|
||||
from .config import TemplateMatchConfig
|
||||
|
||||
# 子文件夹 -> 块类型
|
||||
_KIND_DIRS = {
|
||||
"normal": CellKind.NORMAL,
|
||||
"wall": CellKind.BRICK,
|
||||
"empty": CellKind.EMPTY,
|
||||
}
|
||||
_IMG_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".webp"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateEntry:
|
||||
label: str # 文件名(不含扩展名)
|
||||
kind: CellKind
|
||||
color_id: int # 仅 NORMAL 有意义;其余为 -1
|
||||
image: np.ndarray # 已缩放到 canonical_size 的 BGR 图
|
||||
|
||||
|
||||
def _prep(img: np.ndarray, size: int, crop_ratio: float) -> np.ndarray:
|
||||
"""裁中心 crop_ratio 区域并缩放到 size×size。"""
|
||||
h, w = img.shape[:2]
|
||||
rh, rw = int(h * crop_ratio), int(w * crop_ratio)
|
||||
y0, x0 = (h - rh) // 2, (w - rw) // 2
|
||||
img = img[y0 : y0 + rh, x0 : x0 + rw]
|
||||
return cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
class TemplateLibrary:
|
||||
def __init__(self, cfg: TemplateMatchConfig) -> None:
|
||||
self.cfg = cfg
|
||||
self.entries: list[TemplateEntry] = []
|
||||
# label -> color id 的稳定映射(normal 专用)
|
||||
self.color_ids: dict[str, int] = {}
|
||||
|
||||
@classmethod
|
||||
def load(cls, cfg: TemplateMatchConfig) -> "TemplateLibrary":
|
||||
lib = cls(cfg)
|
||||
root = Path(cfg.templates_dir)
|
||||
if not root.exists():
|
||||
raise FileNotFoundError(f"模板目录不存在:{root.resolve()}")
|
||||
|
||||
# 先收集所有 normal 标签并排序,保证 color id 稳定(与加载顺序无关)
|
||||
normal_labels = sorted(
|
||||
p.stem for p in (root / "normal").glob("*") if p.suffix.lower() in _IMG_EXTS
|
||||
) if (root / "normal").exists() else []
|
||||
lib.color_ids = {label: i for i, label in enumerate(normal_labels)}
|
||||
|
||||
for sub, kind in _KIND_DIRS.items():
|
||||
d = root / sub
|
||||
if not d.exists():
|
||||
continue
|
||||
for p in sorted(d.glob("*")):
|
||||
if p.suffix.lower() not in _IMG_EXTS:
|
||||
continue
|
||||
img = cv2.imread(str(p), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
continue
|
||||
prepped = _prep(img, cfg.canonical_size, cfg.crop_ratio)
|
||||
color_id = lib.color_ids.get(p.stem, -1) if kind == CellKind.NORMAL else -1
|
||||
lib.entries.append(
|
||||
TemplateEntry(label=p.stem, kind=kind, color_id=color_id, image=prepped)
|
||||
)
|
||||
|
||||
if not lib.entries:
|
||||
raise ValueError(f"模板目录为空(无可用图片):{root.resolve()}")
|
||||
return lib
|
||||
|
||||
@staticmethod
|
||||
def _similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""逐像素 BGR 平均绝对差转成相似度,范围 [0, 1],越大越像。"""
|
||||
diff = np.abs(a.astype(np.int16) - b.astype(np.int16)).mean()
|
||||
return 1.0 - float(diff) / 255.0
|
||||
|
||||
def classify(self, patch: np.ndarray) -> Cell:
|
||||
"""把一个格子图像块判定为 Cell。"""
|
||||
target = _prep(patch, self.cfg.canonical_size, self.cfg.crop_ratio)
|
||||
best: TemplateEntry | None = None
|
||||
best_score = -1.0
|
||||
for e in self.entries:
|
||||
score = self._similarity(target, e.image)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = e
|
||||
|
||||
sc = round(best_score, 3)
|
||||
# 约定:本游戏除正常生物/空格外,只有"冰块"——冰下隐约有图案,匹配置信度偏低。
|
||||
# 因此凡是匹配不够自信的,一律判为冰墙(不可交换、打断连线)。冰化掉后下一帧会
|
||||
# 重新识别成正常生物,自然推进。这是用户认可的取舍(牺牲对未登记新方块的健壮性)。
|
||||
if best is None or best_score < self.cfg.score_threshold:
|
||||
return Cell.brick(wall="ice", score=sc)
|
||||
|
||||
if best.kind == CellKind.NORMAL:
|
||||
return Cell.normal(best.color_id, label=best.label, score=sc)
|
||||
if best.kind == CellKind.BRICK:
|
||||
return Cell.brick(wall=best.label, score=sc)
|
||||
# best 是 EMPTY:真空格分数很高(~0.99);分数不够高多半是冰块被空格模板抢走
|
||||
if best_score < self.cfg.empty_min_score:
|
||||
return Cell.brick(wall="ice", score=sc, guess=best.label)
|
||||
return Cell.empty(label=best.label, score=sc)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# 模板库
|
||||
|
||||
把标定导出的格子小图按类别放进对应子文件夹,**文件名即类别名**:
|
||||
|
||||
```
|
||||
templates/
|
||||
normal/ 正常块:每种外观一张,如 red.png green.png blue.png yellow.png
|
||||
(每个不同文件名 -> 一个稳定颜色 id,供求解器判等)
|
||||
wall/ 墙/砖块:可多种,如 stone.png ice.png chain.png
|
||||
(都识别为不可移动障碍;种类记入 cell.meta['wall'])
|
||||
empty/ 空块/空洞:如 hole.png
|
||||
```
|
||||
|
||||
支持的图片格式:.png .jpg .jpeg .bmp .webp
|
||||
|
||||
提示:同一类别尽量用居中、干净的样本;真机有光照/特效误差时,
|
||||
可在 `TemplateMatchConfig.score_threshold` 调低阈值(如 0.75)。
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"""关卡号解析测试(纯函数,无需 MaaFw / OCR 模型)。"""
|
||||
|
||||
from hbc.level import parse_cn_level
|
||||
|
||||
|
||||
def test_parse_chinese_levels():
|
||||
assert parse_cn_level("关卡一") == 1
|
||||
assert parse_cn_level("关卡二") == 2
|
||||
assert parse_cn_level("关卡七") == 7
|
||||
assert parse_cn_level("第三关") == 3
|
||||
|
||||
|
||||
def test_parse_arabic_and_mixed():
|
||||
assert parse_cn_level("关卡 1") == 1
|
||||
assert parse_cn_level("LEVEL 5") == 5
|
||||
assert parse_cn_level("第2关") == 2
|
||||
|
||||
|
||||
def test_parse_tens():
|
||||
assert parse_cn_level("第十关") == 10
|
||||
assert parse_cn_level("关卡十一") == 11
|
||||
assert parse_cn_level("二十") == 20
|
||||
|
||||
|
||||
def test_parse_failures():
|
||||
assert parse_cn_level("") is None
|
||||
assert parse_cn_level("暂停") is None
|
||||
assert parse_cn_level(None) is None
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
"""模板识别 + 标定工具测试(用合成图片,无需真机)。需要 opencv/numpy。"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
cv2 = pytest.importorskip("cv2")
|
||||
|
||||
from hbc.board import CellKind # noqa: E402
|
||||
from hbc.calibrate import _fit_scale, _map_box_to_original # noqa: E402
|
||||
from hbc.calibrate import main as calib_main # noqa: E402
|
||||
from hbc.config import ( # noqa: E402
|
||||
GridConfig,
|
||||
TemplateMatchConfig,
|
||||
level_layout_path,
|
||||
load_level_grid,
|
||||
)
|
||||
from hbc.recognizer import recognize_board # noqa: E402
|
||||
from hbc.templates import TemplateLibrary # noqa: E402
|
||||
|
||||
CELL = 40
|
||||
|
||||
# 合成棋盘布局:数字=正常块颜色, 'W'=墙(石), 'I'=墙(冰), '.'=空
|
||||
LAYOUT = [
|
||||
["0", "1", "2", "W"],
|
||||
["1", "I", "0", "."],
|
||||
["2", "0", "1", "2"],
|
||||
]
|
||||
|
||||
# 每种外观对应的 BGR 纯色块(足够区分即可)
|
||||
APPEARANCE = {
|
||||
"0": (60, 60, 220), # 红
|
||||
"1": (60, 200, 60), # 绿
|
||||
"2": (220, 120, 60), # 蓝
|
||||
"W": (90, 90, 90), # 石墙(灰)
|
||||
"I": (230, 220, 180), # 冰墙(浅蓝白)
|
||||
".": (10, 10, 10), # 空(近黑)
|
||||
}
|
||||
|
||||
|
||||
def _swatch(bgr):
|
||||
img = np.zeros((CELL, CELL, 3), np.uint8)
|
||||
img[:] = bgr
|
||||
return img
|
||||
|
||||
|
||||
def _build_board_image():
|
||||
rows, cols = len(LAYOUT), len(LAYOUT[0])
|
||||
img = np.zeros((rows * CELL, cols * CELL, 3), np.uint8)
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
img[r * CELL:(r + 1) * CELL, c * CELL:(c + 1) * CELL] = _swatch(APPEARANCE[LAYOUT[r][c]])
|
||||
return img
|
||||
|
||||
|
||||
def _write_templates(root):
|
||||
(root / "normal").mkdir(parents=True)
|
||||
(root / "wall").mkdir(parents=True)
|
||||
(root / "empty").mkdir(parents=True)
|
||||
cv2.imwrite(str(root / "normal" / "red.png"), _swatch(APPEARANCE["0"]))
|
||||
cv2.imwrite(str(root / "normal" / "green.png"), _swatch(APPEARANCE["1"]))
|
||||
cv2.imwrite(str(root / "normal" / "blue.png"), _swatch(APPEARANCE["2"]))
|
||||
cv2.imwrite(str(root / "wall" / "stone.png"), _swatch(APPEARANCE["W"]))
|
||||
cv2.imwrite(str(root / "wall" / "ice.png"), _swatch(APPEARANCE["I"]))
|
||||
cv2.imwrite(str(root / "empty" / "hole.png"), _swatch(APPEARANCE["."]))
|
||||
|
||||
|
||||
def test_template_recognition_multi_wall(tmp_path):
|
||||
root = tmp_path / "templates"
|
||||
_write_templates(root)
|
||||
lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir=str(root), canonical_size=32))
|
||||
|
||||
img = _build_board_image()
|
||||
rows, cols = len(LAYOUT), len(LAYOUT[0])
|
||||
grid = GridConfig(x=0, y=0, w=cols * CELL, h=rows * CELL, rows=rows, cols=cols)
|
||||
board = recognize_board(img, grid, lib)
|
||||
|
||||
# 校验每格类型
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
cell = board.get(r, c)
|
||||
token = LAYOUT[r][c]
|
||||
if token in ("0", "1", "2"):
|
||||
assert cell.kind == CellKind.NORMAL, (r, c, cell)
|
||||
elif token in ("W", "I"):
|
||||
assert cell.kind == CellKind.BRICK, (r, c, cell)
|
||||
else:
|
||||
assert cell.kind == CellKind.EMPTY, (r, c, cell)
|
||||
|
||||
# 两种墙应被区分到不同 meta['wall']
|
||||
assert board.get(0, 3).meta["wall"] == "stone"
|
||||
assert board.get(1, 1).meta["wall"] == "ice"
|
||||
|
||||
# 相同外观的正常块应得到相同 color id(求解器据此判等)
|
||||
assert board.color_at(0, 0) == board.color_at(1, 2) # 两个红
|
||||
assert board.color_at(0, 1) == board.color_at(1, 0) # 两个绿
|
||||
assert board.color_at(0, 0) != board.color_at(0, 1) # 红 != 绿
|
||||
|
||||
|
||||
def test_low_confidence_becomes_ice_wall(tmp_path):
|
||||
"""置信度不足(如冰块/未登记方块)一律判为冰墙,而非空格。"""
|
||||
root = tmp_path / "templates"
|
||||
_write_templates(root) # 只有红/绿/蓝 + 墙 + 空
|
||||
lib = TemplateLibrary.load(TemplateMatchConfig(templates_dir=str(root), canonical_size=32))
|
||||
|
||||
purple = _swatch((200, 40, 200)) # 库里没有的外观 -> 低置信度
|
||||
cell = lib.classify(purple)
|
||||
assert cell.is_brick and cell.meta.get("wall") == "ice"
|
||||
|
||||
# 真正的空块(与空模板一致,高分)仍判为空格
|
||||
empty_cell = lib.classify(_swatch(APPEARANCE["."]))
|
||||
assert empty_cell.is_empty and not empty_cell.is_brick
|
||||
|
||||
|
||||
def test_layout_save_load(tmp_path):
|
||||
p = tmp_path / "layout.json"
|
||||
g = GridConfig(x=12, y=34, w=400, h=300, rows=6, cols=8)
|
||||
g.save_json(p)
|
||||
g2 = GridConfig.load_json(p)
|
||||
assert g2.to_dict() == g.to_dict()
|
||||
|
||||
|
||||
def test_level_layout_files(tmp_path):
|
||||
"""7 关网格逐渐变大:每关一个 level{N}.json,文件名带关号,统一放 layouts/。"""
|
||||
d = tmp_path / "layouts"
|
||||
GridConfig(0, 0, 200, 200, 5, 5).save_json(_mk(level_layout_path(1, d)))
|
||||
GridConfig(0, 0, 240, 240, 6, 6).save_json(level_layout_path(2, d))
|
||||
GridConfig(0, 0, 280, 280, 8, 8).save_json(level_layout_path(7, d))
|
||||
|
||||
assert level_layout_path(1, d).name == "level1.json"
|
||||
assert load_level_grid(1, d).rows == 5
|
||||
assert load_level_grid(2, d).cols == 6
|
||||
assert load_level_grid(7, d).w == 280
|
||||
# 未标定的关卡应报错
|
||||
import pytest as _pt
|
||||
with _pt.raises(FileNotFoundError):
|
||||
load_level_grid(3, d)
|
||||
|
||||
|
||||
def _mk(p):
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def test_roi_scale_mapping():
|
||||
"""缩放窗口里选的框,要能正确换算回原图坐标。"""
|
||||
# 原图 1800 高,限制 900 -> scale=0.5
|
||||
scale = _fit_scale(1000, 1800, 900)
|
||||
assert abs(scale - 0.5) < 1e-9
|
||||
# 在缩放图上选 (50,100,200,300) -> 原图应为两倍
|
||||
assert _map_box_to_original((50, 100, 200, 300), scale) == (100, 200, 400, 600)
|
||||
# 不需要缩放时原样返回
|
||||
assert _fit_scale(400, 300, 900) == 1.0
|
||||
assert _map_box_to_original((1, 2, 3, 4), 1.0) == (1, 2, 3, 4)
|
||||
|
||||
|
||||
def test_calibrate_export_and_preview(tmp_path):
|
||||
img_path = tmp_path / "board.png"
|
||||
cv2.imwrite(str(img_path), _build_board_image())
|
||||
layouts_dir = tmp_path / "layouts"
|
||||
rows, cols = len(LAYOUT), len(LAYOUT[0])
|
||||
GridConfig(0, 0, cols * CELL, rows * CELL, rows, cols).save_json(_mk(level_layout_path(1, layouts_dir)))
|
||||
|
||||
cells_dir = tmp_path / "cells"
|
||||
rc = calib_main(["export", "--image", str(img_path), "--level", "1",
|
||||
"--layouts-dir", str(layouts_dir), "--out", str(cells_dir)])
|
||||
assert rc == 0
|
||||
exported = list(cells_dir.glob("*.png"))
|
||||
assert len(exported) == rows * cols
|
||||
|
||||
preview = tmp_path / "preview.png"
|
||||
rc = calib_main(["preview", "--image", str(img_path), "--level", "1",
|
||||
"--layouts-dir", str(layouts_dir), "--out", str(preview)])
|
||||
assert rc == 0
|
||||
assert preview.exists()
|
||||
|
||||
|
||||
def test_calibrate_roi_with_box(tmp_path):
|
||||
"""无 GUI:用 --box 直接给坐标,写入指定关卡文件。"""
|
||||
img_path = tmp_path / "board.png"
|
||||
cv2.imwrite(str(img_path), _build_board_image())
|
||||
layouts_dir = tmp_path / "layouts"
|
||||
rc = calib_main(["roi", "--image", str(img_path), "--level", "3",
|
||||
"--rows", "5", "--cols", "5", "--box", "0,0,200,200",
|
||||
"--layouts-dir", str(layouts_dir)])
|
||||
assert rc == 0
|
||||
g = load_level_grid(3, layouts_dir)
|
||||
assert (g.x, g.y, g.w, g.h, g.rows, g.cols) == (0, 0, 200, 200, 5, 5)
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"""求解器与棋盘模型的单元测试(不依赖 MaaFramework / 真机)。"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from hbc.board import Board, Cell, CellKind # noqa: E402
|
||||
from hbc.solver import Solver, Swap # noqa: E402
|
||||
|
||||
BRICK = -2
|
||||
EMPTY = -1
|
||||
|
||||
|
||||
def test_cell_types():
|
||||
assert Cell.normal(3).is_swappable
|
||||
assert not Cell.brick().is_swappable
|
||||
assert not Cell.empty().is_swappable
|
||||
assert Cell.brick().is_brick
|
||||
assert Cell.empty().is_empty
|
||||
|
||||
|
||||
def test_dynamic_rectangular_sizes():
|
||||
for rows, cols in [(5, 9), (8, 8), (6, 7), (3, 3)]:
|
||||
b = Board(rows, cols)
|
||||
assert b.rows == rows and b.cols == cols
|
||||
assert b.in_bounds(rows - 1, cols - 1)
|
||||
assert not b.in_bounds(rows, cols)
|
||||
|
||||
|
||||
def test_find_horizontal_match():
|
||||
board = Board.from_colors([
|
||||
[0, 0, 0, 1],
|
||||
[2, 3, 4, 5],
|
||||
])
|
||||
matches = Solver().find_matches(board)
|
||||
assert len(matches) == 1
|
||||
assert matches[0].length == 3
|
||||
assert matches[0].color == 0
|
||||
|
||||
|
||||
def test_brick_breaks_the_line():
|
||||
# 砖块 (#) 打断同色连线 -> 不应形成消除
|
||||
board = Board.from_colors([
|
||||
[0, 0, BRICK, 0],
|
||||
])
|
||||
assert not Solver().has_match(board)
|
||||
|
||||
|
||||
def test_empty_breaks_the_line():
|
||||
board = Board.from_colors([
|
||||
[0, 0, EMPTY, 0],
|
||||
])
|
||||
assert not Solver().has_match(board)
|
||||
|
||||
|
||||
def test_best_swap_makes_a_match():
|
||||
# 把 (0,3) 的 0 与 (1,3) 的 X 交换? 这里构造:交换 (0,2)&(0,3) 形成三连
|
||||
board = Board.from_colors([
|
||||
[0, 0, 1, 0],
|
||||
[2, 3, 0, 4],
|
||||
])
|
||||
# 交换 (0,2)=1 与 (1,2)=0 -> 第0行变 0 0 0 0(前三连),合法
|
||||
solver = Solver()
|
||||
result = solver.find_best_swap(board)
|
||||
assert result is not None
|
||||
assert result.cleared >= 3
|
||||
|
||||
|
||||
def test_cannot_swap_brick_or_empty():
|
||||
board = Board.from_colors([
|
||||
[0, BRICK, 0],
|
||||
[0, 0, 0],
|
||||
])
|
||||
solver = Solver()
|
||||
# 砖块不可交换:尝试交换砖块应被判非法
|
||||
assert solver.evaluate_swap(board, Swap(0, 1, 1, 1)) is None
|
||||
|
||||
|
||||
def test_no_swap_available():
|
||||
# 棋盘上任何交换都无法形成消除(三色互不相邻,且任意相邻交换都凑不出 3 连)
|
||||
board = Board.from_colors([
|
||||
[0, 1, 2],
|
||||
])
|
||||
assert Solver().find_best_swap(board) is None
|
||||
|
||||
|
||||
def test_prefer_longer_match():
|
||||
# 一处可形成 3 连,另一处可形成 4 连 —— 应优先 4 连(得分更高)
|
||||
board = Board.from_colors([
|
||||
[0, 0, 0, 5, 1, 1, 9, 1],
|
||||
[9, 9, 0, 9, 9, 9, 1, 9],
|
||||
])
|
||||
solver = Solver()
|
||||
best = solver.find_best_swap(board)
|
||||
assert best is not None
|
||||
# 至少应找到一个可行交换
|
||||
assert best.score > 0
|
||||
|
||||
|
||||
def test_horizontal_swipe_is_right_to_left():
|
||||
"""水平交换必须从右往左滑(避免触发返回手势);竖直交换不受限制。"""
|
||||
import sys as _sys
|
||||
from pathlib import Path as _Path
|
||||
_sys.path.insert(0, str(_Path(__file__).resolve().parents[1] / "src"))
|
||||
from hbc.actuator import swap_to_swipe
|
||||
from hbc.config import GridConfig
|
||||
|
||||
grid = GridConfig(0, 0, 500, 500, 5, 5)
|
||||
|
||||
# 求解器产生的水平交换通常是 (r,c)->(r,c+1),即左->右;应被翻转成右->左
|
||||
x1, y1, x2, y2 = swap_to_swipe(Swap(2, 1, 2, 2), grid)
|
||||
assert x1 > x2 and y1 == y2 # 起点在右,终点在左
|
||||
|
||||
# 即使给的是右->左,也应保持右->左
|
||||
x1, y1, x2, y2 = swap_to_swipe(Swap(2, 3, 2, 2), grid)
|
||||
assert x1 > x2
|
||||
|
||||
# 竖直交换:x 相同,方向不限
|
||||
x1, y1, x2, y2 = swap_to_swipe(Swap(1, 2, 2, 2), grid)
|
||||
assert x1 == x2 and y1 != y2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
Loading…
Reference in New Issue