diff --git a/PROJECT_PROGRESS.md b/PROJECT_PROGRESS.md index 13f4727..fb2a2f4 100644 --- a/PROJECT_PROGRESS.md +++ b/PROJECT_PROGRESS.md @@ -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`,手动网格仍保留显式覆盖。墙边界不依赖“该处是否已有彩色方块”,底边取深灰墙上内沿;边界不清晰时使用可读的最短倒计时截图作为墙参考。 diff --git a/README.md b/README.md index ac9aaaf..679c57b 100644 --- a/README.md +++ b/README.md @@ -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 源码快照及关卡库。部分历史校准/审计脚本引用外部原图或游戏路径,不是工作台启动依赖。 diff --git a/colorBlock-level-editor/docs/SCREENSHOT_RECOGNITION_EXPLAINED.md b/colorBlock-level-editor/docs/SCREENSHOT_RECOGNITION_EXPLAINED.md index 2720e9b..6acaa83 100644 --- a/colorBlock-level-editor/docs/SCREENSHOT_RECOGNITION_EXPLAINED.md +++ b/colorBlock-level-editor/docs/SCREENSHOT_RECOGNITION_EXPLAINED.md @@ -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。 diff --git a/colorBlock-level-editor/package.json b/colorBlock-level-editor/package.json index 7001944..92bd3cc 100644 --- a/colorBlock-level-editor/package.json +++ b/colorBlock-level-editor/package.json @@ -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"}} diff --git a/colorBlock-level-editor/src/local-vision.js b/colorBlock-level-editor/src/local-vision.js index 827b369..722f6f9 100644 --- a/colorBlock-level-editor/src/local-vision.js +++ b/colorBlock-level-editor/src/local-vision.js @@ -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);y0&&(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)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=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.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{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);yw*.13)tops.push(y);if(up>w*.13)bottoms.push(y);} + for(let y=mapSearchStart(frame);yw*.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-topn+(k===12),0),preferFloor=floorCount>w*h*.08; const lines=[];for(let y=top+margin;y=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;xw*.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=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&&nn===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.areai/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)x.d-y.d)[0];if(near&&near.dn+d,0)/target.width;if(matched.length>=3&&(!best||score>best.score))best={scale,dx,dy,score,matches:matched.length};}} diff --git a/colorBlock-level-editor/src/server.js b/colorBlock-level-editor/src/server.js index e2cd5c6..d9d8da9 100644 --- a/colorBlock-level-editor/src/server.js +++ b/colorBlock-level-editor/src/server.js @@ -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;}); diff --git a/colorBlock-level-editor/test/editor.test.js b/colorBlock-level-editor/test/editor.test.js index ed4b90a..2225a86 100644 --- a/colorBlock-level-editor/test/editor.test.js +++ b/colorBlock-level-editor/test/editor.test.js @@ -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/); diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new0.json b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new0.json new file mode 100644 index 0000000..10a8c8c --- /dev/null +++ b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new0.json @@ -0,0 +1 @@ +{"width": 784, "height": 1174, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-8deff59f-76bc-4ec0-ad6e-f8d8897664b3.png"} \ No newline at end of file diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new0.rgba.gz b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new0.rgba.gz new file mode 100644 index 0000000..ce87ab0 Binary files /dev/null and b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new0.rgba.gz differ diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new1.json b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new1.json new file mode 100644 index 0000000..d41bc89 --- /dev/null +++ b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new1.json @@ -0,0 +1 @@ +{"width": 734, "height": 1194, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-75b0a00e-8f57-4dc6-b888-e33e8b02bedd.png"} \ No newline at end of file diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new1.rgba.gz b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new1.rgba.gz new file mode 100644 index 0000000..b5534e1 Binary files /dev/null and b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new1.rgba.gz differ diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new2.json b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new2.json new file mode 100644 index 0000000..f41623b --- /dev/null +++ b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new2.json @@ -0,0 +1 @@ +{"width": 772, "height": 1228, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-9a291798-f82a-46a2-8221-1518f771bd0e.png"} \ No newline at end of file diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new2.rgba.gz b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new2.rgba.gz new file mode 100644 index 0000000..85ed709 Binary files /dev/null and b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new2.rgba.gz differ diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new3.json b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new3.json new file mode 100644 index 0000000..a66b620 --- /dev/null +++ b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new3.json @@ -0,0 +1 @@ +{"width": 760, "height": 1200, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-427f9e34-1784-4301-a306-00ab51d7867c.png"} \ No newline at end of file diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new3.rgba.gz b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new3.rgba.gz new file mode 100644 index 0000000..33dbca6 Binary files /dev/null and b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new3.rgba.gz differ diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new4.json b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new4.json new file mode 100644 index 0000000..7a38409 --- /dev/null +++ b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new4.json @@ -0,0 +1 @@ +{"width": 1014, "height": 1516, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-31e3e6a8-1828-478f-b69a-a7b56a4241a7.png"} \ No newline at end of file diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new4.rgba.gz b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new4.rgba.gz new file mode 100644 index 0000000..bb77a1f Binary files /dev/null and b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new4.rgba.gz differ diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new5.json b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new5.json new file mode 100644 index 0000000..e0e322a --- /dev/null +++ b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new5.json @@ -0,0 +1 @@ +{"width": 1032, "height": 1540, "source": "/var/folders/rr/z5kyv4ln7g7f3f2xkhkttwv00000gn/T/codex-clipboard-1901bdbc-d637-43ec-b04a-760cd77b0a2e.png"} \ No newline at end of file diff --git a/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new5.rgba.gz b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new5.rgba.gz new file mode 100644 index 0000000..3eb03d8 Binary files /dev/null and b/colorBlock-level-editor/test/fixtures/local-vision/level25-user-20261019/new5.rgba.gz differ diff --git a/colorBlock-level-editor/test/local-vision-level25-user.test.js b/colorBlock-level-editor/test/local-vision-level25-user.test.js new file mode 100644 index 0000000..2c139a4 --- /dev/null +++ b/colorBlock-level-editor/test/local-vision-level25-user.test.js @@ -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); +}); diff --git a/colorBlock-level-editor/web/index.html b/colorBlock-level-editor/web/index.html index 8da7ed2..a4b4780 100644 --- a/colorBlock-level-editor/web/index.html +++ b/colorBlock-level-editor/web/index.html @@ -1,5 +1,5 @@ ColorBlock · 关卡编辑器 -
▦
ColorBlock STUDIO关卡编辑器 · 本地工作空间 · v0.18.0
尚未保存
+
▦
ColorBlock STUDIO关卡编辑器 · 本地工作空间 · v0.19.0
尚未保存