@@ -89,17 +89,17 @@
|
||||
- 合并块整体拖动
|
||||
- 单块拖到合并块位置时拆分合并块
|
||||
10. 游戏画面必须显示作者信息和关卡名。
|
||||
11. 前端只负责表现和交互输入,逻辑、数据、关卡裁决、推荐计算、状态存储全部放到 `server-rs` 后端,由 `Axum + SpacetimeDB + OSS` 方案承接。
|
||||
11. 拼块交换、拖动、合并、拆分和本关通关判定由前端即时裁决;`server-rs` 继续承接素材、开局、下一关推荐、扣费、排行榜和跨端服务侧状态。
|
||||
|
||||
### 第一版单机例外说明 2026-04-24
|
||||
### 运行态前端裁决说明 2026-04-29
|
||||
|
||||
为了先把拼图玩法跑通,第一版运行态采用单机本地版本,作为上面总原则的阶段性例外:
|
||||
为了保证拖动手感和正式链路玩法一致,拼图运行态采用前端即时裁决:
|
||||
|
||||
1. Agent 会话、结果页草稿、正式候选图生成、封面确认、发布、作品读取,仍然全部走 Rust 后端。
|
||||
2. 进入拼图玩法后的 `run` 只在前端本地内存中存在。
|
||||
3. 交换、拖动、通关判断不写回后端。
|
||||
4. 关闭玩法后不保留本次运行态,不做断点续玩。
|
||||
5. 后续如果要做跨端续玩、多端同步或排行榜,再把运行态真相源收回后端。
|
||||
2. 进入拼图玩法后,拼块布局、合并组、拆分结果和本关通关状态由前端基于当前 `PuzzleRunSnapshot` 计算。
|
||||
3. 交换、拖动、合并、拆分、通关判断不写回后端。
|
||||
4. 正式 run 的下一关推荐、道具扣费、暂停计时同步、排行榜提交继续走后端。
|
||||
5. 后续如果要做跨端续玩或多端同步,必须先更新本文档,再决定哪些运行态真相源收回后端。
|
||||
|
||||
---
|
||||
|
||||
@@ -114,7 +114,7 @@
|
||||
5. 不做复杂剧情文本或玩法说明文本默认堆在 UI 中。
|
||||
6. 不做独立于平台创作中心之外的新创作站点。
|
||||
7. 不做前端本地计算下一关推荐结果。
|
||||
8. 不做前端本地裁决拼块合并、拆分和关卡完成。
|
||||
8. 不做前端本地计算下一关推荐、扣费或排行榜;拼块交换、拖动、合并、拆分和本关通关判定由前端即时裁决。
|
||||
9. 不把拼图玩法继续命名挂在 `customWorld` 或 `rpgWorld` 老前缀下。
|
||||
|
||||
---
|
||||
@@ -374,8 +374,8 @@ interface PuzzleAnchorPack {
|
||||
|
||||
拼图图片的正式资产要求:
|
||||
|
||||
1. 官方拼图原图统一使用 `9:16` 竖屏比例。
|
||||
2. 建议第一版正式生成尺寸为 `720 x 1280`。
|
||||
1. 官方拼图原图统一使用 `1:1` 正方形比例。
|
||||
2. 建议第一版正式生成尺寸为 `1024 x 1024`。
|
||||
3. 图中不允许生成标题字、水印、边框、按钮或 UI。
|
||||
4. 图像主体必须足够清晰,切成 `4*4` 后仍然有辨识信息。
|
||||
|
||||
@@ -502,7 +502,7 @@ tagSimilarityScore =
|
||||
画面要求:
|
||||
|
||||
1. 拼图舞台占满可用全屏区域
|
||||
2. 真正可操作的拼图棋盘按 `9:16` 竖屏比例填满安全区域
|
||||
2. 真正可操作的拼图棋盘按正方形比例填满安全区域,并在移动端贴近屏幕两侧边缘
|
||||
3. 棋盘外延空间用同图模糊背景或纯净氛围底承接
|
||||
4. 不默认堆玩法说明文字
|
||||
|
||||
@@ -625,7 +625,7 @@ V1 规则如下:
|
||||
|
||||
## 9.11 重算范围
|
||||
|
||||
为了避免前端和后端做整盘重复计算,每次操作后只重算受影响区域:
|
||||
为了避免前端每次操作后做整盘重复计算,只重算受影响区域:
|
||||
|
||||
1. 本次发生交换的源格子
|
||||
2. 本次发生交换的目标格子
|
||||
@@ -639,7 +639,7 @@ V1 规则如下:
|
||||
1. 所有拼块合并成 `1` 个覆盖全盘的大合并块
|
||||
2. 所有拼块都回到原始正确位置
|
||||
|
||||
在正式实现中,建议以后端 `allTilesResolved = true` 作为唯一真相。
|
||||
在正式实现中,前端以本地计算得到的 `allTilesResolved = true` 或关卡 `status = cleared` 作为本关通关真相;后端不再参与拼块布局裁决。
|
||||
|
||||
## 9.13 限时与失败
|
||||
|
||||
@@ -755,17 +755,17 @@ interface PuzzleRunSnapshot {
|
||||
1. 展示 Agent 聊天界面
|
||||
2. 展示结果页
|
||||
3. 展示拼图画布、选中态、拖动反馈、合并反馈
|
||||
4. 发起交换、拖动、发布、开始游戏等请求
|
||||
4. 即时裁决拼块交换、拖动、合并、拆分和本关通关状态
|
||||
5. 发起发布、开始游戏、下一关、道具、排行榜等请求
|
||||
|
||||
前端不负责:
|
||||
|
||||
1. 解析锚点完成度
|
||||
2. 计算推荐下一关
|
||||
3. 计算标签相似度
|
||||
4. 判定哪些块应该合并
|
||||
5. 判定合并块何时拆分
|
||||
6. 判定通关
|
||||
7. 保存 run 状态
|
||||
4. 计算下一关推荐
|
||||
5. 保存跨端 run 状态
|
||||
6. 执行道具扣费或排行榜写入
|
||||
|
||||
## 11.2 后端职责
|
||||
|
||||
@@ -777,10 +777,9 @@ interface PuzzleRunSnapshot {
|
||||
4. 发布作品到拼图广场
|
||||
5. 创建 run
|
||||
6. 初始化关卡棋盘
|
||||
7. 裁决交换、合并、拆分、拖动结果
|
||||
8. 判定通关
|
||||
9. 计算下一关推荐
|
||||
10. 保存当前 run 快照
|
||||
7. 计算下一关推荐
|
||||
8. 保存当前 run 的关卡入口、计时、道具和排行榜相关状态
|
||||
9. 兼容保留旧交换接口;拖动接口不作为 Rust API 默认能力暴露,前端不依赖后端裁决拼块布局
|
||||
|
||||
---
|
||||
|
||||
@@ -797,11 +796,12 @@ interface PuzzleRunSnapshot {
|
||||
- 结果页交互
|
||||
- 拼图画布渲染
|
||||
- HUD、选中态、拖动态、合并反馈表现
|
||||
- 拼块交换、拖动、合并、拆分与本关通关判定
|
||||
3. 前端不得承担:
|
||||
- 推荐算法
|
||||
- run 状态持久化
|
||||
- 拼块合并与拆分裁决
|
||||
- 通关判定
|
||||
- run 状态跨端持久化
|
||||
- 道具扣费
|
||||
- 排行榜写入
|
||||
4. 若后续拼图运行时需要实时订阅或读取 `SpacetimeDB` 数据,前端接入必须显式以 `spacetimedb-typescript` 约束为准。
|
||||
|
||||
### HTTP 与外部副作用层
|
||||
@@ -818,15 +818,16 @@ interface PuzzleRunSnapshot {
|
||||
|
||||
### 状态真相源
|
||||
|
||||
1. 拼图玩法的运行时状态、作品状态、Agent 会话状态、广场投影状态,统一以 `SpacetimeDB` 为唯一真相源。
|
||||
2. `SpacetimeDB` 中应承担:
|
||||
1. 拼图玩法的作品状态、Agent 会话状态、广场投影状态、下一关推荐、道具扣费和排行榜等服务侧状态,统一以 `SpacetimeDB` 为唯一真相源。
|
||||
2. 当前关卡的拼块布局、合并组、拆分结果和本关通关状态,运行中以前端即时计算结果为准,不要求每一步写回 `SpacetimeDB`。
|
||||
3. `SpacetimeDB` 中应承担:
|
||||
- 拼图作品 profile 表
|
||||
- 拼图 Agent session / message / operation 表
|
||||
- 拼图 run 与关卡状态表
|
||||
- 拼块与合并组状态表
|
||||
- 拼图 run 入口、关卡服务状态与排行榜表
|
||||
- 下一关候选、道具扣费和榜单聚合所需状态
|
||||
- 拼图广场投影表
|
||||
- 标签相似度计算所需的规范化标签字段
|
||||
3. 所有真正修改状态的行为必须通过 reducer 或 procedure 完成,不能由 Axum 或前端偷偷改状态。
|
||||
4. 所有服务侧状态修改必须通过 reducer 或 procedure 完成,不能由 Axum 或前端偷偷改服务侧状态。
|
||||
|
||||
### 资产存储
|
||||
|
||||
@@ -1036,9 +1037,12 @@ interface PuzzleRunSnapshot {
|
||||
|
||||
1. `POST /api/runtime/puzzle-runtime/runs`
|
||||
2. `GET /api/runtime/puzzle-runtime/runs/:runId`
|
||||
3. `POST /api/runtime/puzzle-runtime/runs/:runId/swap`
|
||||
4. `POST /api/runtime/puzzle-runtime/runs/:runId/drag`
|
||||
5. `POST /api/runtime/puzzle-runtime/runs/:runId/next-level`
|
||||
3. `POST /api/runtime/puzzle-runtime/runs/:runId/next-level`
|
||||
4. `POST /api/runtime/puzzle-runtime/runs/:runId/pause`
|
||||
5. `POST /api/runtime/puzzle-runtime/runs/:runId/props`
|
||||
6. `POST /api/runtime/puzzle-runtime/runs/:runId/leaderboard`
|
||||
|
||||
`swap` 兼容接口可以保留,但前端默认不再调用;`drag` 不作为默认 HTTP 入口暴露。拼块布局由前端即时裁决。
|
||||
|
||||
---
|
||||
|
||||
@@ -1084,7 +1088,7 @@ interface PuzzleRunSnapshot {
|
||||
建议布局:
|
||||
|
||||
1. 顶部轻量 HUD
|
||||
2. 中间 `9:16` 竖屏拼图棋盘
|
||||
2. 中间正方形拼图棋盘,移动端贴近屏幕两侧边缘
|
||||
3. 底部不常驻大段文案
|
||||
|
||||
如需操作提示,只允许短暂轻提示,不允许占据长期版面。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 拼图图片与前端规则裁决对齐 2026-04-29
|
||||
|
||||
## 背景
|
||||
|
||||
本轮明确调整拼图运行态边界:
|
||||
|
||||
1. 拼图生成图片重新回到 `1:1` 正方形。
|
||||
2. 拼图中的拖动、交换、合并、拆分与通关判定由前端即时计算。
|
||||
3. 移动端运行时棋盘需要贴近屏幕两侧边缘,减少无效留白。
|
||||
|
||||
此前误按 `9:16` 竖屏统一图片和棋盘,会让拼图块在移动端可操作面积不足,也和拼图素材的切块体验不匹配。本轮回到正方形棋盘与正方形生图。
|
||||
|
||||
## 落地结论
|
||||
|
||||
### 1. 图片生成
|
||||
|
||||
1. 拼图生成图固定使用 `1024*1024`。
|
||||
2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成竖屏或横版图。
|
||||
3. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留 `3x3 或 4x4 拼图切块`、主体清晰、层次明确、无文字水印等约束。
|
||||
4. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。
|
||||
|
||||
### 2. 前端规则裁决
|
||||
|
||||
1. 运行态的交换、拖动、合并、拆分、通关判定由前端基于 `PuzzleRunSnapshot` 即时计算。
|
||||
2. 正式 run 与本地测试 run 复用同一套前端规则函数,避免正式链路和测试链路玩法漂移。
|
||||
3. 后端仍负责开始 run、进入下一关、道具扣费、暂停计时同步、排行榜提交、作品与下一关候选读取。
|
||||
4. 正式 run 的 `/drag` 后端 HTTP 接口已撤出 Rust API;拖动不再有后端入口。`/swap` 暂作点击交换兼容入口,拖动中的交换由前端本地规则完成。
|
||||
|
||||
### 3. 移动端棋盘布局
|
||||
|
||||
1. 运行时棋盘根容器恢复 `aspect-square`。
|
||||
2. 移动端横向 padding 收紧到 `0.25rem`,棋盘宽度使用 `min(99vw, 可用高度)`,尽量贴近屏幕两侧边缘。
|
||||
3. 单格不设置固定最小高度,避免移动端被单格撑破。
|
||||
4. 顶部 HUD 与底部道具仍保留安全区,不能遮挡棋盘可操作区域。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size` 为 `1024*1024`。
|
||||
2. 图片提示词包含 `1:1 正方形拼图关卡`。
|
||||
3. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`。
|
||||
4. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。
|
||||
5. 下一关、道具、排行榜仍走现有后端链路,不把外部 I/O 或扣费逻辑塞回前端。
|
||||
@@ -1,42 +0,0 @@
|
||||
# 拼图图片生成与运行时 9:16 对齐 2026-04-29
|
||||
|
||||
## 背景
|
||||
|
||||
拼图生成图和运行时画面需要统一为竖屏游戏口径。此前链路里存在两类不一致:
|
||||
|
||||
1. 旧方案按 `1:1` 正方形生成与承载。
|
||||
2. 上一轮误按 `16:9` 横版对齐,和本轮竖屏玩法目标相反。
|
||||
|
||||
本次统一为 `9:16` 竖屏尺寸,确保生成图、结果页预览、发布正式图、历史素材缩略和实际游戏棋盘使用同一画面比例。
|
||||
|
||||
## 落地结论
|
||||
|
||||
### 1. 图片生成
|
||||
|
||||
1. 拼图生成图固定使用 `720*1280`。
|
||||
2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成正方形或横版图。
|
||||
3. 拼图图片提示词明确写入 `9:16 竖屏画布`,并继续保留 `3x3 或 4x4 拼图切块`、主体清晰、层次明确、无文字水印等约束。
|
||||
4. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 只负责 session、draft、candidate、work profile 的确定性落库,不做网络 I/O。
|
||||
|
||||
### 2. 结果页与素材选择
|
||||
|
||||
1. 画面预览容器使用 `aspect-[9/16]`。
|
||||
2. 发布弹窗正式图使用 `aspect-[9/16]`。
|
||||
3. 历史拼图素材卡片缩略图使用 `aspect-[9/16]`。
|
||||
4. 图片显示继续使用 `object-cover`,兼容历史正方形或横版素材,但新生成素材的真相比例为 `9:16`。
|
||||
|
||||
### 3. 运行时棋盘
|
||||
|
||||
1. `PuzzleRuntimeShell` 继续作为唯一运行时承载组件,不新增页面。
|
||||
2. 棋盘根容器使用 `aspect-[9/16]`,并显式设置行列网格,3x3 / 4x4 都在竖屏舞台内切片。
|
||||
3. 棋盘最大宽度按可用视口高度反推,避免桌面端竖屏棋盘被宽容器撑出首屏。
|
||||
4. 单格不设置固定最小高度,避免移动端竖屏棋盘被单格高度撑破。
|
||||
5. 拼图片背景切片仍按 `board.cols * 100%` 与 `board.rows * 100%` 计算,比例由棋盘容器统一决定。
|
||||
|
||||
## 验收
|
||||
|
||||
1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size` 为 `720*1280`。
|
||||
2. 结果页画面预览、发布弹窗正式图、历史素材缩略图均为 `9:16`。
|
||||
3. 进入拼图运行时后,棋盘整体为 `9:16` 竖屏,不再是正方形或横版。
|
||||
4. 移动端和桌面端运行时棋盘不被单格最小高度撑出首屏,顶部标题、底部状态与棋盘不重叠。
|
||||
5. 旧正方形或横版素材仍能被 `object-cover` 展示和游玩,不阻断历史作品。
|
||||
@@ -103,6 +103,15 @@
|
||||
3. 结算弹窗显示时,如果真实榜单尚未回写完成,可以显示加载态;但不能回退到假数据。
|
||||
4. 下一关开始后,当前关卡榜单状态清空。
|
||||
|
||||
## 7.1 2026-04-29 与前端拖动裁决的对齐
|
||||
|
||||
当前拼图拖动、合并、拆分与通关判定完全由前端运行态负责,后端排行榜接口只负责真实成绩表与榜单聚合:
|
||||
|
||||
1. 排行榜提交不得依赖 SpacetimeDB 里的旧棋盘快照已经通过后端拖动接口进入 `cleared`。
|
||||
2. 后端仍校验 `profileId`、`gridSize`、昵称和成绩,并把当前提交写入真实成绩表。
|
||||
3. 后端响应里的 `leaderboardEntries` 是唯一需要合并回前端当前 run 的数据。
|
||||
4. 前端不能用排行榜响应里的旧棋盘快照覆盖本地拖动后的棋盘,否则会把刚刚通关的前端状态回滚。
|
||||
|
||||
## 8. 测试要求
|
||||
|
||||
至少覆盖:
|
||||
|
||||
@@ -65,6 +65,16 @@
|
||||
7. 每次进入下一关都会重置棋盘、推进关卡序号,并按已通关数量切换 `3x3 / 4x4`
|
||||
8. 当前不依赖后端 `start/swap/drag/next-level` 接口保存过程状态
|
||||
|
||||
## 6. 2026-04-29 拖动责任边界修正
|
||||
|
||||
拼图运行态的拖动逻辑完全交给前端:
|
||||
|
||||
1. `pointerup` 解析出的目标格只调用前端 `dragLocalPuzzlePiece`。
|
||||
2. 单块拖动、合并块整体平移、被覆盖块交换、拆分、重新合并、通关判定,都以前端当前 `PuzzleRunSnapshot` 为准。
|
||||
3. Rust API 不再暴露 `/api/runtime/puzzle/runs/{runId}/drag` 给前端调用;后端旧 procedure 仅作为历史兼容实现,不作为当前运行态入口。
|
||||
4. 真实排行榜仍由后端成绩表负责;提交成功后,前端只把后端返回的 `leaderboardEntries` 合并回当前本地棋盘快照,不能用后端旧棋盘覆盖前端拖动后的状态。
|
||||
5. 下一关仍通过 `advanceLocalPuzzleNextLevel` 把前端当前 run 交给 Rust API 生成候选关卡,后端只裁决图片来源与新关卡初始化,不保存上一关拖动过程。
|
||||
|
||||
## 5. 当前实现判断标准
|
||||
|
||||
当下面结果成立时,视为这一轮目标达成:
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
- [BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md](./BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md):记录大鱼吃小鱼草稿进度页从单步 compile 改为多阶段感知展示,以及大鱼会话读取在 Maincloud 抖动时增加短重试与超时语义收口的修复口径。
|
||||
- [BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md](./BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md):记录大鱼吃小鱼草稿生成、生图、动作三类提示词从业务脚本中抽离到独立 `prompt/big_fish.rs` 模块的边界与职责划分。
|
||||
- [BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md](./BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md):记录大鱼吃小鱼等级主图与动作关键帧正式图在 Rust 后端复用 RPG 角色主图透明背景 alpha 后处理的对齐口径,并明确场地背景不走该处理。
|
||||
- [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1,运行时拖动、交换、合并与拆分由前端即时裁决,以及移动端棋盘贴近屏幕边缘的落地边界。
|
||||
- [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。
|
||||
- [SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md](./SPACETIMEDB_START_SH_EARLY_EXIT_DIAGNOSTICS_2026-04-27.md):记录发布包 `start.sh` 只输出“SpacetimeDB 进程在就绪前退出”时的诊断补强,启动失败或超时时自动回显 `logs/spacetimedb.log`、`server ping`、端口监听和 root-dir 相关进程。
|
||||
- [RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md](./RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md):记录 RPG 运行时 NPC 聊天、RPG/自定义世界 Agent 与大鱼 Agent 从“拼完整 SSE 字符串后一次性返回”改为 `mpsc + Sse<Event>` 真流式输出的后端落地口径。
|
||||
|
||||
@@ -83,12 +83,11 @@ use crate::{
|
||||
profile_identity::update_profile_identity,
|
||||
puzzle::{
|
||||
advance_local_puzzle_next_level, advance_puzzle_next_level, create_puzzle_agent_session,
|
||||
delete_puzzle_work, drag_puzzle_piece_or_group, execute_puzzle_agent_action,
|
||||
get_puzzle_agent_session, get_puzzle_gallery_detail, get_puzzle_run,
|
||||
get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery, put_puzzle_work,
|
||||
remix_puzzle_gallery_work, start_puzzle_run, stream_puzzle_agent_message,
|
||||
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||
delete_puzzle_work, execute_puzzle_agent_action, get_puzzle_agent_session,
|
||||
get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works,
|
||||
list_puzzle_gallery, put_puzzle_work, remix_puzzle_gallery_work, start_puzzle_run,
|
||||
stream_puzzle_agent_message, submit_puzzle_agent_message, submit_puzzle_leaderboard,
|
||||
swap_puzzle_pieces, update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||
},
|
||||
refresh_session::refresh_session,
|
||||
request_context::{attach_request_context, resolve_request_id},
|
||||
@@ -777,13 +776,6 @@ pub fn build_router(state: AppState) -> Router {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/runs/{run_id}/drag",
|
||||
post(drag_puzzle_piece_or_group).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/runtime/puzzle/runs/{run_id}/next-level",
|
||||
post(advance_puzzle_next_level).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -9,10 +9,10 @@ pub(crate) const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str =
|
||||
pub(crate) fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String {
|
||||
format!(
|
||||
concat!(
|
||||
"请生成一张适合 9:16 竖屏拼图关卡的高清插画。",
|
||||
"请生成一张适合 1:1 正方形拼图关卡的高清插画。",
|
||||
"关卡名:{level_name}。",
|
||||
"画面主体:{prompt}。",
|
||||
"画面要求:9:16 竖屏画布,适配 3x3 或 4x4 拼图切块,",
|
||||
"画面要求:1:1 正方形画布,适配 3x3 或 4x4 拼图切块,",
|
||||
"主体要清晰集中,前中后景层次明确,局部细节丰富但不要杂乱,",
|
||||
"避免文字、水印、边框和 UI 元素。"
|
||||
),
|
||||
@@ -31,7 +31,7 @@ mod tests {
|
||||
|
||||
assert!(prompt.contains("雨夜神庙"));
|
||||
assert!(prompt.contains("猫咪在发光遗迹前寻找线索"));
|
||||
assert!(prompt.contains("9:16 竖屏拼图关卡"));
|
||||
assert!(prompt.contains("1:1 正方形拼图关卡"));
|
||||
assert!(prompt.contains("3x3 或 4x4"));
|
||||
assert!(prompt.contains("避免文字、水印、边框和 UI 元素"));
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ use shared_contracts::{
|
||||
},
|
||||
puzzle_gallery::{PuzzleGalleryDetailResponse, PuzzleGalleryResponse},
|
||||
puzzle_runtime::{
|
||||
AdvanceLocalPuzzleNextLevelRequest, DragPuzzlePieceRequest, PuzzleBoardSnapshotResponse,
|
||||
PuzzleCellPositionResponse, PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse,
|
||||
PuzzlePieceStateResponse, PuzzleRunResponse, PuzzleRunSnapshotResponse,
|
||||
PuzzleRuntimeLevelSnapshotResponse, StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest,
|
||||
SwapPuzzlePiecesRequest, UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||
AdvanceLocalPuzzleNextLevelRequest, PuzzleBoardSnapshotResponse, PuzzleCellPositionResponse,
|
||||
PuzzleLeaderboardEntryResponse, PuzzleMergedGroupStateResponse, PuzzlePieceStateResponse,
|
||||
PuzzleRunResponse, PuzzleRunSnapshotResponse, PuzzleRuntimeLevelSnapshotResponse,
|
||||
StartPuzzleRunRequest, SubmitPuzzleLeaderboardRequest, SwapPuzzlePiecesRequest,
|
||||
UpdatePuzzleRuntimePauseRequest, UsePuzzleRuntimePropRequest,
|
||||
},
|
||||
puzzle_works::{
|
||||
PutPuzzleWorkRequest, PuzzleWorkDetailResponse, PuzzleWorkMutationResponse,
|
||||
@@ -57,10 +57,10 @@ use spacetime_client::{
|
||||
PuzzleLeaderboardEntryRecord, PuzzleLeaderboardSubmitRecordInput, PuzzleMergedGroupRecord,
|
||||
PuzzlePieceStateRecord, PuzzlePublishRecordInput, PuzzleResultDraftRecord,
|
||||
PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord,
|
||||
PuzzleRunDragRecordInput, PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord,
|
||||
PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
|
||||
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput,
|
||||
PuzzleWorkUpsertRecordInput, SpacetimeClientError,
|
||||
PuzzleRunPauseRecordInput, PuzzleRunPropRecordInput, PuzzleRunRecord, PuzzleRunStartRecordInput,
|
||||
PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord, PuzzleSelectCoverImageRecordInput,
|
||||
PuzzleWorkProfileRecord, PuzzleWorkRemixRecordInput, PuzzleWorkUpsertRecordInput,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
use std::convert::Infallible;
|
||||
use tokio::time::sleep;
|
||||
@@ -86,7 +86,7 @@ const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery";
|
||||
const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime";
|
||||
const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash";
|
||||
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "720*1280";
|
||||
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
||||
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
@@ -1062,58 +1062,6 @@ pub async fn swap_puzzle_pieces(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn drag_puzzle_piece_or_group(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<DragPuzzlePieceRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let Json(payload) = payload.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_RUNTIME_PROVIDER,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_RUNTIME_PROVIDER,
|
||||
"message": error.body_text(),
|
||||
})),
|
||||
)
|
||||
})?;
|
||||
ensure_non_empty(&request_context, PUZZLE_RUNTIME_PROVIDER, &run_id, "runId")?;
|
||||
ensure_non_empty(
|
||||
&request_context,
|
||||
PUZZLE_RUNTIME_PROVIDER,
|
||||
&payload.piece_id,
|
||||
"pieceId",
|
||||
)?;
|
||||
|
||||
let run = state
|
||||
.spacetime_client()
|
||||
.drag_puzzle_piece_or_group(PuzzleRunDragRecordInput {
|
||||
run_id,
|
||||
owner_user_id: authenticated.claims().user_id().to_string(),
|
||||
piece_id: payload.piece_id,
|
||||
target_row: payload.target_row,
|
||||
target_col: payload.target_col,
|
||||
dragged_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_RUNTIME_PROVIDER,
|
||||
map_puzzle_client_error(error),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleRunResponse {
|
||||
run: map_puzzle_run_response(run),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn advance_puzzle_next_level(
|
||||
State(state): State<AppState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
@@ -1994,7 +1942,7 @@ async fn generate_puzzle_image_candidates(
|
||||
None => None,
|
||||
};
|
||||
// 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。
|
||||
// 中文注释:拼图作品资产统一按 9:16 竖屏生成,运行时棋盘也按同一比例切块承载。
|
||||
// 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。
|
||||
let generated = match reference_image.as_deref() {
|
||||
Some(reference_image) => {
|
||||
create_puzzle_image_to_image_generation(
|
||||
@@ -2411,8 +2359,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_generated_image_size_is_portrait_9_16() {
|
||||
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "720*1280");
|
||||
fn puzzle_generated_image_size_is_square_1_1() {
|
||||
assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1551,9 +1551,6 @@ fn submit_puzzle_leaderboard_entry_tx(
|
||||
.current_level
|
||||
.as_ref()
|
||||
.ok_or_else(|| "拼图关卡不存在".to_string())?;
|
||||
if current_level.status != PuzzleRuntimeLevelStatus::Cleared {
|
||||
return Err("当前关卡尚未通关".to_string());
|
||||
}
|
||||
if current_level.profile_id != input.profile_id {
|
||||
return Err("提交成绩的拼图作品与当前关卡不匹配".to_string());
|
||||
}
|
||||
@@ -1592,9 +1589,14 @@ fn submit_puzzle_leaderboard_entry_tx(
|
||||
10,
|
||||
);
|
||||
if let Some(level) = run.current_level.as_mut() {
|
||||
// 拼图拖动、合并与通关判定由前端运行态即时裁决;服务端只负责真实榜单。
|
||||
// 因此提交榜单时不能要求 SpacetimeDB 里的旧棋盘快照也已经通关。
|
||||
level.status = PuzzleRuntimeLevelStatus::Cleared;
|
||||
level.cleared_at_ms = Some(micros_to_millis(input.submitted_at_micros));
|
||||
level.elapsed_ms = Some(input.elapsed_ms.max(1_000));
|
||||
level.leaderboard_entries = leaderboard_entries.clone();
|
||||
}
|
||||
run.cleared_level_count = run.cleared_level_count.max(run.current_level_index);
|
||||
run.leaderboard_entries = leaderboard_entries;
|
||||
replace_puzzle_runtime_run(ctx, &row, &run, input.submitted_at_micros);
|
||||
Ok(run)
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
const PLACEHOLDER_PUZZLE_IMAGE =
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 1280">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fef3c7" />
|
||||
@@ -30,13 +30,13 @@ const PLACEHOLDER_PUZZLE_IMAGE =
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="720" height="1280" fill="url(#sky)" />
|
||||
<circle cx="310" cy="318" r="210" fill="url(#glow)" />
|
||||
<path d="M0 860 C118 808 226 894 348 828 C474 760 594 824 720 774 V1280 H0 Z" fill="#1e1b4b" opacity="0.9" />
|
||||
<path d="M0 1010 C142 954 282 1040 428 974 C552 918 638 938 720 904 V1280 H0 Z" fill="#111827" opacity="0.78" />
|
||||
<path d="M86 310 C184 242 302 252 376 334 C460 426 574 386 646 310" fill="none" stroke="#fff7ed" stroke-width="16" stroke-linecap="round" opacity="0.72" />
|
||||
<path d="M128 610 h464" stroke="#ffffff" stroke-width="14" stroke-linecap="round" opacity="0.3" />
|
||||
<path d="M174 704 h372" stroke="#ffffff" stroke-width="10" stroke-linecap="round" opacity="0.22" />
|
||||
<rect width="1024" height="1024" fill="url(#sky)" />
|
||||
<circle cx="378" cy="286" r="230" fill="url(#glow)" />
|
||||
<path d="M0 690 C168 626 296 724 446 666 C596 606 744 628 1024 536 V1024 H0 Z" fill="#1e1b4b" opacity="0.9" />
|
||||
<path d="M0 822 C190 760 328 850 516 790 C672 738 824 754 1024 704 V1024 H0 Z" fill="#111827" opacity="0.78" />
|
||||
<path d="M138 328 C226 266 340 266 420 338 C518 426 632 390 740 326 C834 272 920 300 960 362" fill="none" stroke="#fff7ed" stroke-width="18" stroke-linecap="round" opacity="0.72" />
|
||||
<path d="M190 548 h640" stroke="#ffffff" stroke-width="16" stroke-linecap="round" opacity="0.3" />
|
||||
<path d="M268 628 h488" stroke="#ffffff" stroke-width="12" stroke-linecap="round" opacity="0.22" />
|
||||
</svg>`);
|
||||
|
||||
function buildPlaceholderPuzzleWork(): PuzzleWorkSummary {
|
||||
|
||||
@@ -99,11 +99,9 @@ import {
|
||||
} from '../../services/puzzle-gallery';
|
||||
import {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp as consumePuzzleRuntimeProp,
|
||||
} from '../../services/puzzle-runtime';
|
||||
@@ -557,6 +555,37 @@ function LazyPanelFallback({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function mergePuzzleServiceRuntimeState(
|
||||
currentRun: PuzzleRunSnapshot,
|
||||
serviceRun: PuzzleRunSnapshot,
|
||||
): PuzzleRunSnapshot {
|
||||
if (!currentRun.currentLevel || !serviceRun.currentLevel) {
|
||||
return currentRun;
|
||||
}
|
||||
|
||||
const serviceLevel = serviceRun.currentLevel;
|
||||
const leaderboardEntries =
|
||||
serviceLevel.leaderboardEntries.length > 0
|
||||
? serviceLevel.leaderboardEntries
|
||||
: serviceRun.leaderboardEntries;
|
||||
|
||||
return {
|
||||
...currentRun,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentRun.currentLevel,
|
||||
timeLimitMs: serviceLevel.timeLimitMs,
|
||||
remainingMs: serviceLevel.remainingMs,
|
||||
pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs,
|
||||
pauseStartedAtMs: serviceLevel.pauseStartedAtMs,
|
||||
freezeAccumulatedMs: serviceLevel.freezeAccumulatedMs,
|
||||
freezeStartedAtMs: serviceLevel.freezeStartedAtMs,
|
||||
freezeUntilMs: serviceLevel.freezeUntilMs,
|
||||
leaderboardEntries,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function PlatformEntryFlowShellImpl({
|
||||
selectionStage,
|
||||
setSelectionStage,
|
||||
@@ -1565,20 +1594,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
setPuzzleError(null);
|
||||
if (isLocalPuzzleRun(puzzleRun)) {
|
||||
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
|
||||
return;
|
||||
}
|
||||
|
||||
void swapPuzzlePieces(puzzleRun.runId, payload)
|
||||
.then(({ run }) => {
|
||||
setPuzzleRun(run);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。'));
|
||||
});
|
||||
// 交换、合并与通关判定都由前端即时裁决,正式 run 不再等待后端 /swap。
|
||||
setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload));
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError],
|
||||
[isPuzzleBusy, puzzleRun, setPuzzleError],
|
||||
);
|
||||
|
||||
const dragPuzzlePiece = useCallback(
|
||||
@@ -1588,20 +1607,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
setPuzzleError(null);
|
||||
if (isLocalPuzzleRun(puzzleRun)) {
|
||||
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
|
||||
return;
|
||||
}
|
||||
|
||||
void dragPuzzlePieceOrGroup(puzzleRun.runId, payload)
|
||||
.then(({ run }) => {
|
||||
setPuzzleRun(run);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。'));
|
||||
});
|
||||
// 拖动落点、合并、拆分与通关判定都属于前端即时交互裁决。
|
||||
// 后端只保留开局、道具、下一关与真实排行榜等服务侧能力。
|
||||
setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload));
|
||||
},
|
||||
[isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError],
|
||||
[isPuzzleBusy, puzzleRun, setPuzzleError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1641,7 +1651,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { run } = await updatePuzzleRunPause(puzzleRun.runId, {
|
||||
paused,
|
||||
});
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRun((currentRun) =>
|
||||
currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun,
|
||||
);
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
@@ -1669,7 +1681,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
try {
|
||||
const { run } = await getPuzzleRun(puzzleRun.runId);
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRun((currentRun) =>
|
||||
currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun,
|
||||
);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
resolvePuzzleErrorMessage(error, '同步拼图失败状态失败。'),
|
||||
@@ -1703,9 +1717,14 @@ export function PlatformEntryFlowShellImpl({
|
||||
const { run } = await consumePuzzleRuntimeProp(puzzleRun.runId, {
|
||||
propKind,
|
||||
});
|
||||
setPuzzleRun(run);
|
||||
const nextRun = mergePuzzleServiceRuntimeState(
|
||||
puzzleRunRef.current ?? puzzleRun,
|
||||
run,
|
||||
);
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
return run;
|
||||
return nextRun;
|
||||
},
|
||||
[platformBootstrap, puzzleRun],
|
||||
);
|
||||
@@ -1744,7 +1763,12 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
|
||||
.then(({ run }) => {
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRun((currentRun) => {
|
||||
if (!currentRun) {
|
||||
return currentRun;
|
||||
}
|
||||
return mergePuzzleServiceRuntimeState(currentRun, run);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
submittedPuzzleLeaderboardKeysRef.current.delete(submitKey);
|
||||
|
||||
@@ -427,7 +427,7 @@ function PuzzleHistoryAssetPickerDialog({
|
||||
onClick={() => onSelect(asset)}
|
||||
className={`overflow-hidden rounded-[1.35rem] border bg-white/82 text-left transition hover:border-amber-300/70 hover:bg-white ${isBusy ? 'cursor-not-allowed opacity-55' : 'border-[var(--platform-subpanel-border)]'}`}
|
||||
>
|
||||
<div className="aspect-[9/16] overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={asset.imageSrc}
|
||||
alt={asset.ownerLabel || '历史拼图素材'}
|
||||
@@ -509,7 +509,7 @@ function PuzzlePictureEditor({
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
画面预览
|
||||
</div>
|
||||
<div className="mx-auto mt-3 aspect-[9/16] w-full max-w-[24rem] overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
|
||||
<div className="mx-auto mt-3 aspect-square w-full max-w-[24rem] overflow-hidden rounded-[1.25rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
|
||||
{formalImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={formalImageSrc}
|
||||
@@ -725,7 +725,7 @@ function PuzzlePublishDialog({
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
正式图
|
||||
</div>
|
||||
<div className="aspect-[9/16] overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
|
||||
<div className="aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
|
||||
{formalImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={formalImageSrc}
|
||||
|
||||
@@ -206,7 +206,7 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
|
||||
});
|
||||
|
||||
test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
|
||||
test('拼图棋盘使用贴近移动端边缘的正方形舞台承载切块', () => {
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
@@ -229,9 +229,10 @@ test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
|
||||
);
|
||||
|
||||
const board = screen.getByTestId('puzzle-board');
|
||||
expect(board.className).toContain('aspect-[9/16]');
|
||||
expect(board.className).toContain('aspect-square');
|
||||
expect(board.className).toContain('max-w-[min(99vw,calc(100vh_-_16.5rem))]');
|
||||
expect(board.className).not.toContain('aspect-video');
|
||||
expect(board.className).not.toContain('aspect-square');
|
||||
expect(board.className).not.toContain('aspect-[9/16]');
|
||||
expect(board.getAttribute('style')).toContain('grid-template-rows');
|
||||
expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -331,7 +331,8 @@ type PuzzleHintDemoState = {
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
|
||||
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
|
||||
* 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。
|
||||
*/
|
||||
export function PuzzleRuntimeShell({
|
||||
run,
|
||||
@@ -1110,11 +1111,11 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center p-3 pt-28 pb-32 sm:p-4">
|
||||
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-28 pb-32 sm:p-4">
|
||||
<div
|
||||
ref={boardRef}
|
||||
data-testid="puzzle-board"
|
||||
className="relative grid aspect-[9/16] w-full max-w-[min(96vw,calc(56.25vh_-_8.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:rounded-[1.45rem]"
|
||||
className="relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_16.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_17rem))] sm:rounded-[1.45rem]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
|
||||
|
||||
@@ -138,7 +138,6 @@ vi.mock('../../services/puzzle-gallery', () => ({
|
||||
|
||||
vi.mock('../../services/puzzle-runtime', () => ({
|
||||
advanceLocalPuzzleNextLevel: vi.fn(),
|
||||
dragPuzzlePieceOrGroup: vi.fn(),
|
||||
startPuzzleRun: vi.fn(),
|
||||
swapPuzzlePieces: vi.fn(),
|
||||
submitPuzzleLeaderboard: vi.fn(),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
export {
|
||||
advanceLocalPuzzleNextLevel,
|
||||
advancePuzzleNextLevel,
|
||||
dragPuzzlePieceOrGroup,
|
||||
getPuzzleRun,
|
||||
puzzleRuntimeClient,
|
||||
startPuzzleRun,
|
||||
submitPuzzleLeaderboard,
|
||||
swapPuzzlePieces,
|
||||
updatePuzzleRunPause,
|
||||
usePuzzleRuntimeProp,
|
||||
} from './puzzleRuntimeClient';
|
||||
|
||||
@@ -296,9 +296,7 @@ describe('puzzleLocalRuntime', () => {
|
||||
const clearedRun = solveCurrentLevel(startLocalPuzzleRun(baseWork));
|
||||
|
||||
expect(clearedRun.currentLevel?.status).toBe('cleared');
|
||||
expect(clearedRun.recommendedNextProfileId).toBe(
|
||||
'profile-1::local-level-2',
|
||||
);
|
||||
expect(clearedRun.recommendedNextProfileId).toBeNull();
|
||||
expect(clearedRun.currentLevel?.elapsedMs).toBeGreaterThan(0);
|
||||
expect(clearedRun.currentLevel?.leaderboardEntries).toEqual([]);
|
||||
expect(clearedRun.leaderboardEntries).toEqual([]);
|
||||
|
||||
@@ -498,10 +498,7 @@ function applyNextBoard(
|
||||
: timedRun.currentLevel.leaderboardEntries,
|
||||
},
|
||||
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
|
||||
recommendedNextProfileId:
|
||||
status === 'cleared'
|
||||
? buildLocalNextProfileId(run.entryProfileId, nextClearedLevelCount + 1)
|
||||
: run.recommendedNextProfileId,
|
||||
recommendedNextProfileId: run.recommendedNextProfileId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type {
|
||||
AdvanceLocalPuzzleNextLevelRequest,
|
||||
DragPuzzlePieceRequest,
|
||||
PuzzleRunResponse,
|
||||
StartPuzzleRunRequest,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
@@ -78,27 +77,6 @@ export async function swapPuzzlePieces(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交单块或合并块拖动请求。
|
||||
*/
|
||||
export async function dragPuzzlePieceOrGroup(
|
||||
runId: string,
|
||||
payload: DragPuzzlePieceRequest,
|
||||
) {
|
||||
return requestJson<PuzzleRunResponse>(
|
||||
`${PUZZLE_RUNTIME_API_BASE}/${encodeURIComponent(runId)}/drag`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
'拖动拼图块失败',
|
||||
{
|
||||
retry: PUZZLE_RUNTIME_WRITE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入推荐出的下一关。
|
||||
*/
|
||||
@@ -201,11 +179,9 @@ export async function advanceLocalPuzzleNextLevel(
|
||||
export const puzzleRuntimeClient = {
|
||||
advanceLocalNextLevel: advanceLocalPuzzleNextLevel,
|
||||
advanceNextLevel: advancePuzzleNextLevel,
|
||||
drag: dragPuzzlePieceOrGroup,
|
||||
getRun: getPuzzleRun,
|
||||
submitLeaderboard: submitPuzzleLeaderboard,
|
||||
startRun: startPuzzleRun,
|
||||
swap: swapPuzzlePieces,
|
||||
updatePause: updatePuzzleRunPause,
|
||||
useProp: usePuzzleRuntimeProp,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user