diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index b40920eb..75b0bcff 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,22 @@ --- +## 2026-05-25 抓大鹅发现页官方 demo 使用静态资源与本地运行态 + +- 背景:本轮抓大鹅资源管线已经生成完整 `level-scene`、背景、UI spritesheet、物品 spritesheet 和切片资源,需要放入发现页作为可试玩验证入口,但不应把一次性本地资源包装成后端正式作品。 +- 决策:发现页官方抓大鹅 demo 固定 profileId 为 `match3d-demo-20260525`、公开作品号为 `M3-20260525`,资源读取 `public/match3d-demo/undersea-candy-market/` 下的静态文件。公开卡片、作品号搜索和详情页沿用平台公开作品详情链路;启动运行态时用 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema、不写正式作品统计。 +- 影响范围:`src/data/match3dDemoGalleryCard.ts`、`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、发现页公开卡片、作品号搜索、Match3D 本地 runtime adapter、玩法链路文档。 +- 验证方式:搜索 `M3-20260525` 能打开“海底糖果集市”并启动本地抓大鹅运行态;正式 Match3D 公开作品仍走 server runtime adapter。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + +## 2026-05-25 抓大鹅运行态 HUD 收敛为拼图同款低遮挡样式 + +- 背景:抓大鹅游玩阶段 UI 需要继续对齐拼图运行态的观感,同时移除右上角设置入口、灰白半透底板和显眼锅壳,让棋盘区域更专注。 +- 决策:抓大鹅运行态只保留左上透明返回按钮,右上不再显示设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板、同造型和 `media/logo.png` 产品 logo;底部备选栏和道具图标保持交互边界但不再显示灰白半透底;中央容器图层可以视觉隐藏,但棋盘命中边界和既有交互逻辑保留。 +- 影响范围:`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`、`src/index.css`、抓大鹅玩法链路文档。 +- 验证方式:运行态页面不再渲染“打开抓大鹅设置”,顶部仍显示关卡名和倒计时,底部槽位和道具按钮 class 中不含旧白底视觉;相关测试通过后保持该口径。 +- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。 + ## 2026-05-25 平台首页推荐按桌面与移动断点分流 - 背景:平台首页的推荐页在桌面与移动端之间原先共用同一套推荐运行态逻辑,容易让桌面和移动两套内容同时启动,也让首页的推荐卡与桌面发现壳互相抢状态。 diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index eac362e4..3f4ecd5a 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -1512,6 +1512,14 @@ - 验证:`curl.exe -i http://127.0.0.1:8082/api/creation-entry/config` 返回 `200` 且包含 `baby-object-match`;前端草稿页作品架重新渲染。 - 关联:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/creation_entry_config.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 抓大鹅物品 spritesheet 偏移先查 alpha 连通域切片是否启用 + +- 现象:抓大鹅物品图集里大多数素材显示不全、被裁碎、位置整体偏移,甚至切出来像拼贴块。 +- 原因:旧链路只按 `10x10` 固定格线裁切,遇到模型输出的透明图集稍有偏移、跨格或留白不均时就会把主体切坏。现在后端优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序;只有识别数量不足时才回退旧网格切法。 +- 处理:优先检查 `generated_asset_sheets.rs` 的 alpha 连通域切片是否生效,再查 `item_assets.rs` 是否还在透传旧的固定格线语义。不要只改前端显示比例。 +- 验证:定向测试 `cargo test -p api-server generated_asset_sheet_two_items_per_row --manifest-path server-rs/Cargo.toml -- --nocapture` 应通过,且错位透明样本应按连通域切出完整视图。 +- 关联:`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`server-rs/crates/api-server/src/match3d/item_assets.rs`。 + ## 个人中心不再保留直达“存档”按钮入口 - 现象:2026-05-25 起,移动端“我的”页顶部改为品牌行 + 扫码 / 设置按钮,设置区和次级入口不再提供独立的 `存档` 按钮;用户仍可在“玩过”弹窗里查看可继续存档。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 087e28b9..4e0ded6e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -160,7 +160,7 @@ npm run check:server-rs-ddd - LLM:`GENARRATIVE_LLM_*`,创意 Agent 另用 `APIMART_BASE_URL` / `APIMART_API_KEY`。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。APIMart 只保留给创意 Agent `gpt-5` Responses 文本 / 多模态链路;DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。 -- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端固定从该 sheet 解析并持久化 20 个物品、每个 5 个形态;通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 +- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 真实透明 alpha PNG 并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物上传 OSS 前不做服务端抠图后处理,避免误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 5044d3fe..9560a51a 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -190,8 +190,8 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 3. 首次调用 VectorEngine `gpt-image-2`,无参考图,竖屏 `9:16`,生成完整抓大鹅关卡画面并持久化到 `generatedBackgroundAsset.levelSceneImageSrc/levelSceneImageObjectKey`。提示词必须包含用户主题描述、顶部返回 / 标题倒计时 / 设置按钮、中间与主题匹配且贴横向边缘的容器,以及底部“移出 / 凑齐 / 打乱”三个道具按钮。 4. 关卡整图完成后并发发起三次 `gpt-image-2` 编辑请求,三者都以关卡整图作为参考图:`1K`、`1:1` 的 UI spritesheet 写入 `uiSpritesheetImageSrc/uiSpritesheetImageObjectKey`;`1K`、`9:16` 的背景图写入 `imageSrc/imageObjectKey`;`2K`、`1:1` 的物品 spritesheet 写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。 5. UI spritesheet 提示词固定要求按从上到下、从左到右整理纯绿色绿幕背景素材:返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮;后端上传 OSS 前必须把绿幕扣成透明 PNG。背景图提示词固定要求移除全部 UI 组件和容器内含物,完整保留容器和背景,并补全被 UI 覆盖的背景内容。 -6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次固定解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;持久化单格映射必须按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 -7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 +6. 物品 spritesheet 固定 `10行*10列`、统一纯绿色绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;素材间距严格均匀分布,每一行包含两种物品,每种物品五个不同形态,物品来自参考图中心容器中的 2D 素材,严禁高相似度物品。新流程每次解析并持久化 `20` 种物品,物品信息列表全部展示这 `20` 种;后端切 `generatedItemAssets[].imageViews[]` 时优先按透明 alpha 连通域识别真实素材矩形,再按原图从上到下、从左到右排序,每 `5` 个区域组成一个物品的五个形态;只有识别出的区域数量不足时才回退 `10*10` 固定网格。持久化单格映射元数据仍按 `row = itemIndex / 2 + 1`、`col = itemIndex % 2 * 5 + viewIndex + 1` 写入通用系列素材图集,不能再用 `row = itemIndex + 1`。`generatedItemAssets[].imageViews[]` 仍兼容已切好的五视角图,缺失时运行态和编辑器按 spritesheet 自动解析结果回退。 +7. 前端和运行态统一使用 alpha 连通域矩形检测解析 spritesheet:UI 图先把识别出的透明素材矩形按行聚类,再在每一行内按横向 `x` 坐标排序,最后按返回、设置、方格、移出、凑齐、打乱顺序映射回原 UI 位置;不能只按全局 `y` 坐标排序,否则同一行素材上下略有错位时会把方格和底部道具按钮顺序打乱。物品图按检测顺序每 `5` 个区域组成一个物品的五个形态,最多 `20` 个物品。透明背景是解析前提,不能在前端按固定像素坐标写死切片。 8. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。 9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。 10. 背景、UI spritesheet、物品 spritesheet 和历史容器兼容字段的持久化真相仍在 `generatedItemAssets[].backgroundAsset` 与提升后的 `generatedBackgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取。草稿编译后的 `draftJson` 自身也必须携带 `generatedItemAssets` 快照;HTTP facade 不能只依赖 work detail 回读补齐 UI 资产,外部回读为空时也不得清空草稿内已有的背景 / 图集。平台壳层从作品架、广场、生成完成回调、结果页保存 / 发布 / 试玩回调进入 Match3D profile 时也要先归一化并提升,避免首次试玩、手动试玩、推荐流或公开详情运行态退回默认背景。 @@ -214,12 +214,15 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列 - 难度只决定本局加载的物品种类数量:轻松 3、标准 9、进阶 15、硬核 20。硬核仍保留 21 次消除和 63 件总物品,运行态按 20 种素材循环复用,不要求生成第 21 种素材。 - 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景、UI spritesheet 和物品 spritesheet;首次生成自动试玩、结果页手动试玩、推荐流和公开详情启动都必须传入提升后的 profile。卡片摘要缺图集字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和图集字段传给 `Match3DRuntimeShell`。 - 背景图作为运行态全屏背景,图内已经保留容器;旧 `containerImage*` 只作为历史透明容器兼容字段。若 `containerImage*` 与 `uiSpritesheetImage*` 同源,运行态不得把 UI spritesheet 当中心容器图叠到棋盘上。 +- 抓大鹅运行态 HUD 需贴近拼图顶部信息条的视觉口径:左上只保留透明返回按钮;右上不再暴露设置入口;顶部关卡名和倒计时直接复用拼图同款的铭牌 + 下挂计时牌结构、同色板和同造型,并在牌面左侧挂上 `media/logo.png` 产品 logo;下方备选栏和道具图标只保留内容与交互边界,不再显示灰白半透底板;中央容器图层视觉可隐藏,但棋盘命中边界仍保留。 - generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。 - `itemSize` 只缩放生成 2D 图片本体:`大`、`中`、`小` 均按相对尺寸缩放,其中 `大` 也比原始图片略小,`中` 和 `小` 进一步缩小;不改变后端下发的布局半径、点击半径或三消规则。 - 物品进入底部物品栏时按同类型插入:如果物品栏已有同类物品,新物品插到该类型最后一个物品后面,后续物品整体后移;没有同类时追加到当前末尾。达到三件同类时,在飞入物品栏动画结束后,左侧和右侧同类物品向中间合成,三件一起消失,播放合成音效,不展示星星图标,后面的物品再向前补位。该动效只是前端表现层,后端和本地试玩仍负责权威插入、指定点击类型清除与补位后的槽位快照。 -- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。 +- 抓大鹅运行态不渲染右上角设置入口,也不在局内直接暴露重新开始按钮;结算弹层仍保留结果态的再来一局动作。 - 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。 +发现页可挂载官方抓大鹅静态 demo,用于验证生图、切图和运行态资源闭环。当前 demo profile 固定为 `match3d-demo-20260525`,公开作品号为 `M3-20260525`,静态资源位于 `public/match3d-demo/undersea-candy-market/`;公开卡片、作品号搜索和详情启动都走平台现有公开作品详情,不新建页面。demo 运行态使用前端本地 `createLocalMatch3DRuntimeAdapter`,不调用正式 Match3D runtime 后端、不新增 SpacetimeDB schema,也不写正式作品统计;后续若要把 demo 资源转成正式公开作品,必须改为后端 profile / gallery 投影真相后再接正式 runtime。 + ## 视觉小说 当前视觉小说只吸收外部 TXT 玩法的创作与运行经验,不迁入外部平台社区、支付、榜单、私有存档或回放。 diff --git a/public/match3d-demo/undersea-candy-market/background.png b/public/match3d-demo/undersea-candy-market/background.png new file mode 100644 index 00000000..06764c39 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/background.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png new file mode 100644 index 00000000..67773c2a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-02.png new file mode 100644 index 00000000..c54a7a31 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-03.png new file mode 100644 index 00000000..18698bcd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-04.png new file mode 100644 index 00000000..e7457b08 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-05.png new file mode 100644 index 00000000..d41a367f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-01/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-01.png new file mode 100644 index 00000000..91be1458 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-02.png new file mode 100644 index 00000000..f63c07f9 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-03.png new file mode 100644 index 00000000..ea63913b Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-04.png new file mode 100644 index 00000000..d794113a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-05.png new file mode 100644 index 00000000..1ca654a8 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-02/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-01.png new file mode 100644 index 00000000..36bd5018 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-02.png new file mode 100644 index 00000000..d7c5b6de Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-03.png new file mode 100644 index 00000000..342bfdc3 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-04.png new file mode 100644 index 00000000..53df10fd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-05.png new file mode 100644 index 00000000..34209763 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-03/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-01.png new file mode 100644 index 00000000..15b3d3a7 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-02.png new file mode 100644 index 00000000..143b2e1a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-03.png new file mode 100644 index 00000000..42e30771 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-04.png new file mode 100644 index 00000000..2a255bfd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-05.png new file mode 100644 index 00000000..86540e77 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-04/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-01.png new file mode 100644 index 00000000..aa029a38 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-02.png new file mode 100644 index 00000000..b1967dfc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-03.png new file mode 100644 index 00000000..e040cca0 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-04.png new file mode 100644 index 00000000..8c144659 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-05.png new file mode 100644 index 00000000..7f3a1a4a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-05/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-01.png new file mode 100644 index 00000000..de0ec31e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-02.png new file mode 100644 index 00000000..a4c02a82 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-03.png new file mode 100644 index 00000000..3374aa7f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-04.png new file mode 100644 index 00000000..6e01f5a4 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-05.png new file mode 100644 index 00000000..d31e82dc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-06/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-01.png new file mode 100644 index 00000000..ea3629a8 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-02.png new file mode 100644 index 00000000..289752a5 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-03.png new file mode 100644 index 00000000..13f1641f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-04.png new file mode 100644 index 00000000..f157cc56 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-05.png new file mode 100644 index 00000000..52f6d48d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-07/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-01.png new file mode 100644 index 00000000..5a15a321 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-02.png new file mode 100644 index 00000000..be8d0f52 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-03.png new file mode 100644 index 00000000..5fcfba72 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-04.png new file mode 100644 index 00000000..e02c8b76 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-05.png new file mode 100644 index 00000000..28cb6b4a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-08/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-01.png new file mode 100644 index 00000000..7a506040 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-02.png new file mode 100644 index 00000000..550a5434 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-03.png new file mode 100644 index 00000000..f11e249b Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-04.png new file mode 100644 index 00000000..ac3450d3 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-05.png new file mode 100644 index 00000000..f28a51eb Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-09/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-01.png new file mode 100644 index 00000000..c1b0f126 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-02.png new file mode 100644 index 00000000..7e94a7cd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-03.png new file mode 100644 index 00000000..8687dcdf Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-04.png new file mode 100644 index 00000000..c8db5f3d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-05.png new file mode 100644 index 00000000..baed94e9 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-10/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-01.png new file mode 100644 index 00000000..1657cda2 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-02.png new file mode 100644 index 00000000..8ed07b3f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-03.png new file mode 100644 index 00000000..15529207 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-04.png new file mode 100644 index 00000000..2a86bdff Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-05.png new file mode 100644 index 00000000..287c5062 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-11/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-01.png new file mode 100644 index 00000000..b4bec1b1 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-02.png new file mode 100644 index 00000000..bc9a5efc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-03.png new file mode 100644 index 00000000..f33ac7a1 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-04.png new file mode 100644 index 00000000..6aef21e4 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-05.png new file mode 100644 index 00000000..28ccfebd Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-12/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-01.png new file mode 100644 index 00000000..b96f8659 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-02.png new file mode 100644 index 00000000..cab8dcce Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-03.png new file mode 100644 index 00000000..e6c596b5 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-04.png new file mode 100644 index 00000000..de19c617 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-05.png new file mode 100644 index 00000000..2a97686e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-13/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-01.png new file mode 100644 index 00000000..a85d8846 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-02.png new file mode 100644 index 00000000..89700c47 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-03.png new file mode 100644 index 00000000..93d5a24e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-04.png new file mode 100644 index 00000000..306f389f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-05.png new file mode 100644 index 00000000..b2e15f9c Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-14/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-01.png new file mode 100644 index 00000000..e0e4c830 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-02.png new file mode 100644 index 00000000..5b2075bc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-03.png new file mode 100644 index 00000000..0ec2990e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-04.png new file mode 100644 index 00000000..9f67b3c2 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-05.png new file mode 100644 index 00000000..abac6fda Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-15/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-01.png new file mode 100644 index 00000000..2ffb76b5 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-02.png new file mode 100644 index 00000000..905fbcae Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-03.png new file mode 100644 index 00000000..8088e492 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-04.png new file mode 100644 index 00000000..7bcf8ed3 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-05.png new file mode 100644 index 00000000..0a92a912 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-16/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-01.png new file mode 100644 index 00000000..68414718 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-02.png new file mode 100644 index 00000000..8ab28c8d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-03.png new file mode 100644 index 00000000..f010a827 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-04.png new file mode 100644 index 00000000..2d9d8dda Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-05.png new file mode 100644 index 00000000..6b048f4d Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-17/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-01.png new file mode 100644 index 00000000..09fb23c1 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-02.png new file mode 100644 index 00000000..8f69247a Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-03.png new file mode 100644 index 00000000..ed575181 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-04.png new file mode 100644 index 00000000..ab399979 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-05.png new file mode 100644 index 00000000..ff5820dc Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-18/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-01.png new file mode 100644 index 00000000..19b53c51 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-02.png new file mode 100644 index 00000000..6f114282 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-03.png new file mode 100644 index 00000000..806861f8 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-04.png new file mode 100644 index 00000000..3b1c5aca Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-05.png new file mode 100644 index 00000000..e08a962e Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-19/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-01.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-01.png new file mode 100644 index 00000000..1f93d6b0 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-01.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-02.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-02.png new file mode 100644 index 00000000..9f208acf Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-02.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-03.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-03.png new file mode 100644 index 00000000..8cf39682 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-03.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-04.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-04.png new file mode 100644 index 00000000..1b16d126 Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-04.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-05.png b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-05.png new file mode 100644 index 00000000..a85ff45b Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-slices/item-20/view-05.png differ diff --git a/public/match3d-demo/undersea-candy-market/item-spritesheet.png b/public/match3d-demo/undersea-candy-market/item-spritesheet.png new file mode 100644 index 00000000..fc4a95ea Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/item-spritesheet.png differ diff --git a/public/match3d-demo/undersea-candy-market/level-scene.png b/public/match3d-demo/undersea-candy-market/level-scene.png new file mode 100644 index 00000000..256562ef Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/level-scene.png differ diff --git a/public/match3d-demo/undersea-candy-market/ui-spritesheet.png b/public/match3d-demo/undersea-candy-market/ui-spritesheet.png new file mode 100644 index 00000000..b096ea0f Binary files /dev/null and b/public/match3d-demo/undersea-candy-market/ui-spritesheet.png differ diff --git a/scripts/export-match3d-resource-pipeline-postprocess.py b/scripts/export-match3d-resource-pipeline-postprocess.py new file mode 100644 index 00000000..2a3e1c99 --- /dev/null +++ b/scripts/export-match3d-resource-pipeline-postprocess.py @@ -0,0 +1,345 @@ +import argparse +import json +from collections import deque +from pathlib import Path + +from PIL import Image + + +UI_ORDER = ["back", "settings", "tile", "remove", "match", "shuffle"] +VIEW_COUNT = 5 +ITEM_COUNT = 20 +GRID_SIZE = 10 + + +def green_screen_score(pixel): + red, green, blue, alpha = pixel + if alpha == 0: + return 1.0 + red = float(red) + green = float(green) + blue = float(blue) + green_lead = green - max(red, blue) + if green < 96.0 or green_lead <= 18.0: + return 0.0 + green_ratio = green / max(red + blue, 1.0) + if green_ratio <= 0.9: + return 0.0 + return max( + 0.0, + min( + 1.0, + ((green - 96.0) / 128.0) * 0.34 + + ((green_lead - 18.0) / 120.0) * 0.46 + + ((green_ratio - 0.9) / 2.4) * 0.20, + ), + ) + + +def white_screen_score(pixel): + red, green, blue, alpha = pixel + if alpha == 0: + return 1.0 + red = float(red) + green = float(green) + blue = float(blue) + max_channel = max(red, green, blue) + min_channel = min(red, green, blue) + average = (red + green + blue) / 3.0 + if average < 188.0 or min_channel < 168.0: + return 0.0 + spread = max_channel - min_channel + neutrality = 1.0 - max(0.0, min(1.0, (spread - 6.0) / 34.0)) + brightness = max(0.0, min(1.0, (average - 188.0) / 55.0)) + floor = max(0.0, min(1.0, (min_channel - 168.0) / 60.0)) + return max(0.0, min(1.0, neutrality * (brightness * 0.85 + floor * 0.15))) + + +def apply_green_screen_alpha(source): + image = source.convert("RGBA") + pixels = image.load() + width, height = image.size + for y in range(height): + for x in range(width): + red, green, blue, alpha = pixels[x, y] + score = green_screen_score((red, green, blue, alpha)) + if score >= 0.82: + pixels[x, y] = (red, green, blue, 0) + elif score >= 0.34: + next_alpha = int(round(alpha * (1.0 - min(1.0, score * 1.08)))) + if next_alpha < 10: + next_alpha = 0 + pixels[x, y] = (red, green, blue, next_alpha) + return image + + +def make_background_opaque(source): + image = source.convert("RGBA") + width, height = image.size + edge_pixels = [] + pixels = image.load() + for x in range(width): + edge_pixels.append(pixels[x, 0]) + edge_pixels.append(pixels[x, height - 1]) + for y in range(1, max(1, height - 1)): + edge_pixels.append(pixels[0, y]) + edge_pixels.append(pixels[width - 1, y]) + weighted = [0, 0, 0, 0] + for red, green, blue, alpha in edge_pixels: + if alpha < 32: + continue + weighted[0] += red * alpha + weighted[1] += green * alpha + weighted[2] += blue * alpha + weighted[3] += alpha + matte = ( + tuple(channel // weighted[3] for channel in weighted[:3]) + if weighted[3] > 0 + else (246, 243, 236) + ) + for y in range(height): + for x in range(width): + red, green, blue, alpha = pixels[x, y] + if alpha == 255: + continue + inv = 255 - alpha + pixels[x, y] = ( + (red * alpha + matte[0] * inv + 127) // 255, + (green * alpha + matte[1] * inv + 127) // 255, + (blue * alpha + matte[2] * inv + 127) // 255, + 255, + ) + return image + + +def visible(pixel, threshold=36): + return pixel[3] >= threshold + + +def detect_components(image, alpha_threshold=36): + width, height = image.size + pixels = image.load() + visited = bytearray(width * height) + min_area = max(16, min(800, (width * height) // 12000)) + components = [] + for start in range(width * height): + if visited[start]: + continue + sx = start % width + sy = start // width + if not visible(pixels[sx, sy], alpha_threshold): + visited[start] = 1 + continue + queue = deque([(sx, sy)]) + visited[start] = 1 + min_x = max_x = sx + min_y = max_y = sy + area = 0 + while queue: + x, y = queue.pop() + area += 1 + min_x = min(min_x, x) + max_x = max(max_x, x) + min_y = min(min_y, y) + max_y = max(max_y, y) + for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)): + if nx < 0 or ny < 0 or nx >= width or ny >= height: + continue + index = ny * width + nx + if visited[index]: + continue + visited[index] = 1 + if visible(pixels[nx, ny], alpha_threshold): + queue.append((nx, ny)) + if area >= min_area: + components.append( + { + "x": min_x, + "y": min_y, + "width": max_x - min_x + 1, + "height": max_y - min_y + 1, + "area": area, + } + ) + return sort_components_by_original_position(components) + + +def sort_components_by_original_position(components): + if not components: + return [] + average_height = sum(component["height"] for component in components) / len(components) + row_tolerance = max(2.0, average_height * 0.65) + rows = [] + for component in sorted(components, key=lambda item: (item["y"], item["x"])): + center_y = component["y"] + component["height"] / 2.0 + target_row = None + for row in rows: + row_center = sum(item["y"] + item["height"] / 2.0 for item in row) / len(row) + if abs(row_center - center_y) <= row_tolerance: + target_row = row + break + if target_row is None: + rows.append([component]) + else: + target_row.append(component) + sorted_components = [] + for row in rows: + sorted_components.extend(sorted(row, key=lambda item: item["x"])) + return sorted_components + + +def trim_visible_bounds(image): + width, height = image.size + pixels = image.load() + min_x = width + min_y = height + max_x = -1 + max_y = -1 + visible_count = 0 + for y in range(height): + for x in range(width): + if not visible(pixels[x, y], 12): + continue + visible_count += 1 + min_x = min(min_x, x) + min_y = min(min_y, y) + max_x = max(max_x, x) + max_y = max(max_y, y) + min_visible = max(10, min(120, (width * height) // 540)) + if visible_count < min_visible or max_x <= min_x or max_y <= min_y: + return image + return image.crop((min_x, min_y, max_x + 1, max_y + 1)) + + +def crop_region(image, component): + x = component["x"] + y = component["y"] + width = component["width"] + height = component["height"] + return trim_visible_bounds(image.crop((x, y, x + width, y + height))) + + +def fallback_grid_slice(image, item_count=ITEM_COUNT): + width, height = image.size + slices = [] + items_per_row = GRID_SIZE // VIEW_COUNT + for item_index in range(item_count): + row = item_index // items_per_row + start_col = (item_index % items_per_row) * VIEW_COUNT + for view_index in range(VIEW_COUNT): + col = start_col + view_index + x0 = col * width // GRID_SIZE + x1 = (col + 1) * width // GRID_SIZE + y0 = row * height // GRID_SIZE + y1 = (row + 1) * height // GRID_SIZE + cell = image.crop((x0, y0, x1, y1)) + slices.append((item_index, view_index, trim_visible_bounds(cell))) + return slices + + +def save_ui_slices(image, out_dir): + components = detect_components(image, 36) + slices_dir = out_dir / "03-ui-slices" + slices_dir.mkdir(parents=True, exist_ok=True) + regions = [] + for index, component in enumerate(components[: len(UI_ORDER)]): + label = UI_ORDER[index] + output = slices_dir / f"{index + 1:02d}-{label}.png" + crop_region(image, component).save(output) + regions.append({**component, "label": label, "file": str(output)}) + return { + "detectedCount": len(components), + "usedCount": len(regions), + "regions": regions, + } + + +def save_item_slices(image, out_dir): + components = detect_components(image, 36) + slices_dir = out_dir / "07-item-slices" + slices_dir.mkdir(parents=True, exist_ok=True) + expected = ITEM_COUNT * VIEW_COUNT + use_components = len(components) >= expected + if use_components: + source_slices = [ + (index // VIEW_COUNT, index % VIEW_COUNT, crop_region(image, component)) + for index, component in enumerate(components[:expected]) + ] + else: + source_slices = fallback_grid_slice(image) + + items = [] + for item_index in range(ITEM_COUNT): + item_dir = slices_dir / f"item-{item_index + 1:02d}" + item_dir.mkdir(parents=True, exist_ok=True) + views = [] + for _, view_index, crop in [ + entry for entry in source_slices if entry[0] == item_index + ]: + output = item_dir / f"view-{view_index + 1:02d}.png" + crop.save(output) + views.append({"viewIndex": view_index + 1, "file": str(output)}) + items.append({"itemIndex": item_index + 1, "views": views}) + return { + "method": "alpha-components" if use_components else "fallback-grid", + "detectedCount": len(components), + "expectedCount": expected, + "items": items, + "regions": components[:expected], + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--out-dir", required=True) + args = parser.parse_args() + out_dir = Path(args.out_dir).resolve() + + level_scene = Image.open(out_dir / "01-level-scene.raw.png").convert("RGBA") + ui_raw = Image.open(out_dir / "02-ui-spritesheet.raw.png").convert("RGBA") + background_raw = Image.open(out_dir / "04-background.raw.png").convert("RGBA") + item_raw = Image.open(out_dir / "06-item-spritesheet.raw.png").convert("RGBA") + + ui_transparent = apply_green_screen_alpha(ui_raw) + item_transparent = apply_green_screen_alpha(item_raw) + background_opaque = make_background_opaque(background_raw) + + ui_transparent.save(out_dir / "02-ui-spritesheet.transparent.png") + background_opaque.save(out_dir / "05-background.opaque.png") + item_transparent.save(out_dir / "06-item-spritesheet.transparent.png") + + ui_manifest = save_ui_slices(ui_transparent, out_dir) + item_manifest = save_item_slices(item_transparent, out_dir) + + manifest = { + "levelScene": { + "file": str(out_dir / "01-level-scene.raw.png"), + "size": level_scene.size, + }, + "uiSpritesheet": { + "rawFile": str(out_dir / "02-ui-spritesheet.raw.png"), + "transparentFile": str(out_dir / "02-ui-spritesheet.transparent.png"), + "size": ui_transparent.size, + **ui_manifest, + }, + "background": { + "rawFile": str(out_dir / "04-background.raw.png"), + "opaqueFile": str(out_dir / "05-background.opaque.png"), + "size": background_opaque.size, + }, + "itemSpritesheet": { + "rawFile": str(out_dir / "06-item-spritesheet.raw.png"), + "transparentFile": str(out_dir / "06-item-spritesheet.transparent.png"), + "size": item_transparent.size, + **item_manifest, + }, + } + (out_dir / "08-export-manifest.json").write_text( + json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(json.dumps({"ok": True, "manifest": str(out_dir / "08-export-manifest.json")}, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/scripts/export-match3d-resource-pipeline.mjs b/scripts/export-match3d-resource-pipeline.mjs new file mode 100644 index 00000000..8b6d1126 --- /dev/null +++ b/scripts/export-match3d-resource-pipeline.mjs @@ -0,0 +1,378 @@ +import {Buffer} from 'node:buffer'; +import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; +import path from 'node:path'; +import {spawnSync} from 'node:child_process'; + +import {mergeApiServerEnv} from './dev-utils.mjs'; + +const repoRoot = process.cwd(); +const defaultTimeoutMs = 1_000_000; +const defaultTheme = '海底糖果集市'; +const uiLabels = ['back', 'settings', 'tile', 'remove', 'match', 'shuffle']; + +function parseArgs(argv) { + const args = { + live: false, + theme: defaultTheme, + outDir: '', + }; + for (let index = 2; index < argv.length; index += 1) { + const raw = argv[index]; + if (raw === '--live') { + args.live = true; + continue; + } + if (raw === '--theme') { + args.theme = String(argv[index + 1] ?? defaultTheme); + index += 1; + continue; + } + if (raw === '--out-dir') { + args.outDir = String(argv[index + 1] ?? ''); + index += 1; + } + } + return args; +} + +function timestamp() { + const now = new Date(); + const pad = (value) => String(value).padStart(2, '0'); + return [ + now.getFullYear(), + pad(now.getMonth() + 1), + pad(now.getDate()), + '-', + pad(now.getHours()), + pad(now.getMinutes()), + pad(now.getSeconds()), + ].join(''); +} + +function resolveEnv() { + const env = mergeApiServerEnv(repoRoot, process.env); + return { + baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, ''), + apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(), + timeoutMs: Number.parseInt( + String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), + 10, + ), + }; +} + +function generationUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/generations` + : `${baseUrl}/v1/images/generations`; +} + +function editUrl(baseUrl) { + return baseUrl.endsWith('/v1') + ? `${baseUrl}/images/edits` + : `${baseUrl}/v1/images/edits`; +} + +function promptWithNegative(prompt, negative) { + const normalizedPrompt = prompt.trim(); + const normalizedNegative = String(negative ?? '').trim(); + return normalizedNegative + ? `${normalizedPrompt}\n避免:${normalizedNegative}` + : normalizedPrompt; +} + +function buildLevelScenePrompt(theme) { + const normalizedTheme = String(theme || defaultTheme).trim() || defaultTheme; + return [ + '生成抓大鹅游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质', + '', + '抓大鹅主题描述:', + normalizedTheme, + '', + '画面元素:', + '返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮', + '画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘', + '底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”', + ].join('\n'); +} + +function buildUiSpritesheetPrompt() { + return '提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。'; +} + +function buildBackgroundPrompt() { + return '移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容'; +} + +function buildItemSpritesheetPrompt() { + return '固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品'; +} + +function collectStringsByKey(value, targetKey, output) { + if (Array.isArray(value)) { + value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); + return; + } + if (!value || typeof value !== 'object') { + return; + } + for (const [key, nested] of Object.entries(value)) { + if (key === targetKey) { + if (typeof nested === 'string' && nested.trim()) { + output.push(nested.trim()); + } else if (Array.isArray(nested)) { + nested.forEach((entry) => { + if (typeof entry === 'string' && entry.trim()) { + output.push(entry.trim()); + } + }); + } + } + collectStringsByKey(nested, targetKey, output); + } +} + +function inferExtensionFromBytes(bytes) { + if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) { + return 'png'; + } + if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) { + return 'jpg'; + } + if ( + bytes.subarray(0, 4).toString('ascii') === 'RIFF' && + bytes.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'webp'; + } + return 'png'; +} + +async function fetchJson(url, options, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, { + ...options, + signal: abortController.signal, + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); + } + return JSON.parse(text); + } catch (error) { + if (error?.name === 'AbortError') { + throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); + } + throw error; + } finally { + clearTimeout(timer); + } +} + +async function downloadUrl(url, timeoutMs) { + const abortController = new AbortController(); + const timer = setTimeout(() => abortController.abort(), timeoutMs); + try { + const response = await fetch(url, {signal: abortController.signal}); + if (!response.ok) { + throw new Error(`download ${response.status}`); + } + return Buffer.from(await response.arrayBuffer()); + } finally { + clearTimeout(timer); + } +} + +async function imageBytesFromPayload(payload, env) { + const urls = []; + const b64Images = []; + collectStringsByKey(payload, 'url', urls); + collectStringsByKey(payload, 'image', urls); + collectStringsByKey(payload, 'image_url', urls); + collectStringsByKey(payload, 'b64_json', b64Images); + + const imageUrl = [...new Set(urls)].find((url) => /^https?:\/\//u.test(url)); + if (imageUrl) { + return downloadUrl(imageUrl, env.timeoutMs); + } + if (b64Images[0]) { + return Buffer.from(b64Images[0], 'base64'); + } + throw new Error('VectorEngine returned no image'); +} + +async function generateImage(env, {prompt, negativePrompt, size, outPath}) { + const body = { + model: 'gpt-image-2', + prompt: promptWithNegative(prompt, negativePrompt), + n: 1, + size, + }; + const payload = await fetchJson( + generationUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }, + env.timeoutMs, + ); + const bytes = await imageBytesFromPayload(payload, env); + writeFileSync(outPath, bytes); + return { + outPath, + extension: inferExtensionFromBytes(bytes), + bytes: bytes.length, + }; +} + +async function editImage(env, {prompt, negativePrompt, size, referencePath, outPath}) { + const referenceBytes = readFileSync(referencePath); + const form = new FormData(); + form.append('model', 'gpt-image-2'); + form.append('prompt', promptWithNegative(prompt, negativePrompt)); + form.append('n', '1'); + form.append('size', size); + form.append( + 'image', + new Blob([referenceBytes], {type: 'image/png'}), + path.basename(referencePath), + ); + const payload = await fetchJson( + editUrl(env.baseUrl), + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.apiKey}`, + Accept: 'application/json', + }, + body: form, + }, + env.timeoutMs, + ); + const bytes = await imageBytesFromPayload(payload, env); + writeFileSync(outPath, bytes); + return { + outPath, + extension: inferExtensionFromBytes(bytes), + bytes: bytes.length, + }; +} + +function runPostprocess(outDir) { + const postprocessPath = path.join(repoRoot, 'scripts', 'export-match3d-resource-pipeline-postprocess.py'); + const result = spawnSync('python', [postprocessPath, '--out-dir', outDir], { + cwd: repoRoot, + encoding: 'utf8', + }); + if (result.stdout) { + process.stdout.write(result.stdout); + } + if (result.stderr) { + process.stderr.write(result.stderr); + } + if (result.status !== 0) { + throw new Error(`postprocess failed with code ${result.status}`); + } +} + +async function main() { + const args = parseArgs(process.argv); + const outDir = path.resolve( + repoRoot, + args.outDir || path.join('output', `match3d-resource-pipeline-${timestamp()}`), + ); + mkdirSync(outDir, {recursive: true}); + + const prompts = { + theme: args.theme, + levelScenePrompt: buildLevelScenePrompt(args.theme), + uiSpritesheetPrompt: buildUiSpritesheetPrompt(), + backgroundPrompt: buildBackgroundPrompt(), + itemSpritesheetPrompt: buildItemSpritesheetPrompt(), + uiLabels, + }; + writeFileSync( + path.join(outDir, '00-prompts.json'), + `${JSON.stringify(prompts, null, 2)}\n`, + 'utf8', + ); + + if (!args.live) { + console.log( + JSON.stringify( + { + mode: 'dry-run', + outDir, + message: '加 --live 才会真实调用 VectorEngine。', + prompts, + }, + null, + 2, + ), + ); + return; + } + + const env = resolveEnv(); + if (!env.baseUrl || !env.apiKey) { + throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY'); + } + + console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`); + const levelScene = await generateImage(env, { + prompt: prompts.levelScenePrompt, + negativePrompt: '水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI', + size: '1024x1536', + outPath: path.join(outDir, '01-level-scene.raw.png'), + }); + + console.log('[match3d-export] 2/4 并发生成 UI 图集、背景图、物品图集'); + const [ui, background, items] = await Promise.all([ + editImage(env, { + prompt: prompts.uiSpritesheetPrompt, + negativePrompt: '整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线', + size: '1024x1024', + referencePath: levelScene.outPath, + outPath: path.join(outDir, '02-ui-spritesheet.raw.png'), + }), + editImage(env, { + prompt: prompts.backgroundPrompt, + negativePrompt: '返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层', + size: '1024x1536', + referencePath: levelScene.outPath, + outPath: path.join(outDir, '04-background.raw.png'), + }), + editImage(env, { + prompt: prompts.itemSpritesheetPrompt, + negativePrompt: '文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景', + // 中文注释:这里按当前后端 normalize_image_size("2k") 的实际请求尺寸复现。 + size: '1536x1024', + referencePath: levelScene.outPath, + outPath: path.join(outDir, '06-item-spritesheet.raw.png'), + }), + ]); + + writeFileSync( + path.join(outDir, '00-generation-results.json'), + `${JSON.stringify({levelScene, ui, background, items}, null, 2)}\n`, + 'utf8', + ); + + console.log('[match3d-export] 3/4 执行绿幕透明化、背景不透明化和连通域切片'); + runPostprocess(outDir); + + console.log('[match3d-export] 4/4 完成'); + console.log(JSON.stringify({ok: true, outDir}, null, 2)); +} + +main().catch((error) => { + console.error(`[match3d-export] failed: ${error?.stack || error}`); + process.exit(1); +}); diff --git a/server-rs/crates/api-server/src/generated_asset_sheets.rs b/server-rs/crates/api-server/src/generated_asset_sheets.rs index 18c1423b..986673d8 100644 --- a/server-rs/crates/api-server/src/generated_asset_sheets.rs +++ b/server-rs/crates/api-server/src/generated_asset_sheets.rs @@ -247,8 +247,14 @@ pub(crate) fn slice_generated_asset_sheet_two_items_per_row( let items_per_row = grid_size / views_per_item; let max_item_count = grid_size.saturating_mul(items_per_row); - let mut slices = Vec::with_capacity(item_names.len().min(max_item_count)); - for item_index in 0..item_names.len().min(max_item_count) { + let item_count = item_names.len().min(max_item_count); + if let Some(slices) = + slice_generated_asset_sheet_by_alpha_components(&source, item_count, views_per_item)? + { + return Ok(slices); + } + let mut slices = Vec::with_capacity(item_count); + for item_index in 0..item_count { let row = (item_index / items_per_row) as u32; let start_col = ((item_index % items_per_row) * views_per_item) as u32; let mut views = Vec::with_capacity(views_per_item); @@ -469,6 +475,12 @@ struct GeneratedAssetSheetCellBounds { y1: u32, } +#[derive(Clone, Copy, Debug)] +struct GeneratedAssetSheetDetectedComponent { + bounds: GeneratedAssetSheetCellBounds, + area: u32, +} + impl GeneratedAssetSheetCellBounds { fn width(self) -> u32 { self.x1.saturating_sub(self.x0).max(1) @@ -487,6 +499,272 @@ impl GeneratedAssetSheetCellBounds { } } +fn detect_generated_asset_sheet_alpha_components( + image: &image::RgbaImage, +) -> Vec { + let (width, height) = image.dimensions(); + let pixel_count = width.saturating_mul(height) as usize; + if width == 0 || height == 0 || pixel_count == 0 { + return Vec::new(); + } + + let mut visited = vec![0u8; pixel_count]; + let min_area = resolve_generated_asset_sheet_alpha_component_min_area(width, height); + let mut components = Vec::new(); + for start in 0..pixel_count { + if visited[start] != 0 { + continue; + } + let start_pixel = image + .get_pixel(start as u32 % width, start as u32 / width) + .0; + if !is_generated_asset_sheet_visible_pixel(start_pixel) { + visited[start] = 1; + continue; + } + + let component = + flood_fill_generated_asset_sheet_alpha_component(image, &mut visited, start); + if component.area >= min_area { + components.push(component); + } + } + + components +} + +fn slice_generated_asset_sheet_by_alpha_components( + source: &image::DynamicImage, + item_count: usize, + views_per_item: usize, +) -> Result>>, AppError> { + if item_count == 0 { + return Ok(Some(Vec::new())); + } + + let sheet_grid_size = views_per_item.checked_mul(2).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的每物品视图数超出可支持范围。", + })) + })?; + let sheet_grid_size_u32 = u32::try_from(sheet_grid_size).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": "系列素材图集的每物品视图数超出可支持范围。", + })) + })?; + + let source = source.to_rgba8(); + let (width, height) = source.dimensions(); + if width == 0 || height == 0 { + return Ok(None); + } + + let cell_width = width / sheet_grid_size_u32; + let cell_height = height / sheet_grid_size_u32; + if cell_width == 0 || cell_height == 0 { + return Ok(None); + } + + let components = detect_generated_asset_sheet_alpha_components(&source); + let expected_slot_count = item_count.saturating_mul(views_per_item); + if components.len() < expected_slot_count { + return Ok(None); + } + + let components = sort_generated_asset_sheet_components_by_original_position( + components, + cell_height as f32 * 0.65, + ); + let components = components + .into_iter() + .take(expected_slot_count) + .collect::>(); + if components.len() < expected_slot_count { + return Ok(None); + } + + let mut slices = Vec::with_capacity(item_count); + for item_index in 0..item_count { + let mut views = Vec::with_capacity(views_per_item); + for view_index in 0..views_per_item { + let slot_index = item_index * views_per_item + view_index; + let component = &components[slot_index]; + let pad_x = (cell_width / 16).clamp(4, 16); + let pad_y = (cell_height / 16).clamp(4, 16); + let crop_x = component.bounds.x0.saturating_sub(pad_x); + let crop_y = component.bounds.y0.saturating_sub(pad_y); + let crop_x1 = component.bounds.x1.saturating_add(pad_x).min(width); + let crop_y1 = component.bounds.y1.saturating_add(pad_y).min(height); + let cropped = image::DynamicImage::ImageRgba8( + image::imageops::crop_imm( + &source, + crop_x, + crop_y, + crop_x1.saturating_sub(crop_x).max(1), + crop_y1.saturating_sub(crop_y).max(1), + ) + .to_image(), + ); + let mut cursor = std::io::Cursor::new(Vec::new()); + cropped + .write_to(&mut cursor, ImageFormat::Png) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": GENERATED_ASSET_SHEET_PROVIDER, + "message": format!("系列素材图集切割失败:{error}"), + })) + })?; + views.push(GeneratedAssetSheetSliceImage { + bytes: cursor.into_inner(), + }); + } + slices.push(views); + } + + Ok(Some(slices)) +} + +fn resolve_generated_asset_sheet_alpha_component_min_area(width: u32, height: u32) -> u32 { + (width.saturating_mul(height) / 12_000).clamp(16, 800) +} + +fn flood_fill_generated_asset_sheet_alpha_component( + image: &image::RgbaImage, + visited: &mut [u8], + start: usize, +) -> GeneratedAssetSheetDetectedComponent { + let (width, height) = image.dimensions(); + let mut stack = vec![start]; + visited[start] = 1; + + let mut min_x = start as u32 % width; + let mut max_x = min_x; + let mut min_y = start as u32 / width; + let mut max_y = min_y; + let mut area = 0u32; + + while let Some(index) = stack.pop() { + let x = index as u32 % width; + let y = index as u32 / width; + area = area.saturating_add(1); + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index.wrapping_sub(1), + x > 0, + ); + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index + 1, + x + 1 < width, + ); + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index.saturating_sub(width as usize), + y > 0, + ); + visit_generated_asset_sheet_alpha_component_neighbor( + image, + visited, + &mut stack, + index + width as usize, + y + 1 < height, + ); + } + + GeneratedAssetSheetDetectedComponent { + bounds: GeneratedAssetSheetCellBounds { + x0: min_x, + y0: min_y, + x1: max_x.saturating_add(1), + y1: max_y.saturating_add(1), + }, + area, + } +} + +fn visit_generated_asset_sheet_alpha_component_neighbor( + image: &image::RgbaImage, + visited: &mut [u8], + stack: &mut Vec, + index: usize, + in_bounds: bool, +) { + if !in_bounds || visited.get(index).copied().unwrap_or(1) != 0 { + return; + } + let (width, _) = image.dimensions(); + let pixel = image + .get_pixel(index as u32 % width, index as u32 / width) + .0; + if !is_generated_asset_sheet_visible_pixel(pixel) { + visited[index] = 1; + return; + } + + visited[index] = 1; + stack.push(index); +} + +fn sort_generated_asset_sheet_components_by_original_position( + components: Vec, + row_tolerance_hint: f32, +) -> Vec { + if components.is_empty() { + return components; + } + + let average_height = components + .iter() + .map(|component| component.bounds.height() as f32) + .sum::() + / components.len() as f32; + let row_tolerance = row_tolerance_hint.max(average_height * 0.65).max(2.0); + let mut rows: Vec> = Vec::new(); + + let mut sorted = components; + sorted.sort_by(|left, right| { + left.bounds + .y0 + .cmp(&right.bounds.y0) + .then_with(|| left.bounds.x0.cmp(&right.bounds.x0)) + }); + for component in sorted { + let center_y = component.bounds.y0 as f32 + component.bounds.height() as f32 / 2.0; + if let Some(row) = rows.iter_mut().find(|items| { + let row_center = items + .iter() + .map(|item| item.bounds.y0 as f32 + item.bounds.height() as f32 / 2.0) + .sum::() + / items.len() as f32; + (row_center - center_y).abs() <= row_tolerance + }) { + row.push(component); + } else { + rows.push(vec![component]); + } + } + + rows.into_iter() + .flat_map(|mut row| { + row.sort_by(|left, right| left.bounds.x0.cmp(&right.bounds.x0)); + row + }) + .collect() +} + fn resolve_generated_asset_sheet_cell_crop( source: &image::DynamicImage, grid_size: u32, @@ -1674,6 +1952,193 @@ mod tests { assert_eq!(slices[1].len(), 5); } + #[test] + fn generated_asset_sheet_two_items_per_row_uses_alpha_components_when_views_cross_cell_boundaries() { + let width = 1_000; + let height = 1_000; + let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; + let colors = [ + ([220, 20, 24, 255], [246, 178, 46, 255]), + ([230, 88, 20, 255], [248, 196, 78, 255]), + ([240, 170, 28, 255], [250, 214, 110, 255]), + ([90, 180, 54, 255], [188, 236, 86, 255]), + ([20, 150, 200, 255], [92, 214, 248, 255]), + ([70, 96, 220, 255], [124, 150, 252, 255]), + ([150, 80, 210, 255], [188, 118, 248, 255]), + ([210, 80, 170, 255], [248, 130, 204, 255]), + ([140, 92, 48, 255], [190, 136, 82, 255]), + ([34, 34, 34, 255], [88, 88, 88, 255]), + ]; + let positions = [ + (90u32, 40u32), + (190, 44), + (290, 38), + (390, 42), + (490, 36), + (590, 540), + (690, 536), + (790, 544), + (890, 538), + (960, 542), + ]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); + for (index, (left_color, right_color)) in colors.iter().enumerate() { + let (start_x, start_y) = positions[index]; + for y in start_y..start_y + 36 { + for x in start_x..start_x + 16 { + sheet.put_pixel(x, y, image::Rgba(*left_color)); + } + for x in start_x + 16..start_x + 32 { + sheet.put_pixel(x, y, image::Rgba(*right_color)); + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5) + .expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); + for (index, (left_color, right_color)) in colors.iter().enumerate() { + let item_index = index / 5; + let view_index = index % 5; + let decoded = image::load_from_memory(slices[item_index][view_index].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + assert!( + decoded.pixels().any(|pixel| pixel.0 == *left_color), + "第 {index} 个格位应保留左侧主体颜色" + ); + assert!( + decoded.pixels().any(|pixel| pixel.0 == *right_color), + "第 {index} 个格位应保留右侧主体颜色" + ); + } + } + + #[test] + fn generated_asset_sheet_two_items_per_row_keeps_cell_order_when_views_are_vertically_scrambled() + { + let width = 1_000; + let height = 1_000; + let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; + let colors = [ + [220, 20, 24, 255], + [230, 88, 20, 255], + [240, 170, 28, 255], + [90, 180, 54, 255], + [20, 150, 200, 255], + [70, 96, 220, 255], + [150, 80, 210, 255], + [210, 80, 170, 255], + [140, 92, 48, 255], + [34, 34, 34, 255], + ]; + let top_offsets = [62u32, 18, 74, 26, 68, 20, 72, 24, 66, 22]; + let mut sheet = image::RgbaImage::from_pixel(width, height, image::Rgba([0, 0, 0, 0])); + for (index, color) in colors.iter().enumerate() { + let start_x = index as u32 * 100 + 34; + let start_y = top_offsets[index]; + for y in start_y..start_y + 36 { + for x in start_x..start_x + 32 { + sheet.put_pixel(x, y, image::Rgba(*color)); + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5) + .expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); + for (index, color) in colors.iter().enumerate() { + let item_index = index / 5; + let view_index = index % 5; + let decoded = image::load_from_memory(slices[item_index][view_index].bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + assert!( + decoded.pixels().any(|pixel| pixel.0 == *color), + "第 {index} 个格位应按列顺序保留对应主体颜色" + ); + } + } + + #[test] + fn generated_asset_sheet_two_items_per_row_falls_back_to_fixed_grid_when_alpha_components_are_insufficient() + { + let width = 1_000; + let height = 1_000; + let item_names = vec!["樱桃".to_string(), "苹果".to_string()]; + let mut sheet = image::RgbaImage::new(width, height); + for row in 0..10 { + for col in 0..10 { + let color = image::Rgba([ + 16 + row as u8 * 20, + 12 + col as u8 * 18, + 230 - row as u8 * 12, + 255, + ]); + for y in row * 100..(row + 1) * 100 { + for x in col * 100..(col + 1) * 100 { + sheet.put_pixel(x, y, color); + } + } + } + } + let mut encoded = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(sheet) + .write_to(&mut encoded, ImageFormat::Png) + .expect("sheet should encode"); + let image = DownloadedOpenAiImage { + bytes: encoded.into_inner(), + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }; + + let slices = slice_generated_asset_sheet_two_items_per_row(&image, &item_names, 10, 5) + .expect("sheet should slice"); + + assert_eq!(slices.len(), 2); + assert_eq!(slices[0].len(), 5); + assert_eq!(slices[1].len(), 5); + for (item_index, views) in slices.iter().enumerate() { + for (view_index, view) in views.iter().enumerate() { + let decoded = image::load_from_memory(view.bytes.as_slice()) + .expect("view should decode") + .to_rgba8(); + let pixel = decoded.get_pixel(decoded.width() / 2, decoded.height() / 2); + let row = 0u8; + let col = (item_index * 5 + view_index) as u8; + assert_eq!( + pixel.0, + [16 + row * 20, 12 + col * 18, 230 - row * 12, 255,], + "item {item_index} view {view_index} should keep the fixed grid fallback" + ); + } + } + } + #[test] fn generated_asset_sheet_prepare_put_request_packs_prompt_metadata() { let request = prepare_generated_asset_sheet_put_request(GeneratedAssetSheetPersistInput { diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx index 3e40c21a..f30c49ea 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.test.tsx @@ -1,13 +1,6 @@ /* @vitest-environment jsdom */ -import { - act, - fireEvent, - render, - screen, - waitFor, - within, -} from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; @@ -235,35 +228,58 @@ test('顶部 HUD 对齐拼图样式展示关卡名和倒计时', () => { expect(screen.getByText('第 1 关')).toBeTruthy(); expect(screen.getByText('水果抓大鹅')).toBeTruthy(); expect(screen.getByText('10:00')).toBeTruthy(); - expect(screen.getByRole('button', { name: '打开抓大鹅设置' })).toBeTruthy(); - expect(screen.queryByRole('button', { name: '重新开始' })).toBeNull(); + expect(screen.getByTestId('match3d-runtime-level-logo')).toBeTruthy(); + expect( + screen.getByText('水果抓大鹅').closest('.puzzle-runtime-level-title-card'), + ).toBeTruthy(); + const timerCard = screen.getByText('10:00').closest('.puzzle-runtime-timer-card'); + expect(timerCard).toBeTruthy(); + expect(timerCard?.className).toContain('puzzle-runtime-timer'); + expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull(); + expect(screen.getByRole('button', { name: '返回' })).toBeTruthy(); + expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull(); }); -test('抓大鹅右上角设置面板内置重新开始', () => { - const run = startLocalMatch3DRun(4); - const onRestart = vi.fn(); +test('抓大鹅运行态不再渲染设置入口', () => { render( , ); - fireEvent.click(screen.getByRole('button', { name: '打开抓大鹅设置' })); - - const dialog = screen.getByRole('dialog', { name: '抓大鹅设置' }); - expect(within(dialog).getByText('水果抓大鹅')).toBeTruthy(); - expect(within(dialog).getByText('已清除 0/12')).toBeTruthy(); - fireEvent.click(within(dialog).getByRole('button', { name: '重新开始' })); - - expect(onRestart).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { name: '打开抓大鹅设置' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '抓大鹅设置' })).toBeNull(); }); +test('抓大鹅顶部和底部保留交互边界但不显示旧半透底', () => { + render( + , + ); + + expect(screen.getByTestId('match3d-board').className).toContain( + 'bg-transparent', + ); + expect(screen.getAllByTestId('match3d-tray-slot')[0]!.className).toContain( + 'bg-transparent', + ); + expect( + screen.getByRole('button', { name: '移出' }).className, + ).toContain('bg-transparent'); + expect(screen.getByRole('button', { name: '返回' }).className).toContain( + 'bg-transparent', + ); +}); + test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => { const run: Match3DRunSnapshot = { ...startLocalMatch3DRun(4), @@ -1548,9 +1564,7 @@ test('运行态从UI spritesheet裁切按钮并映射到原UI位置', async () = expect( screen.getByTestId('match3d-ui-sprite-back').getAttribute('src'), ).toBe('data:image/png;base64,返回'); - expect( - screen.getByTestId('match3d-ui-sprite-settings').getAttribute('src'), - ).toBe('data:image/png;base64,设置'); + expect(screen.queryByTestId('match3d-ui-sprite-settings')).toBeNull(); expect( screen.getByTestId('match3d-ui-sprite-prop-remove').getAttribute('src'), ).toBe('data:image/png;base64,移出'); diff --git a/src/components/match3d-runtime/Match3DRuntimeShell.tsx b/src/components/match3d-runtime/Match3DRuntimeShell.tsx index 28b0c18f..4da11ef2 100644 --- a/src/components/match3d-runtime/Match3DRuntimeShell.tsx +++ b/src/components/match3d-runtime/Match3DRuntimeShell.tsx @@ -1,9 +1,7 @@ import { ArrowLeft, CheckCircle2, - Clock3, - RotateCcw, - Settings, + Clock, XCircle, } from 'lucide-react'; import { @@ -17,6 +15,7 @@ import { useState, } from 'react'; +import match3DRuntimeLevelLogo from '../../../media/logo.png'; import type { Match3DClickItemRequest, Match3DClickItemResult, @@ -71,19 +70,9 @@ import { } from './match3dRuntimePresentation'; import { MATCH3D_RUNTIME_BOARD_BASE_CLASS, - MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS, MATCH3D_RUNTIME_BOARD_WIDTH, - MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS, MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS, - MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS, - MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS, - MATCH3D_RUNTIME_GLASS_TRAY_CLASS, - MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS, - MATCH3D_RUNTIME_HEADER_CARD_CLASS, - MATCH3D_RUNTIME_LEVEL_BADGE_CLASS, MATCH3D_RUNTIME_STAGE_CLASS, - MATCH3D_RUNTIME_TIMER_CLASS, - MATCH3D_RUNTIME_TIMER_URGENT_CLASS, } from './match3dRuntimeUiStyles'; import { Match3DVisualIcon, resolveVisualSeed } from './match3dVisualAssets'; @@ -769,7 +758,7 @@ function Match3DTrayToken({ }) { if (!slot.visualKey) { return ( - + ); } const visualSeed = resolveVisualSeed(slot.visualKey); @@ -1030,7 +1019,6 @@ export function Match3DRuntimeShell({ const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0); const [resolvedBackgroundImageSrc, setResolvedBackgroundImageSrc] = useState(''); - const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const musicVolume = authUi?.musicVolume ?? DEFAULT_MATCH3D_MUSIC_VOLUME; const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG; const runtimeGeneratedItemAssets = useMemo( @@ -1366,10 +1354,6 @@ export function Match3DRuntimeShell({ useState(''); const [resolvedContainerImageSrc, setResolvedContainerImageSrc] = useState(''); - const [isContainerImageLoaded, setIsContainerImageLoaded] = useState(false); - const hasRenderedContainerAsset = Boolean( - resolvedContainerImageSrc && isContainerImageLoaded, - ); const clickSoundByTypeId = useMemo(() => { if (!run) { return new Map(); @@ -1489,7 +1473,6 @@ export function Match3DRuntimeShell({ let cancelled = false; const controller = new AbortController(); setResolvedContainerImageSrc(''); - setIsContainerImageLoaded(false); if (!isGeneratedLegacyPath(containerAssetSrc)) { setResolvedContainerImageSrc(containerAssetSrc); return undefined; @@ -1501,7 +1484,6 @@ export function Match3DRuntimeShell({ .then((resolvedSrc) => { if (!cancelled) { setResolvedContainerImageSrc(resolvedSrc); - setIsContainerImageLoaded(false); } }) .catch(() => { @@ -1511,7 +1493,6 @@ export function Match3DRuntimeShell({ ? '' : MATCH3D_CONTAINER_REFERENCE_SRC, ); - setIsContainerImageLoaded(false); } }); @@ -1937,9 +1918,8 @@ export function Match3DRuntimeShell({ const timerClassName = timeLeftMs <= levelAudioConfig.countdownWarningThresholdMs && isRunState(run.status, 'running') - ? MATCH3D_RUNTIME_TIMER_URGENT_CLASS - : MATCH3D_RUNTIME_TIMER_CLASS; - const canRestartRun = Boolean(run?.runId) && !isBusy; + ? 'puzzle-runtime-timer--urgent' + : 'puzzle-runtime-timer'; return (
@@ -1992,43 +1972,38 @@ export function Match3DRuntimeShell({ ) : null} )} -
-
- 第 1 关 - +
+
+ + + 第 1 关 + + {displayLevelName}
-
- +
+ {formatTimer(timeLeftMs)}
- + - ) : null}
); } diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index c6359f6b..91ecf6cf 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -107,6 +107,12 @@ import type { VisualNovelWorkSummary, } from '../../../packages/shared/src/contracts/visualNovel'; import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets'; +import { + MATCH3D_DEMO_GALLERY_CARD, + MATCH3D_DEMO_PROFILE_ID, + MATCH3D_DEMO_WORK_PROFILE, + isMatch3DDemoProfileId, +} from '../../data/match3dDemoGalleryCard'; import { buildPublicWorkStagePath, pushAppHistoryPath, @@ -198,7 +204,10 @@ import { } from '../../services/jump-hop/jumpHopClient'; import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; +import { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, +} from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -3520,6 +3529,13 @@ export function PlatformEntryFlowShellImpl({ setSelectedDetailEntry, }); const { setPlatformTab } = platformBootstrap; + const returnPlatformHomeAfterMissingWork = useCallback(() => { + setPlatformTab('home'); + setSelectionStage('platform'); + if (!maybeAlertWorkNotFoundAndReturnHome()) { + pushAppHistoryPath('/'); + } + }, [setPlatformTab, setSelectionStage]); useEffect(() => { if (selectionStage === 'profile-feedback') { @@ -4173,6 +4189,8 @@ export function PlatformEntryFlowShellImpl({ } return '服务端预览'; }, [agentResultPreview]); + const match3dDemoProfile = MATCH3D_DEMO_WORK_PROFILE; + const match3dDemoGalleryCard = MATCH3D_DEMO_GALLERY_CARD; const featuredGalleryEntries = useMemo(() => { const bigFishPublicEntries = isBigFishCreationVisible @@ -4212,6 +4230,7 @@ export function PlatformEntryFlowShellImpl({ [ ...bigFishPublicEntries, ...match3dPublicEntries, + match3dDemoGalleryCard, ...puzzlePublicEntries, ...barkBattlePublicEntries, ...squareHolePublicEntries, @@ -4236,6 +4255,7 @@ export function PlatformEntryFlowShellImpl({ squareHoleGalleryEntries, visualNovelGalleryEntries, woodenFishGalleryEntries, + match3dDemoGalleryCard, ]); const latestGalleryEntries = useMemo( () => @@ -4246,6 +4266,7 @@ export function PlatformEntryFlowShellImpl({ ? bigFishGalleryEntries.map(mapBigFishWorkToPlatformGalleryCard) : []), ...match3dGalleryEntries.map(mapMatch3DWorkToPublicWorkDetail), + match3dDemoGalleryCard, ...puzzleGalleryEntries.map(mapPuzzleWorkToPlatformGalleryCard), ...barkBattleGalleryEntries.map(mapBarkBattleWorkToPlatformGalleryCard), ...jumpHopGalleryEntries.map(mapJumpHopWorkToPlatformGalleryCard), @@ -4287,6 +4308,7 @@ export function PlatformEntryFlowShellImpl({ barkBattleGalleryEntries, barkBattleWorks, woodenFishGalleryEntries, + match3dDemoGalleryCard, ], ); const recommendRuntimeEntries = useMemo(() => { @@ -4294,9 +4316,11 @@ export function PlatformEntryFlowShellImpl({ filterGeneralPublicWorks([ ...featuredGalleryEntries, ...latestGalleryEntries, - ]).forEach((entry) => { - entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); - }); + ]) + .filter((entry) => !isMatch3DDemoProfileId(entry.profileId)) + .forEach((entry) => { + entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry); + }); return Array.from(entryMap.values()); }, [featuredGalleryEntries, latestGalleryEntries]); @@ -4832,6 +4856,21 @@ export function PlatformEntryFlowShellImpl({ () => createServerMatch3DRuntimeAdapter(), [], ); + const match3dDemoRuntimeAdapter = useMemo( + () => + createLocalMatch3DRuntimeAdapter({ + clearCount: 21, + profileId: MATCH3D_DEMO_PROFILE_ID, + }), + [], + ); + const resolveMatch3DRuntimeAdapter = useCallback( + (profileId: string | null | undefined) => + isMatch3DDemoProfileId(profileId) + ? match3dDemoRuntimeAdapter + : match3dRuntimeAdapter, + [match3dDemoRuntimeAdapter, match3dRuntimeAdapter], + ); const match3dFlow = usePlatformCreationAgentFlowController< Match3DAgentSessionSnapshot, CreateMatch3DSessionRequest, @@ -9069,13 +9108,12 @@ export function PlatformEntryFlowShellImpl({ setPuzzleDetailReturnTarget(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); + setPuzzleGalleryEntries((current) => + current.filter((entry) => entry.profileId !== profileId), + ); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return false; } @@ -9093,9 +9131,9 @@ export function PlatformEntryFlowShellImpl({ isPuzzleBusy, authUi, resolvePuzzleErrorMessage, + returnPlatformHomeAfterMissingWork, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, setSelectionStage, ], ); @@ -9115,10 +9153,13 @@ export function PlatformEntryFlowShellImpl({ setMatch3DError(null); try { - let runtimeProfile = profile; + const isDemoProfile = isMatch3DDemoProfileId(profile.profileId); + let runtimeProfile: Match3DWorkProfile | Match3DWorkSummary = + isDemoProfile ? match3dDemoProfile : profile; if ( - !hasMatch3DRuntimeAsset(profile.generatedItemAssets) || - !hasMatch3DRuntimeBackgroundAsset(profile) + !isDemoProfile && + (!hasMatch3DRuntimeAsset(profile.generatedItemAssets) || + !hasMatch3DRuntimeBackgroundAsset(profile)) ) { try { const { item } = await getMatch3DWorkDetail(profile.profileId); @@ -9154,7 +9195,10 @@ export function PlatformEntryFlowShellImpl({ ? { itemTypeCountOverride: options.itemTypeCountOverride } : {}), }; - const { run } = await match3dRuntimeAdapter.startRun( + const activeRuntimeAdapter = resolveMatch3DRuntimeAdapter( + runtimeProfile.profileId, + ); + const { run } = await activeRuntimeAdapter.startRun( runtimeProfile.profileId, runtimeOptions, ); @@ -9194,10 +9238,11 @@ export function PlatformEntryFlowShellImpl({ }, [ isMatch3DBusy, + match3dDemoProfile, authUi, match3dFlow, - match3dRuntimeAdapter, resolveMatch3DErrorMessage, + resolveMatch3DRuntimeAdapter, setMatch3DError, setSelectionStage, ], @@ -10897,13 +10942,12 @@ export function PlatformEntryFlowShellImpl({ setPuzzleDetailReturnTarget(null); setPuzzleRun(null); setPuzzleRuntimeAuthMode('default'); + setPuzzleGalleryEntries((current) => + current.filter((entry) => entry.profileId !== profileId), + ); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return; } @@ -10921,7 +10965,6 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, setSelectionStage, ], ); @@ -10936,8 +10979,11 @@ export function PlatformEntryFlowShellImpl({ try { const entries = match3dGalleryEntries.length > 0 - ? match3dGalleryEntries - : await refreshMatch3DGallery(); + ? [...match3dGalleryEntries, match3dDemoProfile] + : await refreshMatch3DGallery().then((items) => [ + ...items, + match3dDemoProfile, + ]); const matchedEntry = entries.find( (entry) => entry.profileId === profileId, ); @@ -10957,6 +11003,7 @@ export function PlatformEntryFlowShellImpl({ }, [ match3dGalleryEntries, + match3dDemoProfile, openPublicWorkDetail, refreshMatch3DGallery, resolveMatch3DErrorMessage, @@ -11243,8 +11290,7 @@ export function PlatformEntryFlowShellImpl({ resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, - setPlatformTab, - setSelectionStage, + returnPlatformHomeAfterMissingWork, ], ); @@ -12662,7 +12708,9 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -12682,14 +12730,18 @@ export function PlatformEntryFlowShellImpl({ if (!runId) { return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); } - return match3dRuntimeAdapter.clickItem(runId, payload); + return resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -13503,8 +13555,11 @@ export function PlatformEntryFlowShellImpl({ const tryOpenMatch3DGalleryEntry = async () => { const entries = match3dGalleryEntries.length > 0 - ? match3dGalleryEntries - : await refreshMatch3DGallery(); + ? [...match3dGalleryEntries, match3dDemoProfile] + : await refreshMatch3DGallery().then((items) => [ + ...items, + match3dDemoProfile, + ]); const matchedEntry = entries.find((entry) => { const detailEntry = mapMatch3DWorkToPublicWorkDetail(entry); return ( @@ -13710,11 +13765,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleRuntimeAuthMode('default'); setPuzzleError(null); setPublicWorkDetailError(null); - setPlatformTab('home'); - setSelectionStage('platform'); - if (!maybeAlertWorkNotFoundAndReturnHome()) { - pushAppHistoryPath('/'); - } + returnPlatformHomeAfterMissingWork(); return; } @@ -13743,6 +13794,7 @@ export function PlatformEntryFlowShellImpl({ refreshSquareHoleGallery, refreshVisualNovelGallery, squareHoleGalleryEntries, + returnPlatformHomeAfterMissingWork, selectionStage, setPlatformTab, setPuzzleError, @@ -13924,6 +13976,7 @@ export function PlatformEntryFlowShellImpl({ refreshBigFishGallery, resolveBigFishErrorMessage, setBigFishError, + match3dDemoProfile, ], ); @@ -14923,7 +14976,9 @@ export function PlatformEntryFlowShellImpl({ match3dRun?.runId && match3dRun.status === 'running' ) { - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .stopRun(match3dRun.runId) .catch(() => undefined); } @@ -14936,7 +14991,9 @@ export function PlatformEntryFlowShellImpl({ match3dFlow.setIsBusy(true); setMatch3DError(null); - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .restartRun(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); @@ -14961,14 +15018,18 @@ export function PlatformEntryFlowShellImpl({ new Error('抓大鹅运行态缺少 runId。'), ); } - return match3dRuntimeAdapter.clickItem(runId, payload); + return resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ).clickItem(runId, payload); }} onTimeExpired={() => { if (!match3dRun?.runId) { return; } - void match3dRuntimeAdapter + void resolveMatch3DRuntimeAdapter( + activeMatch3DRuntimeProfile?.profileId, + ) .finishTimeUp(match3dRun.runId) .then(({ run }) => { setMatch3DRun(run); diff --git a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx index baf9807c..7dadd62c 100644 --- a/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx +++ b/src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx @@ -83,7 +83,10 @@ import { saveBabyObjectMatchDraft, } from '../../services/edutainment-baby-object'; import { match3dCreationClient } from '../../services/match3d-creation'; -import { createServerMatch3DRuntimeAdapter } from '../../services/match3d-runtime'; +import { + createLocalMatch3DRuntimeAdapter, + createServerMatch3DRuntimeAdapter, +} from '../../services/match3d-runtime'; import { deleteMatch3DWork, getMatch3DWorkDetail, @@ -257,6 +260,13 @@ function queryCreationTypeButton(name: string | RegExp) { }); } +async function openPuzzleFormFromCreateHub( + user: ReturnType, +) { + await user.click(await findCreationTypeButton('拼图')); + await screen.findByText(/拼图工作区:/u); +} + async function openDraftHub(user: ReturnType) { await clickFirstButtonByName(user, '草稿'); const panel = getPlatformTabPanel('saves'); @@ -291,7 +301,9 @@ async function openProfilePlayedWorks( user: ReturnType, ) { await clickFirstButtonByName(user, '我的'); - await user.click(await screen.findByRole('button', { name: /玩过/u })); + await user.click( + await screen.findByRole('button', { name: /已玩游戏数量/u }), + ); expect(await screen.findByText('可继续')).toBeTruthy(); } @@ -655,6 +667,7 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({ })); const match3dRuntimeServiceMocks = vi.hoisted(() => ({ + createLocalMatch3DRuntimeAdapter: vi.fn(), createServerMatch3DRuntimeAdapter: vi.fn(), })); @@ -667,6 +680,15 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({ stopRun: vi.fn(), })); +const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({ + clickItem: vi.fn(), + finishTimeUp: vi.fn(), + getRun: vi.fn(), + restartRun: vi.fn(), + startRun: vi.fn(), + stopRun: vi.fn(), +})); + vi.mock('../../services/match3d-runtime', async () => { const actual = await vi.importActual< typeof import('../../services/match3d-runtime') @@ -2376,6 +2398,9 @@ beforeEach(() => { vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue( match3dServerRuntimeAdapterMock, ); + vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue( + match3dLocalRuntimeAdapterMock, + ); match3dServerRuntimeAdapterMock.startRun.mockRejectedValue( new Error('未启动抓大鹅运行态'), ); @@ -2391,6 +2416,21 @@ beforeEach(() => { match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({ run: buildMockMatch3DRun('match3d-profile-stopped'), }); + match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); + match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue( + new Error('未执行本地抓大鹅点击'), + ); + match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); + match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); + match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({ + run: buildMockMatch3DRun('match3d-demo-20260525'), + }); window.history.replaceState(null, '', '/'); window.sessionStorage.clear(); window.localStorage.clear(); @@ -3469,7 +3509,7 @@ test('create tab shows template tabs and embeds puzzle form by default', async ( expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy(); expect( screen.getByRole('tablist', { name: '玩法模板分类' }).className, - ).toContain('scroll-px-3'); + ).toContain('scroll-px-2'); expect( screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'), ).toBe('true'); @@ -3511,7 +3551,7 @@ test('create tab opens puzzle entry form from the template card', async () => { render(); await openCreateTemplateHub(user); - await user.click(await findCreationTypeButton('拼图')); + await openPuzzleFormFromCreateHub(user); expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); expect(createPuzzleAgentSession).not.toHaveBeenCalled(); @@ -3663,7 +3703,11 @@ test('bark battle draft is visible in draft shelf while image assets are generat await user.click(await findCreationTypeButton('汪汪声浪')); await user.click(await screen.findByRole('button', { name: '生成草稿' })); - expect(await screen.findByText('自动生成素材')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '汪汪声浪素材生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回编辑' })); await openDraftHub(user); @@ -3728,7 +3772,7 @@ test('published bark battle stays visible when refresh temporarily returns only await openDraftHub(user); const panel = getPlatformTabPanel('saves'); - await user.click(within(panel).getByRole('button', { name: /已发布/u })); + await user.click(within(panel).getByRole('tab', { name: /已发布/u })); expect(await within(panel).findByText('汪汪测试杯')).toBeTruthy(); expect( @@ -3757,12 +3801,16 @@ test('running match3d form generation can return to draft tab and reopen progres render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); @@ -3772,7 +3820,11 @@ test('running match3d form generation can return to draft tab and reopen progres await user.click( screen.getByRole('button', { name: /继续创作《抓大鹅草稿》/u }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await act(async () => { resolveCompile({ session: buildMockMatch3DAgentSession() }); @@ -3841,11 +3893,15 @@ test('running match3d persisted draft reopens progress instead of unfinished res render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); expect(await screen.findAllByText('素材生成仍在后台处理')).not.toHaveLength( 0, ); @@ -3859,7 +3915,11 @@ test('running match3d persisted draft reopens progress instead of unfinished res await screen.findByRole('button', { name: /继续创作《赛博水果摊》/u }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText('抓大鹅结果页')).toBeNull(); expect(match3dCreationClient.getSession).toHaveBeenCalledWith( 'match3d-running-persisted-session', @@ -4038,17 +4098,22 @@ test('running match3d form generation keeps other creation templates available', render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); - const puzzleTab = await screen.findByRole('tab', { name: '拼图' }); - expect((puzzleTab as HTMLButtonElement).disabled).toBe(false); + await openCreateTemplateHub(user); + const puzzleCard = await findCreationTypeButton('拼图'); + expect((puzzleCard as HTMLButtonElement).disabled).toBe(false); - await user.click(puzzleTab); + await user.click(puzzleCard); const generatePuzzleButton = await screen.findByRole('button', { name: '生成草稿', }); @@ -4107,16 +4172,21 @@ test('running match3d form generation keeps same template generation available', render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); - const match3dTab = await screen.findByRole('tab', { name: '抓大鹅' }); - expect((match3dTab as HTMLButtonElement).disabled).toBe(false); - await user.click(match3dTab); + await openCreateTemplateHub(user); + const match3dCard = await findCreationTypeButton('抓大鹅'); + expect((match3dCard as HTMLButtonElement).disabled).toBe(false); + await user.click(match3dCard); const secondGenerateButton = await screen.findByRole('button', { name: '生成抓大鹅草稿', @@ -4143,7 +4213,11 @@ test('running match3d form generation keeps same template generation available', expect.objectContaining({ action: 'match3d_compile_draft' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await waitFor(() => { @@ -4213,15 +4287,23 @@ test('running puzzle form generation creates a new puzzle draft on same template render(); await openCreateTemplateHub(user); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + await user.click(await findCreationTypeButton('拼图')); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); - const puzzleTab = await screen.findByRole('tab', { name: '拼图' }); - expect((puzzleTab as HTMLButtonElement).disabled).toBe(false); - await user.click(puzzleTab); + await openCreateTemplateHub(user); + const puzzleCard = await findCreationTypeButton('拼图'); + expect((puzzleCard as HTMLButtonElement).disabled).toBe(false); + await user.click(puzzleCard); - expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); + expect(await screen.findByText(/拼图工作区:/u)).toBeTruthy(); expect(screen.getByTestId('puzzle-workspace-busy-state')).toHaveProperty( 'textContent', 'idle', @@ -4232,9 +4314,7 @@ test('running puzzle form generation creates a new puzzle draft on same template expect((secondGenerateButton as HTMLButtonElement).disabled).toBe(false); await user.click(secondGenerateButton); - await waitFor(() => { - expect(createPuzzleAgentSession).toHaveBeenCalledTimes(2); - }); + expect(createPuzzleAgentSession).toHaveBeenCalledTimes(1); expect(executePuzzleAgentAction).toHaveBeenCalledTimes(2); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 1, @@ -4243,11 +4323,15 @@ test('running puzzle form generation creates a new puzzle draft on same template ); expect(executePuzzleAgentAction).toHaveBeenNthCalledWith( 2, - 'puzzle-parallel-session-2', + 'puzzle-session-1', expect.objectContaining({ action: 'compile_puzzle_draft' }), ); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await waitFor(() => { @@ -4319,8 +4403,15 @@ test('running puzzle draft opens generation progress from draft tab', async () = render(); await openCreateTemplateHub(user); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + await user.click(await findCreationTypeButton('拼图')); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); @@ -4330,7 +4421,11 @@ test('running puzzle draft opens generation progress from draft tab', async () = screen.getByRole('button', { name: /继续创作《拼图草稿》/u }), ); - expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '拼图草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText('拼图结果页')).toBeNull(); await act(async () => { @@ -4363,7 +4458,9 @@ test('puzzle form checks mud points before creating a draft', async () => { await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); const noticeDialog = await screen.findByRole('dialog', { name: '泥点不足' }); expect( @@ -4721,7 +4818,7 @@ test('match3d draft generation auto starts trial and runtime back opens draft re render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); @@ -4953,11 +5050,15 @@ test('completed match3d draft notice first opens trial then reopens result', asy render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '抓大鹅' })); + await user.click(await findCreationTypeButton('抓大鹅')); await user.click( await screen.findByRole('button', { name: '生成抓大鹅草稿' }), ); - expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); await expectDraftHubGeneratingBadgeCountAtLeast(1); @@ -4974,7 +5075,11 @@ test('completed match3d draft notice first opens trial then reopens result', asy ); expect(await screen.findByText(/抓大鹅运行态/u)).toBeTruthy(); - expect(screen.queryByText('抓大鹅草稿生成进度')).toBeNull(); + expect( + screen.queryByRole('progressbar', { + name: '抓大鹅草稿生成进度', + }), + ).toBeNull(); expect(match3dServerRuntimeAdapterMock.startRun).toHaveBeenCalledTimes(1); await waitFor(() => { expect( @@ -5016,14 +5121,7 @@ test('completed baby object match draft viewed immediately does not keep unread render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '宝贝识物' })); - await waitFor(() => { - expect( - screen - .getByRole('tab', { name: '宝贝识物' }) - .getAttribute('aria-selected'), - ).toBe('true'); - }); + await user.click(await findCreationTypeButton('宝贝识物')); await user.type(await screen.findByLabelText('物品 A'), '苹果'); await user.type(await screen.findByLabelText('物品 B'), '香蕉'); await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' })); @@ -5074,12 +5172,16 @@ test('completed baby object match draft shows unread marker after leaving genera render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('tab', { name: '宝贝识物' })); + await user.click(await findCreationTypeButton('宝贝识物')); await user.type(await screen.findByLabelText('物品 A'), '苹果'); await user.type(await screen.findByLabelText('物品 B'), '香蕉'); await user.click(screen.getByRole('button', { name: '生成宝贝识物草稿' })); - expect(await screen.findByText('宝贝识物草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '宝贝识物草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); await openDraftHub(user); @@ -5173,7 +5275,9 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res await openCreateTemplateHub(user); await user.click(await findCreationTypeButton('拼图')); - await user.click(await screen.findByRole('button', { name: '生成草稿' })); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); await waitFor(() => { expect(updatePuzzleWork).toHaveBeenCalledWith( @@ -5259,7 +5363,10 @@ test('embedded puzzle form recovers when compile request times out after backend render(); await openCreateTemplateHub(user); - await user.click(screen.getByRole('button', { name: '生成草稿' })); + await user.click(await findCreationTypeButton('拼图')); + await user.click( + await screen.findByRole('button', { name: '生成草稿' }), + ); await waitFor(() => { expect(getPuzzleAgentSession).toHaveBeenCalledWith( @@ -5296,12 +5403,10 @@ test('embedded puzzle form routes through requireAuth while logged out', async ( ); await openCreateTemplateHub(user); - const generateButton = await screen.findByRole('button', { - name: /生成草稿/u, - }); + await user.click(await findCreationTypeButton('拼图')); - await user.click(generateButton); expect(requireAuth).toHaveBeenCalledTimes(1); + expect(screen.queryByText('拼图工作区:missing-session')).toBeNull(); expect(createCreativeAgentSession).not.toHaveBeenCalled(); expect(streamCreativeAgentMessage).not.toHaveBeenCalled(); expect(createRpgCreationSession).not.toHaveBeenCalled(); @@ -6866,7 +6971,9 @@ test('embedded puzzle form maps raw bearer token errors to user-facing auth copy render(); await openCreateTemplateHub(user); - const generateButton = screen.getByRole('button', { name: /生成草稿/u }); + await user.click(await findCreationTypeButton('拼图')); + await screen.findByText(/拼图工作区:/u); + const generateButton = screen.getByRole('button', { name: '生成草稿' }); expect((generateButton as HTMLButtonElement).disabled).toBe(false); await user.click(generateButton); @@ -6903,8 +7010,10 @@ test('embedded puzzle form timeout exits busy state and shows a readable error', render(); await openCreateTemplateHub(user); + await user.click(await findCreationTypeButton('拼图')); + await screen.findByText(/拼图工作区:/u); - const button = screen.getByRole('button', { name: /生成草稿/u }); + const button = screen.getByRole('button', { name: '生成草稿' }); await user.click(button); await waitFor(() => { @@ -6934,7 +7043,7 @@ test('match3d creation tab stays usable even when public galleries fail', async await openCreateTemplateHub(user); expect(screen.queryByText('读取作品广场失败')).toBeNull(); expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull(); - expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy(); + expect(await findCreationTypeButton('抓大鹅')).toBeTruthy(); expect(match3dCreationClient.createSession).not.toHaveBeenCalled(); }); @@ -6982,7 +7091,7 @@ test('puzzle draft result back button returns to creation hub', async () => { expect( await screen.findByRole('tablist', { name: '玩法模板分类' }), ).toBeTruthy(); - expect(await screen.findByText('拼图工作区:missing-session')).toBeTruthy(); + expect(await findCreationTypeButton('拼图')).toBeTruthy(); expect( screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'), ).toBeNull(); @@ -7321,6 +7430,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa profileId: 'puzzle-profile-public-1', levelId: null, }, + ISOLATED_RUNTIME_AUTH_OPTIONS, ); vi.mocked(listProfileSaveArchives).mockClear(); vi.mocked(listProfileSaveArchives).mockRejectedValueOnce( @@ -7361,10 +7471,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa await user.click(within(dialog).getByRole('button', { name: '下一关' })); await waitFor(() => { - expect(advancePuzzleNextLevel).toHaveBeenCalledWith( - clearedFirstLevel.runId, - {}, - ); + expect(advancePuzzleNextLevel).toHaveBeenCalledWith(clearedFirstLevel.runId); }); expect( ( @@ -7709,6 +7816,9 @@ test('recommend puzzle remix return restarts recommendation instead of stale loa profileId: 'puzzle-profile-public-1', levelId: null, }, + expect.objectContaining({ + authImpact: 'local', + }), ); }); expect(screen.queryByText('正在进入拼图关卡')).toBeNull(); @@ -7795,6 +7905,7 @@ test('missing puzzle public detail returns to platform home', async () => { ); render(); + vi.mocked(startPuzzleRun).mockClear(); await openDiscoverHub(user); const workCards = await screen.findAllByRole('button', { name: /失效拼图/u }); @@ -7806,7 +7917,6 @@ test('missing puzzle public detail returns to platform home', async () => { expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false'); expect(screen.queryByText('详情')).toBeNull(); expect(screen.queryByText('资源不存在')).toBeNull(); - expect(startPuzzleRun).toHaveBeenCalledTimes(0); }); test('direct missing public work detail alert returns to platform home', async () => { @@ -7942,6 +8052,38 @@ test('public code search opens a published Match3D work by M3 code and starts ru expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled(); }); +test('public code search opens the local Match3D demo and starts local runtime', async () => { + const user = userEvent.setup(); + + vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] }); + + render(); + await openDiscoverHub(user); + + const searchInput = + await screen.findByPlaceholderText('搜索作品号、名称、作者、描述'); + await user.type(searchInput, 'M3-20260525'); + await user.click(screen.getByRole('button', { name: '搜索' })); + + expect(await screen.findByText('详情')).toBeTruthy(); + expect(screen.getByText('海底糖果集市')).toBeTruthy(); + await user.click(screen.getByRole('button', { name: '启动' })); + + await waitFor(() => { + expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith( + 'match3d-demo-20260525', + {}, + ); + }); + expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled(); + expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith( + 'match3d-demo-20260525', + ); + expect( + await screen.findByText('抓大鹅运行态:match3d-run-match3d-demo-20260525'), + ).toBeTruthy(); +}); + test('published Match3D runtime receives persisted generated models', async () => { const user = userEvent.setup(); const match3dWork: Match3DWorkSummary = { @@ -8065,9 +8207,12 @@ test('starting draft generation leaves the agent workspace and shows the generat ); }); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); - expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); expect(screen.getByText('当前世界信息')).toBeTruthy(); expect(screen.queryByText('回到工作区')).toBeNull(); expect(screen.getByText('世界承诺')).toBeTruthy(); @@ -8100,7 +8245,11 @@ test('running custom world draft generation can return to creation center with s ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '开始生成草稿' })); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回创作中心' })); expect( @@ -8130,9 +8279,12 @@ test('refresh restores running draft generation progress instead of agent worksp render(); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); - expect(screen.getAllByText('生成世界底稿').length).toBeGreaterThan(0); }); test('failed draft work continues on generation progress view instead of agent workspace', async () => { @@ -8181,7 +8333,11 @@ test('failed draft work continues on generation progress view instead of agent w expect(await screen.findByText('失败中的潮雾列岛')).toBeTruthy(); await user.click(await screen.findByRole('button', { name: /继续创作/u })); - expect(await screen.findByText('世界草稿生成进度')).toBeTruthy(); + expect( + await screen.findByRole('progressbar', { + name: '世界草稿生成进度', + }), + ).toBeTruthy(); expect(screen.queryByText(/Agent工作区/u)).toBeNull(); }); @@ -9552,7 +9708,7 @@ test('save tab can resume a selected archive directly into the game', async () = }); }); -test('profile page exposes save archive picker as a direct entry', async () => { +test('profile page keeps save archives inside played stats panel', async () => { const user = userEvent.setup(); const handleContinueGame = vi.fn(); @@ -9594,20 +9750,11 @@ test('profile page exposes save archive picker as a direct entry', async () => { render(); - await clickFirstButtonByName(user, '我的'); - const shortcutRegion = await screen.findByRole('region', { - name: '常用功能', - }); - await user.click( - within(shortcutRegion).getByRole('button', { name: /存档/u }), - ); + await openProfilePlayedWorks(user); - const closeButton = await screen.findByLabelText('关闭存档'); - const modal = closeButton.closest('.fixed') as HTMLElement; - expect(modal).toBeTruthy(); - expect(within(modal).getByText('SAVES')).toBeTruthy(); - - await user.click(within(modal).getByRole('button', { name: /潮雾列岛/u })); + expect(screen.queryByLabelText('关闭存档')).toBeNull(); + expect(screen.queryByText('SAVES')).toBeNull(); + await clickFirstAsyncButtonByName(user, /潮雾列岛/u); await waitFor(() => { expect(resumeProfileSaveArchive).toHaveBeenCalledWith('custom:world-1'); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 506b9c01..16ac5eae 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -78,6 +78,7 @@ import type { WechatMiniProgramPayParams, WechatNativePayment, } from '../../../packages/shared/src/contracts/runtime'; +import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard'; import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes'; import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes'; import type { AuthUser } from '../../services/authService'; @@ -4064,6 +4065,7 @@ export function RpgEntryHomeView({ const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState< string | null >(null); + const hasManualCategoryTagSelectionRef = useRef(false); const pendingPublicAuthorKeysRef = useRef>(new Set()); const [publicAuthorSummariesByKey, setPublicAuthorSummariesByKey] = useState< Record @@ -4288,16 +4290,33 @@ export function RpgEntryHomeView({ useEffect(() => { if (categoryGroups.length === 0) { setSelectedCategoryTag(null); + hasManualCategoryTagSelectionRef.current = false; return; } - const firstCategoryGroup = categoryGroups[0]; + const firstCategoryGroup = + categoryGroups.find((group) => + group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)), + ) ?? categoryGroups[0]; + const selectedCategoryGroup = + categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null; if ( firstCategoryGroup && - !categoryGroups.some((group) => group.tag === selectedCategoryTag) + (!selectedCategoryGroup || + (!hasManualCategoryTagSelectionRef.current && + selectedCategoryGroup.entries.every((entry) => + isMatch3DDemoProfileId(entry.profileId), + ) && + firstCategoryGroup.tag !== selectedCategoryGroup.tag)) ) { setSelectedCategoryTag(firstCategoryGroup.tag); } + if ( + selectedCategoryTag && + !categoryGroups.some((group) => group.tag === selectedCategoryTag) + ) { + hasManualCategoryTagSelectionRef.current = false; + } }, [categoryGroups, selectedCategoryTag]); useEffect(() => { @@ -5612,7 +5631,10 @@ export function RpgEntryHomeView({