Match3D & Puzzle: runtime UI, assets, drag fix
Backend: stop treating background music as a required draft asset and remove auto-submit/plan for background music; load persisted generated UI/assets into Match3D agent session responses (added helpers to resolve profile id and fetch existing generated assets). Frontend: make Match3D result preview reuse runtime UI styles, unify runtime settings entry, update PuzzleRuntime to apply immediate pointermove transforms (disable drag transition), use SVG clipPath for merged piece rounding, ensure PuzzleRuntimeShell supplies platform theme classes, and adjust related tests. Docs & logs: update decision log, pitfalls and product docs to reflect these changes.
This commit is contained in:
@@ -16,6 +16,54 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-15 抓大鹅结果页 UI 预览复用运行态布局
|
||||
|
||||
- 背景:抓大鹅结果页 `素材配置 > UI` 的预览弹层曾手写简化 HUD 和容器布局,和真实运行态顶部关卡卡片、右上设置入口、容器图定位及槽位样式出现漂移。
|
||||
- 决策:结果页 UI 预览只组合 `match3dRuntimeUiStyles` 中的运行态 HUD、棋盘、容器图和槽位样式常量;运行态尺寸或视觉层级调整时,同步由这些常量影响预览,不再在结果页单独维护另一套预览 UI。
|
||||
- 影响范围:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、`src/components/match3d-runtime/match3dRuntimeUiStyles.ts`、抓大鹅结果页测试和玩法链路文档。
|
||||
- 验证方式:执行 `npm run test -- src/components/match3d-result/Match3DResultView.test.tsx`、`npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认预览顶部 HUD、设置入口、容器图定位和槽位样式与运行态一致。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-15 拼图拖拽视觉即时跟手且拼块裁切圆角
|
||||
|
||||
- 背景:拼图运行态在触控拖拽时不能有追手延迟,同时拼图片需要真实圆角裁切,合并后的 L 形 / 凹形块不能靠单格圆角拼接。
|
||||
- 决策:`PuzzleRuntimeShell` 拖拽视觉在 `pointermove` 期间直接写入当前可见拼块 DOM 的 `translate3d(...)`,拖拽阶段禁用 transform transition,不依赖后端回包、React 重渲染或 `requestAnimationFrame` 队列。拖动过程中不展示拼块选中态或底部“已选择”提示,选中状态只保留给点击交换语义。单块通过 `buildRoundedGridCellClipPath()` 裁切独立圆角;合并块视觉层必须用 SVG 原生 `clipPath` 裁切整体外轮廓,不只依赖 HTML `clip-path:url(#...)`,外凸角和内凹角分开计算半径,内凹角半径更大以避免移动端看起来仍是直角。
|
||||
- 影响范围:拼图运行态拖拽手感、`puzzleRuntimeShape` 圆角路径、拼图运行态测试和玩法链路文档。
|
||||
- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`、`npm run typecheck`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-15 拼图运行态点击弹层不使用像素素材框
|
||||
|
||||
- 背景:拼图运行态主界面已经切到平台主色主题,但提示和设置弹层仍复用像素九宫格素材框,和当前拼图视觉不一致。
|
||||
- 决策:拼图运行态的提示、设置等点击弹层跟随 `puzzle-runtime-*` 主色主题,使用普通圆角主题面板和 CSS 变量分层,不再套 `pixel-nine-slice` / `pixel-modal-shell` 或 `UI_CHROME.modalPanel`。
|
||||
- 影响范围:`PuzzleRuntimeShell` 的道具确认弹窗、设置弹窗、拼图运行态样式和玩法链路文档。
|
||||
- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,确认提示和设置 dialog 不包含像素框类名。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-15 拼图运行态壳层必须自带平台主题类
|
||||
|
||||
- 背景:`/puzzle` 直达页和运行态提前返回分支不一定挂在外层平台壳里,若壳层只依赖父级主题类,就会出现修改已经生效但页面看起来还是旧样式的错觉。
|
||||
- 决策:`PuzzleRuntimeShell` 自身在正常运行态和等待态都补齐 `platform-ui-shell platform-theme platform-theme--light|dark`,让直达页和平台内嵌页共享同一套主题变量。
|
||||
- 影响范围:`PuzzleRuntimeShell` 根容器、路由直达页、拼图运行态测试和玩法链路文档。
|
||||
- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx` 与 `npm run typecheck`,确认壳层 className 包含平台主题类。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-15 抓大鹅草稿自动编排不再要求背景音乐
|
||||
|
||||
- 背景:抓大鹅音频入口关闭后,草稿生成仍可能在后端自动编排中调用 Suno,导致“提交 Suno 背景音乐任务失败”阻断草稿生成。
|
||||
- 决策:`match3d_compile_draft` 的完成条件只包含 2D 五视角物品图片、UI 背景和容器图;点击音效只在 `generateClickSound=true` 的历史配置下作为可选补齐。背景音乐字段仅作为历史兼容传递,不再由草稿自动生成或补齐。
|
||||
- 影响范围:`api-server` Match3D 草稿资产编排、抓大鹅音频关闭口径、玩法链路文档。
|
||||
- 验证方式:执行 `cargo test -p api-server match3d_generated_assets_require_only_images_when_click_sound_is_closed --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-15 抓大鹅运行态右上角统一为设置入口
|
||||
|
||||
- 背景:抓大鹅运行态右上角原先直接暴露重新开始按钮,和拼图的设置入口口径不一致,也不利于把设置、重开和后续扩展动作收口到统一面板。
|
||||
- 决策:`Match3DRuntimeShell` 右上角改为设置按钮,点击后打开独立设置面板;重新开始动作仅放在设置面板内,结算弹层继续保留再来一局。拼图右上角设置图标同步改为非像素风 `lucide-react` `Settings` 图标。
|
||||
- 影响范围:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/match3d-runtime/Match3DRuntimeShell.tsx`、对应测试和玩法链路文档。
|
||||
- 验证方式:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`,确认拼图设置按钮不再渲染像素图片、抓大鹅右上角打开设置面板且面板内可重新开始。
|
||||
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 2026-05-15 汪汪声浪和宝贝识物入口设为敬请期待
|
||||
|
||||
- 背景:当前需要暂时关闭汪汪声浪和宝贝识物两个模板的创建链路,但仍保留创作 Tab 中的模板占位。
|
||||
|
||||
@@ -70,6 +70,22 @@
|
||||
- 验证:`npm run test -- src\services\match3dGeneratedModelCache.test.ts src\components\match3d-result\Match3DResultView.test.tsx src\components\match3d-runtime\Match3DRuntimeShell.test.tsx`;平台推荐流定向跑 `RpgEntryFlowShell.agent.interaction.test.tsx` 中的 Match3D runtime assets 用例;`npm run typecheck`。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
|
||||
## 抓大鹅草稿关闭背景音乐后仍触发 Suno 先查后端自动编排
|
||||
|
||||
- 现象:用户已关闭抓大鹅草稿里的 `生成音效` 或隐藏结果页音频入口,但点击生成仍报 `提交 Suno 背景音乐任务失败`。
|
||||
- 原因:入口开关只影响前端可见控件或点击音效,后端 `match3d_compile_draft` 之前还会把 `backgroundMusic` 当作自动完成条件,继续调用 `generate_background_music_asset_for_creation()`,从而直接发 `/suno/submit/music`。
|
||||
- 处理:抓大鹅草稿自动编排只保留 2D 物品图片、UI 背景和容器图;背景音乐字段只作为历史兼容数据,不再作为草稿完成条件,也不再由自动编排提交 Suno。
|
||||
- 验证:`cargo test -p api-server match3d_generated_assets_require_only_images_when_click_sound_is_closed --manifest-path server-rs/Cargo.toml` 通过,`cargo check -p api-server --manifest-path server-rs/Cargo.toml` 无新的编译问题。
|
||||
- 关联:`server-rs/crates/api-server/src/match3d.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`.hermes/shared-memory/decision-log.md`。
|
||||
|
||||
## 抓大鹅 UI 预览和实际游戏不一致先查运行态样式复用
|
||||
|
||||
- 现象:结果页 `素材配置 > UI` 打开的预览弹层和真实抓大鹅运行态不一致,例如顶部只显示倒计时、右上还是转圈占位、中心容器尺寸或定位和局内不同。
|
||||
- 原因:预览层手写了简化 HUD、棋盘尺寸和容器图样式,没有消费 `Match3DRuntimeShell` 运行态使用的共享样式常量。
|
||||
- 处理:把运行态 HUD、棋盘、容器图和槽位的 Tailwind 类抽到 `match3dRuntimeUiStyles.ts`,结果页预览只复用这些常量;运行态之后改顶部设置入口、关卡卡片、容器图宽度或棋盘兜底样式时,不要在结果页再写一套。
|
||||
- 验证:`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。
|
||||
- 关联:`src/components/match3d-result/Match3DResultView.tsx`、`src/components/match3d-runtime/match3dRuntimeUiStyles.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 中文乱码与编码风险
|
||||
|
||||
- 现象:中文文案、注释、剧情或文档显示为乱码,或被改写成英文。
|
||||
@@ -469,6 +485,14 @@
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation surfaces start failure"`。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
|
||||
## 推荐页拼图改造返回后又卡在进入中
|
||||
|
||||
- 现象:推荐页进入拼图作品后点击改造,返回推荐页时卡在“正在进入拼图关卡”或空白加载态,像是作品还在启动。
|
||||
- 原因:改造或其他页面流程会清掉当前 `puzzleRun`,但推荐页自动启动逻辑曾只检查 `activeRecommendEntryKey` 是否仍在推荐列表中;旧 key 还在、玩法 run 已丢失时,会继续渲染失效的 `PuzzleRuntimeShell` 占位。
|
||||
- 处理:从推荐页拼图进入改造结果页时,先收口推荐嵌入态;推荐页自动启动逻辑还必须校验当前推荐作品对应的 runtime 数据是否存在,例如拼图要有 `puzzleRun`,抓大鹅要有 `match3dRun`。key 还在但 runtime 不完整时,优先重新启动当前作品,不要误判为已激活。
|
||||
- 验证:`npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "home recommendation starts embedded puzzle|home recommendation surfaces start failure|first puzzle runtime back click can open remix result page"`。
|
||||
- 关联:`src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`、`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`。
|
||||
|
||||
## 推荐页未登录入口误打开公开详情
|
||||
|
||||
- 现象:新用户默认在发现页,但点击推荐页或推荐封面后,如果复用公开作品详情入口,可能绕过推荐页“登录后游玩”的产品门禁。
|
||||
@@ -696,6 +720,13 @@
|
||||
- 验证:执行 `npm run test -- src/components/match3d-runtime/Match3DRuntimeShell.test.tsx` 和 `npm run test -- src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx -t "Match3D runtime"`;浏览器 Network 中背景和容器 generated path 应先请求 `/api/assets/read-url` 换签,局内出现 `match3d-background-image` 和 `match3d-container-image` 对应图片。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
|
||||
## 抓大鹅 UI 背景和容器只在物品挂载字段时也要提升
|
||||
|
||||
- 现象:草稿恢复、结果页素材配置、UI 预览或试玩时仍显示默认背景 / 默认容器,但 work profile 的首个 `generatedItemAssets[].backgroundAsset` 里已经有生成的背景和容器图。
|
||||
- 原因:UI 背景和容器资产没有独立表字段,后端持久化常落在 `generatedItemAssets[].backgroundAsset`;如果 session 映射、结果页 profile、推荐运行态详情补读后不提升到顶层 `generatedBackgroundAsset` 和 `backgroundImageSrc`,后续组件会误判“没有生成 UI 资产”。
|
||||
- 处理:Agent session 返回前要用持久化 work profile 资产回填 draft;前端进入结果页、构建草稿 profile、推荐 / 公开作品启动运行态前,都要把 `generatedItemAssets[].backgroundAsset` 提升为顶层背景字段。容器图在运行态和 UI 预览复用同一套居中 `object-contain` 样式,移动端宽度接近屏宽,只有缺失或加载失败时才使用透明参考图兜底。
|
||||
- 验证:`cargo test -p api-server match3d_agent_session_response_hydrates_persisted_ui_assets --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/match3d-result/Match3DResultView.test.tsx src/components/match3d-runtime/Match3DRuntimeShell.test.tsx`。
|
||||
|
||||
## 抓大鹅重启时不要清空 generated 图片签名缓存
|
||||
|
||||
- 现象:抓大鹅进入局内时背景或生成物品首帧缺失;点击右上角重启后,局内短暂显示默认积木,过一段时间才换回实际生成素材。
|
||||
@@ -788,10 +819,18 @@
|
||||
|
||||
- 现象:抓大鹅生成的物品视角图裁剪后仍带白边,或者整块纯绿色绿幕背景没有被透明化,运行态看到绿色方块。
|
||||
- 原因:素材 sheet 可能是“每格内部绿幕、整张图外圈近白底”,内部绿幕不一定连通到 sheet 外边缘;旧 flood fill 只从外边缘找背景会漏掉这种绿幕块。白底抗锯齿如果不纳入抠像和边缘去污染,也会随裁剪输出成一圈白边。即使顺序已是先整张 sheet 去绿再裁剪,较厚的半透明或混色软绿边仍可能低于高置信绿幕阈值,被当作前景带进独立 PNG。
|
||||
- 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边,边缘近白和绿幕抗锯齿做透明或去污染;同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。
|
||||
- 处理:`api-server` 的 `slice_match3d_material_sheet` 必须先在整张 sheet 上做透明背景后处理:外边缘连通绿幕/近白底清 alpha,非连通但高置信纯绿块也清 alpha,沿整张 sheet 透明背景继续吃掉软绿边;每个视角单图还要以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,再按剩余可见主体收紧裁边,同时保护不够纯的绿色主体像素。不要改成先裁剪单格再去绿。
|
||||
- 验证:`cargo test -p api-server match3d_material_sheet_slicing --manifest-path server-rs\Cargo.toml` 覆盖非连通绿幕、白边、贴边主体保留和固定 5x5 切图。
|
||||
- 关联:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 抓大鹅背景 UI 图出现透明区域先查背景不透明后处理
|
||||
|
||||
- 现象:抓大鹅生成的 `9:16` 局内背景 UI 图边缘、角落或局部出现透明 alpha,运行态可能露出底色或透明棋盘底。
|
||||
- 原因:背景图和中心容器图是两张资产;容器图需要透明 alpha,但背景图是整屏底图。如果只靠提示词约束,生图服务仍可能返回带透明通道的 PNG。
|
||||
- 处理:`generate_match3d_background_image` 上传背景前必须调用 `make_match3d_background_image_opaque()`,把所有半透明 / 全透明像素合成到不透明底色;不要把这条逻辑套到容器图,容器图仍由 `make_match3d_container_image_transparent()` 保持透明。
|
||||
- 验证:`cargo test -p api-server match3d_background_image_postprocess_removes_transparent_pixels --manifest-path server-rs\Cargo.toml`,并确认背景提示词包含“全画幅不透明”和“透明 alpha”禁用约束。
|
||||
- 关联:`server-rs/crates/api-server/src/match3d.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 抓大鹅物品详情大方格只做单张大图查看
|
||||
|
||||
- 现象:结果页 `素材配置 > 物品` 打开详情后,上方大方格仍显示横向五图带、焦点内框或小缩略图边框,物品本体看起来偏小且像带着素材自带边框。
|
||||
@@ -856,6 +895,22 @@
|
||||
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拖拽合并大块时底层单格不显示选中色块"`,并确认合并块拖拽时底层 `[data-piece-id]` 仍为 `puzzle-runtime-piece--merged`。
|
||||
- 关联:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`、`docs/【项目基线】当前产品与工程约束-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## 拼图拖拽延迟和圆角异常先查即时 DOM 视觉层
|
||||
|
||||
- 现象:拼图块拖动时有明显追手延迟,或合并块的 L 形 / 凹形内角圆角和外凸角表现一致、出现方角。
|
||||
- 原因:拖拽视觉如果依赖 `requestAnimationFrame` 排队、React 重渲染或 transform transition,会在触控设备上产生一帧以上延迟;合并块如果只靠单格 `border-radius`,无法正确处理整体外轮廓和内凹角。
|
||||
- 处理:`PuzzleRuntimeShell` 的 `pointermove` 期间直接给当前单块或合并组 DOM 写入 `translate3d(...)`,拖拽阶段禁用 transform transition,并隐藏拼块选中态和底部“已选择”提示;单块使用 `buildRoundedGridCellClipPath()` 独立裁切,合并块视觉层使用 SVG 原生 `clipPath` 裁切整体轮廓,不只依赖 HTML `clip-path:url(#...)`,外凸角和内凹角半径分开计算,内凹角半径更大。
|
||||
- 验证:执行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx`,重点覆盖拖拽 move 后 DOM transform 立即变化且不调用 rAF、拖动中不显示已选择状态、基础单块有圆角裁切、合并块内凹角路径使用更大半径且视觉层为 SVG 裁切。
|
||||
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`src/components/puzzle-runtime/puzzleRuntimeShape.ts`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 拼图设置面板当前用时为 0 先查结算字段误用
|
||||
|
||||
- 现象:拼图运行态还在游玩时,设置面板里的“当前用时”一直显示 `0:00.00`,但顶部倒计时正常变化。
|
||||
- 原因:`currentLevel.elapsedMs` 是通关后的结算字段,进行中关卡按契约通常为 `null`;设置面板若直接格式化该字段,就会被归一化为 0。
|
||||
- 处理:设置面板的当前用时按 `startedAtMs`、`pausedAccumulatedMs`、UI 暂停起点、`freezeAccumulatedMs` 和当前冻结区间实时派生,和剩余时间使用同一套暂停 / 冻结扣除口径。
|
||||
- 验证:运行 `npm run test -- src/components/puzzle-runtime/PuzzleRuntimeShell.test.tsx -t "拼图设置面板展示进行中关卡的实时当前用时"`。
|
||||
- 关联:`src/components/puzzle-runtime/PuzzleRuntimeShell.tsx`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 拼图历史图片列表不要把账号归属当图片名
|
||||
|
||||
- 现象:拼图创作页或结果页打开“选择历史图片”后,历史列表显示 `账号 user-1` 之类归属文案而不是图片名;`1713686400.000000Z` 这类时间显示为未知;选中后预览或生成参考图可能被怀疑不可用。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
1. 草稿页作品卡对齐发现页列表卡风格:左侧信息,右侧封面图,移动端单列,桌面两到三列。
|
||||
2. 草稿 / 已发布状态尽量图标化,不使用大段状态文案。
|
||||
3. 删除、分享等低频动作进入左滑或长按操作层,常态不外露破坏卡片密度。
|
||||
3. 草稿卡常态不外露低频动作;已发布作品卡右上角可直接显示无边框分享 icon,删除等破坏性动作继续收口到左滑或长按操作层。
|
||||
4. 生成中作品在整卡上加等待遮罩,但不移除作品基础信息。
|
||||
5. 私有 generated 图片必须通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签读取。
|
||||
|
||||
@@ -30,7 +30,12 @@
|
||||
- 支持画面描述生图、多参考图生图、上传主图后 AI 重绘、上传主图后不重绘。
|
||||
- 草稿生成会保留关卡图和 UI 背景;当前不自动生成背景音乐。
|
||||
- 结果页素材配置当前只保留 UI 相关能力;旧背景音乐入口隐藏。
|
||||
- 拼图运行态默认棋盘不叠加分块蒙版、描边、阴影、圆角切块、选中底色或合并块 SVG 轮廓;原图道具只在用户主动确认后临时覆盖。
|
||||
- 拼图运行态棋盘不叠加分块蒙版、描边、阴影、选中底色或合并块 SVG 轮廓;拼图片本体需要裁切为圆角形状,单块使用独立圆角裁切,合并块使用 SVG 原生 `clipPath` 裁切整体外轮廓,外凸角和内凹角分别计算半径,内凹角半径要比外凸角更明显以避免手机 WebView 中看起来仍是直角。原图道具只在用户主动确认后打开独立原图查看层,不在当前拼图棋盘上叠加原图。
|
||||
- 拼图运行态拖拽必须完全跟随手指或鼠标位置,`pointermove` 期间即时写入可见拼块的 transform,不依赖等待后端回包、React 重渲染或下一帧动画队列;进入拖动后不展示拼块选中态或“已选择”提示,松手后再提交目标格同步规则真相。
|
||||
- 拼图运行态的提示、设置等点击弹层跟随当前运行态主色主题,使用普通圆角主题面板,不复用像素九宫格素材框。
|
||||
- 拼图运行态壳层自身要补齐 `platform-ui-shell` / `platform-theme` / `platform-theme--light|dark`,不能依赖外层平台壳来提供主题变量;`/puzzle` 直达页和平台内嵌页都必须渲染同一套主题语义类。
|
||||
- 拼图运行态进行中关卡的 `elapsedMs` 仍是结算字段,设置面板的“当前用时”必须按 `startedAtMs`、暂停累计和冻结累计实时派生;不要直接把进行中的 `currentLevel.elapsedMs` 当作展示值。
|
||||
- 推荐页里的拼图作品如果从运行态进入“改造”结果页,返回平台后要清掉推荐嵌入态的 `activeRecommendEntryKey` / `activeRecommendRuntimeKind` / `isStartingRecommendEntry`,再重新按推荐页自动启动逻辑进入作品,不能复用已经被清空的旧 `puzzleRun`。
|
||||
- 拼图运行态允许前端低延迟交互表现,但通关、排行榜、奖励和作品状态仍以后端确认为准。
|
||||
|
||||
## 抓大鹅 Match3D
|
||||
@@ -55,21 +60,22 @@
|
||||
当前素材生成流水线:
|
||||
|
||||
1. 点击生成前弹出泥点确认,草稿生成固定消耗 `10` 泥点。
|
||||
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成。
|
||||
2. 先写入可恢复草稿 profile,再执行文本计划、图片生成、切图、OSS 上传、背景和容器生成;草稿完成条件不包含 `backgroundMusic`。
|
||||
3. 物品素材不再调用 Hyper3D Rodin,不再生成 GLB。新草稿和批量新增固定生成 2D 五视角素材。
|
||||
4. 物品 sheet 走 VectorEngine Gemini `gemini-3-pro-image-preview` 原生 `generateContent`,单张 `1:1` 图固定 `5*5`,每张承载 `5` 个物品、每个物品 `5` 个视角。
|
||||
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;不要先裁剪单格再各自去绿。
|
||||
5. 切图前先在整张 sheet 上做绿幕 / 近白底透明化和边缘去污染,再按格子导出独立 PNG;每个视角图再以扩大的 PNG 边界带为种子,把连通的浅绿 / 近白抗锯齿边直接改为透明,并按剩余可见主体二次收紧;不要先裁剪单格再各自去绿。
|
||||
6. `generatedItemAssets[].imageViews[]` 是新素材主字段,`imageSrc/imageObjectKey` 只兼容首张视角。
|
||||
7. 文本生成物品名称时必须同时生成 `itemSize`,只允许 `大`、`中`、`小`。该字段随 `generatedItemAssets[].itemSize` 持久化并下发;历史缺失字段的素材按 `大` 兼容,模型缺失或非法值按物品名本地推断。
|
||||
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品;容器图走 `/v1/images/edits` 参考透明容器图。
|
||||
8. 局内 `9:16` 纯背景图和 `1:1` 中心容器 UI 图分开生成。纯背景不得包含锅、盘、托盘、HUD、按钮、文字或物品,且入库前必须合成为全画幅不透明图片,不允许出现透明区域;容器图走 `/v1/images/edits` 参考透明容器图。
|
||||
9. 当前抓大鹅音频生成关闭:入口无 `生成音效`,草稿不生成背景音乐或点击音效,结果页不展示背景音乐 Tab 或点击音效生成入口。历史 `backgroundMusic` / `clickSound` 字段继续兼容传递。
|
||||
10. UI 背景和容器资产的持久化真相仍在 `generatedItemAssets[].backgroundAsset`;Agent session、work summary/detail、结果页和运行态入口都必须把该字段提升为 `backgroundImageSrc/backgroundImageObjectKey/generatedBackgroundAsset` 读取,避免草稿重进、结果页预览或试玩退回默认素材。
|
||||
|
||||
结果页当前结构:
|
||||
|
||||
- `作品信息`:名称、描述、标签;封面编辑收口到发布面板。
|
||||
- `难度配置`:四档离散拖动条,显示需要消除、总物品数、物品种类、已生成物品种类。
|
||||
- `素材配置 > 物品`:两列素材卡,点击打开独立五视角预览面板;支持删除、批量新增和批量重新生成。替换模式必须保留原 `itemId` 和列表顺序。
|
||||
- `素材配置 > UI`:纯背景图与运行态 UI 预览,重生成消耗 `2` 泥点。
|
||||
- `素材配置 > UI`:纯背景图与运行态 UI 预览,重生成消耗 `2` 泥点;UI 预览必须复用运行态顶部 HUD、中央容器棋盘、容器图定位和底部槽位样式,不单独维护一套简化预览 UI。
|
||||
- `素材配置 > 容器形象`:单独预览和重生成中心容器,消耗 `2` 泥点。
|
||||
|
||||
运行态当前口径:
|
||||
@@ -80,9 +86,11 @@
|
||||
- 初始物品坐标围绕容器口中心生成,并保留内缩安全距离,避免贴边和局部角落聚集。
|
||||
- 本地试玩与 Rust `module-match3d` 后端领域生成使用同一套中心铺开口径;生成点覆盖四象限且均值接近中心。
|
||||
- 运行态优先消费 2D 生成图;默认积木 / 程序化 3D 表现只作为视觉分支和兜底,不改变规则真相。
|
||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。
|
||||
- 运行态启动前要预加载 `generatedItemAssets[].imageViews[]`、顶层 `generatedBackgroundAsset`、物品挂载 `backgroundAsset` 中的背景和容器图;卡片摘要缺 UI 背景或容器字段时,进入运行态前必须补读 work detail。补读后的 profile 也要再次提升 `generatedItemAssets[].backgroundAsset`,确保背景和容器字段传给 `Match3DRuntimeShell`。
|
||||
- 局内容器图在移动端宽度接近屏幕宽度并居中显示,保持原图比例不拉伸;生成容器图加载成功后棋盘外壳透明且 `overflow-visible`,只有生成图缺失或加载失败时才显示透明参考容器兜底。
|
||||
- generated 私有图换签未完成时,局内物品先隐藏等待,不得短暂显示默认积木;同一批资源在重启 run 时保留已解析签名 URL,只有资源源列表变化或换签失败后才允许进入兜底视觉。
|
||||
- `itemSize` 只缩放生成 2D 图片本体:`大` 使用当前默认显示尺寸,`中` 和 `小` 缩小显示;不改变后端下发的布局半径、点击半径或三消规则。
|
||||
- 抓大鹅运行态右上角常驻设置入口,不直接暴露重新开始按钮;重新开始收口到设置面板内,结算弹层仍保留结果态的再来一局动作。
|
||||
- 高 DPR 移动端 WebGL canvas 必须锁定 CSS 尺寸,避免右下溢出。
|
||||
|
||||
## 视觉小说
|
||||
|
||||
@@ -85,6 +85,7 @@ server-rs + Axum + SpacetimeDB
|
||||
7. 主站入口已锁定移动端页面级缩放;单个游戏页面不要再重复实现整页缩放锁定。
|
||||
8. 图像输入通用 UI 统一走 `src/components/common/CreativeImageInputPanel.tsx`。外层页面持有业务状态,组件只承担上传卡、预览、参考图缩略图、AI 重绘开关、错误展示和提交按钮。
|
||||
9. 发现页 `分类` 子频道的筛选必须打开独立 dialog / drawer / modal,至少支持玩法类型过滤与排序切换;筛选结果为空时显示空状态,不把筛选内容展开在当前列表下方。
|
||||
10. “我的”页泥点、游戏时长、玩过三张统计卡只展示各自标签和值,内容居中且不换行,不在统计区底部展示“更新于”时间。
|
||||
|
||||
## 文案与编码
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -427,26 +427,6 @@ pub(super) fn match3d_bad_gateway(message: impl Into<String>) -> AppError {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn match3d_background_music_missing_error(message: impl Into<String>) -> AppError {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": MATCH3D_AGENT_PROVIDER,
|
||||
"message": message.into(),
|
||||
"missingAssets": ["背景音乐"],
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn require_match3d_background_music_title(
|
||||
plan: &Match3DGeneratedBackgroundMusicPlan,
|
||||
) -> Result<String, AppError> {
|
||||
let title = normalize_match3d_audio_title(plan.title.as_str());
|
||||
if title.is_empty() {
|
||||
return Err(match3d_background_music_missing_error(
|
||||
"抓大鹅草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
));
|
||||
}
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
pub(super) fn map_match3d_work_profile_response(
|
||||
item: Match3DWorkProfileRecord,
|
||||
) -> Match3DWorkProfileResponse {
|
||||
|
||||
@@ -633,7 +633,7 @@ test('creation hub published work delete action is revealed without opening card
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
|
||||
screen.getByRole('button', { name: /查看详情《待删拼图》/u }).focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
@@ -684,7 +684,7 @@ test('creation hub opens persisted rpg drafts by card click', async () => {
|
||||
expect(openedItems).toEqual([persistedDraft]);
|
||||
});
|
||||
|
||||
test('creation hub published swipe share button copies share text without opening the card', async () => {
|
||||
test('creation hub published share icon copies share text without opening the card', async () => {
|
||||
const user = userEvent.setup();
|
||||
const writeText = vi.fn(async () => undefined);
|
||||
const onOpenPuzzleDetail = vi.fn();
|
||||
@@ -727,9 +727,11 @@ test('creation hub published swipe share button copies share text without openin
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByRole('button', { name: /查看详情《沉钟拼图》/u }).focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
await user.click(screen.getByRole('button', { name: '分享' }));
|
||||
const shareButton = screen.getByRole('button', { name: '分享' });
|
||||
expect(shareButton).toBeTruthy();
|
||||
expect(screen.queryByText('删除')).toBeNull();
|
||||
|
||||
await user.click(shareButton);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('邀请你来玩《沉钟拼图》'),
|
||||
@@ -746,6 +748,45 @@ test('creation hub published swipe share button copies share text without openin
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('creation hub published share icon is shown directly on the card header', () => {
|
||||
render(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-share-icon',
|
||||
profileId: 'puzzle-profile-share-icon',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '沉钟拼图',
|
||||
summary: '分享入口应直接露出在卡片右上角。',
|
||||
themeTags: ['潮雾'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
remixCount: 2,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creation hub left swipe draft reveals delete without opening card', () => {
|
||||
const onDeletePublished = vi.fn();
|
||||
const onOpenDraft = vi.fn();
|
||||
|
||||
@@ -248,7 +248,7 @@ export function CustomWorldWorkCard({
|
||||
const isPublished = item.status === 'published';
|
||||
const canUseShareAction =
|
||||
isPublished && item.canShare && Boolean(item.sharePath);
|
||||
const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0);
|
||||
const swipeActionCount = onDelete ? 1 : 0;
|
||||
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
|
||||
const canClaimPointIncentive =
|
||||
Boolean(onClaimPointIncentive) &&
|
||||
@@ -584,43 +584,6 @@ export function CustomWorldWorkCard({
|
||||
className="creation-work-card__swipe-underlay"
|
||||
>
|
||||
<div className="creation-work-card__swipe-actions">
|
||||
{canUseShareAction ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={isSwipeActionRevealed ? 0 : -1}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
suppressOpenRef.current = false;
|
||||
copyShareText();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
title={
|
||||
shareState === 'copied'
|
||||
? '已复制'
|
||||
: shareState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享作品'
|
||||
}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
className="creation-work-card__swipe-button creation-work-card__swipe-button--share"
|
||||
>
|
||||
{shareState === 'idle' ? (
|
||||
<Share2 aria-hidden="true" className="h-4 w-4" />
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{shareState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -710,6 +673,43 @@ export function CustomWorldWorkCard({
|
||||
{displayTitle}
|
||||
</span>
|
||||
</div>
|
||||
{canUseShareAction ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
suppressOpenRef.current = false;
|
||||
closeSwipeActions();
|
||||
copyShareText();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onTouchStart={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
title={
|
||||
shareState === 'copied'
|
||||
? '已复制'
|
||||
: shareState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享作品'
|
||||
}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
className="creation-work-card__share-button"
|
||||
>
|
||||
<Share2 aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="creation-work-card__meta platform-category-game-item__meta">
|
||||
|
||||
@@ -1349,12 +1349,121 @@ describe('Match3DResultView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
|
||||
expect(screen.getByRole('dialog', { name: 'UI预览' })).toBeTruthy();
|
||||
expect(screen.getByText('第 1 关')).toBeTruthy();
|
||||
expect(screen.getByText('抓大鹅')).toBeTruthy();
|
||||
expect(screen.getByText('1:30')).toBeTruthy();
|
||||
const previewBoard = screen.getByTestId('match3d-ui-preview-board');
|
||||
expect(previewBoard.className).toContain('bg-transparent');
|
||||
expect(previewBoard.className).not.toContain('rounded-full');
|
||||
const containerImage = document.querySelector(
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
);
|
||||
expect(containerImage).toBeTruthy();
|
||||
expect(containerImage?.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage?.className).toContain('-translate-x-1/2');
|
||||
expect(
|
||||
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
|
||||
).toBeNull();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'svg[class*="lucide-settings"], [data-lucide="settings"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 从物品挂载资产展示生成背景和容器', async () => {
|
||||
const onStartTestRun = vi.fn();
|
||||
const profile = createProfile({
|
||||
backgroundPrompt: null,
|
||||
backgroundImageSrc: null,
|
||||
backgroundImageObjectKey: null,
|
||||
generatedBackgroundAsset: null,
|
||||
generatedItemAssets: [
|
||||
{
|
||||
...createReadyGeneratedItemAsset(1),
|
||||
itemName: '草莓',
|
||||
backgroundAsset: {
|
||||
prompt: '果园背景',
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
imageObjectKey:
|
||||
'generated-match3d-assets/session/profile/background/background.png',
|
||||
containerPrompt: '果园容器',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
containerImageObjectKey:
|
||||
'generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
status: 'image_ready',
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(match3dWorksService.updateMatch3DWork).mockResolvedValue({
|
||||
item: createProfile({ generatedItemAssets: [] }),
|
||||
});
|
||||
vi.mocked(
|
||||
match3dWorksService.updateMatch3DGeneratedItemAssets,
|
||||
).mockResolvedValue({
|
||||
item: profile,
|
||||
});
|
||||
|
||||
render(
|
||||
<Match3DResultView
|
||||
profile={profile}
|
||||
onBack={() => {}}
|
||||
onStartTestRun={onStartTestRun}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'UI' }));
|
||||
|
||||
expect(screen.getByAltText('游戏背景图').getAttribute('src')).toBe(
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
);
|
||||
expect(screen.getByLabelText('UI背景图画面描述提示词')).toHaveProperty(
|
||||
'value',
|
||||
'果园背景',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '预览UI页面' }));
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/generated-match3d-assets/session/profile/background/background.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/generated-match3d-assets/session/profile/ui-container/container.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
document.querySelector(
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
),
|
||||
).toBeTruthy();
|
||||
).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '试玩' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStartTestRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
backgroundPrompt: '果园背景',
|
||||
backgroundImageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
generatedBackgroundAsset: expect.objectContaining({
|
||||
imageSrc:
|
||||
'/generated-match3d-assets/session/profile/background/background.png',
|
||||
containerImageSrc:
|
||||
'/generated-match3d-assets/session/profile/ui-container/container.png',
|
||||
}),
|
||||
}),
|
||||
{
|
||||
itemTypeCountOverride: 1,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('素材配置 UI 子 Tab 修改提示词后调用背景图生成接口并刷新素材', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Play,
|
||||
Plus,
|
||||
Send,
|
||||
Settings,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
@@ -24,6 +25,7 @@ import { createPortal } from 'react-dom';
|
||||
import type { CreationAudioAsset } from '../../../packages/shared/src/contracts/creationAudio';
|
||||
import type { Match3DResultDraft } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
PutMatch3DWorkRequest,
|
||||
@@ -49,11 +51,19 @@ import {
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
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_SPINNER_CLASS,
|
||||
MATCH3D_RUNTIME_GLASS_TIMER_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,
|
||||
} from '../match3d-runtime/match3dRuntimeUiStyles';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
@@ -299,6 +309,44 @@ function resolveMatch3DBackgroundPreviewSource(
|
||||
);
|
||||
}
|
||||
|
||||
function findMatch3DGeneratedBackgroundAsset(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] = [],
|
||||
): Match3DGeneratedBackgroundAsset | null {
|
||||
return (
|
||||
generatedItemAssets.find((asset) => asset.backgroundAsset)?.backgroundAsset ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function promoteMatch3DGeneratedBackgroundAsset(
|
||||
profile: Match3DWorkProfile,
|
||||
): Match3DWorkProfile {
|
||||
const fallbackBackground =
|
||||
profile.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets ?? []);
|
||||
if (!fallbackBackground) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
backgroundPrompt:
|
||||
profile.backgroundPrompt ?? fallbackBackground.prompt ?? null,
|
||||
backgroundImageSrc:
|
||||
profile.backgroundImageSrc ??
|
||||
fallbackBackground.imageSrc ??
|
||||
fallbackBackground.imageObjectKey ??
|
||||
null,
|
||||
backgroundImageObjectKey:
|
||||
profile.backgroundImageObjectKey ??
|
||||
fallbackBackground.imageObjectKey ??
|
||||
fallbackBackground.imageSrc ??
|
||||
null,
|
||||
generatedBackgroundAsset:
|
||||
profile.generatedBackgroundAsset ?? fallbackBackground,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveMatch3DBackgroundPrompt(
|
||||
profile: Match3DWorkProfile,
|
||||
draft: Match3DResultDraft | null,
|
||||
@@ -1144,11 +1192,14 @@ function buildPlayableProfile(
|
||||
) {
|
||||
const payload = buildSavePayload(editState);
|
||||
if (!payload) {
|
||||
return attachMatch3DGeneratedItemAssets(profile, generatedItemAssets);
|
||||
return promoteMatch3DGeneratedBackgroundAsset(
|
||||
attachMatch3DGeneratedItemAssets(profile, generatedItemAssets),
|
||||
);
|
||||
}
|
||||
|
||||
return attachMatch3DGeneratedItemAssets(
|
||||
{
|
||||
return promoteMatch3DGeneratedBackgroundAsset(
|
||||
attachMatch3DGeneratedItemAssets(
|
||||
{
|
||||
...profile,
|
||||
gameName: payload.gameName,
|
||||
themeText: payload.themeText ?? profile.themeText,
|
||||
@@ -1157,8 +1208,9 @@ function buildPlayableProfile(
|
||||
coverImageSrc: payload.coverImageSrc,
|
||||
clearCount: payload.clearCount,
|
||||
difficulty: payload.difficulty,
|
||||
},
|
||||
generatedItemAssets,
|
||||
},
|
||||
generatedItemAssets,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1219,15 +1271,15 @@ function attachMatch3DGeneratedItemAssets(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[],
|
||||
) {
|
||||
if (generatedItemAssets.length <= 0) {
|
||||
return profile;
|
||||
return promoteMatch3DGeneratedBackgroundAsset(profile);
|
||||
}
|
||||
|
||||
// 中文注释:试玩入口依赖当前页面可见的生成素材;保存接口若返回旧快照,不能把素材从运行态入参里丢掉。
|
||||
return {
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
...profile,
|
||||
generatedItemAssets:
|
||||
normalizeMatch3DGeneratedItemAssetsForRuntime(generatedItemAssets),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function attachMatch3DGeneratedBackgroundAsset(
|
||||
@@ -2996,35 +3048,46 @@ function Match3DUIRuntimePreviewPanel({
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full object-cover"
|
||||
/>
|
||||
<header className="relative z-10 flex items-center justify-between gap-2">
|
||||
<header className="relative z-10 grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem]">
|
||||
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
|
||||
<ArrowLeft size={20} />
|
||||
</span>
|
||||
<span className={MATCH3D_RUNTIME_GLASS_TIMER_CLASS}>1:30</span>
|
||||
<span className={`${MATCH3D_RUNTIME_HEADER_CARD_CLASS} mx-auto`}>
|
||||
<span className="flex max-w-full items-center justify-center gap-1.5">
|
||||
<span className={MATCH3D_RUNTIME_LEVEL_BADGE_CLASS}>
|
||||
第 1 关
|
||||
</span>
|
||||
<span className="min-w-0 truncate text-sm font-black sm:text-base">
|
||||
抓大鹅
|
||||
</span>
|
||||
</span>
|
||||
<span className={MATCH3D_RUNTIME_TIMER_CLASS}>1:30</span>
|
||||
</span>
|
||||
<span className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}>
|
||||
<span className={MATCH3D_RUNTIME_GLASS_SPINNER_CLASS} />
|
||||
<Settings size={18} />
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="relative z-10 mt-3 flex min-h-0 flex-1 items-center justify-center">
|
||||
<section className={`z-10 ${MATCH3D_RUNTIME_STAGE_CLASS}`}>
|
||||
<div
|
||||
className={`relative aspect-square max-w-full ${
|
||||
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
|
||||
containerPreviewSrc
|
||||
? 'overflow-visible bg-transparent'
|
||||
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
|
||||
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
|
||||
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
|
||||
}`}
|
||||
style={{ width: 'min(96%, 60dvh, 100%)' }}
|
||||
style={{ width: MATCH3D_RUNTIME_BOARD_WIDTH }}
|
||||
aria-hidden="true"
|
||||
data-testid="match3d-ui-preview-board"
|
||||
>
|
||||
{containerPreviewSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={containerPreviewSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-[-10%] h-[120%] w-[120%] object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]"
|
||||
className={MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-[7%] rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
<div className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -3152,6 +3215,10 @@ export function Match3DResultView({
|
||||
onPublished,
|
||||
onStartTestRun,
|
||||
}: Match3DResultViewProps) {
|
||||
const promotedProfile = useMemo(
|
||||
() => promoteMatch3DGeneratedBackgroundAsset(profile),
|
||||
[profile],
|
||||
);
|
||||
const [editState, setEditState] = useState(() => createEditState(profile));
|
||||
const [activeTab, setActiveTab] = useState<Match3DResultTab>('work');
|
||||
const [activeAssetConfigTab, setActiveAssetConfigTab] =
|
||||
@@ -3207,8 +3274,8 @@ export function Match3DResultView({
|
||||
const [isStartingTestRun, setIsStartingTestRun] = useState(false);
|
||||
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
|
||||
const generatedItemAssets = useMemo(
|
||||
() => resolveMatch3DResultGeneratedItemAssets(profile, draft),
|
||||
[draft, profile],
|
||||
() => resolveMatch3DResultGeneratedItemAssets(promotedProfile, draft),
|
||||
[draft, promotedProfile],
|
||||
);
|
||||
const blockers = useMemo(
|
||||
() => buildPublishBlockers(editState, generatedItemAssets),
|
||||
@@ -3221,34 +3288,44 @@ export function Match3DResultView({
|
||||
const canStartTestRun = testRunBlockers.length === 0;
|
||||
const canSubmit = blockers.length === 0;
|
||||
const totalItemCount =
|
||||
(normalizePositiveInteger(editState.clearCountText) ?? profile.clearCount) *
|
||||
3;
|
||||
(normalizePositiveInteger(editState.clearCountText) ??
|
||||
promotedProfile.clearCount) * 3;
|
||||
const backgroundPreviewSrc = useMemo(
|
||||
() =>
|
||||
resolveMatch3DBackgroundPreviewSource(
|
||||
profile,
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, profile],
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const backgroundPrompt = useMemo(
|
||||
() => resolveMatch3DBackgroundPrompt(profile, draft, generatedItemAssets),
|
||||
[draft, generatedItemAssets, profile],
|
||||
() =>
|
||||
resolveMatch3DBackgroundPrompt(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const containerPrompt = useMemo(
|
||||
() => resolveMatch3DContainerPrompt(profile, draft, generatedItemAssets),
|
||||
[draft, generatedItemAssets, profile],
|
||||
() =>
|
||||
resolveMatch3DContainerPrompt(
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
),
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const containerPreviewSrc = useMemo(
|
||||
() =>
|
||||
resolveMatch3DContainerPreviewSource(
|
||||
profile,
|
||||
promotedProfile,
|
||||
draft,
|
||||
generatedItemAssets,
|
||||
) ||
|
||||
MATCH3D_CONTAINER_REFERENCE_PREVIEW_SRC,
|
||||
[draft, generatedItemAssets, profile],
|
||||
[draft, generatedItemAssets, promotedProfile],
|
||||
);
|
||||
const coverSourceAssets = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
@@ -200,6 +207,33 @@ 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();
|
||||
});
|
||||
|
||||
test('抓大鹅右上角设置面板内置重新开始', () => {
|
||||
const run = startLocalMatch3DRun(4);
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
levelName="水果抓大鹅"
|
||||
onBack={vi.fn()}
|
||||
onRestart={onRestart}
|
||||
onOptimisticRunChange={vi.fn()}
|
||||
onClickItem={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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('dialog', { name: '抓大鹅设置' })).toBeNull();
|
||||
});
|
||||
|
||||
test('推荐页抓大鹅运行态隐藏返回按钮和结算返回入口', () => {
|
||||
@@ -991,7 +1025,7 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
|
||||
const containerImage = screen.getByTestId(
|
||||
'match3d-container-image',
|
||||
) as HTMLImageElement;
|
||||
expect(containerImage.className).toContain('w-[min(96vw,28rem)]');
|
||||
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage.className).toContain('h-auto');
|
||||
expect(containerImage.className).toContain('left-1/2');
|
||||
expect(containerImage.className).toContain('-translate-x-1/2');
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -49,11 +50,18 @@ import {
|
||||
resolveRenderableItemFrame,
|
||||
} 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';
|
||||
@@ -697,6 +705,7 @@ 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(
|
||||
@@ -1251,6 +1260,7 @@ export function Match3DRuntimeShell({
|
||||
isRunState(run.status, 'running')
|
||||
? MATCH3D_RUNTIME_TIMER_URGENT_CLASS
|
||||
: MATCH3D_RUNTIME_TIMER_CLASS;
|
||||
const canRestartRun = Boolean(run?.runId) && !isBusy;
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -1311,23 +1321,23 @@ export function Match3DRuntimeShell({
|
||||
<button
|
||||
type="button"
|
||||
className={MATCH3D_RUNTIME_GLASS_ICON_BUTTON_CLASS}
|
||||
onClick={onRestart}
|
||||
aria-label="重新开始"
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开抓大鹅设置"
|
||||
>
|
||||
<RotateCcw size={18} />
|
||||
<Settings size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
|
||||
<section className={MATCH3D_RUNTIME_STAGE_CLASS}>
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`relative aspect-square max-w-full ${
|
||||
className={`${MATCH3D_RUNTIME_BOARD_BASE_CLASS} ${
|
||||
hasRenderedContainerAsset
|
||||
? 'overflow-visible bg-transparent'
|
||||
: 'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]'
|
||||
? MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS
|
||||
: MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS
|
||||
}`}
|
||||
style={{
|
||||
width: 'min(96vw, 60dvh, 100%)',
|
||||
width: MATCH3D_RUNTIME_BOARD_WIDTH,
|
||||
}}
|
||||
onPointerDown={handleBoardPointerDown}
|
||||
onPointerMove={handleBoardPointerMove}
|
||||
@@ -1340,7 +1350,7 @@ export function Match3DRuntimeShell({
|
||||
src={resolvedContainerImageSrc}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(96vw,28rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)] ${
|
||||
className={`${MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS} ${
|
||||
isContainerImageLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
data-testid="match3d-container-image"
|
||||
@@ -1355,7 +1365,7 @@ export function Match3DRuntimeShell({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
||||
<div className={MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS} />
|
||||
)}
|
||||
{run.items.map((item) =>
|
||||
hasPendingMatch3DGeneratedImageForItem(
|
||||
@@ -1462,6 +1472,84 @@ export function Match3DRuntimeShell({
|
||||
onBack={onBack}
|
||||
onRestart={onRestart}
|
||||
/>
|
||||
|
||||
{isSettingsPanelOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-[85] flex items-center justify-center bg-slate-950/42 px-4 py-6 backdrop-blur-sm"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="match3d-settings-title"
|
||||
className="w-full max-w-[20.5rem] overflow-hidden rounded-[1.35rem] border border-white/18 bg-white/95 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="border-b border-slate-200 px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2
|
||||
id="match3d-settings-title"
|
||||
className="text-base font-black"
|
||||
>
|
||||
抓大鹅设置
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭设置"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-600 transition hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
<XCircle size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="space-y-3 px-5 py-4">
|
||||
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
|
||||
当前进度
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-black text-slate-900">
|
||||
{displayLevelName}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-slate-500">
|
||||
已清除 {run.clearedItemCount}/{run.totalItemCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[1rem] border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<div className="text-[10px] font-bold uppercase tracking-[0.16em] text-slate-500">
|
||||
本局时间
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-xl font-black text-slate-900">
|
||||
{formatTimer(timeLeftMs)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="grid gap-3 border-t border-slate-200 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-slate-950 px-4 py-3 text-sm font-black text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
disabled={!canRestartRun}
|
||||
onClick={() => {
|
||||
setIsSettingsPanelOpen(false);
|
||||
onRestart();
|
||||
}}
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
重新开始
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex min-h-12 items-center justify-center rounded-[1rem] border border-slate-200 bg-white px-4 py-3 text-sm font-bold text-slate-700 transition hover:bg-slate-50"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
继续抓大鹅
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,3 +25,23 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_CLASS =
|
||||
|
||||
export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
|
||||
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';
|
||||
|
||||
export const MATCH3D_RUNTIME_STAGE_CLASS =
|
||||
'relative mt-3 flex min-h-0 flex-1 items-center justify-center';
|
||||
|
||||
export const MATCH3D_RUNTIME_BOARD_BASE_CLASS =
|
||||
'relative aspect-square max-w-full';
|
||||
|
||||
export const MATCH3D_RUNTIME_BOARD_WIDTH = 'min(96vw, 60dvh, 100%)';
|
||||
|
||||
export const MATCH3D_RUNTIME_BOARD_WITH_CONTAINER_CLASS =
|
||||
'overflow-visible bg-transparent';
|
||||
|
||||
export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS =
|
||||
'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
|
||||
export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS =
|
||||
'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
|
||||
export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS =
|
||||
'pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]';
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type {
|
||||
Match3DGeneratedBackgroundAsset,
|
||||
Match3DGeneratedItemAsset,
|
||||
Match3DWorkProfile,
|
||||
Match3DWorkSummary,
|
||||
@@ -433,6 +434,16 @@ type BabyObjectMatchRuntimeReturnStage =
|
||||
type VisualNovelEntryGenerationPhase = 'generating' | 'ready' | 'failed';
|
||||
type BabyObjectMatchGenerationPhase = 'generating' | 'ready' | 'failed';
|
||||
|
||||
type RecommendRuntimeState = {
|
||||
activeKind: RecommendRuntimeKind | null;
|
||||
babyObjectMatchDraft: BabyObjectMatchDraft | null;
|
||||
bigFishRun: BigFishRuntimeSnapshotResponse | null;
|
||||
match3dRun: Match3DRunSnapshot | null;
|
||||
puzzleRun: PuzzleRunSnapshot | null;
|
||||
squareHoleRun: SquareHoleRunSnapshot | null;
|
||||
visualNovelRun: VisualNovelRunSnapshot | null;
|
||||
};
|
||||
|
||||
type PuzzleSaveArchiveState = {
|
||||
runtimeKind?: unknown;
|
||||
entryProfileId?: unknown;
|
||||
@@ -529,6 +540,37 @@ function getPlatformRecommendRuntimeKind(
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
function isRecommendRuntimeReadyForEntry(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
state: RecommendRuntimeState,
|
||||
) {
|
||||
const expectedKind = getPlatformRecommendRuntimeKind(entry);
|
||||
if (state.activeKind !== expectedKind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expectedKind === 'big-fish') {
|
||||
return Boolean(state.bigFishRun);
|
||||
}
|
||||
if (expectedKind === 'match3d') {
|
||||
return Boolean(state.match3dRun);
|
||||
}
|
||||
if (expectedKind === 'puzzle') {
|
||||
return Boolean(state.puzzleRun);
|
||||
}
|
||||
if (expectedKind === 'square-hole') {
|
||||
return Boolean(state.squareHoleRun);
|
||||
}
|
||||
if (expectedKind === 'visual-novel') {
|
||||
return Boolean(state.visualNovelRun);
|
||||
}
|
||||
if (expectedKind === 'edutainment') {
|
||||
return Boolean(state.babyObjectMatchDraft);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
@@ -631,7 +673,7 @@ function mapPublicWorkDetailToMatch3DWork(
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: entry.workId,
|
||||
profileId: entry.profileId,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
@@ -663,6 +705,51 @@ function mapPublicWorkDetailToMatch3DWork(
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
entry.generatedItemAssets ?? [],
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function findMatch3DGeneratedBackgroundAsset(
|
||||
generatedItemAssets: readonly Match3DGeneratedItemAsset[] | null | undefined,
|
||||
): Match3DGeneratedBackgroundAsset | null {
|
||||
return (
|
||||
generatedItemAssets
|
||||
?.map((asset) => asset.backgroundAsset ?? null)
|
||||
.find(Boolean) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function promoteMatch3DGeneratedBackgroundAsset<
|
||||
T extends Pick<
|
||||
Match3DWorkSummary,
|
||||
| 'backgroundPrompt'
|
||||
| 'backgroundImageSrc'
|
||||
| 'backgroundImageObjectKey'
|
||||
| 'generatedBackgroundAsset'
|
||||
| 'generatedItemAssets'
|
||||
>,
|
||||
>(profile: T): T {
|
||||
const backgroundAsset =
|
||||
profile.generatedBackgroundAsset ??
|
||||
findMatch3DGeneratedBackgroundAsset(profile.generatedItemAssets);
|
||||
if (!backgroundAsset) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
backgroundPrompt: profile.backgroundPrompt ?? backgroundAsset.prompt ?? null,
|
||||
backgroundImageSrc:
|
||||
profile.backgroundImageSrc ??
|
||||
backgroundAsset.imageSrc ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
null,
|
||||
backgroundImageObjectKey:
|
||||
profile.backgroundImageObjectKey ??
|
||||
backgroundAsset.imageObjectKey ??
|
||||
backgroundAsset.imageSrc ??
|
||||
null,
|
||||
generatedBackgroundAsset:
|
||||
profile.generatedBackgroundAsset ?? backgroundAsset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -675,7 +762,10 @@ function buildMatch3DProfileFromSession(
|
||||
}
|
||||
|
||||
const now = session.updatedAt || new Date().toISOString();
|
||||
return {
|
||||
const generatedItemAssets = normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
draft.generatedItemAssets,
|
||||
);
|
||||
return promoteMatch3DGeneratedBackgroundAsset({
|
||||
workId: draft.profileId,
|
||||
profileId: draft.profileId,
|
||||
ownerUserId: 'current-user',
|
||||
@@ -697,10 +787,8 @@ function buildMatch3DProfileFromSession(
|
||||
backgroundImageSrc: draft.backgroundImageSrc ?? null,
|
||||
backgroundImageObjectKey: draft.backgroundImageObjectKey ?? null,
|
||||
generatedBackgroundAsset: draft.generatedBackgroundAsset ?? null,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
draft.generatedItemAssets,
|
||||
),
|
||||
};
|
||||
generatedItemAssets,
|
||||
});
|
||||
}
|
||||
|
||||
function hasMatch3DRuntimeAsset(
|
||||
@@ -791,10 +879,14 @@ function resolveMatch3DRuntimeGeneratedBackgroundAsset(
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const profileBackground = profile?.generatedBackgroundAsset ?? null;
|
||||
const profileBackground = profile
|
||||
? promoteMatch3DGeneratedBackgroundAsset(profile).generatedBackgroundAsset ??
|
||||
null
|
||||
: null;
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? (publicWorkDetail.generatedBackgroundAsset ?? null)
|
||||
? (promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
.generatedBackgroundAsset ?? null)
|
||||
: null;
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
@@ -832,18 +924,25 @@ function resolveMatch3DRuntimeBackgroundImageSrc(
|
||||
publicWorkDetail: PlatformPublicGalleryCard | null,
|
||||
) {
|
||||
const runProfileId = run?.profileId?.trim() ?? '';
|
||||
const resolvedProfile = profile
|
||||
? promoteMatch3DGeneratedBackgroundAsset(profile)
|
||||
: null;
|
||||
const resolvedPublicWork =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? promoteMatch3DGeneratedBackgroundAsset(publicWorkDetail)
|
||||
: null;
|
||||
const profileBackground =
|
||||
profile?.backgroundImageSrc?.trim() ||
|
||||
profile?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
profile?.backgroundImageObjectKey?.trim() ||
|
||||
profile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
resolvedProfile?.backgroundImageSrc?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedProfile?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedProfile?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
const publicBackground =
|
||||
publicWorkDetail && isMatch3DGalleryEntry(publicWorkDetail)
|
||||
? publicWorkDetail.backgroundImageSrc?.trim() ||
|
||||
publicWorkDetail.backgroundImageObjectKey?.trim() ||
|
||||
''
|
||||
: '';
|
||||
resolvedPublicWork?.backgroundImageSrc?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageSrc?.trim() ||
|
||||
resolvedPublicWork?.backgroundImageObjectKey?.trim() ||
|
||||
resolvedPublicWork?.generatedBackgroundAsset?.imageObjectKey?.trim() ||
|
||||
'';
|
||||
|
||||
if (runProfileId && profile?.profileId === runProfileId) {
|
||||
return profileBackground || publicBackground || null;
|
||||
@@ -2438,6 +2537,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
selectionStageRef.current = selectionStage;
|
||||
}, [selectionStage]);
|
||||
|
||||
const resetRecommendRuntimeSelection = useCallback(() => {
|
||||
// 中文注释:推荐页嵌入运行态进入改造/创作后会清掉玩法 run;
|
||||
// 同步清空推荐选择,避免返回推荐页时复用已失效的运行态占位。
|
||||
recommendRuntimeStartRequestRef.current += 1;
|
||||
setActiveRecommendEntryKey(null);
|
||||
setActiveRecommendRuntimeKind(null);
|
||||
setActiveRecommendRuntimeError(null);
|
||||
setIsStartingRecommendEntry(false);
|
||||
}, []);
|
||||
|
||||
const updatePendingDraftShelfItem = useCallback(
|
||||
(
|
||||
kind: Exclude<CreationWorkShelfKind, 'rpg'>,
|
||||
@@ -3776,13 +3885,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
let runtimeProfile: Match3DWorkProfile | null = null;
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
runtimeProfile = {
|
||||
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
|
||||
...item,
|
||||
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
response.session.draft?.generatedItemAssets,
|
||||
item.generatedItemAssets,
|
||||
),
|
||||
};
|
||||
});
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
await refreshMatch3DShelf().catch(() => undefined);
|
||||
} catch {
|
||||
@@ -4875,13 +4984,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
let runtimeProfile: Match3DWorkProfile | null = null;
|
||||
try {
|
||||
const { item } = await getMatch3DWorkDetail(profileId);
|
||||
runtimeProfile = {
|
||||
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
|
||||
...item,
|
||||
generatedItemAssets: mergeMatch3DGeneratedItemAssetsForRuntime(
|
||||
response.session.draft?.generatedItemAssets,
|
||||
item.generatedItemAssets,
|
||||
),
|
||||
};
|
||||
});
|
||||
if (isViewingMatch3DGeneration(nextSession.sessionId)) {
|
||||
setMatch3DProfile(runtimeProfile);
|
||||
}
|
||||
@@ -5464,14 +5573,16 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const leavePuzzleFlow = useCallback(() => {
|
||||
setPuzzleOperation(null);
|
||||
puzzleRunRef.current = null;
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleGenerationState(null);
|
||||
setIsPuzzleNextLevelGenerating(false);
|
||||
setActiveCreativeAgentSessionId(null);
|
||||
setCreativeDraftEditError(null);
|
||||
resetRecommendRuntimeSelection();
|
||||
puzzleFlow.leaveFlow();
|
||||
}, [puzzleFlow]);
|
||||
}, [puzzleFlow, resetRecommendRuntimeSelection]);
|
||||
|
||||
const leaveVisualNovelFlow = useCallback(() => {
|
||||
setVisualNovelWork(null);
|
||||
@@ -6602,12 +6713,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
// 中文注释:详情补读只为拿完整生成素材;失败时继续按摘要开局,避免推荐流卡死。
|
||||
}
|
||||
}
|
||||
runtimeProfile = {
|
||||
runtimeProfile = promoteMatch3DGeneratedBackgroundAsset({
|
||||
...runtimeProfile,
|
||||
generatedItemAssets: normalizeMatch3DGeneratedItemAssetsForRuntime(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
),
|
||||
};
|
||||
});
|
||||
await preloadMatch3DGeneratedRuntimeAssets(
|
||||
runtimeProfile.generatedItemAssets,
|
||||
runtimeProfile.generatedBackgroundAsset,
|
||||
@@ -7314,6 +7425,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
void remixPuzzleGalleryWork(targetProfileId)
|
||||
.then((response) => {
|
||||
resetRecommendRuntimeSelection();
|
||||
puzzleFlow.setSession(response.session);
|
||||
setPuzzleOperation(null);
|
||||
setPuzzleRun(null);
|
||||
@@ -7338,6 +7450,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPuzzleBusy,
|
||||
puzzleFlow,
|
||||
resolvePuzzleErrorMessage,
|
||||
resetRecommendRuntimeSelection,
|
||||
runProtectedAction,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
@@ -9639,30 +9752,51 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
|
||||
const hasActiveEntry =
|
||||
activeRecommendEntryKey &&
|
||||
recommendRuntimeEntries.some(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
);
|
||||
if (hasActiveEntry || isStartingRecommendEntry) {
|
||||
const activeRecommendEntry = activeRecommendEntryKey
|
||||
? recommendRuntimeEntries.find(
|
||||
(entry) =>
|
||||
getPlatformPublicGalleryEntryKey(entry) === activeRecommendEntryKey,
|
||||
) ?? null
|
||||
: null;
|
||||
const isActiveRecommendRuntimeReady =
|
||||
activeRecommendEntry !== null &&
|
||||
isRecommendRuntimeReadyForEntry(activeRecommendEntry, {
|
||||
activeKind: activeRecommendRuntimeKind,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
match3dRun,
|
||||
puzzleRun,
|
||||
squareHoleRun,
|
||||
visualNovelRun,
|
||||
});
|
||||
if (
|
||||
(activeRecommendEntry !== null && isActiveRecommendRuntimeReady) ||
|
||||
isStartingRecommendEntry
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstRecommendEntry = recommendRuntimeEntries[0];
|
||||
if (firstRecommendEntry) {
|
||||
void selectRecommendRuntimeEntry(firstRecommendEntry);
|
||||
const nextRecommendEntry = activeRecommendEntry ?? recommendRuntimeEntries[0];
|
||||
if (nextRecommendEntry) {
|
||||
void selectRecommendRuntimeEntry(nextRecommendEntry);
|
||||
}
|
||||
}, [
|
||||
activeRecommendEntryKey,
|
||||
activeRecommendRuntimeKind,
|
||||
babyObjectMatchDraft,
|
||||
bigFishRun,
|
||||
isStartingRecommendEntry,
|
||||
match3dRun,
|
||||
platformBootstrap.canReadProtectedData,
|
||||
platformBootstrap.isLoadingPlatform,
|
||||
platformBootstrap.isAuthenticated,
|
||||
platformBootstrap.platformTab,
|
||||
puzzleRun,
|
||||
recommendRuntimeEntries,
|
||||
selectRecommendRuntimeEntry,
|
||||
selectionStage,
|
||||
squareHoleRun,
|
||||
visualNovelRun,
|
||||
]);
|
||||
|
||||
const remixPublicWork = useCallback(
|
||||
@@ -9696,6 +9830,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
void remixPuzzleGalleryWork(entry.profileId)
|
||||
.then((response) => {
|
||||
resetRecommendRuntimeSelection();
|
||||
puzzleFlow.setSession(response.session);
|
||||
setPuzzleOperation(null);
|
||||
enterCreateTab();
|
||||
@@ -9765,6 +9900,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
isPublicWorkDetailBusy,
|
||||
platformBootstrap,
|
||||
puzzleFlow,
|
||||
resetRecommendRuntimeSelection,
|
||||
resolveBigFishErrorMessage,
|
||||
resolvePuzzleErrorMessage,
|
||||
runProtectedAction,
|
||||
|
||||
@@ -168,6 +168,7 @@ test('拼图界面不调用 mocap,也不渲染 mocap 光标或调试面板', (
|
||||
});
|
||||
|
||||
test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const onDragPiece = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -207,6 +208,11 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
const requestAnimationFrame = vi.fn(() => 1);
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: requestAnimationFrame,
|
||||
});
|
||||
board.getBoundingClientRect = () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
@@ -233,6 +239,9 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
expect(piece.style.transform).toBe('translate3d(30px, 30px, 0) scale(1.03)');
|
||||
expect(piece.style.transition).toBe('none');
|
||||
expect(requestAnimationFrame).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 11,
|
||||
@@ -252,11 +261,14 @@ test('指针拖拽时会触发拖拽提交并在松开时落子', () => {
|
||||
expect(onDragPiece).toHaveBeenCalledWith(
|
||||
expect.objectContaining({pieceId: 'piece-0'}),
|
||||
);
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const onDragPiece = vi.fn();
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
@@ -289,16 +301,7 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 1),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
const { container, unmount } = renderPuzzleRuntime(
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
@@ -346,6 +349,13 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
clientY: 210,
|
||||
});
|
||||
});
|
||||
const mergedGroup = container.querySelector(
|
||||
'[data-merged-group-id="group-large"]',
|
||||
) as HTMLElement | null;
|
||||
expect(mergedGroup?.style.transform).toBe(
|
||||
'translate3d(150px, 150px, 0) scale(1.02)',
|
||||
);
|
||||
expect(mergedGroup?.style.transition).toBe('none');
|
||||
act(() => {
|
||||
dispatchPointerEvent(mergedPiece, 'pointerup', {
|
||||
pointerId: 12,
|
||||
@@ -360,16 +370,6 @@ test('指针拖拽合并大块时按大块锚点提交拖拽', () => {
|
||||
targetRow: 2,
|
||||
targetCol: 2,
|
||||
});
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalCancelAnimationFrame,
|
||||
});
|
||||
});
|
||||
|
||||
test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
@@ -483,6 +483,78 @@ test('拖拽合并大块时底层单格不显示选中色块', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('拖动拼图片时不显示已选择状态', () => {
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const board = container.querySelector(
|
||||
'[data-testid="puzzle-board"]',
|
||||
) as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
board.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
const piece = container.querySelector(
|
||||
'[data-piece-id="piece-0"]',
|
||||
) as HTMLElement | null;
|
||||
if (!piece) {
|
||||
throw new Error('缺少测试拼图片');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointerdown', {
|
||||
pointerId: 14,
|
||||
clientX: 40,
|
||||
clientY: 40,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText('已选择')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(piece, 'pointermove', {
|
||||
pointerId: 14,
|
||||
clientX: 70,
|
||||
clientY: 70,
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.queryByText('已选择')).toBeNull();
|
||||
expect(piece.className).not.toContain('puzzle-runtime-piece--selected');
|
||||
});
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
@@ -842,7 +914,11 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
authValue,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
const settingsButton = screen.getByRole('button', { name: '打开拼图设置' });
|
||||
expect(settingsButton.querySelector('img')).toBeNull();
|
||||
expect(settingsButton.querySelector('svg')).toBeTruthy();
|
||||
|
||||
fireEvent.click(settingsButton);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
const slider = within(dialog).getByRole('slider', { name: '拼图音乐音量' });
|
||||
@@ -852,6 +928,44 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
|
||||
});
|
||||
|
||||
test('拼图设置面板展示进行中关卡的实时当前用时', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-15T08:00:10.000Z'));
|
||||
const startedAtMs = Date.now() - 8_500;
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
clearedLevelCount: 0,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
remainingMs: 291_500,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图设置' }));
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '拼图设置' });
|
||||
expect(within(dialog).getByText('0:08.50')).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('推荐页嵌入拼图时隐藏返回和设置里的退出入口', () => {
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
@@ -926,17 +1040,37 @@ test('拼图运行态主体使用主题语义类承接明暗主题', () => {
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'puzzle-runtime-shell',
|
||||
);
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'platform-theme--light',
|
||||
);
|
||||
expect(container.querySelector('.puzzle-runtime-pill')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('拼图直达页在没有外层主题壳时也会自行补齐平台主题类', () => {
|
||||
const { container } = render(
|
||||
<PuzzleRuntimeShell
|
||||
run={null}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.firstElementChild?.className).toContain(
|
||||
'platform-theme--light',
|
||||
);
|
||||
});
|
||||
|
||||
test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
@@ -974,26 +1108,33 @@ test('合并块不叠加可见轮廓和单块阴影', () => {
|
||||
|
||||
expect(outlinedPieces).toHaveLength(3);
|
||||
expect(container.querySelector('.ring-2.ring-emerald-100\\/58')).toBeNull();
|
||||
expect(
|
||||
container.querySelector('[data-merged-group-outline="true"]'),
|
||||
).toBeNull();
|
||||
const outlineStroke = container.querySelector(
|
||||
'[data-merged-group-outline-stroke="true"]',
|
||||
const mergedGroupClipLayer = container.querySelector(
|
||||
'[data-merged-group-id="group-l"] [data-merged-group-clip="true"]',
|
||||
) as SVGSVGElement | null;
|
||||
const mergedGroupClipPath = mergedGroupClipLayer?.querySelector('path');
|
||||
const mergedPieceVisuals = container.querySelectorAll(
|
||||
'[data-merged-piece-visual="true"]',
|
||||
);
|
||||
expect(outlineStroke).toBeNull();
|
||||
expect((outlinedPieces[0] as HTMLElement).style.clipPath).toBe('');
|
||||
expect(mergedGroupClipLayer?.tagName.toLowerCase()).toBe('svg');
|
||||
expect(mergedGroupClipLayer?.getAttribute('viewBox')).toBe('0 0 2 2');
|
||||
expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 2 1 1.84 1');
|
||||
expect(mergedGroupClipPath?.getAttribute('d')).toContain('Q 1 1 1 1.192');
|
||||
expect(mergedPieceVisuals).toHaveLength(3);
|
||||
const mergedImageSlices = mergedGroupClipLayer?.querySelectorAll('image');
|
||||
expect(mergedImageSlices).toHaveLength(3);
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('href')).toBe('/puzzle.png');
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('width')).toBe('3');
|
||||
expect(mergedImageSlices?.[0]?.getAttribute('height')).toBe('3');
|
||||
for (const outlinedPiece of outlinedPieces) {
|
||||
const outlinedPieceElement = outlinedPiece as HTMLElement;
|
||||
expect(outlinedPieceElement.style.clipPath).toBe('');
|
||||
expect(outlinedPieceElement.querySelector('.absolute.inset-0')).toBeNull();
|
||||
expect(outlinedPieceElement.className).not.toContain('bg-emerald-300/10');
|
||||
expect(outlinedPieceElement.className).not.toContain('shadow-[');
|
||||
expect(
|
||||
outlinedPieceElement.querySelector('.absolute.inset-0.bg-black\\/8'),
|
||||
).toBeNull();
|
||||
}
|
||||
const clippedLayer = container.querySelector(
|
||||
'[style*="clip-path"]',
|
||||
) as HTMLElement | null;
|
||||
expect(clippedLayer).toBeNull();
|
||||
});
|
||||
|
||||
test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
@@ -1017,10 +1158,10 @@ test('合并块轮廓路径为内凹角生成圆角曲线', () => {
|
||||
});
|
||||
|
||||
expect(outlinePath).toContain('Q 2 1 1.84 1');
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.16');
|
||||
expect(outlinePath).toContain('Q 1 1 1 1.192');
|
||||
});
|
||||
|
||||
test('基础单块不叠加边框圆角或图片蒙版', () => {
|
||||
test('基础单块使用圆角裁切且不叠加图片蒙版', () => {
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
@@ -1050,7 +1191,7 @@ test('基础单块不叠加边框圆角或图片蒙版', () => {
|
||||
) as HTMLElement | null;
|
||||
expect(basePiece?.className).toContain('overflow-hidden');
|
||||
expect(basePiece?.className).toContain('border-0');
|
||||
expect(basePiece?.className).not.toContain('rounded-[0.85rem]');
|
||||
expect(basePiece?.style.clipPath).toContain('url(#');
|
||||
expect(basePiece?.className).not.toContain('border-2');
|
||||
expect(basePiece?.querySelector('.puzzle-runtime-piece-overlay')).toBeNull();
|
||||
});
|
||||
@@ -1442,7 +1583,7 @@ test('失败续时扣费失败时保留确认弹窗', async () => {
|
||||
expect(screen.getByText('泥点余额不足')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
|
||||
test('查看原图显示独立原图层并在关闭后恢复计时', async () => {
|
||||
const onPauseChange = vi.fn();
|
||||
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
@@ -1478,12 +1619,21 @@ test('查看原图开关打开覆盖层并在关闭后恢复计时', async () =>
|
||||
});
|
||||
|
||||
expect(onUseProp).toHaveBeenCalledWith('reference');
|
||||
expect(screen.getByTestId('puzzle-original-overlay')).toBeTruthy();
|
||||
const originalViewer = screen.getByTestId('puzzle-original-viewer');
|
||||
const board = screen.getByTestId('puzzle-board');
|
||||
expect(originalViewer).toBeTruthy();
|
||||
expect(originalViewer.parentElement).not.toBe(board);
|
||||
expect(
|
||||
within(originalViewer)
|
||||
.getByRole('img', { name: '潮雾拼图 原图' })
|
||||
.getAttribute('src'),
|
||||
).toBe('/puzzle.png');
|
||||
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '原图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭原图' }));
|
||||
|
||||
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
|
||||
expect(screen.queryByTestId('puzzle-original-viewer')).toBeNull();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Settings,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
DragPuzzlePieceRequest,
|
||||
@@ -39,14 +41,15 @@ import {
|
||||
playRuntimeLevelClearSound,
|
||||
resolveRuntimeCountdownSecondBucket,
|
||||
} from '../../services/runtimeAudioFeedback';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildMergedGroupOutlinePath,
|
||||
buildRoundedGridCellClipPath,
|
||||
resolveDraggedMergedGroupLayer,
|
||||
resolveDraggedPieceCellLayer,
|
||||
resolveDraggedPieceLayer,
|
||||
sanitizeSvgId,
|
||||
} from './puzzleRuntimeShape';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
@@ -215,6 +218,25 @@ function resolveRuntimeRemainingMs(
|
||||
return Math.max(0, timeLimitMs - effectiveElapsedMs);
|
||||
}
|
||||
|
||||
function resolveRuntimeElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
uiPauseStartedAtMs: number | null,
|
||||
) {
|
||||
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
|
||||
if (level.status !== 'playing') {
|
||||
return level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs);
|
||||
}
|
||||
|
||||
const timeLimitMs = level.timeLimitMs || level.remainingMs;
|
||||
const remainingMs = resolveRuntimeRemainingMs(
|
||||
level,
|
||||
nowMs,
|
||||
uiPauseStartedAtMs,
|
||||
);
|
||||
return Math.max(0, timeLimitMs - remainingMs);
|
||||
}
|
||||
|
||||
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
|
||||
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
|
||||
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
|
||||
@@ -341,6 +363,7 @@ export function PuzzleRuntimeShell({
|
||||
onUseProp,
|
||||
onTimeExpired,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const runtimeSvgClipId = useId();
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const selectedPieceIdRef = useRef<string | null>(null);
|
||||
@@ -351,7 +374,7 @@ export function PuzzleRuntimeShell({
|
||||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||||
null,
|
||||
);
|
||||
const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] =
|
||||
const [isOriginalImageViewerVisible, setIsOriginalImageViewerVisible] =
|
||||
useState(false);
|
||||
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
|
||||
const [isPropConfirming, setIsPropConfirming] = useState(false);
|
||||
@@ -384,8 +407,6 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string;
|
||||
groupId: string | null;
|
||||
} | null>(null);
|
||||
const dragVisualFrameRef = useRef<number | null>(null);
|
||||
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pieceCellElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
|
||||
@@ -415,13 +436,23 @@ export function PuzzleRuntimeShell({
|
||||
const displayRemainingMs = currentLevel
|
||||
? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
|
||||
: 0;
|
||||
const displayElapsedMs = currentLevel
|
||||
? resolveRuntimeElapsedMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
|
||||
: 0;
|
||||
const runtimeStatus = currentLevel
|
||||
? currentLevel.status === 'playing' && displayRemainingMs <= 0
|
||||
? 'failed'
|
||||
: currentLevel.status
|
||||
: 'playing';
|
||||
const platformThemeClass =
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const isInteractionLocked =
|
||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||
isBusy ||
|
||||
runtimeStatus !== 'playing' ||
|
||||
Boolean(propDialog) ||
|
||||
isOriginalImageViewerVisible;
|
||||
const clearResultKey = currentLevel
|
||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||
: null;
|
||||
@@ -524,6 +555,10 @@ export function PuzzleRuntimeShell({
|
||||
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
|
||||
[pieces],
|
||||
);
|
||||
const singlePieceClipId = sanitizeSvgId(
|
||||
`puzzle-single-piece-${runtimeSvgClipId}`,
|
||||
);
|
||||
const singlePieceClipUrl = `url(#${singlePieceClipId})`;
|
||||
|
||||
useEffect(() => {
|
||||
const signature =
|
||||
@@ -601,6 +636,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
pieceElement.style.transition = '';
|
||||
}
|
||||
|
||||
if (dragVisualTarget.groupId) {
|
||||
@@ -612,23 +648,14 @@ export function PuzzleRuntimeShell({
|
||||
groupElement.style.willChange = '';
|
||||
groupElement.style.zIndex = '';
|
||||
groupElement.style.opacity = '';
|
||||
groupElement.style.transition = '';
|
||||
}
|
||||
}
|
||||
|
||||
dragVisualTargetRef.current = null;
|
||||
};
|
||||
|
||||
const cancelDragVisualFrame = () => {
|
||||
if (dragVisualFrameRef.current === null) {
|
||||
return;
|
||||
}
|
||||
window.cancelAnimationFrame(dragVisualFrameRef.current);
|
||||
dragVisualFrameRef.current = null;
|
||||
};
|
||||
|
||||
const resetDragInteractionState = () => {
|
||||
cancelDragVisualFrame();
|
||||
dragOffsetRef.current = null;
|
||||
dragSessionRef.current = null;
|
||||
draggingTargetRef.current = null;
|
||||
resetDragVisualTarget();
|
||||
@@ -639,7 +666,6 @@ export function PuzzleRuntimeShell({
|
||||
};
|
||||
|
||||
const flushDragVisual = () => {
|
||||
dragVisualFrameRef.current = null;
|
||||
const dragSession = dragSessionRef.current;
|
||||
if (!dragSession || !dragSession.dragging) {
|
||||
resetDragVisualTarget();
|
||||
@@ -653,28 +679,10 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: dragSession.pieceId,
|
||||
groupId,
|
||||
};
|
||||
const previousTarget = dragVisualTargetRef.current;
|
||||
if (
|
||||
previousTarget &&
|
||||
(previousTarget.pieceId !== nextTarget.pieceId ||
|
||||
previousTarget.groupId !== nextTarget.groupId)
|
||||
) {
|
||||
resetDragVisualTarget();
|
||||
}
|
||||
dragVisualTargetRef.current = nextTarget;
|
||||
setDragRenderTarget((currentTarget) => {
|
||||
if (
|
||||
currentTarget?.pieceId === nextTarget.pieceId &&
|
||||
currentTarget.groupId === nextTarget.groupId
|
||||
) {
|
||||
return currentTarget;
|
||||
}
|
||||
return nextTarget;
|
||||
});
|
||||
|
||||
const offsetX = dragSession.currentX - dragSession.startX;
|
||||
const offsetY = dragSession.currentY - dragSession.startY;
|
||||
dragOffsetRef.current = { x: offsetX, y: offsetY };
|
||||
|
||||
if (groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(groupId);
|
||||
@@ -684,6 +692,7 @@ export function PuzzleRuntimeShell({
|
||||
groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`;
|
||||
groupElement.style.zIndex = '90';
|
||||
groupElement.style.opacity = '0.95';
|
||||
groupElement.style.transition = 'none';
|
||||
}
|
||||
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
|
||||
if (pieceCellElement) {
|
||||
@@ -695,6 +704,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.willChange = '';
|
||||
pieceElement.style.zIndex = '';
|
||||
pieceElement.style.opacity = '';
|
||||
pieceElement.style.transition = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -710,19 +720,12 @@ export function PuzzleRuntimeShell({
|
||||
pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`;
|
||||
pieceElement.style.zIndex = '81';
|
||||
pieceElement.style.opacity = '0.95';
|
||||
pieceElement.style.transition = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleDragVisual = () => {
|
||||
if (dragVisualFrameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
},
|
||||
[],
|
||||
@@ -754,7 +757,7 @@ export function PuzzleRuntimeShell({
|
||||
isSettingsPanelOpen ||
|
||||
isExitRemodelPromptOpen ||
|
||||
Boolean(propDialog) ||
|
||||
isOriginalOverlayVisible;
|
||||
isOriginalImageViewerVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (previousUiPauseActiveRef.current === isUiPauseActive) {
|
||||
@@ -986,7 +989,6 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
if (session.dragging) {
|
||||
flushDragVisual();
|
||||
scheduleDragVisual();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1002,6 +1004,11 @@ export function PuzzleRuntimeShell({
|
||||
onDragStart: (session) => {
|
||||
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
|
||||
syncRuntimeDragFromController(session);
|
||||
setDragRenderTarget({
|
||||
pieceId: session.targetId,
|
||||
groupId: draggingTargetRef.current?.groupId ?? null,
|
||||
});
|
||||
flushDragVisual();
|
||||
},
|
||||
onDragMove: (session) => {
|
||||
syncRuntimeDragFromController(session);
|
||||
@@ -1029,7 +1036,7 @@ export function PuzzleRuntimeShell({
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center`}
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center`}
|
||||
>
|
||||
<div className="puzzle-runtime-pill flex items-center gap-2 rounded-full px-5 py-3 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -1076,6 +1083,7 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
||||
const draggingGroupId = dragRenderTarget?.groupId ?? null;
|
||||
const shouldDisplaySelectedState = !dragRenderTarget;
|
||||
const freezeRemainingMs =
|
||||
currentLevel.freezeUntilMs && currentLevel.status === 'playing'
|
||||
? Math.max(0, currentLevel.freezeUntilMs - timerNowMs)
|
||||
@@ -1201,7 +1209,7 @@ export function PuzzleRuntimeShell({
|
||||
playHintDemo();
|
||||
}
|
||||
if (propKind === 'reference') {
|
||||
setIsOriginalOverlayVisible(true);
|
||||
setIsOriginalImageViewerVisible(true);
|
||||
}
|
||||
if (propKind === 'freezeTime') {
|
||||
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
|
||||
@@ -1224,7 +1232,7 @@ export function PuzzleRuntimeShell({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
|
||||
>
|
||||
{resolvedBackgroundMusicSrc ? (
|
||||
<audio
|
||||
@@ -1303,10 +1311,7 @@ export function PuzzleRuntimeShell({
|
||||
title="打开拼图设置"
|
||||
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.settings}
|
||||
className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)] sm:h-[1.4rem] sm:w-[1.4rem]"
|
||||
/>
|
||||
<Settings className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.25)] sm:h-[1.4rem] sm:w-[1.4rem]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1321,12 +1326,28 @@ export function PuzzleRuntimeShell({
|
||||
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute h-0 w-0 overflow-hidden"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id={singlePieceClipId}
|
||||
clipPathUnits="objectBoundingBox"
|
||||
>
|
||||
<path d={buildRoundedGridCellClipPath()} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
{buildBoardCells(board).map((cell) => {
|
||||
const piece = pieceByCell.get(`${cell.row}:${cell.col}`) ?? null;
|
||||
const occupied = Boolean(piece);
|
||||
const isMerged = mergedCellKeys.has(boardCellKey(cell));
|
||||
const isSelected =
|
||||
!isMerged && piece?.pieceId === selectedPieceId;
|
||||
shouldDisplaySelectedState &&
|
||||
!isMerged &&
|
||||
piece?.pieceId === selectedPieceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1372,7 +1393,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-id={piece?.pieceId ?? undefined}
|
||||
className={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden border-0 text-sm font-black transition ${
|
||||
className={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden border-0 text-sm font-black ${
|
||||
occupied
|
||||
? isSelected
|
||||
? 'puzzle-runtime-piece--selected'
|
||||
@@ -1386,6 +1407,10 @@ export function PuzzleRuntimeShell({
|
||||
: 'transition-[opacity,transform]'
|
||||
}`}
|
||||
style={{
|
||||
clipPath: isMerged ? undefined : singlePieceClipUrl,
|
||||
WebkitClipPath: isMerged
|
||||
? undefined
|
||||
: singlePieceClipUrl,
|
||||
zIndex: resolveDraggedPieceLayer(
|
||||
piece?.pieceId,
|
||||
draggingPieceId,
|
||||
@@ -1447,6 +1472,10 @@ export function PuzzleRuntimeShell({
|
||||
);
|
||||
})}
|
||||
{mergedGroups.map((group) => {
|
||||
const mergedGroupClipId = sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}`,
|
||||
);
|
||||
const mergedGroupClipPath = buildMergedGroupOutlinePath(group);
|
||||
return (
|
||||
<div
|
||||
key={group.groupId}
|
||||
@@ -1480,8 +1509,71 @@ export function PuzzleRuntimeShell({
|
||||
height: `${(group.rowSpan / board.rows) * 100}%`,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
|
||||
data-merged-group-clip="true"
|
||||
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
id={mergedGroupClipId}
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d={mergedGroupClipPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath={`url(#${mergedGroupClipId})`}>
|
||||
{group.pieces.map((piece) => (
|
||||
<g
|
||||
key={piece.pieceId}
|
||||
data-merged-piece-visual="true"
|
||||
>
|
||||
<clipPath
|
||||
id={sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
||||
)}
|
||||
clipPathUnits="userSpaceOnUse"
|
||||
>
|
||||
<rect
|
||||
x={piece.localCol}
|
||||
y={piece.localRow}
|
||||
width={1}
|
||||
height={1}
|
||||
/>
|
||||
</clipPath>
|
||||
<g
|
||||
clipPath={`url(#${sanitizeSvgId(
|
||||
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
|
||||
)})`}
|
||||
>
|
||||
{resolvedCoverImage ? (
|
||||
<image
|
||||
href={resolvedCoverImage}
|
||||
xlinkHref={resolvedCoverImage}
|
||||
x={piece.localCol - piece.correctCol}
|
||||
y={piece.localRow - piece.correctRow}
|
||||
width={board.cols}
|
||||
height={board.rows}
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
) : (
|
||||
<rect
|
||||
x={piece.localCol}
|
||||
y={piece.localRow}
|
||||
width={1}
|
||||
height={1}
|
||||
fill="rgba(16,185,129,0.42)"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
className="pointer-events-none relative z-10 grid h-full w-full touch-none overflow-hidden active:scale-[0.992]"
|
||||
className="pointer-events-none relative z-10 grid h-full w-full touch-none active:scale-[0.992]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
|
||||
@@ -1490,7 +1582,7 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden"
|
||||
className="pointer-events-auto relative touch-none"
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
@@ -1511,48 +1603,12 @@ export function PuzzleRuntimeShell({
|
||||
onLostPointerCapture={() => {
|
||||
resetDragInteraction();
|
||||
}}
|
||||
>
|
||||
{resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
|
||||
backgroundPosition: `${
|
||||
board.cols > 1
|
||||
? (piece.correctCol / (board.cols - 1)) * 100
|
||||
: 0
|
||||
}% ${
|
||||
board.rows > 1
|
||||
? (piece.correctRow / (board.rows - 1)) * 100
|
||||
: 0
|
||||
}%`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
|
||||
)}
|
||||
</div>
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isOriginalOverlayVisible && resolvedCoverImage ? (
|
||||
<div
|
||||
data-testid="puzzle-original-overlay"
|
||||
className="pointer-events-none absolute inset-0 z-40 bg-black/10"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-70"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mergeFlash ? (
|
||||
<div
|
||||
key={mergeFlash.key}
|
||||
@@ -1575,7 +1631,9 @@ export function PuzzleRuntimeShell({
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||
{selectedPieceId &&
|
||||
shouldDisplaySelectedState &&
|
||||
runtimeStatus === 'playing' ? (
|
||||
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
|
||||
已选择
|
||||
</div>
|
||||
@@ -1614,17 +1672,17 @@ export function PuzzleRuntimeShell({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={runtimeStatus !== 'playing'}
|
||||
aria-pressed={isOriginalOverlayVisible}
|
||||
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
|
||||
aria-pressed={isOriginalImageViewerVisible}
|
||||
onClick={() => {
|
||||
if (isOriginalOverlayVisible) {
|
||||
setIsOriginalOverlayVisible(false);
|
||||
if (isOriginalImageViewerVisible) {
|
||||
setIsOriginalImageViewerVisible(false);
|
||||
return;
|
||||
}
|
||||
openPropDialog('reference', '查看原图');
|
||||
}}
|
||||
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition disabled:opacity-45 ${
|
||||
isOriginalOverlayVisible
|
||||
isOriginalImageViewerVisible
|
||||
? 'puzzle-runtime-tool-button--active'
|
||||
: 'puzzle-runtime-tool-button'
|
||||
}`}
|
||||
@@ -1667,6 +1725,39 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isOriginalImageViewerVisible && resolvedCoverImage ? (
|
||||
<div
|
||||
data-testid="puzzle-original-viewer"
|
||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-4 backdrop-blur-sm"
|
||||
style={{ background: 'rgba(2, 6, 23, 0.94)' }}
|
||||
onClick={() => {
|
||||
setIsOriginalImageViewerVisible(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭原图"
|
||||
className="puzzle-runtime-secondary-button absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full transition hover:brightness-105"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setIsOriginalImageViewerVisible(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={resolvedCoverImage}
|
||||
alt={`${currentLevel.levelName} 原图`}
|
||||
className="max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{propDialog ? (
|
||||
<div
|
||||
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
@@ -1680,8 +1771,7 @@ export function PuzzleRuntimeShell({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-prop-confirm-title"
|
||||
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
className="puzzle-runtime-dialog w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line flex items-center gap-3 border-b px-5 py-4">
|
||||
@@ -1739,8 +1829,7 @@ export function PuzzleRuntimeShell({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-settings-title"
|
||||
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
className="puzzle-runtime-dialog flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="puzzle-runtime-dialog__line relative border-b px-4 py-3 sm:px-5 sm:py-4">
|
||||
@@ -1763,7 +1852,7 @@ export function PuzzleRuntimeShell({
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
className="puzzle-runtime-dialog__soft absolute right-4 top-3 p-1 transition-colors hover:brightness-75 sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -1827,7 +1916,7 @@ export function PuzzleRuntimeShell({
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="puzzle-runtime-dialog__soft">当前用时</span>
|
||||
<span className="font-mono font-semibold">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
{formatElapsedMs(displayElapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1987,7 +2076,7 @@ export function PuzzleRuntimeShell({
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ type GridEdge = {
|
||||
};
|
||||
|
||||
const MERGED_GROUP_OUTLINE_CORNER_RADIUS = 0.16;
|
||||
const MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR = 1.2;
|
||||
|
||||
function buildLocalCellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
@@ -78,15 +79,38 @@ function removeCollinearGridPoints(points: GridPoint[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function computePolygonSignedArea(points: GridPoint[]) {
|
||||
let area = 0;
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index];
|
||||
const next = points[(index + 1) % points.length];
|
||||
if (!current || !next) {
|
||||
continue;
|
||||
}
|
||||
area += current.x * next.y - next.x * current.y;
|
||||
}
|
||||
return area / 2;
|
||||
}
|
||||
|
||||
type CornerRadiusResolver = (corner: {
|
||||
point: GridPoint;
|
||||
previous: GridPoint;
|
||||
next: GridPoint;
|
||||
isConvex: boolean;
|
||||
radius: number;
|
||||
}) => number;
|
||||
|
||||
function buildRoundedGridCyclePath(
|
||||
points: GridPoint[],
|
||||
radius: number,
|
||||
transformPoint: (point: GridPoint) => GridPoint = (point) => point,
|
||||
resolveCornerRadius?: CornerRadiusResolver,
|
||||
) {
|
||||
const cyclePoints = removeCollinearGridPoints(points);
|
||||
if (cyclePoints.length < 3) {
|
||||
return '';
|
||||
}
|
||||
const polygonOrientation = computePolygonSignedArea(cyclePoints) >= 0 ? 1 : -1;
|
||||
const resolveCorner = (index: number) => {
|
||||
const point = cyclePoints[index];
|
||||
const previous = cyclePoints[
|
||||
@@ -96,8 +120,25 @@ function buildRoundedGridCyclePath(
|
||||
if (!point || !previous || !next) {
|
||||
return null;
|
||||
}
|
||||
const previousVectorX = point.x - previous.x;
|
||||
const previousVectorY = point.y - previous.y;
|
||||
const nextVectorX = next.x - point.x;
|
||||
const nextVectorY = next.y - point.y;
|
||||
const turnCross =
|
||||
previousVectorX * nextVectorY - previousVectorY * nextVectorX;
|
||||
const isConvex = turnCross * polygonOrientation > 0;
|
||||
const resolvedRadius = Math.max(
|
||||
0,
|
||||
resolveCornerRadius?.({
|
||||
point,
|
||||
previous,
|
||||
next,
|
||||
isConvex,
|
||||
radius,
|
||||
}) ?? radius,
|
||||
);
|
||||
const safeRadius = Math.min(
|
||||
radius,
|
||||
resolvedRadius,
|
||||
distanceBetweenGridPoints(point, previous) / 2,
|
||||
distanceBetweenGridPoints(point, next) / 2,
|
||||
);
|
||||
@@ -216,13 +257,34 @@ function buildMergedGroupBoundaryCycles(group: PuzzleMergedGroupShape) {
|
||||
return cycles;
|
||||
}
|
||||
|
||||
export function buildRoundedGridCellClipPath(
|
||||
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
|
||||
) {
|
||||
return buildRoundedGridCyclePath([
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 0, y: 1 },
|
||||
], radius);
|
||||
}
|
||||
|
||||
export function buildMergedGroupOutlinePath(
|
||||
group: PuzzleMergedGroupShape,
|
||||
radius = MERGED_GROUP_OUTLINE_CORNER_RADIUS,
|
||||
) {
|
||||
// 合并块的凹入角不能靠单格 border-radius 稳定拼出来,必须先生成整体外轮廓。
|
||||
return buildMergedGroupBoundaryCycles(group)
|
||||
.map((cycle) => buildRoundedGridCyclePath(cycle, radius))
|
||||
.map((cycle) =>
|
||||
buildRoundedGridCyclePath(
|
||||
cycle,
|
||||
radius,
|
||||
(corner) => corner,
|
||||
({ isConvex }) =>
|
||||
isConvex
|
||||
? radius
|
||||
: radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR,
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
@@ -233,10 +295,18 @@ export function buildMergedGroupClipPath(
|
||||
) {
|
||||
return buildMergedGroupBoundaryCycles(group)
|
||||
.map((cycle) =>
|
||||
buildRoundedGridCyclePath(cycle, radius, (point) => ({
|
||||
x: point.x / group.colSpan,
|
||||
y: point.y / group.rowSpan,
|
||||
})),
|
||||
buildRoundedGridCyclePath(
|
||||
cycle,
|
||||
radius,
|
||||
(point) => ({
|
||||
x: point.x / group.colSpan,
|
||||
y: point.y / group.rowSpan,
|
||||
}),
|
||||
({ isConvex }) =>
|
||||
isConvex
|
||||
? radius
|
||||
: radius * MERGED_GROUP_CONCAVE_CORNER_RADIUS_FACTOR,
|
||||
),
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
@@ -6682,6 +6682,97 @@ test('first puzzle runtime back click can open remix result page', async () => {
|
||||
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('recommend puzzle remix return restarts recommendation instead of stale loading run', async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzleWork: PuzzleWorkSummary = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '雨夜猫塔',
|
||||
summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。',
|
||||
themeTags: ['雨夜', '猫咪', '遗迹'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T12:10:00.000Z',
|
||||
publishedAt: '2026-04-25T12:10:00.000Z',
|
||||
playCount: 8,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
};
|
||||
const anchorPack = buildPuzzleAnchorPack();
|
||||
const remixDraft: PuzzleResultDraft = {
|
||||
workTitle: '改造后的雨夜猫塔',
|
||||
workDescription: '准备改造的拼图草稿。',
|
||||
levelName: '改造后的雨夜猫塔',
|
||||
summary: '一只猫站在雨夜塔顶。',
|
||||
themeTags: ['雨夜', '猫咪', '塔'],
|
||||
forbiddenDirectives: [],
|
||||
creatorIntent: null,
|
||||
anchorPack,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle',
|
||||
levels: [],
|
||||
metadata: null,
|
||||
};
|
||||
const remixSession: PuzzleAgentSessionSnapshot = {
|
||||
sessionId: 'puzzle-session-remix-1',
|
||||
currentTurn: 1,
|
||||
progressPercent: 100,
|
||||
stage: 'ready_to_publish',
|
||||
anchorPack,
|
||||
draft: remixDraft,
|
||||
messages: [],
|
||||
lastAssistantReply: null,
|
||||
publishedProfileId: null,
|
||||
suggestedActions: [],
|
||||
resultPreview: null,
|
||||
updatedAt: '2026-04-25T12:12:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [puzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: puzzleWork,
|
||||
});
|
||||
vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({
|
||||
session: remixSession,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '改造 0' }));
|
||||
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy();
|
||||
|
||||
vi.mocked(startPuzzleRun).mockClear();
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
await clickFirstButtonByName(user, '推荐');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startPuzzleRun).toHaveBeenCalledWith(
|
||||
{
|
||||
profileId: 'puzzle-profile-public-1',
|
||||
levelId: null,
|
||||
},
|
||||
ISOLATED_RUNTIME_AUTH_OPTIONS,
|
||||
);
|
||||
});
|
||||
expect(screen.queryByText('正在进入拼图关卡')).toBeNull();
|
||||
});
|
||||
|
||||
test('public code search opens a published puzzle by PZ code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const puzzleWork: PuzzleWorkSummary = {
|
||||
|
||||
@@ -1526,6 +1526,23 @@ test('profile played works card shows count unit', () => {
|
||||
expect(within(playedCard).getByText('1个')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile stats cards are centered without update timestamp', () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
updatedAt: '2026-05-03T08:01:00Z',
|
||||
});
|
||||
|
||||
const walletCard = screen.getByRole('button', { name: /泥点\s*0/u });
|
||||
const playTimeCard = screen.getByRole('button', { name: /游戏时长/u });
|
||||
const playedCard = screen.getByRole('button', { name: /玩过\s*0个/u });
|
||||
|
||||
for (const card of [walletCard, playTimeCard, playedCard]) {
|
||||
expect(card.className).toContain('items-center');
|
||||
expect(card.className).toContain('justify-center');
|
||||
expect(card.className).toContain('text-center');
|
||||
}
|
||||
expect(screen.queryByText(/更新于/u)).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop account entry uses saved avatar image when available', () => {
|
||||
mockDesktopLayout();
|
||||
const avatarUrl = 'data:image/png;base64,AAAA';
|
||||
@@ -2815,9 +2832,7 @@ test('mobile game category filter dialog filters by play type', async () => {
|
||||
|
||||
await user.click(within(filterDialog).getByRole('button', { name: '抓鹅' }));
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /奇幻拼图,试玩/u }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: /奇幻拼图,试玩/u })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: /奇幻抓鹅,进入/u })).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -2118,24 +2118,6 @@ function formatDashboardCount(value: number) {
|
||||
return normalizedValue.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatDashboardUpdatedAt(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '暂无更新记录';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function isWithinProfileInviteRedeemWindow(
|
||||
createdAt: string | null | undefined,
|
||||
) {
|
||||
@@ -2309,13 +2291,15 @@ function ProfileStatCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
className="platform-subpanel rounded-[1.35rem] px-4 py-3 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[var(--platform-text-soft)]">
|
||||
<div className="flex w-full items-center justify-center gap-2 text-[var(--platform-text-soft)]">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-[11px] tracking-[0.16em]">{label}</span>
|
||||
<span className="whitespace-nowrap text-[11px] tracking-[0.16em]">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-lg font-black text-[var(--platform-text-strong)]">
|
||||
<div className="mt-2 whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
|
||||
{value}
|
||||
</div>
|
||||
</button>
|
||||
@@ -2324,9 +2308,9 @@ function ProfileStatCard({
|
||||
|
||||
function ProfileStatCardSkeleton() {
|
||||
return (
|
||||
<div className="platform-subpanel rounded-[1.35rem] px-4 py-3">
|
||||
<div className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center">
|
||||
<div className="h-4 w-20 animate-pulse rounded-full bg-[var(--platform-subpanel-border)]" />
|
||||
<div className="mt-3 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
|
||||
<div className="mt-2 h-7 w-16 animate-pulse rounded-full bg-[var(--platform-line-soft)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5656,11 +5640,6 @@ export function RpgEntryHomeView({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 text-[11px] text-[var(--platform-text-soft)]">
|
||||
{dashboardError
|
||||
? dashboardError
|
||||
: `更新于 ${formatDashboardUpdatedAt(profileDashboard?.updatedAt)}`}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
@@ -616,8 +616,17 @@ body {
|
||||
--puzzle-runtime-danger-fill: rgba(255, 228, 233, 0.9);
|
||||
--puzzle-runtime-danger-text: #c2415d;
|
||||
--puzzle-runtime-backdrop-fill: rgba(43, 20, 32, 0.34);
|
||||
--puzzle-runtime-dialog-fill: var(--platform-modal-fill);
|
||||
--puzzle-runtime-dialog-border: var(--platform-modal-border);
|
||||
--puzzle-runtime-dialog-fill: radial-gradient(
|
||||
circle at 12% 0%,
|
||||
rgba(255, 91, 132, 0.18),
|
||||
transparent 36%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.98),
|
||||
rgba(255, 246, 249, 0.95)
|
||||
);
|
||||
--puzzle-runtime-dialog-border: rgba(255, 126, 154, 0.3);
|
||||
--puzzle-runtime-table-fill: rgba(255, 255, 255, 0.62);
|
||||
--puzzle-runtime-table-row-fill: rgba(255, 91, 132, 0.12);
|
||||
--puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.66);
|
||||
@@ -848,8 +857,13 @@ body {
|
||||
--puzzle-runtime-danger-fill: rgba(239, 68, 68, 0.2);
|
||||
--puzzle-runtime-danger-text: #fee2e2;
|
||||
--puzzle-runtime-backdrop-fill: rgba(2, 6, 23, 0.68);
|
||||
--puzzle-runtime-dialog-fill: rgba(2, 6, 23, 0.94);
|
||||
--puzzle-runtime-dialog-border: rgba(255, 255, 255, 0.14);
|
||||
--puzzle-runtime-dialog-fill: radial-gradient(
|
||||
circle at 14% 0%,
|
||||
rgba(251, 191, 36, 0.16),
|
||||
transparent 36%
|
||||
),
|
||||
linear-gradient(180deg, rgba(29, 17, 12, 0.96), rgba(2, 6, 23, 0.96));
|
||||
--puzzle-runtime-dialog-border: rgba(251, 191, 36, 0.22);
|
||||
--puzzle-runtime-table-fill: rgba(255, 255, 255, 0.06);
|
||||
--puzzle-runtime-table-row-fill: rgba(251, 191, 36, 0.14);
|
||||
--puzzle-runtime-next-card-fill: rgba(255, 255, 255, 0.06);
|
||||
@@ -1735,14 +1749,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.creation-work-card__swipe-button--share {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
color-mix(in srgb, var(--platform-cool-text) 56%, #2563eb 44%),
|
||||
#172554
|
||||
);
|
||||
}
|
||||
|
||||
.creation-work-card__swipe-button--danger {
|
||||
background: linear-gradient(180deg, #fb7185, #e11d48);
|
||||
}
|
||||
@@ -1805,6 +1811,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
|
||||
.creation-work-card__title-lockup {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 0.42rem;
|
||||
@@ -1853,6 +1860,34 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.creation-work-card__share-button {
|
||||
display: inline-flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--platform-text-soft);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.creation-work-card__share-button:hover {
|
||||
transform: translateY(-1px);
|
||||
background: color-mix(in srgb, var(--platform-cool-bg) 24%, transparent);
|
||||
color: var(--platform-cool-text);
|
||||
}
|
||||
|
||||
.creation-work-card__share-button:focus-visible {
|
||||
outline: 2px solid var(--platform-cool-border);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.creation-work-card__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -2651,9 +2686,38 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
}
|
||||
|
||||
.puzzle-runtime-dialog {
|
||||
position: relative;
|
||||
border: 1px solid var(--puzzle-runtime-dialog-border);
|
||||
background: var(--puzzle-runtime-dialog-fill);
|
||||
color: var(--puzzle-runtime-text-strong);
|
||||
box-shadow:
|
||||
0 24px 80px rgba(0, 0, 0, 0.24),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.puzzle-runtime-dialog::before {
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.16),
|
||||
transparent 36%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 86% 8%,
|
||||
var(--puzzle-runtime-control-hover-fill),
|
||||
transparent 28%
|
||||
);
|
||||
}
|
||||
|
||||
.puzzle-runtime-dialog > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.puzzle-runtime-dialog__line {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('RouteImageReadyGate image url helpers', () => {
|
||||
RouteImageReadyGate,
|
||||
{
|
||||
eyebrow: '正在载入游戏',
|
||||
text: '正在载入冒险...',
|
||||
text: '正在加载内容',
|
||||
},
|
||||
createElement(
|
||||
'section',
|
||||
@@ -100,7 +100,7 @@ describe('RouteImageReadyGate image url helpers', () => {
|
||||
const { container } = render(
|
||||
createElement(RouteLoadingScreen, {
|
||||
eyebrow: '正在载入游戏',
|
||||
text: '正在载入冒险...',
|
||||
text: '正在加载内容',
|
||||
}),
|
||||
);
|
||||
const shell = container.firstElementChild;
|
||||
|
||||
@@ -164,7 +164,7 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
return {
|
||||
kind: 'game',
|
||||
loadingEyebrow: '正在载入游戏',
|
||||
loadingText: '正在载入冒险...',
|
||||
loadingText: '正在加载内容',
|
||||
Component: GameApp,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user