prioritize nonrectangular screenshot map boundaries
This commit is contained in:
+21
-9
@@ -40,12 +40,12 @@
|
||||
|
||||
## 1. 当前版本
|
||||
|
||||
- 交接文件版本:`1.68.0`
|
||||
- 交接文件版本:`1.69.0`
|
||||
- 生成器/独立分析台版本:`0.21.0`
|
||||
- 相似关卡检查器版本:`1.1.3`
|
||||
- 独立关卡编辑器版本:`0.18.0`
|
||||
- 独立关卡编辑器版本:`0.19.0`
|
||||
- 最后更新时间:`2026-10-19`(Asia/Shanghai)
|
||||
- 最新实施(1.68.0):编辑器0.18.0把深灰墙内沿设为完整地图范围的最高优先级,所有自动网格默认+12(0.12),歧义时按最短倒计时图对齐;修复冻结门4/9相邻数字分离、20前导数字和Level25的6×10地图/六组冻结块/三组木板盖板识别。新增几何分组回归,编辑器、生成/分析台、查重器联合测试385/385通过。浏览器上传验证本轮因文件选择器中断,未写成已完成;未做Cocos、原生Windows或Intel实机回放,视觉阈值仍是当前皮肤启发式。
|
||||
- 最新实施(1.69.0):编辑器0.19.0把顶部15%信息区作为所有自动网格候选的扫描下限,继续以固定深灰墙内沿确定地图包络,并明确允许非矩形地图(坐标包络加holes),所有自动网格和补图对齐默认+12;新增六张用户第25关截图回归,每张恢复6×10,边界位于信息区下限之下。新增当前识别算法主说明,编辑器、生成/分析台、查重器联合测试387/387通过,截图专项10/10通过;双平台0.19.0包已按“只保留最新版本”重建并校验。未做浏览器上传、Cocos、原生Windows或Intel实机回放,视觉阈值仍是当前皮肤启发式。
|
||||
- 历史实施(1.66.1):编辑器0.16.1修复选格组成新块后无法应用:同功能的已知色与待确认色允许合并,继承唯一已知色;全部未知仍留待确认。新块立即写入识别结果、保留选格并自动成为改色目标。新增底色选择即应用;开局图与补图均可从多个同色格取色,忽略不明确格但拒绝多种已知色。取色与手选支持撤销,不改变位置、机关和计数。 369项联合回归通过;浏览器第22关实际选格将20候选修正为16块,取色、直接改色及撤销通过,核对后载入按钮启用。Mac便携包21项、Windows包服务层19项通过,详见5.99。
|
||||
- 历史实施(1.66.0):编辑器0.16.0修复第22关:独立墙体内沿候选优先于颜色连通区,以长暗蓝灰墙/内侧彩色表面交界定位矩形地图,再以宽度投票求列数和边缘拟合行距。三张原图恢复6×9、无缺口、4门,冰门10/10/10;六个非矩形盖板计数全6,蓝底叠加色8/10/7/7/10/8;不支持的大蓝连通区按最强双端圆角接缝拆成两根1×3和一个2×2。白特效遮挡4格保留待确认并阻断导出。 365联合/54图像回归与浏览器三图识别通过;理念1.41、EDIT-44/45、feedback91与三台矩阵同步。仅修改新Git仓库;分发及边界见5.98。
|
||||
- 历史维护(1.65.1):将当前完整工程迁移至 /Users/ruoruo/Documents/MatchMaster/MatchMaster_level 空Git仓库,沿用目标main与origin;原工作目录保留。按用户追加要求清理两处旧便携包、重复解压目录及20261012迁移包,本地仅留0.15.0双平台ZIP与SHA256,分发包不提交Git。源码/资源/关卡/测试样本/文档/反馈/验证记录提交,Cocos缓存/个人配置与既有runs忽略规则保留。 新目录358/358回归、四台首页/状态HTTP 200、编辑器2020游戏关卡与生成1835索引通过;最新ZIP SHA256通过。运行版本不变;工程提交d9de408已成功推送origin/main(SSH认证),详见5.97。
|
||||
@@ -3843,15 +3843,15 @@ opening.js未接标准生成/网页,仅手工流程和程序调用可用;有
|
||||
5. 改了运行代码时核对版本/缓存/启动行为,注明是否需重启;不要未经许可关闭用户现有任务。仅文档修改不空升应用版本。
|
||||
6. 查重不提供可解性证明;库仍各有写入边界,不自动覆盖Cocos/共享库/查重库。复制整个目录后的接手者继续执行。
|
||||
|
||||
本轮同步矩阵(2026-10-19 / 交接1.68.0,深灰墙优先、全局+12与Level25):
|
||||
本轮同步矩阵(2026-10-19 / 交接1.69.0,顶部15%信息区、非矩形地图包络与当前算法说明):
|
||||
|
||||
| 入口 | 状态与实现/原因 | 验证与边界 |
|
||||
|---|---|---|
|
||||
| 生成工作台 | 已检查无需改算法/配置/API/UI/报告:截图识别只输出既有游戏JSON协议,本轮无生成规则变化 | 385项联合回归覆盖共享模型;未新增难度或可解性证明 |
|
||||
| 求解与设计分析台 | 已检查无需改模型/算法/API/UI/报告:没有新增机关或字段 | 既有求解/分析入口在385项联合回归中通过;未做本关Cocos回放 |
|
||||
| 关卡查重器 | 已检查无需改算法/配置/API/UI/报告:继续消费同一方块、门和地图字段 | Level25往返解析及全套查重测试通过;不承担可解性证明 |
|
||||
| 关卡编辑器 | 已同步0.18.0墙内沿优先、全局+12、短计时参考、数字分离与Level25分区 | 截图74/74、联合385/385;浏览器上传步骤中断,未声称浏览器通过 |
|
||||
| 理念/总表/feedback/记忆 | 已同步理念1.43、EDIT-49/50、feedback94、交接1.68.0 | 视觉阈值归为启发式;无新卡点/难度/趣味结论 |
|
||||
| 生成工作台 | 已检查无需改算法/配置/API/UI/报告:截图识别只输出既有游戏JSON协议,本轮未新增生成规则 | 387项联合回归覆盖共享模型;未新增难度或可解性证明 |
|
||||
| 求解与设计分析台 | 已检查无需改模型/算法/API/UI/报告:没有新增机关或字段 | 387项联合回归覆盖现有求解/分析消费入口;未做本关Cocos回放 |
|
||||
| 关卡查重器 | 已检查无需改算法/配置/API/UI/报告:继续消费同一方块、门和地图字段 | 387项联合回归、截图专项10项通过;不承担可解性证明 |
|
||||
| 关卡编辑器 | 已同步0.19.0顶部15%信息区、深灰墙内沿、非矩形包络、全局+12、短计时参考、说明与六张用户样本 | 新用户样本2/2、截图专项10/10、联合387/387;未做浏览器上传 |
|
||||
| 理念/总表/feedback/记忆 | 已同步理念1.44、EDIT-51、feedback95、交接1.69.0及当前算法文档 | 视觉阈值归为当前皮肤启发式;无新卡点/难度/趣味结论 |
|
||||
|
||||
本轮同步矩阵(2026-10-16 / 交接1.67.0,第23关墙色与相邻冰门):
|
||||
|
||||
@@ -4463,6 +4463,8 @@ opening.js未接标准生成/网页,仅手工流程和程序调用可用;有
|
||||
|
||||
## 14. 变更日志
|
||||
|
||||
- `2026-10-19 / 1.69.0`:编辑器0.19.0将顶部15%信息区设为自动识别扫描下限,深灰墙内沿继续作为第一地图边界证据;非矩形地图保存为墙内沿坐标包络加holes,所有自动网格和补图对齐默认+12。新增六张用户第25关截图夹具及2项回归,六图均6×10;新增当前识别算法主说明,规则总表EDIT-51、理念1.44和feedback95同步。联合387/387、截图10/10、用户样本2/2通过;双平台0.19.0 ZIP/SHA校验通过,outputs/releases只保留最新包。未做浏览器上传、Cocos、原生Windows/Intel实机或可解/最优证明。
|
||||
|
||||
- `2026-10-16 / 1.66.0`:编辑器0.16.0修复第22关:独立墙体内沿候选优先于颜色连通区,以长暗蓝灰墙/内侧彩色表面交界定位矩形地图,再以宽度投票求列数和边缘拟合行距。三张原图恢复6×9、无缺口、4门,冰门10/10/10;六个非矩形盖板计数全6,蓝底叠加色8/10/7/7/10/8;不支持的大蓝连通区按最强双端圆角接缝拆成两根1×3和一个2×2。白特效遮挡4格保留待确认并阻断导出。 365联合/54图像与浏览器验证;同期修复迁移反馈ID索引。应用0.16.0,理念1.41/feedback91/矩阵已同步;实现限制见5.98。
|
||||
|
||||
- `2026-10-16 / 1.65.1`:当前工程迁移目标Git仓库;用户授权删除历史便携/重复解压/旧迁移包,仅保留最新分发ZIP于本地。新目录358回归与四台HTTP验证通过,运行版本不变,理念1.40.1/feedback90/同步矩阵已维护。
|
||||
@@ -5193,6 +5195,16 @@ opening.js未接标准生成/网页,仅手工流程和程序调用可用;有
|
||||
|
||||
- 1.66.1补充验收:浏览器已实际载入第22关,6×9/16块/4门,静态检查通过;截图outputs/selected-cell-fix-20261016/browser-proof.png。维护测试8/8;Mac搬迁21项、Windows包服务层19项通过(非原生Windows实机)。两包关键源码逐字节与仓库一致。outputs/releases仅保留0.16.1双平台ZIP/SHA,旧0.16.0与重复解压构建已删除。新服务http://127.0.0.1:4502/api/status确认0.16.1,未刷新用户原有校正页。
|
||||
|
||||
### 5.102 顶部15%信息区、非矩形地图包络与当前算法说明(2026-10-19 / 1.69.0)
|
||||
|
||||
- 根因与实现:旧实现虽有墙色和+12先验,但回退路径仍可能把彩色块密度当地图范围,且没有明确记录非矩形坐标包络。`local-vision.js` 新增 `MAP_INFO_FRACTION=0.15`,墙、地板、连通块、矩形扫描、格点、墙地标和补图对齐均从信息区下限开始;墙候选仍优先使用四组固定深灰色,并从墙体内侧边缘取上下左右界。若15%起点遇到HUD暗边,使用同墙算法的27%保守回退,仅拒绝HUD候选,不把彩色块当地图外边界。
|
||||
- 非矩形表示:自动网格附带 `mapInfoCutoff`、`mapBoundary`、`mapShape`、`nonRectangular` 和墙证据字段;四角与行列表达坐标包络,空行、缺列和内部空洞继续进入 `spec.holes`。手动网格仍可显式覆盖,不改变导出JSON协议。
|
||||
- 用户样本:新增 `local-vision-level25-user.test.js` 及六张用户截图RGBA夹具;六张图(原图和标注图)均为 `wall-outline`、6×10、`rowCurve=0.12`,地图上沿位于截图高度15%下限之后,四张原图重建保留6个holes和至少8个可见方块。
|
||||
- 算法说明:`colorBlock-level-editor/docs/SCREENSHOT_RECOGNITION_EXPLAINED.md` 顶部新增0.19.0当前算法章节,列出高优先级顺序、返回字段、非矩形策略和视觉启发式限制;同步维护规则总表EDIT-51、理念1.44和feedback95。
|
||||
- 版本与分发:编辑器版本升至0.19.0,服务状态、网页头、测试断言、README和双平台打包清单同步;旧0.18.0 ZIP/SHA和解压目录从 `outputs/releases` 清理,只保留最新0.19.0包。
|
||||
- 证据:编辑器/生成分析/查重联合回归387/387通过;截图专项10/10通过;新增用户样本2/2通过;`git diff --check`通过;Mac包7830文件、Windows包7827文件,官方运行时哈希/ZIP清单/执行权限由打包脚本校验通过。未浏览器上传、Cocos、原生Windows或Intel实机回放,未证明可解或最优。
|
||||
- §12.1影响矩阵:生成工作台已检查无需改算法/配置/API/UI/报告,截图仍输出同一游戏JSON;求解与设计分析台已检查无需改模型/API/UI/报告,未新增机关或字段;关卡查重器已检查无需改算法/协议/UI/报告,消费同一地图/方块/门字段且不承担可解性证明;关卡编辑器已同步代码、版本、说明、夹具与回归;理念文档、规则总表、feedback和项目记忆已同步。未完成项仅为浏览器端上传、Cocos和原生Windows/Intel实机,阻碍是本轮未执行这些环境验收,下一步按新服务启动后补做。
|
||||
|
||||
### 5.101 深灰墙优先、全局+12与Level25分组(2026-10-19 / 1.68.0)
|
||||
|
||||
- 实现:`colorBlock-level-editor/src/local-vision.js` 的自动候选先用固定深灰墙色和墙体内沿确定完整地图,再处理冰面、木板、空地和方块;所有自动网格统一使用 `rowCurve=0.12`,手动网格仍保留显式覆盖。墙边界不依赖“该处是否已有彩色方块”,底边取深灰墙上内沿;边界不清晰时使用可读的最短倒计时截图作为墙参考。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MatchMaster 关卡工具项目
|
||||
|
||||
当前工程:生成/分析 0.21.0、查重 1.1.3、编辑器 0.18.0。
|
||||
当前工程:生成/分析 0.21.0、查重 1.1.3、编辑器 0.19.0。
|
||||
|
||||
安装 Node.js 20 或以上版本后,Mac 双击根目录的 `.command`,Windows 双击同名 `.bat`:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
`colorBlock/build-templates` 是自定义构建模板,随源码提交。Cocos 的 library/temp/build/local 等缓存、个人导出路径、私有 IDE 配置和便携分发包不提交。
|
||||
生成器 `runs` 是已有忽略规则下的运行结果,仍保留在本地;正式关卡和研究证据另存于 levels、examples、docs、feedback 与 outputs。
|
||||
|
||||
按 2026-10-16 用户要求,旧便携包、重复解压目录及 20261012 迁移包已清理;本地 `outputs/releases` 仅保留最新 0.18.0 Mac/Windows ZIP 与 SHA256。需要重建时运行 scripts 中的打包工具。历史文档提及的旧分发包路径不再有效,但对应验证记录保留。
|
||||
按 2026-10-16 用户要求,旧便携包、重复解压目录及 20261012 迁移包已清理;本地 `outputs/releases` 仅保留最新 0.19.0 Mac/Windows ZIP 与 SHA256。需要重建时运行 scripts 中的打包工具。历史文档提及的旧分发包路径不再有效,但对应验证记录保留。
|
||||
|
||||
Mac 编辑器的“游戏当前关卡”默认从 `~/Documents/MatchMaster/MatchMaster/assets/custom/Json` 读取;可通过 `COLORBLOCK_GAME_LEVELS` 指向实际游戏目录。该外部游戏工程不属于本仓库,本仓库另带 colorBlock 源码快照及关卡库。部分历史校准/审计脚本引用外部原图或游戏路径,不是工作台启动依赖。
|
||||
|
||||
|
||||
@@ -1,3 +1,45 @@
|
||||
# 当前识别算法(编辑器0.19.0,2026-10-19)
|
||||
|
||||
这份是当前代码的主说明;下面保留的0.17.0及更早章节是历史记录。识别在浏览器本地完成,源码入口是 `src/local-vision.js`,工作线程和界面只负责解码、调度、校对与导出。
|
||||
|
||||
## 本轮错误的根因
|
||||
|
||||
旧路径已经有深灰墙色和 `+12` 先验,但这两个条件没有在所有回退路径上形成统一的地图包络约束:当开局图上半段没有足够的彩色块时,回退候选可能把“有颜色的区域”当成地图范围;同时输出虽然能表达 `holes`,却没有明确标出“矩形坐标包络中的非矩形地图”。这会把墙外的门、底部墙条或信息区边缘带进网格。
|
||||
|
||||
当前实现把“地图范围”和“可见方块”分成两个概念。地图先由墙体内沿确定一个坐标包络,再逐格采样;没有方块的格子成为 `holes`,不会因为没有颜色就把整行或整列从地图中删除。
|
||||
|
||||
## 高优先级顺序
|
||||
|
||||
1. **先排除信息区。** `MAP_INFO_FRACTION = 0.15`,所有自动候选的扫描下限都是截图高度的15%。`grid.mapInfoCutoff` 会记录这个像素位置;地图上边界必须在它下面。它只排除关卡号、倒计时、金币和按钮,不要求15%以下每个格子都已经有彩色方块。
|
||||
2. **固定深灰墙色。** `wallPixel()` 使用 `(61,70,104)`、`(28,30,53)`、`(91,103,142)`、`(58,64,95)` 四组样本,每通道最大差16。墙像素只提供候选,随后必须找到墙与内侧地面/方块的交界。
|
||||
3. **取墙的内边缘。** `wallOutlineGrid()` 先拟合左右墙线,再用横墙的上下内沿确定纵向范围;门和同色方块不参与外边界宽度投票。第一候选误把HUD暗边当墙时,会从约27%高度重新拟合同一组墙内沿作为保守回退。
|
||||
4. **允许非矩形地图。** 网格的四角和行列只是坐标包络;`grid.mapShape` 为 `non-rectangular-envelope` 时,空洞仍保留在 `spec.holes`。`sampleGrid()` 不会把空洞改成墙外,也不会用最长彩色连通块决定整张地图大小。
|
||||
5. **默认透视 +12。** `DEFAULT_ROW_CURVE = 0.12` 是所有自动网格和补图对齐的默认行曲线;手动四角仍可覆盖。它表示行距透视曲线,不是12像素或12度。
|
||||
6. **边界含糊时参考最短倒计时图。** `resolveBoundaryReference()` 选择可读倒计时最短的图片,用墙体地标做比例/平移对齐;它只修边界,不改变开局方块和计数。
|
||||
7. **再做格内识别。** 颜色分类、地板、方块连通/接缝、固定木板和叠加块都在网格确定后运行。非矩形缺口不会被补图中的颜色填成方块。
|
||||
8. **最后识别门和数字。** 门按可玩格外侧边分组;浅蓝数字先取字芯,再用描边模板和相邻字形分离,失败时保留待确认。门的宽度永远来自地图内沿,不来自相邻同色块。
|
||||
|
||||
## 当前返回数据中的边界证据
|
||||
|
||||
自动网格会附带以下可审计字段:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `wallAware` / `wallOutline` | 是否由深灰墙内沿候选确定 |
|
||||
| `mapBoundary` | 当前为 `dark-wall-inner-edge` |
|
||||
| `mapInfoFraction` / `mapInfoCutoff` | 顶部信息区比例及其像素下限 |
|
||||
| `mapShape` / `nonRectangular` | 坐标包络是否包含空行、缺列或内部缺口 |
|
||||
| `rowCurve` | 自动透视先验,当前为 `0.12` |
|
||||
| `wallEvidence` | 墙线样本、横墙样本和回退证据数量 |
|
||||
|
||||
这些字段是诊断信息,不是让导出格式依赖某一张截图。导出的关卡仍使用 `width`、`height`、`holes`、`pieces` 和 `gates`;这使非矩形地图可以被完整保存。
|
||||
|
||||
## 识别边界与验证
|
||||
|
||||
墙色、像素阈值、数字模板和透视先验针对当前游戏皮肤,是可解释的视觉启发式,不代表跨皮肤或任意压缩图片的准确率。白色特效、动画碎屑、遮挡后的方块颜色和移动后的补图仍会产生待确认项。当前已用用户提供的第25关六张截图回归:每张得到6×10、`wall-outline`、`rowCurve=0.12`,地图上边界位于15%下限之下,非矩形空洞保留;完整编辑器/生成分析/查重联合测试在本轮完成后记录在 `PROJECT_PROGRESS.md`。
|
||||
|
||||
---
|
||||
|
||||
# 0.17.0 更新:第23关墙沿、透视与相邻冰门(2026-10-16)
|
||||
|
||||
两张原图现在自动得到 **7列×9行、12缺口、24块、8扇门**,开局时间130秒。自动定位和手动网格重识别均保留底部红门4、紫门9;顶部粉门20,左上15、右上16、左下17、右下18。
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"name":"colorblock-level-editor","version":"0.18.0","private":true,"type":"module","engines":{"node":">=20"},"scripts":{"start":"node src/server.js --open","test":"node --test"}}
|
||||
{"name":"colorblock-level-editor","version":"0.19.0","private":true,"type":"module","engines":{"node":">=20"},"scripts":{"start":"node src/server.js --open","test":"node --test"}}
|
||||
|
||||
@@ -9,6 +9,12 @@ const median=a=>{const b=[...a].sort((x,y)=>x-y);return b.length?b[Math.floor(b.
|
||||
// Fixed skin palette measured from the user's horizontal/vertical wall samples.
|
||||
// Include the lit bevel and dark fascia, with a small compression tolerance.
|
||||
export const DEFAULT_ROW_CURVE=.12;
|
||||
// The upper part of a screenshot is HUD (level, timer, coins, controls). The
|
||||
// game board starts below this stable information band even when its first
|
||||
// rows contain no coloured blocks. Every automatic candidate uses this floor
|
||||
// when looking for wall, floor, or component evidence.
|
||||
export const MAP_INFO_FRACTION=.15;
|
||||
const mapSearchStart=frame=>Math.floor(frame.height*MAP_INFO_FRACTION);
|
||||
export function wallPixel(r,g,b){return [[61,70,104],[28,30,53],[91,103,142],[58,64,95]].some(c=>Math.max(Math.abs(r-c[0]),Math.abs(g-c[1]),Math.abs(b-c[2]))<=16);}
|
||||
export function hsv(r,g,b){const max=Math.max(r,g,b),min=Math.min(r,g,b),d=max-min;let h=0;if(d)h=max===r?((g-b)/d+6)%6:max===g?(b-r)/d+2:(r-g)/d+4;return [h*60,max?d/max:0,max/255];}
|
||||
export function pixelClass(r,g,b){
|
||||
@@ -49,7 +55,7 @@ function legacyGrid(frame,labels=classifyFrame(frame)){
|
||||
const w=frame.width,h=frame.height;
|
||||
const comps=components(labels,w,h,{minArea:Math.max(60,w*h/20000)});
|
||||
// Ignore HUD islands by selecting the lowest dense group of sizeable block/tile rectangles.
|
||||
let candidates=comps.filter(p=>p.y>h*.22&&p.w>w*.035&&p.h>w*.035&&p.area>p.w*p.h*.35);
|
||||
let candidates=comps.filter(p=>p.y>mapSearchStart(frame)&&p.w>w*.035&&p.h>w*.035&&p.area>p.w*p.h*.35);
|
||||
const sizes=candidates.filter(p=>p.label===12&&p.w/p.h>.65&&p.w/p.h<1.5).map(p=>Math.min(p.w,p.h));
|
||||
const allSizes=candidates.filter(p=>p.label<=12&&Math.max(p.w,p.h)/Math.min(p.w,p.h)<5).map(p=>Math.min(p.w,p.h));
|
||||
const source=sizes.length>=6?sizes:allSizes;
|
||||
@@ -88,7 +94,7 @@ function legacyGrid(frame,labels=classifyFrame(frame)){
|
||||
// Long interior scanlines remain usable when adjacent ice shells touch or floor tiles are missing.
|
||||
function rectangularGrid(frame,labels){
|
||||
const w=frame.width,h=frame.height,lines=[];
|
||||
for(let y=Math.floor(h*.28);y<h;y+=3){let begin=-1,last=-1,best=null;
|
||||
for(let y=mapSearchStart(frame);y<h;y+=3){let begin=-1,last=-1,best=null;
|
||||
for(let x=0;x<w;x++){const i=y*w+x,p=i*4,label=labels[i],valid=label>0&&(label!==12||Math.max(frame.data[p],frame.data[p+1],frame.data[p+2])>138);
|
||||
if(valid){if(begin<0)begin=x;last=x;}if(begin>=0&&(!valid&&x-last>7||x===w-1)){if(!best||last-begin>best.right-best.left)best={left:begin,right:last,y};begin=-1;}}
|
||||
if(best)lines.push(best);
|
||||
@@ -119,7 +125,7 @@ function rectangularGrid(frame,labels){
|
||||
// upper lobes still belong to the same board; thin gates and HUD islands do not.
|
||||
function latticeGrid(frame,labels,enhanced=false,wallSupported=false){
|
||||
const w=frame.width,h=frame.height,cc=components(labels,w,h,{minArea:Math.max(12,w*w*.00012)});
|
||||
const rectangles=cc.filter(p=>p.label<=12&&p.y>h*.22&&p.w>w*.045&&p.h>w*.045&&(p.area/(p.w*p.h)>.68||wallSupported&&(p.label===6||p.label===8)&&p.area/(p.w*p.h)>.45&&p.h>w*.35));
|
||||
const rectangles=cc.filter(p=>p.label<=12&&p.y>mapSearchStart(frame)&&p.w>w*.045&&p.h>w*.045&&(p.area/(p.w*p.h)>.68||wallSupported&&(p.label===6||p.label===8)&&p.area/(p.w*p.h)>.45&&p.h>w*.35));
|
||||
const sizes=rectangles.filter(p=>p.w/p.h>.23&&p.w/p.h<1.35).map(p=>p.w);
|
||||
if(sizes.length<3)return null;
|
||||
let cluster=[];for(const size of sizes){const near=sizes.filter(v=>Math.abs(v-size)<size*.15);if(near.length>cluster.length)cluster=near;}
|
||||
@@ -184,12 +190,13 @@ function wallSupportedFit(frame,labels,candidate){
|
||||
|
||||
// Resolve rectangular boards from the wall/surface boundary before fitting
|
||||
// coloured pieces. A matching gate may touch a block, but cannot move this edge.
|
||||
export function wallOutlineGrid(frame,labels,fixedPalette=true,trace={}){
|
||||
export function wallOutlineGrid(frame,labels,fixedPalette=true,trace={},startFraction=MAP_INFO_FRACTION){
|
||||
const w=frame.width,h=frame.height,mask=new Uint8Array(w*h);
|
||||
for(let i=0;i<mask.length;i++){const r=frame.data[i*4],g=frame.data[i*4+1],b=frame.data[i*4+2];mask[i]=(fixedPalette?wallPixel(r,g,b):b>=45&&b<=130&&r>=15&&b>r*1.2&&b>g*1.12&&b-r<80)?1:0;}
|
||||
const surface=(x,y)=>{if(x<0||x>=w||y<0||y>=h)return false;const i=y*w+x,k=labels[i];return k>0&&k<=13&&(k!==12||frame.data[i*4+2]>145);};
|
||||
const side={left:[],right:[]},margin=Math.max(5,Math.round(w*.012));
|
||||
for(let y=Math.round(h*.27);y<h-margin;y+=4){
|
||||
const scanStart=Math.max(mapSearchStart(frame),Math.floor(h*startFraction));
|
||||
for(let y=scanStart;y<h-margin;y+=4){
|
||||
for(let x=margin;x<w-margin;x++)if(mask[y*w+x]!==mask[y*w+x+1]){
|
||||
const sign=mask[y*w+x]?1:-1,edge=x+1;let dark=0,inside=0;
|
||||
for(let d=2;d<=margin;d++){dark+=mask[y*w+edge-sign*d]??0;inside+=surface(edge+sign*d,y)?1:0;}
|
||||
@@ -202,7 +209,7 @@ export function wallOutlineGrid(frame,labels,fixedPalette=true,trace={}){
|
||||
// Horizontal walls must bound the same two side lines. Insets/stepped maps
|
||||
// without such support use the integer wall-lattice path instead.
|
||||
const edges={top:[],bottom:[]};
|
||||
for(let y=Math.round(h*.27);y<h-margin;y++){const left=xAt(l.fit,y),right=xAt(r.fit,y);let down=0,up=0,n=0;
|
||||
for(let y=scanStart;y<h-margin;y++){const left=xAt(l.fit,y),right=xAt(r.fit,y);let down=0,up=0,n=0;
|
||||
for(let x=Math.ceil(left+margin);x<right-margin;x+=3){n++;if(mask[(y-1)*w+x]&&!mask[y*w+x]&&surface(x,y+margin)&&mask[(y-margin)*w+x])down++;if(!mask[(y-1)*w+x]&&mask[y*w+x]&&surface(x,y-margin)&&mask[(y+margin)*w+x])up++;}
|
||||
if(n&&down/n>.10)edges.top.push({y,n:down});if(n&&up/n>.10)edges.bottom.push({y,n:up});
|
||||
}
|
||||
@@ -229,7 +236,7 @@ function wallBandGrid(frame,labels){
|
||||
for(let i=0;i<mask.length;i++)mask[i]=wallPixel(data[i*4],data[i*4+1],data[i*4+2])?1:0;
|
||||
const surface=(x,y)=>{const i=y*w+x,k=labels[i];return k>0&&k<13&&!mask[i]&&(k!==12||data[i*4+2]>145);};
|
||||
const tops=[],bottoms=[];
|
||||
for(let y=Math.ceil(h*.29);y<h-margin;y++){let down=0,up=0;for(let x=margin;x<w-margin;x+=2){if(mask[(y-1)*w+x]&&!mask[y*w+x]&&mask[(y-margin)*w+x]&&surface(x,y+margin))down++;if(!mask[(y-1)*w+x]&&mask[y*w+x]&&mask[(y+margin)*w+x]&&surface(x,y-margin))up++;}if(down>w*.13)tops.push(y);if(up>w*.13)bottoms.push(y);}
|
||||
for(let y=mapSearchStart(frame);y<h-margin;y++){let down=0,up=0;for(let x=margin;x<w-margin;x+=2){if(mask[(y-1)*w+x]&&!mask[y*w+x]&&mask[(y-margin)*w+x]&&surface(x,y+margin))down++;if(!mask[(y-1)*w+x]&&mask[y*w+x]&&mask[(y+margin)*w+x]&&surface(x,y-margin))up++;}if(down>w*.13)tops.push(y);if(up>w*.13)bottoms.push(y);}
|
||||
if(!tops.length||!bottoms.length)return null;const top=tops[0],bottom=bottoms.at(-1);if(bottom-top<w*.6)return null;
|
||||
const floorCount=labels.reduce((n,k)=>n+(k===12),0),preferFloor=floorCount>w*h*.08;
|
||||
const lines=[];for(let y=top+margin;y<bottom-margin;y+=3){let begin=-1,last=-1,best=null;for(let x=margin;x<w-margin;x++){if(surface(x,y)){if(begin<0)begin=x;last=x;}if(begin>=0&&(x-last>margin||x===w-margin-1)){if(!best||last-begin>best.right-best.left)best={y,left:begin,right:last+1};begin=-1;}}if(preferFloor){const xs=[];for(let x=margin;x<w-margin;x++)if(labels[y*w+x]===12&&surface(x,y))xs.push(x);if(xs.length>w*.28)best={y,left:xs[0],right:xs.at(-1)+1};else best=null;}if(best)lines.push(best);}
|
||||
@@ -248,7 +255,15 @@ const xAt=(line,y)=>line[0]+line[1]*y;
|
||||
}
|
||||
|
||||
function detectGridCandidate(frame,labels){
|
||||
const outline=wallOutlineGrid(frame,labels);if(outline)return outline;
|
||||
const outline=wallOutlineGrid(frame,labels);
|
||||
// HUD fascia can share the wall palette. When the first wall candidate
|
||||
// starts implausibly high, rerun the same wall-inner-edge fit from the
|
||||
// conservative board band; this keeps the 15% rule while rejecting a HUD
|
||||
// border that happens to contain a few classifiable blue pixels.
|
||||
if(outline&&outline.grid.corners[0][1]>=frame.height*.22)return outline;
|
||||
const conservative=wallOutlineGrid(frame,labels,true,{},.27);
|
||||
if(conservative)return conservative;
|
||||
if(outline)return outline;
|
||||
const legacyWall=wallOutlineGrid(frame,labels,false);if(legacyWall)return legacyWall;
|
||||
const band=wallBandGrid(frame,labels);if(band)return band;
|
||||
const wall=latticeGrid(frame,labels,true,true);
|
||||
@@ -257,12 +272,27 @@ function detectGridCandidate(frame,labels){
|
||||
if(wall&&lattice&&wall.grid.rows>lattice.grid.rows+2){const supported=wallSupportedFit(frame,labels,wall);if(supported)return supported;}
|
||||
let old,error;try{old=legacyGrid(frame,labels);}catch(e){error=e;}const rect=rectangularGrid(frame,labels);const height=g=>g.corners[2][1]-g.corners[0][1];const baseline=rect??old,skewed=baseline&&Math.abs(baseline.grid.corners[3][0]-baseline.grid.corners[0][0])>frame.width*.2||baseline&&baseline.grid.corners[3][0]-baseline.grid.corners[0][0]>frame.width*.05;if(lattice&&(skewed||!old&&!rect||height(lattice.grid)>Math.max(old?height(old.grid):0,rect?height(rect.grid):0)*1.15))return lattice;if(rect&&(!old||rect.grid.corners[2][1]-rect.grid.corners[0][1]>=(old.grid.corners[2][1]-old.grid.corners[0][1])*.85))return rect;if(old)return old;throw error??Error('未定位棋盘,请手动校正。');
|
||||
}
|
||||
function annotateGridEnvelope(frame,labels,result){
|
||||
if(!result?.grid)return result;
|
||||
const grid={...result.grid,mapInfoFraction:MAP_INFO_FRACTION,mapInfoCutoff:mapSearchStart(frame),mapBoundary:'dark-wall-inner-edge'};
|
||||
const occupancy=Array.from({length:grid.rows},()=>Array(grid.cols).fill(false));
|
||||
for(let row=0;row<grid.rows;row++)for(let col=0;col<grid.cols;col++){
|
||||
for(const v of [.25,.5,.75])for(const u of [.25,.5,.75]){const [x,y]=gridPoint(grid,col+u,row+v),xx=clamp(Math.round(x),0,frame.width-1),yy=clamp(Math.round(y),0,frame.height-1),at=yy*frame.width+xx,label=labels[at],p=at*4;
|
||||
if(label>=1&&label<=13&&(label!==12||frame.data[p+2]>=133)){occupancy[row][col]=true;break;}
|
||||
}
|
||||
}
|
||||
const rowSpans=occupancy.map(row=>row.reduce((n,v)=>n+Number(v),0));
|
||||
const nonRectangular=rowSpans.some(n=>n>0&&n<grid.cols)||rowSpans.some(n=>n===0);
|
||||
grid.mapShape=nonRectangular?'non-rectangular-envelope':'rectangular-envelope';
|
||||
grid.nonRectangular=nonRectangular;
|
||||
return {...result,grid,wallEvidence:result.wallEvidence?{...result.wallEvidence,mapInfoCutoff:grid.mapInfoCutoff}:result.wallEvidence};
|
||||
}
|
||||
// User policy: automatic grids always start at +12; only explicit manual edits override it.
|
||||
export function detectGrid(frame,labels=classifyFrame(frame)){
|
||||
const result=detectGridCandidate(frame,labels);result.grid.rowCurve=DEFAULT_ROW_CURVE;return result;
|
||||
const result=annotateGridEnvelope(frame,labels,detectGridCandidate(frame,labels));result.grid.rowCurve=DEFAULT_ROW_CURVE;return result;
|
||||
}
|
||||
function floorGrid(frame,labels,shape,allowSeparated=false){
|
||||
const cc=components(labels,frame.width,frame.height,{minArea:20}),floor=cc.filter(p=>p.label===12&&p.y>frame.height*.22&&p.area>p.w*p.h*.65&&p.w>frame.width*.06&&p.h>frame.width*.06);
|
||||
const cc=components(labels,frame.width,frame.height,{minArea:20}),floor=cc.filter(p=>p.label===12&&p.y>mapSearchStart(frame)&&p.area>p.w*p.h*.65&&p.w>frame.width*.06&&p.h>frame.width*.06);
|
||||
if(!floor.length)return null;
|
||||
const biggest=floor.sort((a,b)=>b.area-a.area)[0];if(biggest.area<frame.width*frame.height*.025){
|
||||
if(!allowSeparated)return null;
|
||||
@@ -532,7 +562,7 @@ function revealedWoodMembers(cover,supplement,frames){
|
||||
}return null;
|
||||
}
|
||||
|
||||
function wallLandmarks(frame){const mask=Uint8Array.from({length:frame.width*frame.height},(_,i)=>i/frame.width>frame.height*.27&&wallPixel(...frame.data.subarray(i*4,i*4+3))?1:0);return components(mask,frame.width,frame.height,{minArea:frame.width**2*.0008}).filter(p=>Math.max(p.w,p.h)>frame.width*.035);}
|
||||
function wallLandmarks(frame){const mask=Uint8Array.from({length:frame.width*frame.height},(_,i)=>i/frame.width>mapSearchStart(frame)&&wallPixel(...frame.data.subarray(i*4,i*4+3))?1:0);return components(mask,frame.width,frame.height,{minArea:frame.width**2*.0008}).filter(p=>Math.max(p.w,p.h)>frame.width*.035);}
|
||||
export function alignWallReference(reference,target,grid){
|
||||
const a=wallLandmarks(reference),b=wallLandmarks(target);if(a.length<3||b.length<3)return null;const base=target.width/reference.width;let best=null;
|
||||
for(let k=-5;k<=5;k++){const scale=base*(1+k*.01);for(const p of a)for(const q of b){if(Math.abs(q.w-p.w*scale)>target.width*.025||Math.abs(q.h-p.h*scale)>target.width*.025)continue;const dx=q.cx-p.cx*scale,dy=q.cy-p.cy*scale,matched=[],used=new Set();for(const p of a){const near=b.map((q,i)=>({q,i,d:Math.hypot(q.cx-p.cx*scale-dx,q.cy-p.cy*scale-dy)})).filter(x=>!used.has(x.i)&&Math.abs(x.q.w-p.w*scale)<target.width*.035&&Math.abs(x.q.h-p.h*scale)<target.width*.035).sort((x,y)=>x.d-y.d)[0];if(near&&near.d<target.width*.012){used.add(near.i);matched.push(near.d);}}const score=matched.length-matched.reduce((n,d)=>n+d,0)/target.width;if(matched.length>=3&&(!best||score>best.score))best={scale,dx,dy,score,matches:matched.length};}}
|
||||
|
||||
@@ -28,7 +28,7 @@ export async function startEditor({port=4488,open=false,draftsDir=path.join(appR
|
||||
const host=`127.0.0.1:${server.address().port}`;
|
||||
if(req.headers.host!==host||(req.headers.origin&&req.headers.origin!==`http://${host}`))return send(res,403,{error:'仅允许当前本地页面访问。'});
|
||||
const url=new URL(req.url,`http://${host}`),p=url.pathname;
|
||||
if(req.method==='GET'&&p==='/api/status'){const available=await Promise.all(Object.entries(sources).map(async([id,s])=>({id,name:s.name,available:id==='drafts'||await stat(s.dir).then(x=>x.isDirectory()).catch(()=>false)})));return send(res,200,{app:'colorblock-editor',version:'0.18.0',game:await gameContext(sources.matchmaster?.dir),sources:available,draftsDir});}
|
||||
if(req.method==='GET'&&p==='/api/status'){const available=await Promise.all(Object.entries(sources).map(async([id,s])=>({id,name:s.name,available:id==='drafts'||await stat(s.dir).then(x=>x.isDirectory()).catch(()=>false)})));return send(res,200,{app:'colorblock-editor',version:'0.19.0',game:await gameContext(sources.matchmaster?.dir),sources:available,draftsDir});}
|
||||
if(req.method==='GET'&&(p==='/api/levels'||p==='/api/level')){const sourceId=url.searchParams.get('source'),source=sources[sourceId];if(!source)throw Error('未知关卡来源。');if(p==='/api/levels'){const names=(await readdir(source.dir).catch(e=>{if(sourceId==='drafts'&&e.code==='ENOENT')return [];throw e;})).filter(n=>validName(sourceId,n)).sort((a,b)=>Number(a.match(/\d+/)[0])-Number(b.match(/\d+/)[0]));return send(res,200,{names});}const name=url.searchParams.get('name');if(!validName(sourceId,name??''))throw Error('关卡文件名无效。');const raw=await readFile(path.join(source.dir,name),'utf8');return send(res,200,{data:parseLevel(raw),name});}
|
||||
if(req.method==='POST'&&p==='/api/screenshots/recognize'){
|
||||
if(recognizing)return send(res,409,{error:'正在识别,请等本次完成或取消。'});
|
||||
@@ -60,4 +60,4 @@ export async function startEditor({port=4488,open=false,draftsDir=path.join(appR
|
||||
if(open){const cmd=process.platform==='darwin'?'open':process.platform==='win32'?'cmd':'xdg-open';spawn(cmd,process.platform==='win32'?['/c','start','',url]:[url],{stdio:'ignore'}).on('error',()=>{});}
|
||||
return {server,url};
|
||||
}
|
||||
if(process.argv[1]&&pathToFileURL(path.resolve(process.argv[1])).href===import.meta.url)startEditor({open:process.argv.includes('--open')}).then(a=>console.log(`ColorBlock 关卡编辑器 0.18.0\n${a.url}\n关闭此窗口停止服务。`)).catch(e=>{console.error(e);process.exitCode=1;});
|
||||
if(process.argv[1]&&pathToFileURL(path.resolve(process.argv[1])).href===import.meta.url)startEditor({open:process.argv.includes('--open')}).then(a=>console.log(`ColorBlock 关卡编辑器 0.19.0\n${a.url}\n关闭此窗口停止服务。`)).catch(e=>{console.error(e);process.exitCode=1;});
|
||||
|
||||
@@ -21,7 +21,7 @@ test('spawn structural checks mirror Map.ts debug contract and preserve data',()
|
||||
test('malformed record rejects without damaging a document',()=>{const e=new Editor(),before=e.serialize();assert.throws(()=>e.change('bad',d=>d.BLOCK_INFO[0].push(null)));assert.equal(e.serialize(),before);});
|
||||
test('HTTP: read-only library, safe unique draft saves, source traversal guard, real solve and compare',async t=>{const drafts=await mkdtemp(path.join(os.tmpdir(),'cb-editor-'));const app=await startEditor({port:0,draftsDir:drafts,sources:{test:{name:'fixture',dir:source}}});t.after(async()=>{await new Promise(r=>app.server.close(r));await rm(drafts,{recursive:true,force:true});});
|
||||
const get=async p=>{const r=await fetch(app.url+p);return {status:r.status,data:await r.json()};};const post=async(p,b,headers={})=>{const r=await fetch(app.url+p,{method:'POST',headers:{'Content-Type':'application/json',...headers},body:JSON.stringify(b)});return {status:r.status,data:await r.json()};};
|
||||
assert.equal((await get('/api/status')).data.version,'0.18.0');assert.deepEqual((await get('/api/level?source=test&name=level1.json')).data.data,await fixture());assert.equal((await get('/api/level?source=test&name=../../x')).status,400);assert.equal((await post('/api/save',{level:await fixture()},{Origin:'https://example.com'})).status,403);
|
||||
assert.equal((await get('/api/status')).data.version,'0.19.0');assert.deepEqual((await get('/api/level?source=test&name=level1.json')).data.data,await fixture());assert.equal((await get('/api/level?source=test&name=../../x')).status,400);assert.equal((await post('/api/save',{level:await fixture()},{Origin:'https://example.com'})).status,403);
|
||||
const d=await fixture();const a=await post('/api/save',{level:d}),b=await post('/api/save',{level:d});assert.notEqual(a.data.path,b.data.path);assert.deepEqual(JSON.parse(await readFile(a.data.path,'utf8')),d);
|
||||
const run=await post('/api/run',{level:d,mode:'solve',seconds:2});assert.equal(run.status,202);let job;for(let i=0;i<60;i++){job=(await get('/api/jobs/'+run.data.id)).data;if(job.status!=='running')break;await new Promise(r=>setTimeout(r,100));}assert.ok(['done','budget'].includes(job.status),JSON.stringify(job));
|
||||
const spawned=structuredClone(d);spawned.LEVEL_INFO[0].spawnGates=[{x:1,y:1,width:2,height:2,spawnQueue:[]}];const denied=await post('/api/run',{level:spawned,mode:'solve',seconds:2});assert.equal(denied.status,202);let deniedJob;for(let i=0;i<30;i++){deniedJob=(await get('/api/jobs/'+denied.data.id)).data;if(deniedJob.status!=='running')break;await new Promise(r=>setTimeout(r,100));}assert.equal(deniedJob.status,'error');assert.match(deniedJob.error,/spawnGates/);
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
{"width": 784, "height": 1174, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-8deff59f-76bc-4ec0-ad6e-f8d8897664b3.png"}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
{"width": 734, "height": 1194, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-75b0a00e-8f57-4dc6-b888-e33e8b02bedd.png"}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
{"width": 772, "height": 1228, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-9a291798-f82a-46a2-8221-1518f771bd0e.png"}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
{"width": 760, "height": 1200, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-427f9e34-1784-4301-a306-00ab51d7867c.png"}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
{"width": 1014, "height": 1516, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-31e3e6a8-1828-478f-b69a-a7b56a4241a7.png"}
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
{"width": 1032, "height": 1540, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-1901bdbc-d637-43ec-b04a-760cd77b0a2e.png"}
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {gunzipSync} from 'node:zlib';
|
||||
import {detectGrid,reconstructLocal,MAP_INFO_FRACTION} from '../src/local-vision.js';
|
||||
|
||||
function load(name){
|
||||
const base=new URL(`fixtures/local-vision/level25-user-20261019/${name}`,import.meta.url);
|
||||
const meta=JSON.parse(readFileSync(new URL(`${name}.json`,base)));
|
||||
return {...meta,data:new Uint8ClampedArray(gunzipSync(readFileSync(new URL(`${name}.rgba.gz`,base))))};
|
||||
}
|
||||
const frames=[0,1,2,3,4,5].map(i=>load(`new${i}`));
|
||||
|
||||
test('user Level25 samples use wall inner edges, +12, and the top-information cutoff',()=>{
|
||||
for(const frame of frames){
|
||||
const result=detectGrid(frame),g=result.grid;
|
||||
assert.equal(result.method,'wall-outline');
|
||||
assert.deepEqual([g.cols,g.rows,g.rowCurve],[6,10,.12]);
|
||||
assert.equal(g.mapInfoFraction,MAP_INFO_FRACTION);
|
||||
assert.equal(g.mapInfoCutoff,Math.floor(frame.height*MAP_INFO_FRACTION));
|
||||
assert.equal(g.mapBoundary,'dark-wall-inner-edge');
|
||||
assert.equal(g.mapShape,'non-rectangular-envelope');
|
||||
assert.ok(g.corners[0][1]>g.mapInfoCutoff);
|
||||
}
|
||||
});
|
||||
|
||||
test('nonrectangular board keeps its full wall envelope when upper or side cells are empty',()=>{
|
||||
const result=reconstructLocal(frames.slice(0,4));
|
||||
assert.deepEqual([result.spec.width,result.spec.height,result.spec.holes.length],[6,10,6]);
|
||||
assert.ok(result.inspections.every(i=>i.grid.mapShape==='non-rectangular-envelope'));
|
||||
assert.ok(result.spec.pieces.length>=8);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>ColorBlock · 关卡编辑器</title><link rel="stylesheet" href="/style.css"></head><body>
|
||||
<header><div class="brand"><span class="brandmark">▦</span><div><b>ColorBlock <span>STUDIO</span></b><small>关卡编辑器 · 本地工作空间 · v0.18.0</small></div></div><div class="head-status" id="dirty">尚未保存</div><div class="actions"><button id="new">新建</button><button id="screenshots">截图还原</button><button id="import">导入 JSON</button><button id="save">保存草稿</button><button id="export" class="primary">导出游戏 JSON ↗</button></div></header>
|
||||
<header><div class="brand"><span class="brandmark">▦</span><div><b>ColorBlock <span>STUDIO</span></b><small>关卡编辑器 · 本地工作空间 · v0.19.0</small></div></div><div class="head-status" id="dirty">尚未保存</div><div class="actions"><button id="new">新建</button><button id="screenshots">截图还原</button><button id="import">导入 JSON</button><button id="save">保存草稿</button><button id="export" class="primary">导出游戏 JSON ↗</button></div></header>
|
||||
<div id="recovery" hidden><span>检测到上次未关闭的本地草稿。</span><button id="recover">恢复草稿</button><button id="discard">忽略</button></div>
|
||||
<main><aside class="left"><div class="section-title">关卡资源 <span>01</span></div><select id="source" aria-label="关卡来源"></select><div class="row"><input id="levelSearch" placeholder="搜索关卡编号" aria-label="搜索关卡编号"><button id="load">打开</button></div><select id="levelList" size="4" aria-label="选择关卡"></select><div id="draftActions" class="row" hidden><button id="deleteDraft" class="danger">删除选中草稿</button><button id="clearDrafts" class="danger">清空草稿</button></div><p class="muted" id="sourceStatus">正在连接本地关卡库…</p>
|
||||
<div class="section-title">添加对象 <span>02</span></div><div class="add-tabs"><button data-tool="place">▧ 方块</button><button data-tool="gate">▥ 门</button></div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
本轮追加偏好(2026-10-15):叠加块用“叠加3”表示上层3号颜色,下层仍显示“色号X”;已同步标注模块、回归和双平台分发,三台数据协议无需修改。
|
||||
|
||||
最新维护:feedback94 / 理念1.43 / 交接1.68.0;编辑器0.18.0修复深灰墙优先、全局+12和Level25分组:固定四组暗蓝灰墙色容差,横墙内沿与上下宽区/中间窄区联合定位;初始透视先验+12,H形候选固定0.12,历史矩形候选仍可由可靠边缘微调。两原图恢复7×9、12缺口、24块、8门,底部相邻冰门4/9与顶部20分别读出,补图恢复全部门色。
|
||||
最新维护:feedback95 / 理念1.44 / 交接1.69.0;编辑器0.19.0把顶部15%信息区作为所有自动候选的扫描下限,深灰墙内沿仍是地图范围第一证据,并明确用坐标包络加holes表达非矩形地图。所有自动网格和补图对齐统一+12;六张用户第25关截图回归为6×10,墙内沿边界和非矩形空洞字段可审计。新增当前算法主说明;视觉阈值仍是当前皮肤启发式。
|
||||
|
||||
维护日期:2026-10-19。适用工程:ColorBlock 三个工作台及手工设计流程。
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|---|---|---|
|
||||
| EDIT-42 | 图像启发式 | 深灰墙与内容接触的内沿参与透视/整格拟合;同色块仍检查明暗接缝,不把连通区域直接当地图或一块。 |
|
||||
| EDIT-43 | 硬数据兼容 / 图像启发式 | 固定木板成员保留共享floor/floorTime/floorMove:false;相接木板按多个计数与矩形范围分组属于启发式,缺揭盖图不猜隐藏块。 |
|
||||
| EDIT-51 | 图像硬检查 + 启发式 | 自动识别先排除截图顶部15%信息区,再以固定深灰墙的内侧边缘确定地图坐标包络;地图允许非矩形,空行、缺列和内部缺口进入holes,不因缺少彩色方块裁剪。所有自动网格默认+12,边界含糊按最短倒计时图的墙地标对齐;这是当前皮肤视觉规则,不等同玩法硬规则。 |
|
||||
|
||||
编辑器0.15.0修复第21关阶梯地图:三块相接木板被矩形密度筛掉导致仅顶部3行、蓝门与蓝块相连多算一列、黄色单格与7字形因同色连续性被合并。新增暗蓝灰墙内沿证据与整数格拟合、非矩形木板候选、多剖面明暗接缝、多个计数分盖板及补图内沿对齐。四原图自动8×9/30缺口/21块/6门;盖板10/16/18恢复2/3/2成员,冰门7/1/1。两枚人工数字6/计时8模板带来源。 已运行47项图像回归、358项完整回归及浏览器四图识别载入。本关是开发/校准样本,不是独立准确率;颜色阈值/矩形盖板分配/墙候选仍是启发式,揭盖后移动、碎屑、低分辨率及其他皮肤须核对。未Cocos实机、未证明可解或最优,原生Windows/Intel未测。 无新增生成难度启发式或历史专项硬规则;三台影响见PROJECT_PROGRESS第12.1。反馈ID:`wall-inner-edge-same-color-seam-20261016`。 分发已验证Mac搬迁21项、Windows文件/服务层19项,非原生Windows/Intel/Cocos验收。
|
||||
|
||||
@@ -926,3 +927,5 @@ PLAY-21、案例/交接同步,未Cocos实机,新关趣味待用户。
|
||||
### EDIT-50 冰冻数字与同色分块(2026-10-19)
|
||||
|
||||
来源:`frozen-counter-and-shape-partition-20261019`。图像启发式:先分离浅蓝字芯,再按数字中心分配相邻冰门,避免4/9合并;冻结块按数字锚点和格覆盖分区,木板盖板按数字与矩形覆盖分区,不能用同色连通性直接合并。低清、特效遮挡或不完整字形保留待确认。硬检查继续阻断未知门色/非法形状导出;生成器、求解器和查重器无新增玩法协议。
|
||||
|
||||
来源:`nonrectangular-map-top15-boundary-20261019`。本轮将顶部15%信息区、深灰墙内沿、+12和非矩形坐标包络落实到编辑器0.19.0;六张用户第25关截图回归为6×10,holes保留空区域。来源:`screenshot-recognition-algorithm-doc-20261019`。当前识别顺序、边界证据字段和视觉启发式限制已写入 `colorBlock-level-editor/docs/SCREENSHOT_RECOGNITION_EXPLAINED.md` 顶部主说明。
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
本轮追加偏好(2026-10-15):叠加块用“叠加3”表示上层3号颜色,下层仍显示“色号X”;已同步标注模块、回归和双平台分发,三台数据协议无需修改。
|
||||
|
||||
文档版本:1.43。维护日期:2026-10-19。核对生成器/分析核心:0.21.0,查重1.1.3,编辑器0.18.0。
|
||||
文档版本:1.44。维护日期:2026-10-19。核对生成器/分析核心:0.21.0,查重1.1.3,编辑器0.19.0。
|
||||
|
||||
最新维护(交接1.69.0/feedback95):本轮没有新增玩法卡点或难度数值结论,维护的是截图空间表示。地图边界按顶部15%信息区之后的深灰墙内沿确定;墙内沿给出坐标包络,非矩形地图用holes保留空行、缺列和内部缺口,不能把“暂无彩色方块”当作地图外。所有自动透视默认+12,最短倒计时图只用于边界歧义时对齐。直觉难度、数据难度、最优拖动/每块平均、可行解统计和人工评价继续分开;这些视觉启发式不替代Cocos可达性验收。本轮六张用户第25关截图回归为6×10,完整联合回归与限制见PROJECT_PROGRESS第12.1。
|
||||
|
||||
最新维护(交接1.67.0/feedback93):编辑器0.17.0修复第23关H形地图:固定四组暗蓝灰墙色容差,横墙内沿与上下宽区/中间窄区联合定位;初始透视先验+12,H形候选固定0.12,历史矩形候选仍可由可靠边缘微调。两原图恢复7×9、12缺口、24块、8门,底部相邻冰门4/9与顶部20分别读出,补图恢复全部门色。 本轮无新增卡点分类/解释/例子、直觉与数据双维难度、趣味定义及其与难度关系;维护的是墙/门/棋盘的空间表示。最优拖动/每块平均、可行解统计、人工评价继续分开。验收为375回归及实际浏览器首次/手动重识别通过;三台字段协议和玩法算法未变,影响矩阵见PROJECT_PROGRESS第12.1。墙色/几何/OCR仍是当前皮肤的启发式;三张色样覆盖率均超过94%,不等于跨皮肤准确率。第23关是开发回归样本。仅开局图保留7个未知门色并阻断载入,不猜隐藏色;未做Cocos实机、原生Windows或Intel实机,也未证明可解或最优。
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": 94,
|
||||
"version": 95,
|
||||
"invariants": {
|
||||
"requireFullShapeExitCorridorClearance": true,
|
||||
"replayIncumbentWithCurrentModelBeforeUse": true,
|
||||
@@ -1281,6 +1281,16 @@
|
||||
"id": "frozen-counter-and-shape-partition-20261019",
|
||||
"date": "2026-10-19",
|
||||
"lesson": "用户反馈底部门4和9、顶端20漏识别,以及同色块/冻结块误合并。编辑器现在分离浅蓝数字字芯并按门格分配,冻结块按锚点覆盖分区,木板按矩形覆盖分区;不完整字形和遮挡信息保留待确认。截图回归74/74、联合385/385;未做Cocos或原生Windows/Intel实机。"
|
||||
},
|
||||
{
|
||||
"id": "nonrectangular-map-top15-boundary-20261019",
|
||||
"date": "2026-10-19",
|
||||
"lesson": "用户指出地图可以是非矩形,且截图顶部约15%是关卡信息;信息区以下即属于地图包络,不能因没有彩色方块就裁掉区域。编辑器0.19.0将MAP_INFO_FRACTION=0.15下沉到所有自动候选和补图对齐,深灰墙内沿继续作为第一边界证据;网格以wall-inner-edge坐标包络加holes表达非矩形地图,自动透视统一+12。六张用户第25关截图回归为6×10,边界位于信息区下限之下。该视觉规则是当前皮肤启发式,不升级为玩法硬规则;生成器、求解分析台、查重器字段协议无需改变。"
|
||||
},
|
||||
{
|
||||
"id": "screenshot-recognition-algorithm-doc-20261019",
|
||||
"date": "2026-10-19",
|
||||
"lesson": "用户要求提供当前识别算法说明。SCREENSHOT_RECOGNITION_EXPLAINED.md顶部新增编辑器0.19.0主说明,明确15%信息区、固定墙色、墙内沿、非矩形包络、+12、最短倒计时边界参考、格内识别和数字/门识别顺序,并列出可审计网格字段与视觉启发式限制。"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function startPortableEditor({port=4488,open=true}={}) {
|
||||
}
|
||||
if(process.argv[1]&&pathToFileURL(path.resolve(process.argv[1])).href===import.meta.url){
|
||||
startPortableEditor({open:!process.argv.includes('--no-open')}).then(app=>{
|
||||
console.log('ColorBlock Editor 0.15.0 - macOS portable');
|
||||
console.log('ColorBlock Editor 0.19.0 - macOS portable');
|
||||
console.log('Open this address if the browser did not open: '+app.url);
|
||||
console.log('Keep this window open while editing. Close it to stop the editor.');
|
||||
}).catch(error=>{console.error(error.message);process.exitCode=1;});
|
||||
|
||||
@@ -7,7 +7,7 @@ spec=importlib.util.spec_from_file_location('windows_packager',ROOT/'scripts/pac
|
||||
common=importlib.util.module_from_spec(spec);spec.loader.exec_module(common)
|
||||
VERSION='22.23.3'
|
||||
HASHES={'arm64':'23b25245dcfb9af7262f8ff142e9e2e0af025368117329e7a7458a51e5922f53','x64':'8a677b0219178efd6eb0e475457c4afb452b521a92f6e67845a73bd85727f2a8'}
|
||||
NAME='ColorBlockEditor-Mac-0.18.0-20261019'
|
||||
NAME='ColorBlockEditor-Mac-0.19.0-20261019'
|
||||
def sha(data):return hashlib.sha256(data).hexdigest()
|
||||
def put(root,rel,data,mode=0o644):
|
||||
p=root/rel;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data);p.chmod(mode)
|
||||
|
||||
@@ -6,7 +6,7 @@ ROOT=Path(__file__).resolve().parents[1]
|
||||
NODE_VERSION='22.23.3'
|
||||
ZIP_SHA='2b0ff57b049cda1bbcea2240eec20467018713c1efe1f7360c2681859b90ed71'
|
||||
EXE_SHA='9c9245166b4a8e182e0b797da9c20136117ff24368eaff1fec8343a123c8db0e'
|
||||
NAME='ColorBlockEditor-Windows-x64-0.18.0-20261019'
|
||||
NAME='ColorBlockEditor-Windows-x64-0.19.0-20261019'
|
||||
def sha(data):return hashlib.sha256(data).hexdigest()
|
||||
def put(target,relative,data):
|
||||
p=target/relative;p.parent.mkdir(parents=True,exist_ok=True);p.write_bytes(data)
|
||||
@@ -61,7 +61,7 @@ def build(node_zip,game,out):
|
||||
for p in sorted(target.rglob('*')):
|
||||
if p.is_file():
|
||||
data=p.read_bytes();files.append({'path':p.relative_to(target).as_posix(),'bytes':len(data),'sha256':sha(data)})
|
||||
manifest={'editor':'0.18.0','generator':'0.21.0','checker':'1.1.3','date':'2026-10-16','runtime':runtime,'levelCounts':counts,'excluded':['personal settings','draft samples','Git','caches','test outputs','project conversation history','full Cocos project'],'files':files}
|
||||
manifest={'editor':'0.19.0','generator':'0.21.0','checker':'1.1.3','date':'2026-10-16','runtime':runtime,'levelCounts':counts,'excluded':['personal settings','draft samples','Git','caches','test outputs','project conversation history','full Cocos project'],'files':files}
|
||||
put(target,'PACKAGE_MANIFEST.json',(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n').encode())
|
||||
with zipfile.ZipFile(archive,'x',zipfile.ZIP_DEFLATED,compresslevel=6) as z:
|
||||
for p in sorted(target.rglob('*')):
|
||||
|
||||
@@ -12,7 +12,7 @@ export async function startPortableEditor({port=4488,open=true}={}) {
|
||||
}
|
||||
if(process.argv[1]&&pathToFileURL(path.resolve(process.argv[1])).href===import.meta.url){
|
||||
startPortableEditor().then(app=>{
|
||||
console.log('ColorBlock Editor 0.15.0 - Windows portable');
|
||||
console.log('ColorBlock Editor 0.19.0 - Windows portable');
|
||||
console.log('Open this address if the browser did not open: '+app.url);
|
||||
console.log('Keep this window open while editing. Close it to stop the editor.');
|
||||
}).catch(error=>{console.error(error.message);process.exitCode=1;});
|
||||
|
||||
Reference in New Issue
Block a user