diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index a8bf72df..f5fd1ae3 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -112,6 +112,14 @@ - 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx -t "已有卡片因重力下沉时目标格不被过渡状态隐藏成空位"`,并保留领域侧 `cargo test -p module-puzzle-clear refill --manifest-path server-rs/Cargo.toml`。 - 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`、`server-rs/crates/module-puzzle-clear/src/application.rs`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。 +## 拼消消完整消除反馈不要让补牌抢帧 + +- 现象:玩家正确拼完整组后,卡片几乎瞬间消失,顶部补牌马上出现或下落,导致“拼对了”的确认反馈很弱。 +- 原因:前端一收到新 snapshot 就同时播放消除和掉落叠层,旧消除动画时长较短;新补入卡牌的下落延迟接近 0ms,视觉上会抢在消除反馈之前开始。 +- 处理:局部正确拼合但未消除时只给锁定组做一次高光;完整消除时让旧卡片在消除叠层中短暂放大展示再淡出;新补入卡牌的下落延迟到淡出尾段,并继续只隐藏新补入目标格,不隐藏已有场上卡片下沉后的最终格。 +- 验证:`npm run test -- src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`,浏览器里确认局部拼合会闪、完整消除会放大淡出、补牌在淡出后段才开始掉落。 +- 关联:`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx`、`src/index.css`、`src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx`。 + ## 首页推荐分流参数不能条件性调用 hook - 现象:桌面首页或移动首页在 HMR、断点切换或重新渲染后直接报 React hook 顺序错误,页面停在“正在加载内容”。 @@ -1838,8 +1846,9 @@ - 现象:一张卡牌切片里同时出现两个或多个错位图案,或空白格、相邻编号区域里混入其他图案碎片。 - 原因:provider 生成的 `1024x1536 / 4x6` 工作表可能违反视觉契约;旧流程只校验布局元数据和切片数量,无法发现图像内容已经主体缺失或污染空白格。边界贴边检测容易把正常铺满主体误判成跨格污染,不能作为高可靠硬门禁。 - 处理:先强化 atlas prompt,要求每个 `256x256` 单元独立查看时只能包含一个主体或同一主体单一局部;服务端在 sheet 切片前做像素级质量门禁,硬拦截非空格前景占比过低和空白格污染,严重多边非同组边界贴边只记录 warning 供排查,不直接让创作失败。硬门禁失败的 sheet 最多尝试 4 次,仍失败则拒绝持久化脏 atlas。 -- 追加处理:照片式微场景素材必须把每个 `256x256` 单元收束为一张完整的单场景照片裁片;同编号连续格只表示玩法分组,不要暗示 provider 生成横跨多格的大图或照片拼贴。禁止单格内部出现两张照片、两个不同场景、拼接线、内部竖切、内部横切或左右 / 上下两块不同背景;质量门禁需要扫描单格内部贯穿大部分高度或宽度的强色差直线,命中时按“单格内部疑似拼接线”硬失败并重试 sheet。 +- 追加处理:照片式微场景素材必须把每个 `256x256` 单元收束为一张完整的单场景照片裁片;同编号连续格表示同一视觉家族,不是随机独立小图,要求共享同一场景锚点、主色和道具语言。禁止单格内部出现两张照片、两个不同场景、拼接线、内部竖切、内部横切或左右 / 上下两块不同背景;质量门禁只在单格内部强色差直线贯穿大部分高度或宽度,且两侧都像低纹理人工平铺色块时,按“单格内部疑似拼接线”硬失败并重试 sheet,避免把窗框、桌沿、地平线等自然场景强边缘误杀。 - 追加处理:sheet 生成时如果 VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时,例如 nginx HTML `502 Bad Gateway`,不要立刻把草稿置为 failed,应消耗同一 sheet 的下一次 attempt;仍失败再回写失败状态。 +- 追加处理:`sheet-03` 原本唯一空白格容易被模型画入主题主体,导致第 6 行第 4 列反复报“空白格有主体”并消耗多次 image2 请求。该格改为 `FILL` 补位格,允许生成主题小图但服务端切片、atlas 合成和运行态全部丢弃;前端拼消消 action 等待窗口同步提高到 40 分钟,避免上游单图慢返回时用户侧 20 分钟超时。 - 验证:`cargo test -p api-server puzzle_clear --manifest-path server-rs/Cargo.toml -- --nocapture`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/puzzle_clear.rs`、`docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md`。 diff --git a/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md index c81ab11e..b66efa99 100644 --- a/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md +++ b/docs/technical/【玩法创作】拼消消玩法模板技术方案-2026-05-30.md @@ -21,7 +21,7 @@ - image2 调用:4 次,每次生成 1 张 `1024x1536` 竖版素材工作表。 - sheet 裁切:每张按 `4 列 x 6 行` 裁切,每个 1x1 单元为 `256x256`。 - 最终 atlas:服务端把 95 个切片按领域坐标合成 `10x10 / 2560x2560` PNG,空单元保留浅色背景。 -- 运行态素材:最终写回 `35` 个复合图案组和 `95` 个 1x1 卡牌切片。 +- 运行态素材:最终写回 `35` 个复合图案组和 `95` 个 1x1 卡牌切片;`sheet-03` 的第 6 行第 4 列为 `FILL` 补位格,只为填满 4x6 工作表,生成后会被服务端丢弃,不进入最终 atlas 或运行态卡牌。 服务端固定布局如下: @@ -47,14 +47,15 @@ 验证策略: - 生图 prompt 明确要求照片式构图 / 绘本式渲染的主题微场景拼图卡,每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面,禁止出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右 / 上下两块不同背景,场景变化只能发生在 256 单元边界上。 -- 同编号连续格只表示玩法上的同组关系,不再暗示 provider 把连续区域画成一张横跨多格的大图;同组格子用色调、道具和背景线索呼应,但每个 256x256 单元独立查看时都必须完整成图。 +- 同编号连续格表示同一视觉家族,不是随机独立小图;同组格子要共享同一场景锚点、主色和道具语言,像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族。 - 同一张 sheet 内不同编号必须发散成不同视觉概念;以水果为例,应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等微场景,禁止同品种主体换角度、换大小或换姿势后重复出现。 - 每个 256x256 小卡切片独立查看时也要有可辨识的背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素,避免“孤立主体 + 纯色背景”导致运行态难区分。 - 生图 prompt 明确禁止文字、水印、UI、边框标签、切分线、网格线、裁切参考线、纯色背景、白底商品图、孤立主体、同品种重复和同一物体多角度。 - 复合图案组本身不画任何可见分割辅助线,但 prompt 必须说明每个 `1x2`、`1x3`、`2x2`、`2x3` 图案都能被服务端按等大的 1x1 方形单元切分;纵向 `1x2` 按横向切线分成两个 1x1,横向 `1x2` 按纵向切线分成两个 1x1,其他形状同理。图案组可以在语义上成组,但不能把一张大图的照片边界或拼贴边界落在单个 1x1 单元内部。 -- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形。 -- 每张 sheet 生成后、正式切片前执行像素级质量门禁:非空格必须达到最低前景占比,空白格前景占比不得超阈值,单格内部不得出现贯穿大部分高度或宽度的强色差拼接线;非同组边界前景贴边仅记录为质量提示,不作为硬失败,避免把模型正常铺满主体的图集误杀。 +- 服务端保留 `PuzzleClearPatternGroup` 坐标清单,切片前校验每个 sheet 正式编号出现次数等于领域图案组 `width * height`,并要求同编号区域是完整连续矩形;`FILL` 补位格不参与校验、切片、atlas 合成和运行态。 +- 每张 sheet 生成后、正式切片前执行像素级质量门禁:非空格必须达到最低前景占比,空白格前景占比不得超阈值,单格内部明显人工拼贴式分割需要硬失败;内部强边缘检测必须同时满足“贯穿大部分高度或宽度”和“两侧近似低纹理平铺色块”,避免把照片式微场景里的窗框、桌沿、地平线等自然结构误杀。非同组边界前景贴边仅记录为质量提示,不作为硬失败,避免把模型正常铺满主体的图集误杀。 - 每张 sheet 生成最多尝试 4 次;除质量门禁失败外,VectorEngine 返回 `retryable=true` 的 `502`、`504`、`429` 或请求超时也应消耗下一次 sheet attempt,避免上游 nginx 偶发 502 或单次拼贴式坏图直接把草稿置为 failed。 +- 前端拼消消创作 action 的请求等待窗口为 40 分钟,用于覆盖 VectorEngine 单张图偶发 10 分钟以上的慢返回;这只是本地验收稳定性兜底,后续若继续优化体验,应把素材生成迁到后台任务 / 轮询进度链路。 - sheet 多次生成仍未通过硬质量门禁时,生成任务进入 `failed` 并写入错误原因;不得把明显空白格污染或主体缺失的工作表切成正式卡牌资产。 - 首版若当前 provider 无法稳定产出可切 atlas,生成任务进入 `failed`,错误写入审计;不得退回前端假素材或绕过平台资产底座。 - 草稿编译和作品发布都必须拒绝缺失 atlas、缺失卡牌切片、空 `assetObjectId` / `imageObjectKey` 或 `placeholder` 占位资产;`spacetime-client` 不再为编译请求合成默认 atlas / card assets。 @@ -107,7 +108,7 @@ api-server 路由熔断使用 SpacetimeDB 创作入口配置 `puzzle-clear`, - `puzzle-clear-result` -> `/creation/puzzle-clear/result` - `puzzle-clear-runtime` -> `/runtime/puzzle-clear` -runtime 移动端优先,首屏结构为顶部倒计时 / 单关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 3px 格间距。动画包括开场翻转、列补牌下落和消除表现,不再有下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象。 +runtime 移动端优先,首屏结构为顶部倒计时 / 单关铭牌、顶部列准备区、棋盘、失败 / 完成弹层。棋盘主网格、半锁定组覆盖层和消除 / 掉落覆盖层统一使用 1.5px 格间距。动画包括开场翻转、局部正确拼合高光、完整消除放大淡出和列补牌延迟下落,不再有下一关切换。消除和补牌动画只能作为当前后端 snapshot 的表现层覆盖;已有场上卡片因重力下沉后的最终格不得被旧消除坐标或掉落覆盖层隐藏,避免出现“下方空位但上方卡片未下落”的视觉假象;新补入卡牌应等完整消除淡出进入尾段后再播放下落反馈。 ## 验证计划 diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 878063f6..1221df47 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -59,6 +59,8 @@ const PUZZLE_CLEAR_SHEET_COLUMNS_USIZE: usize = 4; const PUZZLE_CLEAR_SHEET_ROWS_USIZE: usize = 6; const PUZZLE_CLEAR_FINAL_ATLAS_COLUMNS: u32 = 10; const PUZZLE_CLEAR_FINAL_ATLAS_ROWS: u32 = 10; +const PUZZLE_CLEAR_SHEET_UNUSED_CELL: &str = "."; +const PUZZLE_CLEAR_SHEET_FILLER_CELL: &str = "FILL"; const PUZZLE_CLEAR_ATLAS_GENERATION_SIZE: &str = "1024x1536"; const PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE: &str = "1024x1024"; const PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC: &str = "/creation-type-references/puzzle.webp"; @@ -68,8 +70,10 @@ const PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO: f32 = 0.018; const PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO: f32 = 0.045; const PUZZLE_CLEAR_SHEET_EDGE_RATIO_THRESHOLD: f32 = 0.34; const PUZZLE_CLEAR_SHEET_STRONG_EDGE_RATIO_THRESHOLD: f32 = 0.66; -const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 155; -const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.86; +const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_DIFF_THRESHOLD: i32 = 170; +const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD: f32 = 0.92; +const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0; +const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0; const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插"; pub async fn create_puzzle_clear_session( @@ -937,7 +941,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第4行:D02 D02 C01 C01\n", "第5行:D02 D02 C02 C02\n", "第6行:A01 A01 C02 C02\n\n", - "A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴。" + "A 表示 1x2 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" ), }, PuzzleClearAtlasSheetSpec { @@ -958,7 +962,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第4行:C03 C03 C04 C04\n", "第5行:B01 B01 B01 A06\n", "第6行:B03 B03 B03 A06\n\n", - "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴。" + "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案,C 表示 2x2 复合图案,D 表示 2x3 或 3x2 复合图案。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" ), }, PuzzleClearAtlasSheetSpec { @@ -969,7 +973,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { ["B02", "B04", "A07", "A07"], ["B05", "B05", "B05", "A08"], ["A09", "A09", "A10", "A08"], - ["A11", "A11", "A10", "."], + ["A11", "A11", "A10", PUZZLE_CLEAR_SHEET_FILLER_CELL], ], layout_prompt: concat!( "本张 sheet 的布局如下,编号只给后台理解,绝对不要画在图中:\n\n", @@ -978,8 +982,8 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第3行:B02 B04 A07 A07\n", "第4行:B05 B05 B05 A08\n", "第5行:A09 A09 A10 A08\n", - "第6行:A11 A11 A10 空白\n\n", - "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。空白格保持干净浅色背景,不要生成图案。相同编号只表示玩法分组:请生成一组主题一致的小照片裁片,每个 256 单元都要独立成图,不要把连续区域画成一张横跨多格的大图或照片拼贴。" + "第6行:A11 A11 A10 FILL\n\n", + "A 表示 1x2 复合图案,B 表示 1x3 或 3x1 复合图案。FILL 是后台会丢弃的补位格,请画成主题一致但不参与玩法的小照片裁片,不要写字或编号。相同编号表示同一视觉家族:请生成一组主题一致、共享同一场景锚点的小照片裁片;同组格子要像同一套连拍或同一场景的不同局部,彼此能看出是同一个故事或场景家族,但每个 256 单元仍需完整可读,不要做成彼此无关的随机独立小图。" ), }, PuzzleClearAtlasSheetSpec { @@ -1000,7 +1004,7 @@ fn puzzle_clear_atlas_sheet_specs() -> Vec { "第4行:A16 A19 A19 A18\n", "第5行:A20 A21 A21 A22\n", "第6行:A20 A23 A23 A22\n\n", - "A 表示 1x2 复合图案。相同编号只表示玩法分组:横向 1x2 和纵向 1x2 用色调、道具和背景线索互相呼应,每个 256 单元都要独立成图,不要把连续区域画成一张横跨两格的大图或照片拼贴。" + "A 表示 1x2 复合图案。相同编号表示同一视觉家族:横向 1x2 和纵向 1x2 要共享同一场景锚点,用色调、道具和背景线索互相呼应;每个 256 单元仍需完整可读,但不要做成彼此无关的随机独立小图。" ), }, ] @@ -1015,12 +1019,12 @@ fn build_puzzle_clear_atlas_prompt( concat!( "生成一张拼消消素材工作表,主题是「{subject}」,竖版 1024x1536。\n\n", "这张图供程序后台按 4 列 x 6 行裁切,每个裁切单元为 256x256 的正方形。4x6 网格只用于后台理解,画面中绝对不要画出网格线、切分线、边框、编号或坐标。\n\n", - "这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域必须有明确背景、环境、道具、光影和构图线索,像从一张丰富照片或插画中裁出的局部。\n\n", - "每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n", - "相同编号连续占据的格子只表示玩法上的同组关系,不是要求把一张大图横跨多个格子。请把同编号区域画成色调、道具、背景线索互相呼应的一组小照片裁片;每个格子独立查看时都必须完整成图,不能在单格内部再切出第二张图或第二个场景。\n\n", - "同一张 sheet 内,不同编号必须使用不同视觉概念,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n", + "这不是单个物体素材表,而是一组照片式构图、绘本式渲染的主题微场景拼图卡。每个编号区域都要属于同一视觉家族,必须有明确背景、环境、道具、光影和构图线索,像从同一组丰富照片或插画中裁出的局部。\n\n", + "每个 256x256 单元本身就是一张完整的单场景照片裁片,单元内部只能有一个连续画面;同组之间要共享同一场景锚点、主色和道具语言。禁止在一个单元内部出现两张照片、两个不同场景、拼接线、分割线、内部竖切、内部横切、左右/上下两块不同背景。场景变化只能发生在 256 单元边界上。\n\n", + "相同编号连续占据的格子表示同一视觉家族,不是随机独立小图。请把同编号区域画成一组可辨认的兄弟卡片,至少共享一个明显场景锚点(同一张桌子、同一窗景、同一庭院、同一篮子或同一器物系统);每个格子可以展示这个家族的不同局部、视角或连贯片段,但仍需完整可读,不能在单格内部再切出第二张图或第二个场景。\n\n", + "同一张 sheet 内,不同编号必须使用不同视觉概念,并且拉开主色、场景和道具,不要把同一种主体换角度、换大小、换姿势后重复使用。比如主题是水果时,不要重复生成不同角度的葡萄、菠萝、西瓜、橙子;应扩展为果园、集市摊位、野餐布、果汁杯、厨房案板、甜品盘、篮筐、玻璃罐、窗边餐桌、花园背景等不同场景。\n\n", "每个 256x256 单元独立查看时,都应该有可辨识的局部信息:可以包含主体局部、背景纹理、桌面、草地、天空、建筑、布料、器皿、叶片、阴影或装饰元素。不要让小卡只有一个孤立主体加纯色背景。\n\n", - "不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;空白格必须保持干净浅色背景,不要出现任何图案碎片。\n\n", + "不同编号区域之间保持干净边界,主体不能越界或挤入相邻编号区域;FILL 补位格可以生成主题一致的小照片裁片,但后台会丢弃它,不要在 FILL 中写字、编号或画规则说明。\n\n", "图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片。不能有外轮廓框、白色描边、圆角框、阴影框、分隔线、参考线或贴纸边。\n\n", "画风为高清、清爽、适合休闲消除游戏的丰富主题插画;颜色鲜明,边缘干净,不能出现文字、Logo、水印、按钮、UI 或教程元素。\n\n", "{layout_prompt}" @@ -1208,12 +1212,15 @@ fn validate_puzzle_clear_sheet_quality( let quality = analyze_puzzle_clear_sheet_cell_quality(&source, sheet_spec, row, col, bounds); let cell_label = format!("第{}行第{}列", row + 1, col + 1); - if group_id == "." { + if group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL { if quality.foreground_ratio > PUZZLE_CLEAR_SHEET_BLANK_MAX_FOREGROUND_RATIO { findings.push(format!("{cell_label} 空白格有主体")); } continue; } + if group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL { + continue; + } if quality.foreground_ratio < PUZZLE_CLEAR_SHEET_MIN_FOREGROUND_RATIO { findings.push(format!("{cell_label} 主体过少")); @@ -1463,7 +1470,14 @@ fn measure_puzzle_clear_sheet_internal_seam( total = total.saturating_add(1); } if total > 0 { - strongest = strongest.max(strong as f32 / total as f32); + let line_ratio = strong as f32 / total as f32; + if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD + && puzzle_clear_sheet_internal_seam_has_flat_split( + source, bounds, x, true, x_start, x_end, y_start, y_end, + ) + { + strongest = strongest.max(line_ratio); + } } } @@ -1481,19 +1495,135 @@ fn measure_puzzle_clear_sheet_internal_seam( total = total.saturating_add(1); } if total > 0 { - strongest = strongest.max(strong as f32 / total as f32); + let line_ratio = strong as f32 / total as f32; + if line_ratio > PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_RATIO_THRESHOLD + && puzzle_clear_sheet_internal_seam_has_flat_split( + source, bounds, y, false, x_start, x_end, y_start, y_end, + ) + { + strongest = strongest.max(line_ratio); + } } } strongest } +fn puzzle_clear_sheet_internal_seam_has_flat_split( + source: &image::DynamicImage, + bounds: PuzzleClearSheetCellBounds, + split: u32, + vertical: bool, + x_start: u32, + x_end: u32, + y_start: u32, + y_end: u32, +) -> bool { + // 中文注释:富场景照片里常有窗框、桌沿、地平线等贯穿强边,只有两侧都近似人工平铺色块时才按拼贴硬失败。 + let band = (bounds.width().min(bounds.height()) / 10).clamp(14, 28); + let (first, second) = if vertical { + let left_start = split.saturating_sub(band).max(x_start); + let left_end = split.saturating_sub(2).max(left_start); + let right_start = split.saturating_add(2).min(x_end); + let right_end = split.saturating_add(band).min(x_end).max(right_start); + ( + puzzle_clear_rgb_stats_for_region(source, left_start, left_end, y_start, y_end), + puzzle_clear_rgb_stats_for_region(source, right_start, right_end, y_start, y_end), + ) + } else { + let top_start = split.saturating_sub(band).max(y_start); + let top_end = split.saturating_sub(2).max(top_start); + let bottom_start = split.saturating_add(2).min(y_end); + let bottom_end = split.saturating_add(band).min(y_end).max(bottom_start); + ( + puzzle_clear_rgb_stats_for_region(source, x_start, x_end, top_start, top_end), + puzzle_clear_rgb_stats_for_region(source, x_start, x_end, bottom_start, bottom_end), + ) + }; + + if first.count == 0 || second.count == 0 { + return false; + } + + let side_contrast = puzzle_clear_rgb_stats_distance(first, second); + let side_texture = first.texture().max(second.texture()); + side_contrast >= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD + && side_texture <= PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX +} + +#[derive(Clone, Copy, Debug, Default)] +struct PuzzleClearRgbStats { + count: u64, + sum: [u64; 3], + sum_square: [u64; 3], +} + +impl PuzzleClearRgbStats { + fn push(&mut self, pixel: [u8; 4]) { + self.count = self.count.saturating_add(1); + for (index, channel) in pixel.iter().take(3).enumerate() { + let value = *channel as u64; + self.sum[index] = self.sum[index].saturating_add(value); + self.sum_square[index] = self.sum_square[index].saturating_add(value * value); + } + } + + fn mean_channel(self, index: usize) -> f32 { + if self.count == 0 { + return 0.0; + } + self.sum[index] as f32 / self.count as f32 + } + + fn texture(self) -> f32 { + if self.count == 0 { + return f32::MAX; + } + let mut variance_sum = 0.0f32; + for index in 0..3 { + let mean = self.mean_channel(index); + let mean_square = self.sum_square[index] as f32 / self.count as f32; + variance_sum += (mean_square - mean * mean).max(0.0); + } + variance_sum.sqrt() + } +} + +fn puzzle_clear_rgb_stats_for_region( + source: &image::DynamicImage, + x0: u32, + x1: u32, + y0: u32, + y1: u32, +) -> PuzzleClearRgbStats { + let mut stats = PuzzleClearRgbStats::default(); + if x0 >= x1 || y0 >= y1 { + return stats; + } + for y in (y0..y1).step_by(4) { + for x in (x0..x1).step_by(4) { + stats.push(source.get_pixel(x, y).0); + } + } + stats +} + +fn puzzle_clear_rgb_stats_distance(left: PuzzleClearRgbStats, right: PuzzleClearRgbStats) -> f32 { + (0..3) + .map(|index| (left.mean_channel(index) - right.mean_channel(index)).abs()) + .sum() +} + fn puzzle_clear_rgb_distance(left: [u8; 4], right: [u8; 4]) -> i32 { (left[0] as i32 - right[0] as i32).abs() + (left[1] as i32 - right[1] as i32).abs() + (left[2] as i32 - right[2] as i32).abs() } +fn is_puzzle_clear_sheet_discarded_cell(group_id: &str) -> bool { + group_id == PUZZLE_CLEAR_SHEET_UNUSED_CELL || group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL +} + fn puzzle_clear_sheet_neighbor_is_same_group( sheet_spec: &PuzzleClearAtlasSheetSpec, row: u32, @@ -1502,7 +1632,7 @@ fn puzzle_clear_sheet_neighbor_is_same_group( col_delta: i32, ) -> bool { let current = sheet_spec.layout[row as usize][col as usize]; - if current == "." { + if is_puzzle_clear_sheet_discarded_cell(current) { return false; } let neighbor_row = row as i32 + row_delta; @@ -1543,7 +1673,7 @@ fn slice_puzzle_clear_sheet( let mut cells_by_group: BTreeMap<&str, Vec<(u32, u32)>> = BTreeMap::new(); for (row, cells) in sheet_spec.layout.iter().enumerate() { for (col, group_id) in cells.iter().enumerate() { - if *group_id == "." { + if is_puzzle_clear_sheet_discarded_cell(group_id) { continue; } cells_by_group @@ -2072,10 +2202,12 @@ fn build_puzzle_clear_public_work_code(profile_id: &str) -> String { mod tests { use super::{ PUZZLE_CLEAR_ATLAS_CELL_SIZE, PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT, - PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, build_puzzle_clear_atlas_prompt, + PUZZLE_CLEAR_BOARD_BACKGROUND_GENERATION_SIZE, PUZZLE_CLEAR_SHEET_FILLER_CELL, + PUZZLE_CLEAR_SHEET_UNUSED_CELL, PuzzleClearAtlasSheetSpec, build_puzzle_clear_atlas_prompt, build_puzzle_clear_board_background_prompt, build_puzzle_clear_draft, - is_retryable_puzzle_clear_sheet_generation_error, planned_puzzle_clear_pattern_groups, - puzzle_clear_atlas_sheet_specs, validate_puzzle_clear_sheet_quality, + is_puzzle_clear_sheet_discarded_cell, is_retryable_puzzle_clear_sheet_generation_error, + planned_puzzle_clear_pattern_groups, puzzle_clear_atlas_sheet_specs, + validate_puzzle_clear_sheet_quality, }; use crate::http_error::AppError; use crate::openai_image_generation::DownloadedOpenAiImage; @@ -2105,15 +2237,18 @@ mod tests { assert!(prompt.contains("内部竖切")); assert!(prompt.contains("内部横切")); assert!(prompt.contains("场景变化只能发生在 256 单元边界上")); - assert!(prompt.contains("相同编号连续占据的格子只表示玩法上的同组关系")); - assert!(prompt.contains("不是要求把一张大图横跨多个格子")); + assert!(prompt.contains("同一视觉家族")); + assert!(prompt.contains("同一场景锚点")); + assert!(prompt.contains("同一套连拍")); + assert!(prompt.contains("彼此无关的随机独立小图")); assert!(prompt.contains("不能在单格内部再切出第二张图或第二个场景")); assert!(prompt.contains("不同编号必须使用不同视觉概念")); assert!(prompt.contains("不要把同一种主体换角度、换大小、换姿势后重复使用")); assert!(prompt.contains("果园、集市摊位、野餐布、果汁杯、厨房案板")); assert!(prompt.contains("可以包含主体局部、背景纹理、桌面、草地、天空")); assert!(prompt.contains("不要让小卡只有一个孤立主体加纯色背景")); - assert!(prompt.contains("空白格必须保持干净浅色背景")); + assert!(prompt.contains("FILL 补位格可以生成主题一致的小照片裁片")); + assert!(prompt.contains("后台会丢弃它")); assert!(prompt.contains("图案不要做成商品素材表、卡牌、贴纸、图标格子或带框小卡片")); assert!(prompt.contains("外轮廓框")); assert!(prompt.contains("贴纸边")); @@ -2139,16 +2274,26 @@ mod tests { fn puzzle_clear_sheet_plan_matches_reduced_asset_strategy() { let sheets = puzzle_clear_atlas_sheet_specs(); let groups = planned_puzzle_clear_pattern_groups(); - let sheet_cells = sheets + let occupied_sheet_cells = sheets .iter() .flat_map(|sheet| sheet.layout.iter().flatten()) - .filter(|group_id| **group_id != ".") + .filter(|group_id| **group_id != PUZZLE_CLEAR_SHEET_UNUSED_CELL) + .count(); + let playable_sheet_cells = sheets + .iter() + .flat_map(|sheet| sheet.layout.iter().flatten()) + .filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id)) + .count(); + let filler_sheet_cells = sheets + .iter() + .flat_map(|sheet| sheet.layout.iter().flatten()) + .filter(|group_id| **group_id == PUZZLE_CLEAR_SHEET_FILLER_CELL) .count(); let mut sheet_cells_by_group = std::collections::BTreeMap::<&str, u32>::new(); for group_id in sheets .iter() .flat_map(|sheet| sheet.layout.iter().flatten()) - .filter(|group_id| **group_id != ".") + .filter(|group_id| !is_puzzle_clear_sheet_discarded_cell(group_id)) { *sheet_cells_by_group.entry(*group_id).or_default() += 1; } @@ -2159,7 +2304,9 @@ mod tests { assert_eq!(sheets.len(), 4); assert_eq!(groups.len(), 35); - assert_eq!(sheet_cells, 95); + assert_eq!(occupied_sheet_cells, 96); + assert_eq!(playable_sheet_cells, 95); + assert_eq!(filler_sheet_cells, 1); assert_eq!(group_cells, 95); assert_eq!(PUZZLE_CLEAR_ATLAS_CELL_SIZE, 256); assert_eq!(sheet_cells_by_group.len(), groups.len()); @@ -2216,12 +2363,88 @@ mod tests { .expect("edge contact is advisory because generated sheets often touch borders"); } + fn build_test_puzzle_clear_sheet_image_with_cell_pollution( + row: u32, + col: u32, + ) -> DownloadedOpenAiImage { + let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); + for row in 0..6u32 { + for col in 0..4u32 { + let base_x = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let base_y = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let color = Rgba([ + 70u8.saturating_add((row * 23) as u8), + 80u8.saturating_add((col * 31) as u8), + 160, + 255, + ]); + for y in base_y + 80..base_y + 176 { + for x in base_x + 80..base_x + 176 { + source.put_pixel(x, y, color); + } + } + } + } + + let cell_y0 = row * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + let cell_x0 = col * PUZZLE_CLEAR_ATLAS_CELL_SIZE; + for y in cell_y0 + 40..cell_y0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { + for x in cell_x0 + 40..cell_x0 + PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { + source.put_pixel(x, y, Rgba([215, 48, 62, 255])); + } + } + + let mut encoded = Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(source) + .write_to(&mut encoded, ImageFormat::Png) + .expect("test image should encode"); + DownloadedOpenAiImage { + extension: "png".to_string(), + mime_type: "image/png".to_string(), + bytes: encoded.into_inner(), + } + } + #[test] - fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() { + fn puzzle_clear_sheet_quality_allows_filler_cell_pollution() { let sheet = puzzle_clear_atlas_sheet_specs() .into_iter() .find(|sheet| sheet.sheet_id == "sheet-03") .expect("sheet exists"); + + let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(5, 3); + + validate_puzzle_clear_sheet_quality(&image, &sheet) + .expect("filler cell is generated only to stabilize the sheet and is discarded later"); + } + + #[test] + fn puzzle_clear_sheet_quality_rejects_blank_cell_pollution() { + let sheet = PuzzleClearAtlasSheetSpec { + sheet_id: "blank-test", + layout: [ + [PUZZLE_CLEAR_SHEET_UNUSED_CELL, "A01", "A01", "A02"], + ["A03", "A03", "A04", "A04"], + ["A05", "A05", "A06", "A06"], + ["A07", "A07", "A08", "A08"], + ["A09", "A09", "A10", "A10"], + ["A11", "A11", "A12", "A12"], + ], + layout_prompt: "test", + }; + let image = build_test_puzzle_clear_sheet_image_with_cell_pollution(0, 0); + + let error = validate_puzzle_clear_sheet_quality(&image, &sheet) + .expect_err("blank cell pollution should be rejected"); + assert!(error.body_text().contains("空白格有主体")); + } + + #[test] + fn puzzle_clear_sheet_quality_allows_textured_scene_divider() { + let sheet = puzzle_clear_atlas_sheet_specs() + .into_iter() + .find(|sheet| sheet.sheet_id == "sheet-04") + .expect("sheet exists"); let mut source = RgbaImage::from_pixel(1024, 1536, Rgba([250, 249, 242, 255])); for row in 0..6u32 { for col in 0..4u32 { @@ -2241,10 +2464,27 @@ mod tests { } } - for y in 5 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..6 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { - for x in 3 * PUZZLE_CLEAR_ATLAS_CELL_SIZE + 40..4 * PUZZLE_CLEAR_ATLAS_CELL_SIZE - 40 { - source.put_pixel(x, y, Rgba([215, 48, 62, 255])); + for y in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE { + for x in 0..PUZZLE_CLEAR_ATLAS_CELL_SIZE { + let noise = ((x * 17 + y * 31) % 120) as u8; + let color = if x < PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2 { + Rgba([ + 72u8.saturating_add(noise), + 88u8.saturating_add(((x * 7 + y * 11) % 96) as u8), + 112u8.saturating_add(((x * 5 + y * 13) % 72) as u8), + 255, + ]) + } else { + Rgba([ + 104u8.saturating_add(((x * 19 + y * 3) % 92) as u8), + 76u8.saturating_add(noise), + 56u8.saturating_add(((x * 11 + y * 23) % 88) as u8), + 255, + ]) + }; + source.put_pixel(x, y, color); } + source.put_pixel(PUZZLE_CLEAR_ATLAS_CELL_SIZE / 2, y, Rgba([24, 24, 24, 255])); } let mut encoded = Cursor::new(Vec::new()); @@ -2257,9 +2497,8 @@ mod tests { bytes: encoded.into_inner(), }; - let error = validate_puzzle_clear_sheet_quality(&image, &sheet) - .expect_err("blank cell pollution should be rejected"); - assert!(error.body_text().contains("空白格有主体")); + validate_puzzle_clear_sheet_quality(&image, &sheet) + .expect("textured photo-like scene divider should not be rejected as collage"); } #[test] diff --git a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx index 27245f3c..3fbf02e1 100644 --- a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx +++ b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx @@ -1015,6 +1015,67 @@ test('消除成功时会播放消除和掉落过渡动画', async () => { '.puzzle-clear-transition-piece--drop', ).length, ).toBeGreaterThan(0); + expect( + screen + .getByTestId('puzzle-clear-board') + .querySelector('.puzzle-clear-transition-piece--drop') + ?.getAttribute('style') ?? '', + ).toContain('--puzzle-clear-drop-delay: 520ms'); +}); + +test('正确局部拼合但未消除时会高光提醒拼合组', async () => { + const previousRun = cloneRunWithBoard(createRun(), { + rows: 3, + cols: 3, + cells: [ + { row: 0, col: 0, card: createCard(40, 0, 0), lockedGroupId: null }, + { row: 0, col: 1, card: createCard(40, 1, 0), lockedGroupId: null }, + { row: 0, col: 2, card: createCard(42, 0, 0), lockedGroupId: null }, + { row: 1, col: 0, card: createCard(43, 0, 0), lockedGroupId: null }, + { row: 1, col: 1, card: createCard(44, 0, 0), lockedGroupId: null }, + { row: 1, col: 2, card: createCard(45, 0, 0), lockedGroupId: null }, + { row: 2, col: 0, card: createCard(46, 0, 0), lockedGroupId: null }, + { row: 2, col: 1, card: createCard(47, 0, 0), lockedGroupId: null }, + { row: 2, col: 2, card: createCard(48, 0, 0), lockedGroupId: null }, + ], + }); + const nextRun = cloneRunWithBoard(previousRun, { + rows: 3, + cols: 3, + cells: previousRun.board.cells.map((cell) => + cell.row === 0 && (cell.col === 0 || cell.col === 1) + ? { ...cell, lockedGroupId: 'group-40' } + : cell, + ), + }); + + const { rerender } = render( + , + ); + + rerender( + , + ); + + await waitFor(() => + expect( + screen.getByTestId('puzzle-clear-locked-group-visual').className, + ).toContain('puzzle-clear-locked-group-visual--highlight'), + ); }); test('完成拼接的局部会以连续组面板呈现,而不是单个绿框', () => { @@ -1162,7 +1223,7 @@ test('棋盘继承拼图模板的正方形触控面约束', () => { expect(board.className).toContain('aspect-square'); expect(board.className).toContain('touch-none'); expect(board.className).toContain('select-none'); - expect(board.className).toContain('gap-[3px]'); + expect(board.className).toContain('gap-[1.5px]'); expect(board.className).not.toContain('gap-1.5'); expect(board.className).not.toContain('h-full'); expect(board.className).toContain('relative'); diff --git a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx index e2e50cef..50afc99b 100644 --- a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx +++ b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx @@ -113,8 +113,18 @@ type PuzzleClearClearTransitionState = { }>; }; +type PuzzleClearMergeHighlightState = { + highlightKey: number; + groupIds: string[]; +}; + type PuzzleClearLockedGroupViewModel = PuzzleClearDragGroupState; +const PUZZLE_CLEAR_CLEAR_TRANSITION_MS = 1120; +const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520; +const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180; +const PUZZLE_CLEAR_MERGE_HIGHLIGHT_MS = 760; + function getRun( run: PuzzleClearRuntimeSnapshotResponse | null | undefined, snapshot: PuzzleClearRuntimeSnapshotResponse | null | undefined, @@ -272,6 +282,60 @@ function cloneDragGroupState( }; } +function buildPuzzleClearLockedGroupSignatures( + run: PuzzleClearRuntimeSnapshotResponse, +) { + // 只比较锁定组包含的卡片,避免同一组整体移动时误触发拼合高光。 + const groupCards = new Map(); + for (const cell of run.board.cells) { + if (!cell.card || !cell.lockedGroupId) { + continue; + } + const cardIds = groupCards.get(cell.lockedGroupId) ?? []; + cardIds.push(cell.card.cardId); + groupCards.set(cell.lockedGroupId, cardIds); + } + + const signatures = new Map(); + for (const [groupId, cardIds] of groupCards.entries()) { + if (cardIds.length <= 1) { + continue; + } + signatures.set(groupId, cardIds.sort().join('|')); + } + return signatures; +} + +function buildPuzzleClearMergeHighlight( + previousRun: PuzzleClearRuntimeSnapshotResponse, + nextRun: PuzzleClearRuntimeSnapshotResponse, +): PuzzleClearMergeHighlightState | null { + if ( + previousRun.runId !== nextRun.runId || + previousRun.clearsDone !== nextRun.clearsDone + ) { + return null; + } + + const previousGroups = buildPuzzleClearLockedGroupSignatures(previousRun); + const nextGroups = buildPuzzleClearLockedGroupSignatures(nextRun); + const groupIds = [...nextGroups.entries()] + .filter(([groupId, nextSignature]) => { + const previousSignature = previousGroups.get(groupId); + return !previousSignature || previousSignature !== nextSignature; + }) + .map(([groupId]) => groupId); + + if (groupIds.length === 0) { + return null; + } + + return { + highlightKey: Date.now(), + groupIds, + }; +} + function buildPuzzleClearClearTransition( previousRun: PuzzleClearRuntimeSnapshotResponse, nextRun: PuzzleClearRuntimeSnapshotResponse, @@ -327,7 +391,12 @@ function buildPuzzleClearClearTransition( col: cell.col, card: cell.card!, distance: movedDistance, - delayMs: Math.min(220, Math.max(0, (movedDistance - 1) * 60 + cell.row * 16)), + delayMs: + PUZZLE_CLEAR_REFILL_DROP_DELAY_MS + + Math.min( + PUZZLE_CLEAR_DROP_STAGGER_MAX_MS, + Math.max(0, (movedDistance - 1) * 52 + cell.row * 14), + ), hideBoardCell: !previousPosition, }; }) @@ -383,6 +452,7 @@ export function PuzzleClearRuntimeShell({ const swapFlightTimerRef = useRef(null); const previousRunRef = useRef(null); const clearTransitionTimerRef = useRef(null); + const mergeHighlightTimerRef = useRef(null); const cellElementRefMap = useRef(new Map()); const [selectedCell, setSelectedCell] = useState( null, @@ -395,6 +465,8 @@ export function PuzzleClearRuntimeShell({ ); const [clearTransition, setClearTransition] = useState(null); + const [mergeHighlight, setMergeHighlight] = + useState(null); const [showOpeningPhase, setShowOpeningPhase] = useState(Boolean(activeRun)); const [secondsLeft, setSecondsLeft] = useState( activeRun?.levelDurationSeconds ?? 600, @@ -460,6 +532,10 @@ export function PuzzleClearRuntimeShell({ .filter((group) => group.cells.length > 1) .sort((left, right) => left.groupId.localeCompare(right.groupId)); }, [board]); + const mergeHighlightGroupIds = useMemo( + () => new Set(mergeHighlight?.groupIds ?? []), + [mergeHighlight], + ); const draggedGroup = dragState?.group ?? null; const draggedGroupId = draggedGroup?.groupId ?? null; const draggedGroupCellKeys = useMemo(() => { @@ -580,24 +656,41 @@ export function PuzzleClearRuntimeShell({ window.clearTimeout(clearTransitionTimerRef.current); clearTransitionTimerRef.current = null; } + if (mergeHighlightTimerRef.current !== null) { + window.clearTimeout(mergeHighlightTimerRef.current); + mergeHighlightTimerRef.current = null; + } if (!activeRun || !previousRun) { setClearTransition(null); + setMergeHighlight(null); return; } const transition = buildPuzzleClearClearTransition(previousRun, activeRun); - if (!transition) { - setClearTransition(null); + if (transition) { + setDragState(null); + setSwapFeedback(null); + setSwapFlight(null); + setSelectedCell(null); + setMergeHighlight(null); + setClearTransition(transition); + clearTransitionTimerRef.current = window.setTimeout(() => { + clearTransitionTimerRef.current = null; + setClearTransition(null); + }, PUZZLE_CLEAR_CLEAR_TRANSITION_MS); return; } - setDragState(null); - setSwapFeedback(null); - setSwapFlight(null); - setSelectedCell(null); - setClearTransition(transition); - clearTransitionTimerRef.current = window.setTimeout(() => { - clearTransitionTimerRef.current = null; - setClearTransition(null); - }, 640); + + const highlight = buildPuzzleClearMergeHighlight(previousRun, activeRun); + setClearTransition(null); + if (!highlight) { + setMergeHighlight(null); + return; + } + setMergeHighlight(highlight); + mergeHighlightTimerRef.current = window.setTimeout(() => { + mergeHighlightTimerRef.current = null; + setMergeHighlight(null); + }, PUZZLE_CLEAR_MERGE_HIGHLIGHT_MS); }, [ activeRun, activeRun?.board, @@ -615,7 +708,16 @@ export function PuzzleClearRuntimeShell({ setDragState(null); setSwapFeedback(null); setSwapFlight(null); + if (clearTransitionTimerRef.current !== null) { + window.clearTimeout(clearTransitionTimerRef.current); + clearTransitionTimerRef.current = null; + } + if (mergeHighlightTimerRef.current !== null) { + window.clearTimeout(mergeHighlightTimerRef.current); + mergeHighlightTimerRef.current = null; + } setClearTransition(null); + setMergeHighlight(null); }, [activeRun?.levelIndex, activeRun?.status]); useEffect( @@ -629,6 +731,12 @@ export function PuzzleClearRuntimeShell({ if (swapFlightTimerRef.current !== null) { window.clearTimeout(swapFlightTimerRef.current); } + if (clearTransitionTimerRef.current !== null) { + window.clearTimeout(clearTransitionTimerRef.current); + } + if (mergeHighlightTimerRef.current !== null) { + window.clearTimeout(mergeHighlightTimerRef.current); + } }, [], ); @@ -964,7 +1072,7 @@ export function PuzzleClearRuntimeShell({
group.groupId !== draggedGroupId) .map((group) => (